Skip to content

Instantiate validators at definition time#2657

Open
ericproulx wants to merge 1 commit intomasterfrom
revisit_validators
Open

Instantiate validators at definition time#2657
ericproulx wants to merge 1 commit intomasterfrom
revisit_validators

Conversation

@ericproulx
Copy link
Contributor

@ericproulx ericproulx commented Feb 12, 2026

Summary

Validators are now instantiated once at route definition time rather than per-request via `ValidatorFactory`. This eliminates repeated object allocation on every request and moves expensive setup (option parsing, converter building, message formatting) out of the hot path.

Because validator instances are shared across all concurrent requests they are frozen after initialization, enforced by a `Validators::Base.new` override that calls `super.freeze`. All inputs (`options`, `opts`, `attrs`) arrive pre-frozen from the DSL boundary via `DeepFreeze` and `Array#freeze`, so subclass ivars derived from them are frozen by construction. A new `Grape::Util::DeepFreeze` helper recursively freezes Hash/Array/String values while intentionally leaving Procs, coercers, Classes, and other mutable objects unfrozen.

Fix #2519

Changes

Core

  • `ParamsScope#validate` instantiates the validator class directly and stores the instance in `namespace_stackable[:validations]`; removes `ValidatorFactory`
  • `Endpoint#run_validators` reads validator instances directly from `saved_validations` and removes the `validations` enumerator method
  • `ParamsScope` is frozen at the end of `initialize`; `element` is no longer a public reader; `parent` and `Attr#key`/`Attr#scope` changed from `attr_accessor` to `attr_reader`
  • `full_path` is now cached at init time as `@full_path` (via private `build_full_path`) and exposed as an `attr_reader`, making it safe to call on a frozen scope
  • `coerce_type` now receives only the pre-extracted coercion keys (`validations.extract!(:coerce, :coerce_with, :coerce_message)`) so callers don't need to delete them afterwards
  • `derive_validator_options` result is frozen before being passed to validators
  • `depends_on_met?` refactored to use `Enumerable#all?` with destructuring

`Validators::Base`

  • `Base.new` overrides `.new` to call `super.freeze`, ensuring every validator is immutable after construction
  • `@attrs` frozen via `Array(attrs).freeze`; `@option` deep-frozen via `Grape::Util::DeepFreeze.deep_freeze`
  • `fail_fast?` promoted to explicit public method
  • `validate_param!` promoted to `protected` with a `NotImplementedError` default
  • New private helpers extracted: `validation_error!`, `hash_like?` (replaces inline `respond_to?(:key?)` checks), `option_value`, `scrub`, and a refactored `message` with optional block for computed fallbacks

`Grape::Util::DeepFreeze`

  • New module with a single `deep_freeze(obj)` function
  • Freezes Hash (keys + values), Array (elements), and String recursively
  • Returns all other types (Proc, Class, coercers, etc.) untouched

Validator-level eager initialization

  • `AllowBlankValidator`: caches `@value` and `@exception_message`
  • `AllOrNoneOfValidator` / `AtLeastOneOfValidator` / `MutuallyExclusiveValidator`: cache exception messages; minor logic cleanups (`.any?`, `@attrs.length`)
  • `CoerceValidator`: resolves type and builds converter at definition time; `@converter` is left unfrozen automatically (DeepFreeze skips non-Hash/Array/String types); `valid_type?` removed in favour of inline `is_a?(Types::InvalidValue)`; `params:` argument now passes a scalar instead of a single-element array
  • `ContractScopeValidator`: no longer inherits from `Base`; simple `initialize(schema:)` called directly from `ContractScope`; adds standalone `fail_fast?` returning `false`
  • `DefaultValidator`: pre-builds `@default_call` lambda at init; reads `@option` directly (drops separate `@default` ivar)
  • `ExactlyOneOfValidator`: caches both `@exactly_one_exception_message` and `@mutual_exclusion_exception_message`
  • `ExceptValuesValidator`: validates that Proc must have arity zero at init (raises `ArgumentError` otherwise); wraps value in `@excepts_call` lambda for a uniform call interface
  • `LengthValidator`: uses `values_at` for option extraction
  • `PresenceValidator` / `RegexpValidator` / `SameAsValidator`: cache exception messages
  • `ValuesValidator`: splits into `@values_call` + `@values_is_predicate` to distinguish zero-arity collection procs from per-element predicate procs

Specs

  • New `DeepFreezeSpec`
  • New specs for `SameAsValidator` and `ExceptValuesValidator`
  • `ContractScopeValidatorSpec` / `custom_validations_spec` updated for the new instantiation model

Test plan

  • `bundle exec rspec`
  • Verify no regressions in validation behaviour
  • Confirm frozen validator instances raise `FrozenError` on any attempt to mutate state at request time

@ericproulx ericproulx marked this pull request as draft February 12, 2026 08:42
@github-actions
Copy link

github-actions bot commented Feb 12, 2026

Danger Report

No issues found.

View run

@ericproulx ericproulx force-pushed the revisit_validators branch 2 times, most recently from 5d145a5 to f87920f Compare February 12, 2026 08:49
@ericproulx
Copy link
Contributor Author

Missing UPGRADING notes. Working on it

@ericproulx ericproulx force-pushed the revisit_validators branch 3 times, most recently from 2ecb403 to cf04c9d Compare February 12, 2026 13:04
@dblock
Copy link
Member

dblock commented Feb 12, 2026

Is there a tradeoff with things like @exception_message = message(:all_or_none) being always allocated? can they become class variables?

@ericproulx
Copy link
Contributor Author

Is there a tradeoff with things like @exception_message = message(:all_or_none) being always allocated? can they become class variables?
message involves @option so it can't be a class variable.

@ericproulx ericproulx force-pushed the revisit_validators branch 12 times, most recently from e16efcf to 0b9e34b Compare February 18, 2026 22:27
@ericproulx ericproulx force-pushed the revisit_validators branch 8 times, most recently from a503556 to 2b16ba1 Compare February 23, 2026 11:05
@ericproulx ericproulx force-pushed the revisit_validators branch 7 times, most recently from 52b7862 to adac9ed Compare March 14, 2026 21:54
@ericproulx ericproulx marked this pull request as ready for review March 14, 2026 22:05
@ericproulx ericproulx force-pushed the revisit_validators branch 2 times, most recently from 2a39a4d to 2d51b32 Compare March 14, 2026 22:35
@ericproulx ericproulx marked this pull request as draft March 14, 2026 22:35
@ericproulx ericproulx force-pushed the revisit_validators branch 2 times, most recently from 177506d to c4df5d3 Compare March 21, 2026 16:01
@ericproulx ericproulx marked this pull request as ready for review March 21, 2026 16:02
@ericproulx
Copy link
Contributor Author

Code review

Found 2 issues:

  1. pr_description.md is committed to the repo root and tracked by git — this file should not be part of the repository.

## Summary
Validators are now instantiated once at route definition time rather than per-request via `ValidatorFactory`. This eliminates repeated object allocation on every request and moves expensive setup (option parsing, converter building, message formatting) out of the hot path.
Because validator instances are shared across all concurrent requests they are frozen after initialization, enforced by a `Validators::Base.new` override that calls `freeze_state!` then `freeze`. A new `Grape::Util::DeepFreeze` helper recursively freezes Hash/Array/String values while intentionally leaving Procs, coercers, and other mutable objects unfrozen.

  1. endpoint_run_validators.grape instrumentation event is no longer fired when validators is empty. run_validators now returns early before the ActiveSupport::Notifications.instrument call, so consumers subscribing to this event won't receive it for routes with no validators. The README documents this as a public API (with validators in the payload), and UPGRADING.md doesn't mention this behavioral change.

grape/lib/grape/endpoint.rb

Lines 208 to 217 in c4df5d3

def run_validators(request:)
validators = inheritable_setting.route[:saved_validations]
return if validators.empty?
validation_errors = []
Grape::Validations::ParamScopeTracker.track do
ActiveSupport::Notifications.instrument('endpoint_run_validators.grape', endpoint: self, validators: validators, request:) do
validators.each do |validator|
validator.validate(request)

🤖 Generated with Claude Code

If this code review was useful, please react with 👍. Otherwise, react with 👎.

@ericproulx ericproulx force-pushed the revisit_validators branch 13 times, most recently from 0a19583 to d1e35a1 Compare March 21, 2026 16:58
Copy link
Member

@dblock dblock left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work. I think deep_freeze can't return on obj.frozen?.


#### Custom validators: use `default_message_key` and `validation_error!`

Validators are now instantiated once at definition time and frozen. Any setup should happen in `initialize`, not in `validate_param!`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So will users have to rewrite custom validators? Explain how if so with a simple example.

Validators are now instantiated once at route definition time and frozen,
rather than per-request via ValidatorFactory. This removes repeated object
allocation from the hot path and moves option parsing, converter building,
and message formatting into initialize.

- Remove ValidatorFactory; ParamsScope#validate instantiates validators directly
- Freeze validator instances via Base.new override (super.freeze)
- Add Grape::Util::DeepFreeze to recursively freeze Hash/Array/String options
- Freeze ParamsScope at end of initialize; cache full_path as @full_path
- Promote validate_param! to protected with NotImplementedError default
- Extract validation_error!, hash_like?, option_value, scrub helpers in Base
- Add default_message_key class macro for pre-computing exception messages
- Eager-initialize all built-in validators (coerce, default, values, regexp, etc.)
- ContractScopeValidator no longer inherits Base (standalone with freeze)
- coerce_type receives only extracted coerce keys; callers skip manual deletes
- Add DeepFreeze spec; add SameAsValidator and ExceptValuesValidator specs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LengthValidator will raise ArgumentException at runtime if conditions aren't met

2 participants