From 0f9fcb4a58a05a40f6cd0cbcec4cebca3e915f46 Mon Sep 17 00:00:00 2001 From: "phillschnei@web.de" Date: Sat, 14 Feb 2026 10:05:25 +0100 Subject: [PATCH 1/2] Resolve custom validators from API namespace without manual require When a validator short name (e.g. :uuid) is not in the global registry, Grape now looks up the constant in the API's namespace under Validators:: (e.g. Api::V2::Validators::Uuid for Api::V2::Jobs). This fixes the require-order / double-load issue when using custom validators in Rails: params like 'uuid: true' can resolve to a validator class in the same API namespace without explicit require or global Grape::Validations.register, and without triggering 'already initialized constant' warnings. - Add optional scope: to require_validator; pass API from ParamsScope - Add find_custom_validator_in_scope and resolve_api_class (use mount instance's @base for named API class when present) - Add specs for scope-based lookup and integration with params Co-authored-by: Cursor --- CHANGELOG.md | 1 + lib/grape/validations.rb | 43 ++++++++++++++-- lib/grape/validations/params_scope.rb | 2 +- spec/grape/api/custom_validations_spec.rb | 63 +++++++++++++++++++++++ 4 files changed, 105 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d009aa085..fc7530fb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ #### Features * [#2656](https://github.com/ruby-grape/grape/pull/2656): Remove useless instance_variable_defined? checks - [@ericproulx](https://github.com/ericproulx). +* Resolve custom validators from API namespace (e.g. `Api::V2::Validators::Uuid` for `uuid: true`) without manual require or global registration - [@contributor](https://github.com/contributor). * Your contribution here. #### Fixes diff --git a/lib/grape/validations.rb b/lib/grape/validations.rb index fd33071d0..ef1900e31 100644 --- a/lib/grape/validations.rb +++ b/lib/grape/validations.rb @@ -6,10 +6,47 @@ module Validations module_function - def require_validator(short_name) - raise Grape::Exceptions::UnknownValidator, short_name unless registry.key?(short_name) + def require_validator(short_name, scope: nil) + return registry[short_name] if registry.key?(short_name) - registry[short_name] + custom_klass = find_custom_validator_in_scope(short_name, scope) if scope + return custom_klass if custom_klass + + raise Grape::Exceptions::UnknownValidator, short_name + end + + # Tries to resolve a custom validator constant in the API's namespace. + # Convention: for API class Api::V2::Jobs and short_name :uuid, looks up + # Api::V2::Validators::Uuid. This allows custom validators to be used + # without explicit require and without polluting the global validator registry. + # When +scope+ is a Grape mount instance (anonymous class), uses its +@base+ to + # resolve the named API class for constant lookup. + def find_custom_validator_in_scope(short_name, scope) + api_class = resolve_api_class(scope) + return unless api_class&.name.present? + + namespace = api_class.module_parent + return if namespace == Object + + class_name = short_name.to_s.camelize + validators_mod = namespace.const_get(:Validators) + klass = validators_mod.const_get(class_name) + return unless klass.is_a?(Class) + return unless klass <= Grape::Validations::Validators::Base + + klass + rescue NameError + nil + end + + def resolve_api_class(scope) + if scope.instance_variable_defined?(:@base) + scope.instance_variable_get(:@base) + elsif scope.is_a?(Module) + scope + else + scope.class + end end def build_short_name(klass) diff --git a/lib/grape/validations/params_scope.rb b/lib/grape/validations/params_scope.rb index 612d65f05..8e1d43356 100644 --- a/lib/grape/validations/params_scope.rb +++ b/lib/grape/validations/params_scope.rb @@ -470,7 +470,7 @@ def validate(type, options, attrs, required, opts) required: required, params_scope: self, opts: opts, - validator_class: Validations.require_validator(type) + validator_class: Validations.require_validator(type, scope: @api) } @api.inheritable_setting.namespace_stackable[:validations] = validator_options end diff --git a/spec/grape/api/custom_validations_spec.rb b/spec/grape/api/custom_validations_spec.rb index 374416acc..78e66cc35 100644 --- a/spec/grape/api/custom_validations_spec.rb +++ b/spec/grape/api/custom_validations_spec.rb @@ -251,4 +251,67 @@ def validate_param!(_attr_name, _params) expect(last_response.status).to eq 200 end end + + describe 'find_custom_validator_in_scope' do + it 'resolves validator when scope has @base in namespace with Validators::Uuid' do + # Define a named module with eval so constant names and module_parent resolve correctly + eval(<<~RUBY, binding, __FILE__, __LINE__ + 1) + module CustomValidatorScopeForUuidSpec + module Validators + class Uuid < Grape::Validations::Validators::Base + def validate_param!(_attr_name, _params); end + end + end + class Jobs < Grape::API + end + end + RUBY + + fake_scope = Object.new + fake_scope.instance_variable_set(:@base, CustomValidatorScopeForUuidSpec::Jobs) + + expect(described_class.require_validator(:uuid, scope: fake_scope)).to eq(CustomValidatorScopeForUuidSpec::Validators::Uuid) + end + end + + describe 'custom validator resolved from API namespace without global registration' do + before do + # Define a named module so the API class has a name when params run (required for + # resolving the custom validator from the API namespace). + eval(<<~RUBY, binding, __FILE__, __LINE__ + 1) + module CustomValidatorScope + module Validators + class Uuid < Grape::Validations::Validators::Base + def validate_param!(attr_name, params) + return if params[attr_name].to_s.match?(/\\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\z/i) + raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: 'must be a valid UUID') + end + end + end + class Jobs < Grape::API + params do + requires :id, type: String, uuid: true + end + get ':id' do + body 'ok' + end + end + end + RUBY + end + + let(:app) { CustomValidatorScope::Jobs } + + it 'accepts a valid UUID' do + get '/550e8400-e29b-41d4-a716-446655440000' + expect(last_response.status).to eq 200 + expect(last_response.body).to eq 'ok' + end + + it 'rejects an invalid UUID' do + get '/not-a-uuid' + expect(last_response.status).to eq 400 + expect(last_response.body).to include 'must be a valid UUID' + end + end end From 8bb87a33d0d21b4304c0b5ed6dee970bcfe623fc Mon Sep 17 00:00:00 2001 From: "phillschnei@web.de" Date: Sat, 14 Feb 2026 10:29:14 +0100 Subject: [PATCH 2/2] Resolve custom validators from API namespace (#1178) Co-authored-by: Cursor --- CHANGELOG.md | 2 +- lib/grape/api/instance.rb | 1 + lib/grape/validations.rb | 61 ++++++------- lib/grape/validations/params_scope.rb | 4 +- spec/grape/api/custom_validations_spec.rb | 105 ++++++++++++---------- 5 files changed, 88 insertions(+), 85 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc7530fb9..4eb59e96c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,12 @@ #### Features * [#2656](https://github.com/ruby-grape/grape/pull/2656): Remove useless instance_variable_defined? checks - [@ericproulx](https://github.com/ericproulx). -* Resolve custom validators from API namespace (e.g. `Api::V2::Validators::Uuid` for `uuid: true`) without manual require or global registration - [@contributor](https://github.com/contributor). * Your contribution here. #### Fixes * [#2655](https://github.com/ruby-grape/grape/pull/2655): Fix `before_each` method to handle `nil` parameter correctly - [@ericproulx](https://github.com/ericproulx). +* [#2658](https://github.com/ruby-grape/grape/pull/XXXX): Custom validators resolved from API namespace without explicit require (fixes #1178) - [@GulfGulfinson](https://github.com/GulfGulfinson). * Your contribution here. ### 3.1.0 (2026-01-25) diff --git a/lib/grape/api/instance.rb b/lib/grape/api/instance.rb index 8e081fd91..144deec30 100644 --- a/lib/grape/api/instance.rb +++ b/lib/grape/api/instance.rb @@ -22,6 +22,7 @@ class << self extend Forwardable attr_accessor :configuration + attr_reader :base def_delegators :@base, :to_s diff --git a/lib/grape/validations.rb b/lib/grape/validations.rb index ef1900e31..48e3faa3d 100644 --- a/lib/grape/validations.rb +++ b/lib/grape/validations.rb @@ -6,47 +6,40 @@ module Validations module_function - def require_validator(short_name, scope: nil) + def require_validator(short_name, api_class: nil) return registry[short_name] if registry.key?(short_name) - custom_klass = find_custom_validator_in_scope(short_name, scope) if scope - return custom_klass if custom_klass - - raise Grape::Exceptions::UnknownValidator, short_name - end - - # Tries to resolve a custom validator constant in the API's namespace. - # Convention: for API class Api::V2::Jobs and short_name :uuid, looks up - # Api::V2::Validators::Uuid. This allows custom validators to be used - # without explicit require and without polluting the global validator registry. - # When +scope+ is a Grape mount instance (anonymous class), uses its +@base+ to - # resolve the named API class for constant lookup. - def find_custom_validator_in_scope(short_name, scope) - api_class = resolve_api_class(scope) - return unless api_class&.name.present? - - namespace = api_class.module_parent - return if namespace == Object - - class_name = short_name.to_s.camelize - validators_mod = namespace.const_get(:Validators) - klass = validators_mod.const_get(class_name) - return unless klass.is_a?(Class) - return unless klass <= Grape::Validations::Validators::Base + klass = find_validator_in_namespace(short_name, api_class) if api_class + raise Grape::Exceptions::UnknownValidator, short_name unless klass klass - rescue NameError - nil end - def resolve_api_class(scope) - if scope.instance_variable_defined?(:@base) - scope.instance_variable_get(:@base) - elsif scope.is_a?(Module) - scope - else - scope.class + def find_validator_in_namespace(short_name, api_class) + const_name = short_name.to_s.camelize + lookup_class = api_class + lookup_class = lookup_class.superclass while lookup_class && lookup_class.name.blank? + name = lookup_class&.name + return nil if name.blank? + + parent_name = name.rpartition('::').first + return nil if parent_name.blank? + + namespace = Object.const_get(parent_name) + while namespace.is_a?(Module) && namespace != Object + if namespace.const_defined?(:Validators, false) + validators_mod = namespace.const_get(:Validators) + if validators_mod.const_defined?(const_name, false) + klass = validators_mod.const_get(const_name) + return klass if klass.is_a?(Class) && klass < Validators::Base + end + end + parent_name = namespace.name.rpartition('::').first + break if parent_name.blank? + + namespace = Object.const_get(parent_name) end + nil end def build_short_name(klass) diff --git a/lib/grape/validations/params_scope.rb b/lib/grape/validations/params_scope.rb index 8e1d43356..f26eb7dd7 100644 --- a/lib/grape/validations/params_scope.rb +++ b/lib/grape/validations/params_scope.rb @@ -464,13 +464,15 @@ def check_incompatible_option_values(default, values, except_values) end def validate(type, options, attrs, required, opts) + api_class = @api.is_a?(Module) ? @api : @api.class + api_class = api_class.base if api_class.respond_to?(:base) && api_class.base validator_options = { attributes: attrs, options: options, required: required, params_scope: self, opts: opts, - validator_class: Validations.require_validator(type, scope: @api) + validator_class: Validations.require_validator(type, api_class: api_class) } @api.inheritable_setting.namespace_stackable[:validations] = validator_options end diff --git a/spec/grape/api/custom_validations_spec.rb b/spec/grape/api/custom_validations_spec.rb index 78e66cc35..840cd89c6 100644 --- a/spec/grape/api/custom_validations_spec.rb +++ b/spec/grape/api/custom_validations_spec.rb @@ -252,66 +252,73 @@ def validate_param!(_attr_name, _params) end end - describe 'find_custom_validator_in_scope' do - it 'resolves validator when scope has @base in namespace with Validators::Uuid' do - # Define a named module with eval so constant names and module_parent resolve correctly - eval(<<~RUBY, binding, __FILE__, __LINE__ + 1) - module CustomValidatorScopeForUuidSpec - module Validators - class Uuid < Grape::Validations::Validators::Base - def validate_param!(_attr_name, _params); end - end - end - class Jobs < Grape::API - end + describe 'custom validator namespace lookup' do + let(:uuid_validator) do + regex = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/ + Class.new(Grape::Validations::Validators::Base) do + define_singleton_method(:regex) { regex } + def validate_param!(attr_name, params) + return if params[attr_name].to_s.match?(self.class.regex) + + raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: 'must be a valid UUID') end - RUBY + end + end - fake_scope = Object.new - fake_scope.instance_variable_set(:@base, CustomValidatorScopeForUuidSpec::Jobs) + context 'when constants are set up' do + before do + described_class.deregister(:uuid) + stub_const('Api', Module.new) + stub_const('Api::V2', Module.new) + stub_const('Api::V2::Validators', Module.new) + Api::V2::Validators.const_set(:Uuid, uuid_validator) + Api::V2.const_set(:Jobs, Class.new(Grape::API)) + end - expect(described_class.require_validator(:uuid, scope: fake_scope)).to eq(CustomValidatorScopeForUuidSpec::Validators::Uuid) + after do + described_class.deregister(:uuid) + end + + it 'finds validator via require_validator with api_class' do + expect(described_class.require_validator(:uuid, api_class: Api::V2::Jobs)).to eq Api::V2::Validators::Uuid + end end - end - describe 'custom validator resolved from API namespace without global registration' do - before do - # Define a named module so the API class has a name when params run (required for - # resolving the custom validator from the API namespace). - eval(<<~RUBY, binding, __FILE__, __LINE__ + 1) - module CustomValidatorScope - module Validators - class Uuid < Grape::Validations::Validators::Base - def validate_param!(attr_name, params) - return if params[attr_name].to_s.match?(/\\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\z/i) - raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: 'must be a valid UUID') - end - end + context 'when API uses uuid param' do + before do + described_class.deregister(:uuid) + stub_const('Api', Module.new) + stub_const('Api::V2', Module.new) + stub_const('Api::V2::Validators', Module.new) + Api::V2::Validators.const_set(:Uuid, uuid_validator) + Api::V2.const_set(:Jobs, Class.new(Grape::API)) + Api::V2::Jobs.class_eval do + params do + requires :id, type: String, uuid: true end - class Jobs < Grape::API - params do - requires :id, type: String, uuid: true - end - get ':id' do - body 'ok' - end + get ':id' do + 'ok' end end - RUBY - end + end - let(:app) { CustomValidatorScope::Jobs } + after do + described_class.deregister(:uuid) + end - it 'accepts a valid UUID' do - get '/550e8400-e29b-41d4-a716-446655440000' - expect(last_response.status).to eq 200 - expect(last_response.body).to eq 'ok' - end + let(:app) { Api::V2::Jobs } - it 'rejects an invalid UUID' do - get '/not-a-uuid' - expect(last_response.status).to eq 400 - expect(last_response.body).to include 'must be a valid UUID' + it 'finds validator in API namespace without explicit require' do + get '/550e8400-e29b-41d4-a716-446655440000' + expect(last_response.status).to eq 200 + expect(last_response.body).to eq 'ok' + end + + it 'validates with namespace validator and returns 400 for invalid UUID' do + get '/not-a-uuid' + expect(last_response.status).to eq 400 + expect(last_response.body).to include 'must be a valid UUID' + end end end end