diff --git a/CHANGELOG.md b/CHANGELOG.md index d009aa085..4eb59e96c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ #### 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 fd33071d0..48e3faa3d 100644 --- a/lib/grape/validations.rb +++ b/lib/grape/validations.rb @@ -6,10 +6,40 @@ 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, api_class: nil) + return registry[short_name] if registry.key?(short_name) - registry[short_name] + klass = find_validator_in_namespace(short_name, api_class) if api_class + raise Grape::Exceptions::UnknownValidator, short_name unless klass + + klass + end + + 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 612d65f05..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) + 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 374416acc..840cd89c6 100644 --- a/spec/grape/api/custom_validations_spec.rb +++ b/spec/grape/api/custom_validations_spec.rb @@ -251,4 +251,74 @@ def validate_param!(_attr_name, _params) expect(last_response.status).to eq 200 end 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 + end + end + + 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 + + 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 + + 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 + get ':id' do + 'ok' + end + end + end + + after do + described_class.deregister(:uuid) + end + + let(:app) { Api::V2::Jobs } + + 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