Skip to content

Commit 6aa92f1

Browse files
committed
Revisit coerce and validators
1 parent 5dca095 commit 6aa92f1

File tree

11 files changed

+256
-246
lines changed

11 files changed

+256
-246
lines changed

lib/grape/dsl/parameters.rb

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ def requires(*attrs, &block)
127127
orig_attrs = attrs.clone
128128

129129
opts = attrs.extract_options!.clone
130-
opts[:presence] = { value: true, message: opts[:message] }
130+
opts[:presence] = { value: true, message: opts.delete(:message) }
131131
opts = @group.deep_merge(opts) if instance_variable_defined?(:@group) && @group
132132

133133
if opts[:using]
@@ -175,25 +175,25 @@ def with(*attrs, &block)
175175
# Disallow the given parameters to be present in the same request.
176176
# @param attrs [*Symbol] parameters to validate
177177
def mutually_exclusive(*attrs)
178-
validates(attrs, mutual_exclusion: { value: true, message: extract_message_option(attrs) })
178+
validates(attrs, mutual_exclusion: extract_options_with_value(attrs))
179179
end
180180

181181
# Require exactly one of the given parameters to be present.
182182
# @param (see #mutually_exclusive)
183183
def exactly_one_of(*attrs)
184-
validates(attrs, exactly_one_of: { value: true, message: extract_message_option(attrs) })
184+
validates(attrs, exactly_one_of: extract_options_with_value(attrs))
185185
end
186186

187187
# Require at least one of the given parameters to be present.
188188
# @param (see #mutually_exclusive)
189189
def at_least_one_of(*attrs)
190-
validates(attrs, at_least_one_of: { value: true, message: extract_message_option(attrs) })
190+
validates(attrs, at_least_one_of: extract_options_with_value(attrs))
191191
end
192192

193193
# Require that either all given params are present, or none are.
194194
# @param (see #mutually_exclusive)
195195
def all_or_none_of(*attrs)
196-
validates(attrs, all_or_none_of: { value: true, message: extract_message_option(attrs) })
196+
validates(attrs, all_or_none_of: extract_options_with_value(attrs))
197197
end
198198

199199
# Define a block of validations which should be applied if and only if
@@ -259,6 +259,10 @@ def params(params)
259259
def first_hash_key_or_param(parameter)
260260
parameter.is_a?(Hash) ? parameter.keys.first : parameter
261261
end
262+
263+
def extract_options_with_value(attrs)
264+
attrs.extract_options!.merge!(value: true)
265+
end
262266
end
263267
end
264268
end

lib/grape/endpoint.rb

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -337,12 +337,10 @@ def run_filters(filters, type = :other)
337337
end
338338
end
339339

340-
def validations
341-
return enum_for(:validations) unless block_given?
340+
def validations(&block)
341+
return enum_for(:validations) unless block
342342

343-
route_setting(:saved_validations)&.each do |saved_validation|
344-
yield Grape::Validations::ValidatorFactory.create_validator(saved_validation)
345-
end
343+
route_setting(:saved_validations)&.each(&block)
346344
end
347345

348346
def options?

lib/grape/validations/attributes_doc.rb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,12 @@ def extract_details(validations)
2626
documentation = validations.delete(:documentation)
2727

2828
details[:documentation] = documentation if documentation
29-
3029
details[:default] = validations[:default] if validations.key?(:default)
3130

32-
details[:min_length] = validations[:length][:min] if validations.key?(:length) && validations[:length].key?(:min)
33-
details[:max_length] = validations[:length][:max] if validations.key?(:length) && validations[:length].key?(:max)
31+
return unless validations.key?(:length)
32+
33+
details[:min_length] = validations[:length][:min] if validations[:length].key?(:min)
34+
details[:max_length] = validations[:length][:max] if validations[:length].key?(:max)
3435
end
3536

3637
def document(attrs)

lib/grape/validations/contract_scope.rb

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ class ContractScope
1010
def initialize(api, contract = nil, &block)
1111
# When block is passed, the first arg is either schema or nil.
1212
contract = Dry::Schema.Params(parent: contract, &block) if block
13-
1413
if contract.respond_to?(:schema)
1514
# It's a Dry::Validation::Contract, then.
1615
contract = contract.new
@@ -21,13 +20,7 @@ def initialize(api, contract = nil, &block)
2120
end
2221

2322
api.namespace_stackable(:contract_key_map, key_map)
24-
25-
validator_options = {
26-
validator_class: Grape::Validations.require_validator(:contract_scope),
27-
opts: { schema: contract, fail_fast: false }
28-
}
29-
30-
api.namespace_stackable(:validations, validator_options)
23+
api.namespace_stackable(:validations, Validators::ContractScopeValidator.new(nil, nil, nil, nil, schema: contract, fail_fast: false))
3124
end
3225
end
3326
end

lib/grape/validations/params_scope.rb

Lines changed: 67 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,18 @@
33
module Grape
44
module Validations
55
class ParamsScope
6+
include Grape::DSL::Parameters
7+
68
attr_accessor :element, :parent, :index
79
attr_reader :type, :params_meeting_dependency
810

9-
include Grape::DSL::Parameters
10-
1111
# There are a number of documentation options on entities that don't have
1212
# corresponding validators. Since there is nowhere that enumerates them all,
1313
# we maintain a list of them here and skip looking up validators for them.
1414
RESERVED_DOCUMENTATION_KEYWORDS = %i[as required param_type is_array format example].freeze
1515

16+
ValidatorOptions = Struct.new(:attributes, :options, :required, :params_scope, :opts)
17+
1618
class Attr
1719
attr_accessor :key, :scope
1820

@@ -85,7 +87,7 @@ def configuration
8587
def should_validate?(parameters)
8688
scoped_params = params(parameters)
8789

88-
return false if @optional && (scoped_params.blank? || all_element_blank?(scoped_params))
90+
return false if @optional && scoped_params.blank?
8991
return false unless meets_dependency?(scoped_params, parameters)
9092
return true if parent.nil?
9193

@@ -325,36 +327,19 @@ def configure_declared_params
325327
def validates(attrs, validations)
326328
doc = AttributesDoc.new @api, self
327329
doc.extract_details validations
330+
doc.type = infer_coercion_type(validations)
331+
doc.values = extract_value_option(validations[:values])
332+
except_values = extract_value_option(validations[:except_values])
328333

329-
coerce_type = infer_coercion(validations)
330-
331-
doc.type = coerce_type
332-
333-
default = validations[:default]
334-
values = validations[:values].is_a?(Hash) ? validations.dig(:values, :value) : validations[:values]
335-
336-
doc.values = values
337-
338-
except_values = validations[:except_values].is_a?(Hash) ? validations.dig(:except_values, :value) : validations[:except_values]
339-
340-
# NB. values and excepts should be nil, Proc, Array, or Range.
341-
# Specifically, values should NOT be a Hash
342-
343-
# use values or excepts to guess coerce type when stated type is Array
344-
coerce_type = guess_coerce_type(coerce_type, values, except_values)
345-
346-
# default value should be present in values array, if both exist and are not procs
347-
check_incompatible_option_values(default, values, except_values)
348-
349-
# type should be compatible with values array, if both exist
350-
validate_value_coercion(coerce_type, values, except_values)
334+
check_values_coercing!(doc.type, doc.values, except_values)
335+
check_default_inclusion!(validations[:default], doc.values, except_values)
351336

352337
doc.document attrs
353338

354339
opts = derive_validator_options(validations)
355340

356341
# Validate for presence before any other validators
357-
validates_presence(validations, attrs, doc, opts)
342+
validates_presence(validations.delete(:presence), attrs, doc, opts)
358343

359344
# Before we run the rest of the validators, let's handle
360345
# whatever coercion so that we are working with correctly
@@ -382,29 +367,16 @@ def validates(attrs, validations)
382367
# parameter declaration
383368
# @return [class-like] type to which the parameter will be coerced
384369
# @raise [ArgumentError] if the given type options are invalid
385-
def infer_coercion(validations)
386-
raise ArgumentError, ':type may not be supplied with :types' if validations.key?(:type) && validations.key?(:types)
370+
def infer_coercion_type(validations)
371+
coerce_options = validations.extract!(:type, :types)
372+
return if coerce_options.empty?
387373

388-
validations[:coerce] = (options_key?(:type, :value, validations) ? validations[:type][:value] : validations[:type]) if validations.key?(:type)
389-
validations[:coerce_message] = (options_key?(:type, :message, validations) ? validations[:type][:message] : nil) if validations.key?(:type)
390-
validations[:coerce] = (options_key?(:types, :value, validations) ? validations[:types][:value] : validations[:types]) if validations.key?(:types)
391-
validations[:coerce_message] = (options_key?(:types, :message, validations) ? validations[:types][:message] : nil) if validations.key?(:types)
374+
raise ArgumentError, ':type may not be supplied with :types' if coerce_options.size == 2
392375

393-
validations.delete(:types) if validations.key?(:types)
394-
395-
coerce_type = validations[:coerce]
396-
397-
# Special case - when the argument is a single type that is a
398-
# variant-type collection.
399-
if Types.multiple?(coerce_type) && validations.key?(:type)
400-
validations[:coerce] = Types::VariantCollectionCoercer.new(
401-
coerce_type,
402-
validations.delete(:coerce_with)
403-
)
376+
add_validations_coercion_options(coerce_options[:type] || coerce_options[:types], validations).tap do |coerce_type|
377+
# special case of variant-member-type see https://github.com/ruby-grape/grape/tree/master?tab=readme-ov-file#multiple-allowed-types
378+
validations[:coerce_variant_collection] = Types.multiple?(coerce_type) if coerce_options.key?(:type)
404379
end
405-
validations.delete(:type)
406-
407-
coerce_type
408380
end
409381

410382
# Enforce correct usage of :coerce_with parameter.
@@ -418,7 +390,7 @@ def check_coerce_with(validations)
418390

419391
# but not special JSON types, which
420392
# already imply coercion method
421-
return if [JSON, Array[JSON]].exclude? validations[:coerce]
393+
return if Types::DISALLOWED_COERCE_TYPES.exclude? validations[:coerce]
422394

423395
raise ArgumentError, 'coerce_with disallowed for type: JSON'
424396
end
@@ -432,95 +404,83 @@ def check_coerce_with(validations)
432404
def coerce_type(validations, attrs, doc, opts)
433405
check_coerce_with(validations)
434406

435-
return unless validations.key?(:coerce)
407+
coerce_validations_options = validations.extract!(:coerce, :coerce_with, :coerce_message, :coerce_variant_collection)
408+
return unless coerce_validations_options[:coerce]
436409

437410
coerce_options = {
438-
type: validations[:coerce],
439-
method: validations[:coerce_with],
440-
message: validations[:coerce_message]
411+
type: coerce_validations_options[:coerce],
412+
method: coerce_validations_options[:coerce_with],
413+
message: coerce_validations_options[:coerce_message],
414+
variant_collection: coerce_validations_options[:coerce_variant_collection]
441415
}
442-
validate('coerce', coerce_options, attrs, doc, opts)
443-
validations.delete(:coerce_with)
444-
validations.delete(:coerce)
445-
validations.delete(:coerce_message)
446-
end
447416

448-
def guess_coerce_type(coerce_type, *values_list)
449-
return coerce_type unless coerce_type == Array
450-
451-
values_list.each do |values|
452-
next if !values || values.is_a?(Proc)
453-
return values.first.class if values.is_a?(Range) || !values.empty?
454-
end
455-
coerce_type
417+
validate(:coerce, coerce_options, attrs, doc, opts)
456418
end
457419

458-
def check_incompatible_option_values(default, values, except_values)
420+
def check_default_inclusion!(default, values, except_values)
459421
return unless default && !default.is_a?(Proc)
460422

461-
raise Grape::Exceptions::IncompatibleOptionValues.new(:default, default, :values, values) if values && !values.is_a?(Proc) && !Array(default).all? { |def_val| values.include?(def_val) }
423+
raise Grape::Exceptions::IncompatibleOptionValues.new(:default, default, :values, values) if values && !values.is_a?(Proc) && !Array(default).all? { |def_value| values.include?(def_value) }
462424

463-
return unless except_values && !except_values.is_a?(Proc) && Array(default).any? { |def_val| except_values.include?(def_val) }
425+
return unless except_values && !except_values.is_a?(Proc) && Array(default).any? { |def_value| except_values.include?(def_value) }
464426

465427
raise Grape::Exceptions::IncompatibleOptionValues.new(:default, default, :except, except_values)
466428
end
467429

468430
def validate(type, options, attrs, doc, opts)
469-
validator_options = {
470-
attributes: attrs,
471-
options: options,
472-
required: doc.required,
473-
params_scope: self,
474-
opts: opts,
475-
validator_class: Validations.require_validator(type)
476-
}
477-
@api.namespace_stackable(:validations, validator_options)
431+
validator_class = Validations.require_validator(type)
432+
@api.namespace_stackable(:validations, validator_class.new(attrs, options, doc.required, self, opts))
478433
end
479434

480-
def validate_value_coercion(coerce_type, *values_list)
481-
return unless coerce_type
435+
# Validators don't have access to each other and they don't need, however,
436+
# some validators might influence others, so their options should be shared
437+
def derive_validator_options(validations)
438+
{
439+
allow_blank: extract_value_option(validations[:allow_blank]) || false,
440+
fail_fast: validations.delete(:fail_fast) || false
441+
}
442+
end
482443

483-
coerce_type = coerce_type.first if coerce_type.is_a?(Enumerable)
484-
values_list.each do |values|
485-
next if !values || values.is_a?(Proc)
444+
def validates_presence(presence, attrs, doc, opts)
445+
return unless presence
486446

487-
value_types = values.is_a?(Range) ? [values.begin, values.end].compact : values
488-
value_types = value_types.map { |type| Grape::API::Boolean.build(type) } if coerce_type == Grape::API::Boolean
489-
raise Grape::Exceptions::IncompatibleOptionValues.new(:type, coerce_type, :values, values) unless value_types.all?(coerce_type)
490-
end
447+
validate(:presence, presence, attrs, doc, opts)
491448
end
492449

493-
def extract_message_option(attrs)
494-
return nil unless attrs.is_a?(Array)
450+
def extract_value_option(option)
451+
return option unless option.is_a?(Hash)
495452

496-
opts = attrs.last.is_a?(Hash) ? attrs.pop : {}
497-
opts.key?(:message) && !opts[:message].nil? ? opts.delete(:message) : nil
453+
option[:value]
498454
end
499455

500-
def options_key?(type, key, validations)
501-
validations[type].respond_to?(:key?) && validations[type].key?(key) && !validations[type][key].nil?
502-
end
456+
def extract_message_option(option)
457+
return unless option.is_a?(Hash)
503458

504-
def all_element_blank?(scoped_params)
505-
scoped_params.respond_to?(:all?) && scoped_params.all?(&:blank?)
459+
option[:message]
506460
end
507461

508-
# Validators don't have access to each other and they don't need, however,
509-
# some validators might influence others, so their options should be shared
510-
def derive_validator_options(validations)
511-
allow_blank = validations[:allow_blank]
512-
513-
{
514-
allow_blank: allow_blank.is_a?(Hash) ? allow_blank[:value] : allow_blank,
515-
fail_fast: validations.delete(:fail_fast) || false
516-
}
462+
def add_validations_coercion_options(coercer_options, validations)
463+
if coercer_options.is_a?(Hash)
464+
options = coercer_options.extract!(:value, :message)
465+
validations[:coerce_message] = options[:message]
466+
validations[:coerce] = options[:value]
467+
else
468+
validations[:coerce] = coercer_options
469+
end
517470
end
518471

519-
def validates_presence(validations, attrs, doc, opts)
520-
return unless validations.key?(:presence) && validations[:presence]
472+
def check_values_coercing!(type, *values_list)
473+
return unless type && values_list.any? { |v| v.present? && !v.is_a?(Proc) }
474+
475+
coerce_type = type == Array ? values_list.find(&:itself).first.class : type
476+
coerce_type = coerce_type.first if coerce_type.is_a?(Enumerable)
477+
values_list.each do |values|
478+
next if values.blank?
521479

522-
validate('presence', validations.delete(:presence), attrs, doc, opts)
523-
validations.delete(:message) if validations.key?(:message)
480+
value_types = values.is_a?(Range) ? [values.begin, values.end].compact : values
481+
value_types = value_types.map { |type| Grape::API::Boolean.build(type) } if coerce_type == Grape::API::Boolean
482+
raise Grape::Exceptions::IncompatibleOptionValues.new(:type, coerce_type, :values, values) unless value_types.all?(coerce_type)
483+
end
524484
end
525485
end
526486
end

lib/grape/validations/types.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ module Types
4747

4848
GROUPS = [Array, Hash, JSON, Array[JSON]].freeze
4949

50+
DISALLOWED_COERCE_TYPES = [JSON, Array[JSON]].freeze
51+
5052
# Is the given class a primitive type as recognized by Grape?
5153
#
5254
# @param type [Class] type to check

lib/grape/validations/validators/coerce_validator.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ class CoerceValidator < Base
77
def initialize(attrs, options, required, scope, opts)
88
super
99

10-
@converter = if type.is_a?(Grape::Validations::Types::VariantCollectionCoercer)
11-
type
10+
@converter = if @option[:variant_collection]
11+
Types::VariantCollectionCoercer.new(type, @option[:method])
1212
else
1313
Types.build_coercer(type, method: @option[:method])
1414
end

0 commit comments

Comments
 (0)