Skip to content

Commit 5dd46f9

Browse files
committed
Revisit coerce and validators
1 parent 3559681 commit 5dd46f9

File tree

11 files changed

+257
-258
lines changed

11 files changed

+257
-258
lines changed

lib/grape/dsl/parameters.rb

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

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

135135
if opts[:using]
@@ -177,25 +177,25 @@ def with(*attrs, &block)
177177
# Disallow the given parameters to be present in the same request.
178178
# @param attrs [*Symbol] parameters to validate
179179
def mutually_exclusive(*attrs)
180-
validates(attrs, mutual_exclusion: { value: true, message: extract_message_option(attrs) })
180+
validates(attrs, mutual_exclusion: extract_options_with_value(attrs))
181181
end
182182

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

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

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

201201
# Define a block of validations which should be applied if and only if
@@ -261,6 +261,10 @@ def params(params)
261261
def first_hash_key_or_param(parameter)
262262
parameter.is_a?(Hash) ? parameter.keys.first : parameter
263263
end
264+
265+
def extract_options_with_value(attrs)
266+
attrs.extract_options!.merge!(value: true)
267+
end
264268
end
265269
end
266270
end

lib/grape/endpoint.rb

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -351,12 +351,10 @@ def finallies
351351
namespace_stackable(:finallies)
352352
end
353353

354-
def validations
355-
return enum_for(:validations) unless block_given?
354+
def validations(&block)
355+
return enum_for(:validations) unless block
356356

357-
route_setting(:saved_validations)&.each do |saved_validation|
358-
yield Grape::Validations::ValidatorFactory.create_validator(saved_validation)
359-
end
357+
route_setting(:saved_validations)&.each(&block)
360358
end
361359

362360
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: 68 additions & 119 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
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

@@ -84,7 +86,7 @@ def configuration
8486
def should_validate?(parameters)
8587
scoped_params = params(parameters)
8688

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

@@ -320,43 +322,19 @@ def configure_declared_params
320322
def validates(attrs, validations)
321323
doc = AttributesDoc.new @api, self
322324
doc.extract_details validations
325+
doc.type = infer_coercion_type(validations)
326+
doc.values = extract_value_option(validations[:values])
327+
except_values = extract_value_option(validations[:except_values])
323328

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

354332
doc.document attrs
355333

356334
opts = derive_validator_options(validations)
357335

358336
# Validate for presence before any other validators
359-
validates_presence(validations, attrs, doc, opts)
337+
validates_presence(validations.delete(:presence), attrs, doc, opts)
360338

361339
# Before we run the rest of the validators, let's handle
362340
# whatever coercion so that we are working with correctly
@@ -384,29 +362,16 @@ def validates(attrs, validations)
384362
# parameter declaration
385363
# @return [class-like] type to which the parameter will be coerced
386364
# @raise [ArgumentError] if the given type options are invalid
387-
def infer_coercion(validations)
388-
raise ArgumentError, ':type may not be supplied with :types' if validations.key?(:type) && validations.key?(:types)
389-
390-
validations[:coerce] = (options_key?(:type, :value, validations) ? validations[:type][:value] : validations[:type]) if validations.key?(:type)
391-
validations[:coerce_message] = (options_key?(:type, :message, validations) ? validations[:type][:message] : nil) if validations.key?(:type)
392-
validations[:coerce] = (options_key?(:types, :value, validations) ? validations[:types][:value] : validations[:types]) if validations.key?(:types)
393-
validations[:coerce_message] = (options_key?(:types, :message, validations) ? validations[:types][:message] : nil) if validations.key?(:types)
394-
395-
validations.delete(:types) if validations.key?(:types)
365+
def infer_coercion_type(validations)
366+
coerce_options = validations.extract!(:type, :types)
367+
return if coerce_options.empty?
396368

397-
coerce_type = validations[:coerce]
369+
raise ArgumentError, ':type may not be supplied with :types' if coerce_options.size == 2
398370

399-
# Special case - when the argument is a single type that is a
400-
# variant-type collection.
401-
if Types.multiple?(coerce_type) && validations.key?(:type)
402-
validations[:coerce] = Types::VariantCollectionCoercer.new(
403-
coerce_type,
404-
validations.delete(:coerce_with)
405-
)
371+
add_validations_coercion_options(coerce_options[:type] || coerce_options[:types], validations).tap do |coerce_type|
372+
# special case of variant-member-type see https://github.com/ruby-grape/grape/tree/master?tab=readme-ov-file#multiple-allowed-types
373+
validations[:coerce_variant_collection] = Types.multiple?(coerce_type) if coerce_options.key?(:type)
406374
end
407-
validations.delete(:type)
408-
409-
coerce_type
410375
end
411376

412377
# Enforce correct usage of :coerce_with parameter.
@@ -420,7 +385,7 @@ def check_coerce_with(validations)
420385

421386
# but not special JSON types, which
422387
# already imply coercion method
423-
return if [JSON, Array[JSON]].exclude? validations[:coerce]
388+
return if Types::DISALLOWED_COERCE_TYPES.exclude? validations[:coerce]
424389

425390
raise ArgumentError, 'coerce_with disallowed for type: JSON'
426391
end
@@ -434,99 +399,83 @@ def check_coerce_with(validations)
434399
def coerce_type(validations, attrs, doc, opts)
435400
check_coerce_with(validations)
436401

437-
return unless validations.key?(:coerce)
402+
coerce_validations_options = validations.extract!(:coerce, :coerce_with, :coerce_message, :coerce_variant_collection)
403+
return unless coerce_validations_options[:coerce]
438404

439405
coerce_options = {
440-
type: validations[:coerce],
441-
method: validations[:coerce_with],
442-
message: validations[:coerce_message]
406+
type: coerce_validations_options[:coerce],
407+
method: coerce_validations_options[:coerce_with],
408+
message: coerce_validations_options[:coerce_message],
409+
variant_collection: coerce_validations_options[:coerce_variant_collection]
443410
}
444-
validate('coerce', coerce_options, attrs, doc, opts)
445-
validations.delete(:coerce_with)
446-
validations.delete(:coerce)
447-
validations.delete(:coerce_message)
448-
end
449-
450-
def guess_coerce_type(coerce_type, *values_list)
451-
return coerce_type unless coerce_type == Array
452411

453-
values_list.each do |values|
454-
next if !values || values.is_a?(Proc)
455-
return values.first.class if values.is_a?(Range) || !values.empty?
456-
end
457-
coerce_type
412+
validate(:coerce, coerce_options, attrs, doc, opts)
458413
end
459414

460-
def check_incompatible_option_values(default, values, except_values, excepts)
415+
def check_default_inclusion!(default, values, except_values)
461416
return unless default && !default.is_a?(Proc)
462417

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

465-
if except_values && !except_values.is_a?(Proc) && Array(default).any? { |def_val| except_values.include?(def_val) }
466-
raise Grape::Exceptions::IncompatibleOptionValues.new(:default, default, :except, except_values)
467-
end
420+
return unless except_values && !except_values.is_a?(Proc) && Array(default).any? { |def_value| except_values.include?(def_value) }
468421

469-
return unless excepts && !excepts.is_a?(Proc)
470-
raise Grape::Exceptions::IncompatibleOptionValues.new(:default, default, :except, excepts) \
471-
unless Array(default).none? { |def_val| excepts.include?(def_val) }
422+
raise Grape::Exceptions::IncompatibleOptionValues.new(:default, default, :except, except_values)
472423
end
473424

474425
def validate(type, options, attrs, doc, opts)
475-
validator_options = {
476-
attributes: attrs,
477-
options: options,
478-
required: doc.required,
479-
params_scope: self,
480-
opts: opts,
481-
validator_class: Validations.require_validator(type)
482-
}
483-
@api.namespace_stackable(:validations, validator_options)
426+
validator_class = Validations.require_validator(type)
427+
@api.namespace_stackable(:validations, validator_class.new(attrs, options, doc.required, self, opts))
484428
end
485429

486-
def validate_value_coercion(coerce_type, *values_list)
487-
return unless coerce_type
430+
# Validators don't have access to each other and they don't need, however,
431+
# some validators might influence others, so their options should be shared
432+
def derive_validator_options(validations)
433+
{
434+
allow_blank: extract_value_option(validations[:allow_blank]) || false,
435+
fail_fast: validations.delete(:fail_fast) || false
436+
}
437+
end
488438

489-
coerce_type = coerce_type.first if coerce_type.is_a?(Enumerable)
490-
values_list.each do |values|
491-
next if !values || values.is_a?(Proc)
439+
def validates_presence(presence, attrs, doc, opts)
440+
return unless presence
492441

493-
value_types = values.is_a?(Range) ? [values.begin, values.end].compact : values
494-
value_types = value_types.map { |type| Grape::API::Boolean.build(type) } if coerce_type == Grape::API::Boolean
495-
raise Grape::Exceptions::IncompatibleOptionValues.new(:type, coerce_type, :values, values) unless value_types.all?(coerce_type)
496-
end
442+
validate(:presence, presence, attrs, doc, opts)
497443
end
498444

499-
def extract_message_option(attrs)
500-
return nil unless attrs.is_a?(Array)
445+
def extract_value_option(option)
446+
return option unless option.is_a?(Hash)
501447

502-
opts = attrs.last.is_a?(Hash) ? attrs.pop : {}
503-
opts.key?(:message) && !opts[:message].nil? ? opts.delete(:message) : nil
448+
option[:value]
504449
end
505450

506-
def options_key?(type, key, validations)
507-
validations[type].respond_to?(:key?) && validations[type].key?(key) && !validations[type][key].nil?
508-
end
451+
def extract_message_option(option)
452+
return unless option.is_a?(Hash)
509453

510-
def all_element_blank?(scoped_params)
511-
scoped_params.respond_to?(:all?) && scoped_params.all?(&:blank?)
454+
option[:message]
512455
end
513456

514-
# Validators don't have access to each other and they don't need, however,
515-
# some validators might influence others, so their options should be shared
516-
def derive_validator_options(validations)
517-
allow_blank = validations[:allow_blank]
518-
519-
{
520-
allow_blank: allow_blank.is_a?(Hash) ? allow_blank[:value] : allow_blank,
521-
fail_fast: validations.delete(:fail_fast) || false
522-
}
457+
def add_validations_coercion_options(coercer_options, validations)
458+
if coercer_options.is_a?(Hash)
459+
options = coercer_options.extract!(:value, :message)
460+
validations[:coerce_message] = options[:message]
461+
validations[:coerce] = options[:value]
462+
else
463+
validations[:coerce] = coercer_options
464+
end
523465
end
524466

525-
def validates_presence(validations, attrs, doc, opts)
526-
return unless validations.key?(:presence) && validations[:presence]
467+
def check_values_coercing!(type, *values_list)
468+
return unless type && values_list.any? { |v| v.present? && !v.is_a?(Proc) }
527469

528-
validate('presence', validations.delete(:presence), attrs, doc, opts)
529-
validations.delete(:message) if validations.key?(:message)
470+
coerce_type = type == Array ? values_list.find(&:itself).first.class : type
471+
coerce_type = coerce_type.first if coerce_type.is_a?(Enumerable)
472+
values_list.each do |values|
473+
next if values.blank?
474+
475+
value_types = values.is_a?(Range) ? [values.begin, values.end].compact : values
476+
value_types = value_types.map { |type| Grape::API::Boolean.build(type) } if coerce_type == Grape::API::Boolean
477+
raise Grape::Exceptions::IncompatibleOptionValues.new(:type, coerce_type, :values, values) unless value_types.all?(coerce_type)
478+
end
530479
end
531480
end
532481
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)