Skip to content

Commit 5d8d929

Browse files
committed
Custom parameter coercion with coerce_using
Addresses issue #1135. Adds `coerce_using` option to `Grape::DSL::Parameters::requires` and `::optional`, allowing implementors arbitrary coercion uncoupled from the parameter type. See README.md#custom-types-and-coercions for usage.
1 parent 790f2de commit 5d8d929

File tree

7 files changed

+147
-16
lines changed

7 files changed

+147
-16
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+
* [#1161](https://github.com/ruby-grape/grape/pull/1161): Custom parameter coercion with `coerce_using` - [@dslh](https://github.com/dslh).
89
* [#1134](https://github.com/ruby-grape/grape/pull/1134): Adds a code of conduct - [@towanda](https://github.com/towanda).
910

1011
#### Fixes

README.md

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
- [Include Missing](#include-missing)
3131
- [Parameter Validation and Coercion](#parameter-validation-and-coercion)
3232
- [Supported Parameter Types](#supported-parameter-types)
33-
- [Custom Types](#custom-types)
33+
- [Custom Types and Coercions](#custom-types-and-coercions)
3434
- [Validation of Nested Parameters](#validation-of-nested-parameters)
3535
- [Dependent Parameters](#dependent-parameters)
3636
- [Built-in Validators](#built-in-validators)
@@ -733,12 +733,13 @@ The following are all valid types, supported out of the box by Grape:
733733
* Symbol
734734
* Rack::Multipart::UploadedFile
735735

736-
### Custom Types
736+
### Custom Types and Coercions
737737

738738
Aside from the default set of supported types listed above, any class can be
739-
used as a type so long as it defines a class-level `parse` method. This method
740-
must take one string argument and return an instance of the correct type, or
741-
raise an exception to indicate the value was invalid. E.g.,
739+
used as a type so long as an explicit coercion method is supplied. If the type
740+
implements a class-level `parse` method, Grape will use it automatically.
741+
This method must take one string argument and return an instance of the correct
742+
type, or raise an exception to indicate the value was invalid. E.g.,
742743

743744
```ruby
744745
class Color
@@ -765,6 +766,23 @@ get '/stuff' do
765766
end
766767
```
767768

769+
Alternatively, a custom coercion method may be supplied for any type of parameter
770+
with `coerce_using`. Any class or object may be given that implements a `parse` or
771+
`call` method, in that order of precedence. The method must accept a single string
772+
parameter, and the return value must match the given `type`.
773+
774+
```ruby
775+
params do
776+
requires :passwd, type: String, coerce_using: Base64.method(:decode)
777+
requires :loud_color, type: Color, coerce_using: ->(c) { Color.parse(c.downcase) }
778+
779+
requires :obj, type: Hash, coerce_using: JSON do
780+
requires :words, type: Array[String], coerce_using: ->(val) { val.split(/\s+/) }
781+
optional :time, type: Time, coerce_using: Chronic
782+
end
783+
end
784+
```
785+
768786
### Validation of Nested Parameters
769787

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

lib/grape/dsl/parameters.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ def use(*names)
6262
# the :using Hash. The meaning of this depends on if :all or :none was
6363
# passed; :all + :except will make the :except fields optional, whereas
6464
# :none + :except will make the :except fields required
65+
# @option attrs :coerce_using [#parse, #call] method to use when coercing
66+
# the parameter to the type named by +attrs[:type]. Any class or object
67+
# that defines `::parse` or `::call` may be used.
6568
#
6669
# @example
6770
#

lib/grape/validations/params_scope.rb

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,11 @@ def validates(attrs, validations)
180180
# special case (type = coerce)
181181
validations[:coerce] = validations.delete(:type) if validations.key?(:type)
182182

183+
# type must be supplied for coerce_using
184+
if validations.key?(:coerce_using) && !validations.key?(:coerce)
185+
fail ArgumentError, 'must supply type for coerce_using'
186+
end
187+
183188
coerce_type = validations[:coerce]
184189

185190
doc_attrs[:type] = coerce_type.to_s if coerce_type
@@ -216,7 +221,12 @@ def validates(attrs, validations)
216221
# whatever coercion so that we are working with correctly
217222
# type casted values
218223
if validations.key? :coerce
219-
validate('coerce', validations[:coerce], attrs, doc_attrs)
224+
coerce_options = {
225+
type: validations[:coerce],
226+
method: validations[:coerce_using]
227+
}
228+
validate('coerce', coerce_options, attrs, doc_attrs)
229+
validations.delete(:coerce_using)
220230
validations.delete(:coerce)
221231
end
222232

lib/grape/validations/validators/base.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ module Validations
33
class Base
44
attr_reader :attrs
55

6+
# Creates a new Validator from options specified
7+
# by a +requires+ or +optional+ directive during
8+
# parameter definition.
9+
# @param attrs [Array] names of attributes to which the Validator applies
10+
# @param options [Object] implementation-dependent Validator options
11+
# @param required [Boolean] attribute(s) are required or optional
12+
# @param scope [ParamsScope] parent scope for this Validator
613
def initialize(attrs, options, required, scope)
714
@attrs = Array(attrs)
815
@option = options

lib/grape/validations/validators/coerce.rb

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,20 @@ def _valid_single_type?(klass, val)
4343
def valid_type?(val)
4444
if val.instance_of?(InvalidValue)
4545
false
46-
elsif @option.is_a?(Array) || @option.is_a?(Set)
47-
_valid_array_type?(@option.first, val)
46+
elsif type.is_a?(Array) || type.is_a?(Set)
47+
_valid_array_type?(type.first, val)
4848
else
49-
_valid_single_type?(@option, val)
49+
_valid_single_type?(type, val)
5050
end
5151
end
5252

5353
def coerce_value(val)
5454
# Don't coerce things other than nil to Arrays or Hashes
55-
return val || [] if type == Array
56-
return val || Set.new if type == Set
57-
return val || {} if type == Hash
55+
unless @option[:method] && !val.nil?
56+
return val || [] if type == Array
57+
return val || Set.new if type == Set
58+
return val || {} if type == Hash
59+
end
5860

5961
converter.coerce(val)
6062

@@ -65,18 +67,50 @@ def coerce_value(val)
6567
end
6668

6769
def type
68-
@option
70+
@option[:type]
6971
end
7072

7173
def converter
7274
@converter ||=
7375
begin
74-
# To support custom types that Virtus can't easily coerce, pass in an
75-
# explicit coercer. Custom types must implement a `parse` class method.
76+
# If any custom conversion method has been supplied
77+
# via the coerce_using parameter, pass it on to Virtus.
7678
converter_options = {}
77-
if ParameterTypes.custom_type?(type)
79+
if @option[:method]
80+
# Accept classes implementing parse()
81+
coercer = if @option[:method].respond_to? :parse
82+
@option[:method].method(:parse)
83+
else
84+
# Otherwise expect a lambda function or similar
85+
@option[:method]
86+
end
87+
88+
# Enforce symbolized keys for complex types
89+
# by wrapping the coercion method.
90+
# This helps common libs such as JSON to work easily.
91+
if type == Array || type == Set
92+
converter_options[:coercer] = lambda do |val|
93+
coercer.call(val).tap do |new_value|
94+
new_value.each do |item|
95+
Hashie.symbolize_keys!(item) if item.is_a? Hash
96+
end
97+
end
98+
end
99+
elsif type == Hash
100+
converter_options[:coercer] = lambda do |val|
101+
Hashie.symbolize_keys! coercer.call(val)
102+
end
103+
else
104+
# Simple types do not need a wrapper
105+
converter_options[:coercer] = coercer
106+
end
107+
108+
# Custom types may be used without an explicit coercion method
109+
# if they implement a `parse` class method.
110+
elsif ParameterTypes.custom_type?(type)
78111
converter_options[:coercer] = type.method(:parse)
79112
end
113+
80114
Virtus::Attribute.build(type, converter_options)
81115
end
82116
end

spec/grape/validations/validators/coerce_spec.rb

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,64 @@ class User
253253
end
254254
end
255255

256+
context 'with coerce_using' do
257+
it 'uses parse where available' do
258+
subject.params do
259+
requires :ints, type: Array, coerce_using: JSON do
260+
requires :i, type: Integer
261+
requires :j
262+
end
263+
end
264+
subject.get '/ints' do
265+
ints = params[:ints].first
266+
'coercion works' if ints[:i] == 1 && ints[:j] == '2'
267+
end
268+
269+
get '/ints', ints: [{ i: 1, j: '2' }]
270+
expect(last_response.status).to eq(400)
271+
expect(last_response.body).to eq('ints is invalid')
272+
273+
get '/ints', ints: '{"i":1,"j":"2"}'
274+
expect(last_response.status).to eq(400)
275+
expect(last_response.body).to eq('ints[i] is missing, ints[i] is invalid, ints[j] is missing')
276+
277+
get '/ints', ints: '[{"i":"1","j":"2"}]'
278+
expect(last_response.status).to eq(200)
279+
expect(last_response.body).to eq('coercion works')
280+
end
281+
282+
it 'accepts any callable' do
283+
subject.params do
284+
requires :ints, type: Hash, coerce_using: JSON.method(:parse) do
285+
requires :int, type: Integer, coerce_using: ->(val) { val == 'three' ? 3 : val }
286+
end
287+
end
288+
subject.get '/ints' do
289+
params[:ints][:int]
290+
end
291+
292+
get '/ints', ints: '{"int":"3"}'
293+
expect(last_response.status).to eq(400)
294+
expect(last_response.body).to eq('ints[int] is invalid')
295+
296+
get '/ints', ints: '{"int":"three"}'
297+
expect(last_response.status).to eq(200)
298+
expect(last_response.body).to eq('3')
299+
300+
get '/ints', ints: '{"int":3}'
301+
expect(last_response.status).to eq(200)
302+
expect(last_response.body).to eq('3')
303+
end
304+
305+
it 'must be supplied with :type or :coerce' do
306+
expect do
307+
subject.params do
308+
requires :ints, coerce_using: JSON
309+
end
310+
end.to raise_error(ArgumentError)
311+
end
312+
end
313+
256314
context 'converter' do
257315
it 'does not build Virtus::Attribute multiple times' do
258316
subject.params do

0 commit comments

Comments
 (0)