Skip to content

Commit b559a5a

Browse files
committed
Merge pull request #1163 from dslh/first_class_json
First-class JSON parameter type #1135
2 parents 7722cd1 + 80a46ba commit b559a5a

File tree

7 files changed

+189
-20
lines changed

7 files changed

+189
-20
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+
* [#1163](https://github.com/ruby-grape/grape/pull/1163): First-class `JSON` parameter type - [@dslh](https://github.com/dslh).
89
* [#1161](https://github.com/ruby-grape/grape/pull/1161): Custom parameter coercion using `coerce_with` - [@dslh](https://github.com/dslh).
910
* [#1134](https://github.com/ruby-grape/grape/pull/1134): Adds a code of conduct - [@towanda](https://github.com/towanda).
1011

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
- [Parameter Validation and Coercion](#parameter-validation-and-coercion)
3232
- [Supported Parameter Types](#supported-parameter-types)
3333
- [Custom Types and Coercions](#custom-types-and-coercions)
34+
- [First-Class `JSON` Types](#first-class-json-types)
3435
- [Validation of Nested Parameters](#validation-of-nested-parameters)
3536
- [Dependent Parameters](#dependent-parameters)
3637
- [Built-in Validators](#built-in-validators)
@@ -783,6 +784,47 @@ params do
783784
end
784785
```
785786

787+
### First-Class `JSON` Types
788+
789+
Grape supports complex parameters given as JSON-formatted strings using the special `type: JSON`
790+
declaration. JSON objects and arrays of objects are accepted equally, with nested validation
791+
rules applied to all objects in either case:
792+
793+
```ruby
794+
params do
795+
requires :json, type: JSON do
796+
requires :int, type: Integer, values: [1, 2, 3]
797+
end
798+
end
799+
get '/' do
800+
params[:json].inspect
801+
end
802+
803+
# ...
804+
805+
client.get('/', json: '{"int":1}') # => "{:int=>1}"
806+
client.get('/', json: '[{"int":"1"}]') # => "[{:int=>1}]"
807+
808+
client.get('/', json: '{"int":4}') # => HTTP 400
809+
client.get('/', json: '[{"int":4}]') # => HTTP 400
810+
```
811+
812+
Additionally `type: Array[JSON]` may be used, which explicitly marks the parameter as an array
813+
of objects. If a single object is supplied it will be wrapped. For stricter control over the
814+
type of JSON structure which may be supplied, use `type: Array, coerce_with: JSON` or
815+
`type: Hash, coerce_with: JSON`.
816+
817+
```ruby
818+
params do
819+
requires :json, type: Array[JSON] do
820+
requires :int, type: Integer
821+
end
822+
end
823+
get '/' do
824+
params[:json].each { |obj| ... } # always works
825+
end
826+
```
827+
786828
### Validation of Nested Parameters
787829

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

lib/grape/dsl/parameters.rb

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,21 +49,25 @@ def use(*names)
4949
# the :using hash. The last key can be a hash, which specifies
5050
# options for the parameters
5151
# @option attrs :type [Class] the type to coerce this parameter to before
52-
# passing it to the endpoint. See Grape::ParameterTypes for supported
53-
# types, or use a class that defines `::parse` as a custom type
52+
# passing it to the endpoint. See {Grape::ParameterTypes} for a list of
53+
# types that are supported automatically. Custom classes may be used
54+
# where they define a class-level `::parse` method, or in conjunction
55+
# with the `:coerce_with` parameter. `JSON` may be supplied to denote
56+
# `JSON`-formatted objects or arrays of objects. `Array[JSON]` accepts
57+
# the same values as `JSON` but will wrap single objects in an `Array`.
5458
# @option attrs :desc [String] description to document this parameter
5559
# @option attrs :default [Object] default value, if parameter is optional
5660
# @option attrs :values [Array] permissable values for this field. If any
5761
# other value is given, it will be handled as a validation error
5862
# @option attrs :using [Hash[Symbol => Hash]] a hash defining keys and
59-
# options, like that returned by Grape::Entity#documentation. The value
63+
# options, like that returned by {Grape::Entity#documentation}. The value
6064
# of each key is an options hash accepting the same parameters
6165
# @option attrs :except [Array[Symbol]] a list of keys to exclude from
6266
# the :using Hash. The meaning of this depends on if :all or :none was
6367
# passed; :all + :except will make the :except fields optional, whereas
6468
# :none + :except will make the :except fields required
6569
# @option attrs :coerce_with [#parse, #call] method to be used when coercing
66-
# the parameter to the type named by +attrs[:type]. Any class or object
70+
# the parameter to the type named by `attrs[:type]`. Any class or object
6771
# that defines `::parse` or `::call` may be used.
6872
#
6973
# @example

lib/grape/locale/en.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ en:
3535
exactly_one: 'are missing, exactly one parameter must be provided'
3636
all_or_none: 'provide all or none of parameters'
3737
missing_group_type: 'group type is required'
38-
unsupported_group_type: 'group type must be Array or Hash'
38+
unsupported_group_type: 'group type must be Array, Hash, JSON or Array[JSON]'
3939
invalid_message_body:
4040
problem: "message body does not match declared format"
4141
resolution:

lib/grape/validations/params_scope.rb

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ def new_scope(attrs, optional = false, &block)
137137
type = attrs[1] ? attrs[1][:type] : nil
138138
if attrs.first && !optional
139139
fail Grape::Exceptions::MissingGroupTypeError.new if type.nil?
140-
fail Grape::Exceptions::UnsupportedGroupTypeError.new unless [Array, Hash].include?(type)
140+
fail Grape::Exceptions::UnsupportedGroupTypeError.new unless [Array, Hash, JSON, Array[JSON]].include?(type)
141141
end
142142

143143
opts = attrs[1] || { type: Array }
@@ -180,11 +180,6 @@ 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_with
184-
if validations.key?(:coerce_with) && !validations.key?(:coerce)
185-
fail ArgumentError, 'must supply type for coerce_with'
186-
end
187-
188183
coerce_type = validations[:coerce]
189184

190185
doc_attrs[:type] = coerce_type.to_s if coerce_type
@@ -220,21 +215,48 @@ def validates(attrs, validations)
220215
# Before we run the rest of the validators, lets handle
221216
# whatever coercion so that we are working with correctly
222217
# type casted values
223-
if validations.key? :coerce
224-
coerce_options = {
225-
type: validations[:coerce],
226-
method: validations[:coerce_with]
227-
}
228-
validate('coerce', coerce_options, attrs, doc_attrs)
229-
validations.delete(:coerce_with)
230-
validations.delete(:coerce)
231-
end
218+
coerce_type validations, attrs, doc_attrs
232219

233220
validations.each do |type, options|
234221
validate(type, options, attrs, doc_attrs)
235222
end
236223
end
237224

225+
# Enforce correct usage of :coerce_with parameter.
226+
# We do not allow coercion without a type, nor with
227+
# +JSON+ as a type since this defines its own coercion
228+
# method.
229+
def check_coerce_with(validations)
230+
return unless validations.key?(:coerce_with)
231+
# type must be supplied for coerce_with..
232+
fail ArgumentError, 'must supply type for coerce_with' unless validations.key?(:coerce)
233+
234+
# but not special JSON types, which
235+
# already imply coercion method
236+
return unless [JSON, Array[JSON]].include? validations[:coerce]
237+
fail ArgumentError, 'coerce_with disallowed for type: JSON'
238+
end
239+
240+
# Add type coercion validation to this scope,
241+
# if any has been specified.
242+
# This validation has special handling since it is
243+
# composited from more than one +requires+/+optional+
244+
# parameter, and needs to be run before most other
245+
# validations.
246+
def coerce_type(validations, attrs, doc_attrs)
247+
check_coerce_with(validations)
248+
249+
return unless validations.key?(:coerce)
250+
251+
coerce_options = {
252+
type: validations[:coerce],
253+
method: validations[:coerce_with]
254+
}
255+
validate('coerce', coerce_options, attrs, doc_attrs)
256+
validations.delete(:coerce_with)
257+
validations.delete(:coerce)
258+
end
259+
238260
def guess_coerce_type(coerce_type, values)
239261
return coerce_type if !values || values.is_a?(Proc)
240262
return values.first.class if coerce_type == Array && (values.is_a?(Range) || !values.empty?)

lib/grape/validations/validators/coerce.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ def _valid_single_type?(klass, val)
4343
def valid_type?(val)
4444
if val.instance_of?(InvalidValue)
4545
false
46+
elsif type == JSON
47+
# Special JSON type is ambiguously defined.
48+
# We allow both objects and arrays.
49+
val.is_a?(Hash) || _valid_array_type?(Hash, val)
50+
elsif type == Array[JSON]
51+
# Array[JSON] shorthand wraps single objects.
52+
_valid_array_type?(Hash, val)
4653
elsif type.is_a?(Array) || type.is_a?(Set)
4754
_valid_array_type?(type.first, val)
4855
else
@@ -51,6 +58,14 @@ def valid_type?(val)
5158
end
5259

5360
def coerce_value(val)
61+
# JSON is not a type as Virtus understands it,
62+
# so we bypass normal coercion.
63+
if type == JSON
64+
return val ? JSON.parse(val, symbolize_names: true) : {}
65+
elsif type == Array[JSON]
66+
return val ? Array.wrap(JSON.parse(val, symbolize_names: true)) : []
67+
end
68+
5469
# Don't coerce things other than nil to Arrays or Hashes
5570
unless @option[:method] && !val.nil?
5671
return val || [] if type == Array

spec/grape/validations/validators/coerce_spec.rb

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,91 @@ class User
311311
end
312312
end
313313

314+
context 'first-class JSON' do
315+
it 'parses objects and arrays' do
316+
subject.params do
317+
requires :splines, type: JSON do
318+
requires :x, type: Integer, values: [1, 2, 3]
319+
optional :ints, type: Array[Integer]
320+
optional :obj, type: Hash do
321+
optional :y
322+
end
323+
end
324+
end
325+
subject.get '/' do
326+
if params[:splines].is_a? Hash
327+
params[:splines][:obj][:y]
328+
else
329+
'arrays work' if params[:splines].any? { |s| s.key? :obj }
330+
end
331+
end
332+
333+
get '/', splines: '{"x":1,"ints":[1,2,3],"obj":{"y":"woof"}}'
334+
expect(last_response.status).to eq(200)
335+
expect(last_response.body).to eq('woof')
336+
337+
get '/', splines: '[{"x":2,"ints":[]},{"x":3,"ints":[4],"obj":{"y":"quack"}}]'
338+
expect(last_response.status).to eq(200)
339+
expect(last_response.body).to eq('arrays work')
340+
341+
get '/', splines: '{"x":4,"ints":[2]}'
342+
expect(last_response.status).to eq(400)
343+
expect(last_response.body).to eq('splines[x] does not have a valid value')
344+
345+
get '/', splines: '[{"x":1,"ints":[]},{"x":4,"ints":[]}]'
346+
expect(last_response.status).to eq(400)
347+
expect(last_response.body).to eq('splines[x] does not have a valid value')
348+
end
349+
350+
it 'accepts Array[JSON] shorthand' do
351+
subject.params do
352+
requires :splines, type: Array[JSON] do
353+
requires :x, type: Integer, values: [1, 2, 3]
354+
requires :y
355+
end
356+
end
357+
subject.get '/' do
358+
params[:splines].first[:y].class.to_s
359+
spline = params[:splines].first
360+
"#{spline[:x].class}.#{spline[:y].class}"
361+
end
362+
363+
get '/', splines: '{"x":"1","y":"woof"}'
364+
expect(last_response.status).to eq(200)
365+
expect(last_response.body).to eq('Fixnum.String')
366+
367+
get '/', splines: '[{"x":1,"y":2},{"x":1,"y":"quack"}]'
368+
expect(last_response.status).to eq(200)
369+
expect(last_response.body).to eq('Fixnum.Fixnum')
370+
371+
get '/', splines: '{"x":"4","y":"woof"}'
372+
expect(last_response.status).to eq(400)
373+
expect(last_response.body).to eq('splines[x] does not have a valid value')
374+
375+
get '/', splines: '[{"x":"4","y":"woof"}]'
376+
expect(last_response.status).to eq(400)
377+
expect(last_response.body).to eq('splines[x] does not have a valid value')
378+
end
379+
380+
it "doesn't make sense using coerce_with" do
381+
expect do
382+
subject.params do
383+
requires :bad, type: JSON, coerce_with: JSON do
384+
requires :x
385+
end
386+
end
387+
end.to raise_error(ArgumentError)
388+
389+
expect do
390+
subject.params do
391+
requires :bad, type: Array[JSON], coerce_with: JSON do
392+
requires :x
393+
end
394+
end
395+
end.to raise_error(ArgumentError)
396+
end
397+
end
398+
314399
context 'converter' do
315400
it 'does not build Virtus::Attribute multiple times' do
316401
subject.params do

0 commit comments

Comments
 (0)