Skip to content

Instantiate validators at definition time#2657

Draft
ericproulx wants to merge 1 commit intomasterfrom
revisit_validators
Draft

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

Instantiate validators at definition time

Summary

Validators are now instantiated at definition time (in ParamsScope and ContractScope) rather than at request time via ValidatorFactory. This eliminates per-request object allocation overhead and allows expensive setup (option parsing, converter building) to happen once.

I18n translation is handled through a shared Grape::Util::Translation module. Validators that need interpolation parameters (e.g. LengthValidator, SameAsValidator) store a Hash { key: :length, min: 2, max: 5 } as their message. Exceptions::Base#translate_message dispatches on type — Symbol, Hash, Proc, or String — so translation with the correct locale always happens at error-raise time.

Changes

Shared translation module (Grape::Util::Translation)

  • Extracted translate (with fallback locale logic) into Grape::Util::Translation, included by both Exceptions::Base and Validators::Base
  • Removed FALLBACK_LOCALE, translate, and fallback_message from Exceptions::Base
  • Exceptions::Base#translate_message and translate_attributes delegate to translate with a scope: parameter
  • Exceptions::Base#translate_message now supports Hash messages: { key: :symbol, **interpolation_params } for deferred translation with interpolation
  • Validators::Base#translate_message delegates to translate with the grape.errors.messages scope

Core architecture

  • ParamsScope#validate and ContractScope now store validator instances instead of option hashes in namespace_stackable[:validations]
  • ParamsScope#coerce_type receives validations.extract!(:coerce, :coerce_with, :coerce_message) instead of the full hash, replacing the individual delete calls that followed
  • Endpoint#run_validators takes a request: keyword arg, reads validators directly from inheritable_setting.route[:saved_validations], and short-circuits with return if validators.blank?
  • Removed Endpoint#validations enumerator method
  • Removed ValidatorFactory

Validator base (Validators::Base)

  • fail_fast? is now an explicit public method
  • Moved validate!, message, options_key? to private
  • options_key? simplified to a single key parameter
  • Added private helpers: hash_like?, option_value, scrub, translate_message
  • message accepts a block for lazy default generation, returns key when present
  • @fail_fast, @allow_blank = opts.values_at(:fail_fast, :allow_blank) in constructor

Validator optimizations — eagerly compute in initialize

  • AllowBlankValidator: caches @value and @exception_message; uses hash_like? and scrub
  • CoerceValidator: resolves type and builds converter; caches @exception_message; inlines valid_type? check; removes type, converter, valid_type?
  • DefaultValidator: pre-builds @default_call lambda; removes validate_param!, inlines into validate!
  • ExceptValuesValidator: validates proc arity; pre-builds @excepts_call lambda; caches @exception_message
  • LengthValidator: validates arguments; stores a Hash message ({ key: :length, min:, max: }) for deferred i18n translation with interpolation
  • ValuesValidator: validates proc arity; pre-builds @values_call lambda; caches @exception_message
  • RegexpValidator: caches @value and @exception_message; uses Base#scrub
  • SameAsValidator: caches @value; stores a Hash message ({ key: :same_as, parameter: }) for deferred i18n translation with interpolation
  • PresenceValidator: caches @exception_message
  • AllOrNoneOfValidator, MutuallyExclusiveValidator: cache @exception_message
  • AtLeastOneOfValidator: caches @exception_message; inverts guard to return if keys_in_common(params).any?
  • ExactlyOneOfValidator: caches @exactly_one_exception_message and @mutual_exclusion_exception_message
  • ContractScopeValidator: no longer inherits from Base; standalone class with (schema:) constructor and its own fail_fast?

params: argument simplification

  • Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)]) simplified to params: @scope.full_name(attr_name) throughout, since Validation now coerces to array internally
  • attr_accessor :params, :message_key changed to attr_reader

Specs

  • Replaced shared let_it_be(:app) with per-describe let(:app) blocks so each test group defines only the route(s) it needs
  • Removed endpoint_run_validators.grape notification expectations for empty-validator cases
  • Added i18n tests for LengthValidator and SameAsValidator confirming request-time locale is used regardless of definition-time locale
  • Added zh-CN translations for length and same_as messages

Removed test-prof dependency

  • Removed test-prof gem from Gemfile
  • Deleted spec/config/spec_test_prof.rb
  • Removed config from the spec helper directory loader

Test plan

  • Run full test suite (bundle exec rspec)
  • Verify no regressions in validation behavior
  • Benchmark request throughput to confirm reduced per-request allocations

@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 5 times, most recently from 7a04c46 to a0c19b8 Compare February 13, 2026 17:55
  - Store validator instances in ParamsScope/ContractScope and have
    Endpoint#run_validators read them directly
  - Remove ValidatorFactory indirection and eagerly compute validator
    messages/options in constructors
  - Extract Grape::Util::Translation module shared by Exceptions::Base
    and Validators::Base for I18n translate with fallback locale
  - Support Hash messages in translate_message for deferred translation
    with interpolation parameters (e.g. { key: :length, min: 2 })
  - Normalize Grape::Exceptions::Validation params handling and refactor
    validator specs to define routes per example group
  - Drop test-prof dependency and its spec config

Co-authored-by: Cursor <cursoragent@cursor.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.

2 participants