From 985d739469a48c904a50cabb9b13c07c64b621b1 Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Fri, 15 May 2026 11:24:47 +0200 Subject: [PATCH] Introduce Grape::Validations::CoerceOptions value object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ValidationsSpec#coerce_options` returned an ad-hoc `{ type:, method:, message: }` Hash that flowed (deep-frozen) into `CoerceValidator` as its `options` argument and was poked at by `ParamsScope#check_coerce_with` / `#validate_coerce`. Unlike most validator options, this Hash is *never written by the user* — it is assembled internally from the parsed `type:` / `coerce_with:` / `coerce_message` declaration — so it has a fixed shape and no public contract to preserve. Replace it with a `Grape::Validations::CoerceOptions` Data value object (`Data.define(:type, :coerce_method, :message)`, all defaulting nil). The member is `coerce_method`, not `method`, to avoid shadowing `Object#method` (`Lint/DataDefineOverride`); it also matches `ValidationsSpec`'s existing `coerce_method` vocabulary. - `ValidationsSpec#coerce_options` builds the value object. - `ParamsScope#check_coerce_with` / `#validate_coerce` read `.type` / `.coerce_method` instead of `[:type]` / `[:method]`. - `CoerceValidator` reads `@options.type` / `@options.coerce_method`. `Base#message` can't see a custom message off a Data (it probes Hash-like `key?`), so `CoerceValidator#initialize` restores `@exception_message` from `@options.message`, preserving `type: { value:, message: }` behaviour. `Grape::Util::DeepFreeze` returns the Data as-is (its `else` branch); `Base.new` already freezes the whole validator instance, and the Data is structurally immutable. No user-facing contract changes — users never construct or read this Hash; no spec hand-builds it. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + lib/grape/validations/coerce_options.rb | 21 +++++++++++++++++++ lib/grape/validations/params_scope.rb | 10 ++++----- lib/grape/validations/validations_spec.rb | 2 +- .../validators/coerce_validator.rb | 9 ++++++-- 5 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 lib/grape/validations/coerce_options.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 61e3e6eca..483b20bda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ * [#2710](https://github.com/ruby-grape/grape/pull/2710): Tidy up `Grape::DeclaredParamsHandler` - [@ericproulx](https://github.com/ericproulx). * [#2714](https://github.com/ruby-grape/grape/pull/2714): Drop unused `Grape::Middleware::Globals` and its `grape.request*` env constants - [@ericproulx](https://github.com/ericproulx). * [#2717](https://github.com/ruby-grape/grape/pull/2717): Convert `Grape::Exceptions::ErrorResponse` to a `Data` value object - [@ericproulx](https://github.com/ericproulx). +* [#2722](https://github.com/ruby-grape/grape/pull/2722): Introduce `Grape::Validations::CoerceOptions` value object for the internal coerce options - [@ericproulx](https://github.com/ericproulx). * Your contribution here. #### Fixes diff --git a/lib/grape/validations/coerce_options.rb b/lib/grape/validations/coerce_options.rb new file mode 100644 index 000000000..274bfdd66 --- /dev/null +++ b/lib/grape/validations/coerce_options.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Grape + module Validations + # Immutable value object describing how a parameter is coerced. Assembled + # by {ValidationsSpec#coerce_options} from the parsed +type+/+coerce_with+/ + # +coerce_message+ declaration — never written by the user — and consumed + # by {ParamsScope#check_coerce_with} / {ParamsScope#validate_coerce} and by + # {Validators::Validators::CoerceValidator} (which receives it as its + # +options+ argument). + # + # All three fields may be +nil+ (e.g. a remountable API evaluated on its + # base instance has no resolved +type+ yet). + # +coerce_method+ (not +method+) avoids shadowing +Object#method+. + CoerceOptions = Data.define(:type, :coerce_method, :message) do + def initialize(type: nil, coerce_method: nil, message: nil) + super + end + end + end +end diff --git a/lib/grape/validations/params_scope.rb b/lib/grape/validations/params_scope.rb index aaeb5f5fd..2bc4900f7 100644 --- a/lib/grape/validations/params_scope.rb +++ b/lib/grape/validations/params_scope.rb @@ -350,13 +350,13 @@ def validates(attrs, validations) end end - # Enforce correct usage of :coerce_with on a coerce_options hash. + # Enforce correct usage of :coerce_with on a CoerceOptions. # We do not allow coercion without a type, nor with +JSON+ as a type # since that defines its own coercion method. def check_coerce_with(coerce_options) - return unless coerce_options[:method] - raise ArgumentError, 'must supply type for coerce_with' unless coerce_options[:type] - return unless SPECIAL_JSON.include?(coerce_options[:type]) + return unless coerce_options.coerce_method + raise ArgumentError, 'must supply type for coerce_with' unless coerce_options.type + return unless SPECIAL_JSON.include?(coerce_options.type) raise ArgumentError, 'coerce_with disallowed for type: JSON' end @@ -375,7 +375,7 @@ def validate_coerce(spec, attrs) # configuration[:some_type] evaluates to nil. Skipping instantiation # here is correct — the real mounted instance will replay this step # with the actual type value. - return unless coerce_options[:type] + return unless coerce_options.type validate('coerce', coerce_options, attrs, spec.required?, spec.shared_opts) end diff --git a/lib/grape/validations/validations_spec.rb b/lib/grape/validations/validations_spec.rb index 40f3b549b..19b9aac33 100644 --- a/lib/grape/validations/validations_spec.rb +++ b/lib/grape/validations/validations_spec.rb @@ -63,7 +63,7 @@ def required? end def coerce_options - { type: @coerce_type, method: @coerce_method, message: @coerce_message } + CoerceOptions.new(type: @coerce_type, coerce_method: @coerce_method, message: @coerce_message) end private diff --git a/lib/grape/validations/validators/coerce_validator.rb b/lib/grape/validations/validators/coerce_validator.rb index 222351c80..477bf5d6b 100644 --- a/lib/grape/validations/validators/coerce_validator.rb +++ b/lib/grape/validations/validators/coerce_validator.rb @@ -9,13 +9,18 @@ class CoerceValidator < Base def initialize(attrs, options, required, scope, opts) super - raw_type = @options[:type] + # +@options+ is a Grape::Validations::CoerceOptions. +Base#message+ + # can't see a custom message off a Data (it probes Hash-like + # +key?+), so restore it here to preserve `type: { value:, message: }`. + @exception_message = @options.message unless @options.message.nil? + + raw_type = @options.type type = hash_like?(raw_type) ? raw_type[:value] : raw_type @converter = if type.is_a?(Grape::Validations::Types::VariantCollectionCoercer) type else - Types.build_coercer(type, method: @options[:method]) + Types.build_coercer(type, method: @options.coerce_method) end end