Skip to content

Commit d721c5d

Browse files
committed
Merge pull request #1188 from dslh/multiple_allowed_types
Allow parameters with more than one type.
2 parents 9c8713d + 3195ad2 commit d721c5d

File tree

10 files changed

+416
-31
lines changed

10 files changed

+416
-31
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
* Your contribution here.
77

8+
* [#1188](https://github.com/ruby-grape/grape/putt/1188): Allow parameters with more than one type - [@dslh](https://github.com/dslh).
89
* [#1179](https://github.com/ruby-grape/grape/pull/1179): Allow all RFC6838 valid characters in header vendor - [@suan](https://github.com/suan).
910
* [#1170](https://github.com/ruby-grape/grape/pull/1170): Allow dashes and periods in header vendor - [@suan](https://github.com/suan).
1011
* [#1167](https://github.com/ruby-grape/grape/pull/1167): Convenience wrapper `type: File` for validating multipart file parameters - [@dslh](https://github.com/dslh).

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
- [Custom Types and Coercions](#custom-types-and-coercions)
3434
- [Multipart File Parameters](#multipart-file-parameters)
3535
- [First-Class `JSON` Types](#first-class-json-types)
36+
- [Multiple Allowed Types](#multiple-allowed-types)
3637
- [Validation of Nested Parameters](#validation-of-nested-parameters)
3738
- [Dependent Parameters](#dependent-parameters)
3839
- [Built-in Validators](#built-in-validators)
@@ -852,6 +853,41 @@ end
852853
For stricter control over the type of JSON structure which may be supplied,
853854
use `type: Array, coerce_with: JSON` or `type: Hash, coerce_with: JSON`.
854855

856+
### Multiple Allowed Types
857+
858+
Variant-type parameters can be declared using the `types` option rather than `type`:
859+
860+
```ruby
861+
params do
862+
requires :status_code, types: [Integer, String, Array[Integer, String]]
863+
end
864+
get '/' do
865+
params[:status_code].inspect
866+
end
867+
868+
# ...
869+
870+
client.get('/', status_code: 'OK_GOOD') # => "OK_GOOD"
871+
client.get('/', status_code: 300) # => 300
872+
client.get('/', status_code: %w(404 NOT FOUND)) # => [404, "NOT", "FOUND"]
873+
```
874+
875+
As a special case, variant-member-type collections may also be declared, by
876+
passing a `Set` or `Array` with more than one member to `type`:
877+
878+
```ruby
879+
params do
880+
requires :status_codes, type: Array[Integer,String]
881+
end
882+
get '/' do
883+
params[:status_codes].inspect
884+
end
885+
886+
# ...
887+
888+
client.get('/', status_codes: %w(1 two)) # => [1, "two"]
889+
```
890+
855891
### Validation of Nested Parameters
856892

857893
Parameters can be nested using `group` or by calling `requires` or `optional` with a block.

lib/grape/dsl/parameters.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ def use(*names)
5555
# with the `:coerce_with` parameter. `JSON` may be supplied to denote
5656
# `JSON`-formatted objects or arrays of objects. `Array[JSON]` accepts
5757
# the same values as `JSON` but will wrap single objects in an `Array`.
58+
# @option attrs :types [Array<Class>] may be supplied in place of +:type+
59+
# to declare an attribute that has multiple allowed types. See
60+
# {Validations::Types::MultipleTypeCoercer} for more details on coercion
61+
# and validation rules for variant-type parameters.
5862
# @option attrs :desc [String] description to document this parameter
5963
# @option attrs :default [Object] default value, if parameter is optional
6064
# @option attrs :values [Array] permissable values for this field. If any
@@ -158,7 +162,7 @@ def all_or_none_of(*attrs)
158162
# the given parameter is present. The parameters are not nested.
159163
# @param attr [Symbol] the parameter which, if present, triggers the
160164
# validations
161-
# @throws Grape::Exceptions::UnknownParameter if `attr` has not been
165+
# @raise Grape::Exceptions::UnknownParameter if `attr` has not been
162166
# defined in this scope yet
163167
# @yield a parameter definition DSL
164168
def given(attr, &block)
@@ -168,7 +172,7 @@ def given(attr, &block)
168172

169173
# Test for whether a certain parameter has been defined in this params
170174
# block yet.
171-
# @returns [Boolean] whether the parameter has been defined
175+
# @return [Boolean] whether the parameter has been defined
172176
def declared_param?(param)
173177
# @declared_params also includes hashes of options and such, but those
174178
# won't be flattened out.

lib/grape/validations/params_scope.rb

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,10 +177,7 @@ def configure_declared_params
177177
def validates(attrs, validations)
178178
doc_attrs = { required: validations.keys.include?(:presence) }
179179

180-
# special case (type = coerce)
181-
validations[:coerce] = validations.delete(:type) if validations.key?(:type)
182-
183-
coerce_type = validations[:coerce]
180+
coerce_type = infer_coercion(validations)
184181

185182
doc_attrs[:type] = coerce_type.to_s if coerce_type
186183

@@ -212,7 +209,7 @@ def validates(attrs, validations)
212209
validations.delete(:presence)
213210
end
214211

215-
# Before we run the rest of the validators, lets handle
212+
# Before we run the rest of the validators, let's handle
216213
# whatever coercion so that we are working with correctly
217214
# type casted values
218215
coerce_type validations, attrs, doc_attrs
@@ -222,6 +219,42 @@ def validates(attrs, validations)
222219
end
223220
end
224221

222+
# Validate and comprehend the +:type+, +:types+, and +:coerce_with+
223+
# options that have been supplied to the parameter declaration.
224+
# The +:type+ and +:types+ options will be removed from the
225+
# validations list, replaced appropriately with +:coerce+ and
226+
# +:coerce_with+ options that will later be passed to
227+
# {Validators::CoerceValidator}. The type that is returned may be
228+
# used for documentation and further validation of parameter
229+
# options.
230+
#
231+
# @param validations [Hash] list of validations supplied to the
232+
# parameter declaration
233+
# @return [class-like] type to which the parameter will be coerced
234+
# @raise [ArgumentError] if the given type options are invalid
235+
def infer_coercion(validations)
236+
if validations.key?(:type) && validations.key?(:types)
237+
fail ArgumentError, ':type may not be supplied with :types'
238+
end
239+
240+
validations[:coerce] = validations[:type] if validations.key?(:type)
241+
validations[:coerce] = validations.delete(:types) if validations.key?(:types)
242+
243+
coerce_type = validations[:coerce]
244+
245+
# Special case - when the argument is a single type that is a
246+
# variant-type collection.
247+
if Types.multiple?(coerce_type) && validations.key?(:type)
248+
validations[:coerce] = Types::VariantCollectionCoercer.new(
249+
coerce_type,
250+
validations.delete(:coerce_with)
251+
)
252+
end
253+
validations.delete(:type)
254+
255+
coerce_type
256+
end
257+
225258
# Enforce correct usage of :coerce_with parameter.
226259
# We do not allow coercion without a type, nor with
227260
# +JSON+ as a type since this defines its own coercion

lib/grape/validations/types.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
require_relative 'types/build_coercer'
22
require_relative 'types/custom_type_coercer'
3+
require_relative 'types/multiple_type_coercer'
4+
require_relative 'types/variant_collection_coercer'
35
require_relative 'types/json'
46
require_relative 'types/file'
57

@@ -20,6 +22,10 @@ module Validations
2022
# and {Grape::Dsl::Parameters#optional}. The main
2123
# entry point for this process is {Types.build_coercer}.
2224
module Types
25+
# Instances of this class may be used as tokens to denote that
26+
# a parameter value could not be coerced.
27+
class InvalidValue; end
28+
2329
# Types representing a single value, which are coerced through Virtus
2430
# or special logic in Grape.
2531
PRIMITIVES = [
@@ -78,6 +84,18 @@ def self.structure?(type)
7884
STRUCTURES.include?(type)
7985
end
8086

87+
# Is the declared type in fact an array of multiple allowed types?
88+
# For example the declaration +types: [Integer,String]+ will attempt
89+
# first to coerce given values to integer, but will also accept any
90+
# other string.
91+
#
92+
# @param type [Array<Class>,Set<Class>] type (or type list!) to check
93+
# @return [Boolean] +true+ if the given value will be treated as
94+
# a list of types.
95+
def self.multiple?(type)
96+
(type.is_a?(Array) || type.is_a?(Set)) && type.size > 1
97+
end
98+
8199
# Does the given class implement a type system that Grape
82100
# (i.e. the underlying virtus attribute system) supports
83101
# out-of-the-box? Currently supported are +axiom-types+
@@ -115,6 +133,7 @@ def self.special?(type)
115133
def self.custom?(type)
116134
!primitive?(type) &&
117135
!structure?(type) &&
136+
!multiple?(type) &&
118137
!recognized?(type) &&
119138
!special?(type) &&
120139
type.respond_to?(:parse) &&

lib/grape/validations/types/build_coercer.rb

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,30 +20,33 @@ module Types
2020
# @return [Virtus::Attribute] object to be used
2121
# for coercion and type validation
2222
def self.build_coercer(type, method = nil)
23-
if type.is_a? Virtus::Attribute
24-
# Accept pre-rolled virtus attributes without interference
25-
type
26-
else
27-
converter_options = {
28-
nullify_blank: true
29-
}
30-
conversion_type = type
23+
# Accept pre-rolled virtus attributes without interference
24+
return type if type.is_a? Virtus::Attribute
3125

32-
# Use a special coercer for custom types and coercion methods.
33-
if method || Types.custom?(type)
34-
converter_options[:coercer] = Types::CustomTypeCoercer.new(type, method)
26+
converter_options = {
27+
nullify_blank: true
28+
}
29+
conversion_type = type
3530

36-
# Grape swaps in its own Virtus::Attribute implementations
37-
# for certain special types that merit first-class support
38-
# (but not if a custom coercion method has been supplied).
39-
elsif Types.special?(type)
40-
conversion_type = Types::SPECIAL[type]
41-
end
31+
# Use a special coercer for multiply-typed parameters.
32+
if Types.multiple?(type)
33+
converter_options[:coercer] = Types::MultipleTypeCoercer.new(type, method)
34+
conversion_type = Object
4235

43-
# Virtus will infer coercion and validation rules
44-
# for many common ruby types.
45-
Virtus::Attribute.build(conversion_type, converter_options)
36+
# Use a special coercer for custom types and coercion methods.
37+
elsif method || Types.custom?(type)
38+
converter_options[:coercer] = Types::CustomTypeCoercer.new(type, method)
39+
40+
# Grape swaps in its own Virtus::Attribute implementations
41+
# for certain special types that merit first-class support
42+
# (but not if a custom coercion method has been supplied).
43+
elsif Types.special?(type)
44+
conversion_type = Types::SPECIAL[type]
4645
end
46+
47+
# Virtus will infer coercion and validation rules
48+
# for many common ruby types.
49+
Virtus::Attribute.build(conversion_type, converter_options)
4750
end
4851
end
4952
end
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
module Grape
2+
module Validations
3+
module Types
4+
# This class is intended for use with Grape endpoint parameters that
5+
# have been declared to be of variant-type using the +:types+ option.
6+
# +MultipleTypeCoercer+ will build a coercer for each type declared
7+
# in the array passed to +:types+ using {Types.build_coercer}. It will
8+
# apply these coercers to parameter values in the order given to
9+
# +:types+, and will return the value returned by the first coercer
10+
# to successfully coerce the parameter value. Therefore if +String+ is
11+
# an allowed type it should be declared last, since it will always
12+
# successfully "coerce" the value.
13+
class MultipleTypeCoercer
14+
# Construct a new coercer that will attempt to coerce
15+
# values to the given list of types in the given order.
16+
#
17+
# @param types [Array<Class>] list of allowed types
18+
# @param method [#call,#parse] method by which values should be
19+
# coerced. See class docs for default behaviour.
20+
def initialize(types, method = nil)
21+
@method = method.respond_to?(:parse) ? method.method(:parse) : method
22+
23+
@type_coercers = types.map do |type|
24+
if Types.multiple? type
25+
VariantCollectionCoercer.new type
26+
else
27+
Types.build_coercer type
28+
end
29+
end
30+
end
31+
32+
# This method is called from somewhere within
33+
# +Virtus::Attribute::coerce+ in order to coerce
34+
# the given value.
35+
#
36+
# @param value [String] value to be coerced, in grape
37+
# this should always be a string.
38+
# @return [Object,InvalidValue] the coerced result, or an instance
39+
# of {InvalidValue} if the value could not be coerced.
40+
def call(value)
41+
return @method.call(value) if @method
42+
43+
@type_coercers.each do |coercer|
44+
coerced = coercer.coerce(value)
45+
46+
return coerced if coercer.value_coerced? coerced
47+
end
48+
49+
# Declare that we couldn't coerce the value in such a way
50+
# that Grape won't ask us again if the value is valid
51+
InvalidValue.new
52+
end
53+
54+
# This method is called from somewhere within
55+
# +Virtus::Attribute::value_coerced?+ in order to
56+
# assert that the value has been coerced successfully.
57+
# Due to Grape's design this will in fact only be called
58+
# if a custom coercion method is being used, since {#call}
59+
# returns an {InvalidValue} object if the value could not
60+
# be coerced.
61+
#
62+
# @param _primitive [Axiom::Types::Type] primitive type
63+
# for the coercion as detected by axiom-types' inference
64+
# system. For custom types this is typically not much use
65+
# (i.e. it is +Axiom::Types::Object+) unless special
66+
# inference rules have been declared for the type.
67+
# @param value [Object] a coerced result returned from {#call}
68+
# @return [true,false] whether or not the coerced value
69+
# satisfies type requirements.
70+
def success?(_primitive, value)
71+
@type_coercers.any? { |coercer| coercer.value_coerced? value }
72+
end
73+
end
74+
end
75+
end
76+
end
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
module Grape
2+
module Validations
3+
module Types
4+
# This class wraps {MultipleTypeCoercer}, for use with collections
5+
# that allow members of more than one type.
6+
class VariantCollectionCoercer < Virtus::Attribute
7+
# Construct a new coercer that will attempt to coerce
8+
# a list of values such that all members are of one of
9+
# the given types. The container may also optionally be
10+
# coerced to a +Set+. An arbitrary coercion +method+ may
11+
# be supplied, which will be passed the entire collection
12+
# as a parameter and should return a new collection, or
13+
# may return the same one if no coercion was required.
14+
#
15+
# @param types [Array<Class>,Set<Class>] list of allowed types,
16+
# also specifying the container type
17+
# @param method [#call,#parse] method by which values should be coerced
18+
def initialize(types, method = nil)
19+
@types = types
20+
@method = method.respond_to?(:parse) ? method.method(:parse) : method
21+
22+
# If we have a coercion method, pass it in here to save
23+
# building another one, even though we call it directly.
24+
@member_coercer = MultipleTypeCoercer.new types, method
25+
end
26+
27+
# Coerce the given value.
28+
#
29+
# @param value [Array<String>] collection of values to be coerced
30+
# @return [Array<Object>,Set<Object>,InvalidValue]
31+
# the coerced result, or an instance
32+
# of {InvalidValue} if the value could not be coerced.
33+
def coerce(value)
34+
return InvalidValue.new unless value.is_a? Array
35+
36+
value =
37+
if @method
38+
@method.call(value)
39+
else
40+
value.map { |v| @member_coercer.call(v) }
41+
end
42+
return Set.new value if @types.is_a? Set
43+
44+
value
45+
end
46+
47+
# Assert that the value has been coerced successfully.
48+
#
49+
# @param value [Object] a coerced result returned from {#coerce}
50+
# @return [true,false] whether or not the coerced value
51+
# satisfies type requirements.
52+
def value_coerced?(value)
53+
value.is_a?(@types.class) &&
54+
value.all? { |v| @member_coercer.success?(@types, v) }
55+
end
56+
end
57+
end
58+
end
59+
end

0 commit comments

Comments
 (0)