Skip to content

First-class JSON parameter type #1135 #1163

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Sep 24, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

* Your contribution here.

* [#1163](https://github.com/ruby-grape/grape/pull/1163): First-class `JSON` parameter type - [@dslh](https://github.com/dslh).
* [#1161](https://github.com/ruby-grape/grape/pull/1161): Custom parameter coercion using `coerce_with` - [@dslh](https://github.com/dslh).
* [#1134](https://github.com/ruby-grape/grape/pull/1134): Adds a code of conduct - [@towanda](https://github.com/towanda).

Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
- [Parameter Validation and Coercion](#parameter-validation-and-coercion)
- [Supported Parameter Types](#supported-parameter-types)
- [Custom Types and Coercions](#custom-types-and-coercions)
- [First-Class `JSON` Types](#first-class-json-types)
- [Validation of Nested Parameters](#validation-of-nested-parameters)
- [Dependent Parameters](#dependent-parameters)
- [Built-in Validators](#built-in-validators)
Expand Down Expand Up @@ -783,6 +784,47 @@ params do
end
```

### First-Class `JSON` Types

Grape supports complex parameters given as JSON-formatted strings using the special `type: JSON`
declaration. JSON objects and arrays of objects are accepted equally, with nested validation
rules applied to all objects in either case:

```ruby
params do
requires :json, type: JSON do
requires :int, type: Integer, values: [1, 2, 3]
end
end
get '/' do
params[:json].inspect
end

# ...

client.get('/', json: '{"int":1}') # => "{:int=>1}"
client.get('/', json: '[{"int":"1"}]') # => "[{:int=>1}]"

client.get('/', json: '{"int":4}') # => HTTP 400
client.get('/', json: '[{"int":4}]') # => HTTP 400
```

Additionally `type: Array[JSON]` may be used, which explicitly marks the parameter as an array
of objects. If a single object is supplied it will be wrapped. For stricter control over the
type of JSON structure which may be supplied, use `type: Array, coerce_with: JSON` or
`type: Hash, coerce_with: JSON`.

```ruby
params do
requires :json, type: Array[JSON] do
requires :int, type: Integer
end
end
get '/' do
params[:json].each { |obj| ... } # always works
end
```

### Validation of Nested Parameters

Parameters can be nested using `group` or by calling `requires` or `optional` with a block.
Expand Down
12 changes: 8 additions & 4 deletions lib/grape/dsl/parameters.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,21 +49,25 @@ def use(*names)
# the :using hash. The last key can be a hash, which specifies
# options for the parameters
# @option attrs :type [Class] the type to coerce this parameter to before
# passing it to the endpoint. See Grape::ParameterTypes for supported
# types, or use a class that defines `::parse` as a custom type
# passing it to the endpoint. See {Grape::ParameterTypes} for a list of
# types that are supported automatically. Custom classes may be used
# where they define a class-level `::parse` method, or in conjunction
# with the `:coerce_with` parameter. `JSON` may be supplied to denote
# `JSON`-formatted objects or arrays of objects. `Array[JSON]` accepts
# the same values as `JSON` but will wrap single objects in an `Array`.
# @option attrs :desc [String] description to document this parameter
# @option attrs :default [Object] default value, if parameter is optional
# @option attrs :values [Array] permissable values for this field. If any
# other value is given, it will be handled as a validation error
# @option attrs :using [Hash[Symbol => Hash]] a hash defining keys and
# options, like that returned by Grape::Entity#documentation. The value
# options, like that returned by {Grape::Entity#documentation}. The value
# of each key is an options hash accepting the same parameters
# @option attrs :except [Array[Symbol]] a list of keys to exclude from
# the :using Hash. The meaning of this depends on if :all or :none was
# passed; :all + :except will make the :except fields optional, whereas
# :none + :except will make the :except fields required
# @option attrs :coerce_with [#parse, #call] method to be used when coercing
# the parameter to the type named by +attrs[:type]. Any class or object
# the parameter to the type named by `attrs[:type]`. Any class or object
# that defines `::parse` or `::call` may be used.
#
# @example
Expand Down
2 changes: 1 addition & 1 deletion lib/grape/locale/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ en:
exactly_one: 'are missing, exactly one parameter must be provided'
all_or_none: 'provide all or none of parameters'
missing_group_type: 'group type is required'
unsupported_group_type: 'group type must be Array or Hash'
unsupported_group_type: 'group type must be Array, Hash, JSON or Array[JSON]'
invalid_message_body:
problem: "message body does not match declared format"
resolution:
Expand Down
52 changes: 37 additions & 15 deletions lib/grape/validations/params_scope.rb
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ def new_scope(attrs, optional = false, &block)
type = attrs[1] ? attrs[1][:type] : nil
if attrs.first && !optional
fail Grape::Exceptions::MissingGroupTypeError.new if type.nil?
fail Grape::Exceptions::UnsupportedGroupTypeError.new unless [Array, Hash].include?(type)
fail Grape::Exceptions::UnsupportedGroupTypeError.new unless [Array, Hash, JSON, Array[JSON]].include?(type)
end

opts = attrs[1] || { type: Array }
Expand Down Expand Up @@ -180,11 +180,6 @@ def validates(attrs, validations)
# special case (type = coerce)
validations[:coerce] = validations.delete(:type) if validations.key?(:type)

# type must be supplied for coerce_with
if validations.key?(:coerce_with) && !validations.key?(:coerce)
fail ArgumentError, 'must supply type for coerce_with'
end

coerce_type = validations[:coerce]

doc_attrs[:type] = coerce_type.to_s if coerce_type
Expand Down Expand Up @@ -220,21 +215,48 @@ def validates(attrs, validations)
# Before we run the rest of the validators, lets handle
# whatever coercion so that we are working with correctly
# type casted values
if validations.key? :coerce
coerce_options = {
type: validations[:coerce],
method: validations[:coerce_with]
}
validate('coerce', coerce_options, attrs, doc_attrs)
validations.delete(:coerce_with)
validations.delete(:coerce)
end
coerce_type validations, attrs, doc_attrs

validations.each do |type, options|
validate(type, options, attrs, doc_attrs)
end
end

# Enforce correct usage of :coerce_with parameter.
# We do not allow coercion without a type, nor with
# +JSON+ as a type since this defines its own coercion
# method.
def check_coerce_with(validations)
return unless validations.key?(:coerce_with)
# type must be supplied for coerce_with..
fail ArgumentError, 'must supply type for coerce_with' unless validations.key?(:coerce)

# but not special JSON types, which
# already imply coercion method
return unless [JSON, Array[JSON]].include? validations[:coerce]
fail ArgumentError, 'coerce_with disallowed for type: JSON'
end

# Add type coercion validation to this scope,
# if any has been specified.
# This validation has special handling since it is
# composited from more than one +requires+/+optional+
# parameter, and needs to be run before most other
# validations.
def coerce_type(validations, attrs, doc_attrs)
check_coerce_with(validations)

return unless validations.key?(:coerce)

coerce_options = {
type: validations[:coerce],
method: validations[:coerce_with]
}
validate('coerce', coerce_options, attrs, doc_attrs)
validations.delete(:coerce_with)
validations.delete(:coerce)
end

def guess_coerce_type(coerce_type, values)
return coerce_type if !values || values.is_a?(Proc)
return values.first.class if coerce_type == Array && (values.is_a?(Range) || !values.empty?)
Expand Down
15 changes: 15 additions & 0 deletions lib/grape/validations/validators/coerce.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ def _valid_single_type?(klass, val)
def valid_type?(val)
if val.instance_of?(InvalidValue)
false
elsif type == JSON
# Special JSON type is ambiguously defined.
# We allow both objects and arrays.
val.is_a?(Hash) || _valid_array_type?(Hash, val)
elsif type == Array[JSON]
# Array[JSON] shorthand wraps single objects.
_valid_array_type?(Hash, val)
elsif type.is_a?(Array) || type.is_a?(Set)
_valid_array_type?(type.first, val)
else
Expand All @@ -51,6 +58,14 @@ def valid_type?(val)
end

def coerce_value(val)
# JSON is not a type as Virtus understands it,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a future improvement maybe we should write a custom Virtus type for this, to be consistent and avoid special handling?

# so we bypass normal coercion.
if type == JSON
return val ? JSON.parse(val, symbolize_names: true) : {}
elsif type == Array[JSON]
return val ? Array.wrap(JSON.parse(val, symbolize_names: true)) : []
end

# Don't coerce things other than nil to Arrays or Hashes
unless @option[:method] && !val.nil?
return val || [] if type == Array
Expand Down
85 changes: 85 additions & 0 deletions spec/grape/validations/validators/coerce_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,91 @@ class User
end
end

context 'first-class JSON' do
it 'parses objects and arrays' do
subject.params do
requires :splines, type: JSON do
requires :x, type: Integer, values: [1, 2, 3]
optional :ints, type: Array[Integer]
optional :obj, type: Hash do
optional :y
end
end
end
subject.get '/' do
if params[:splines].is_a? Hash
params[:splines][:obj][:y]
else
'arrays work' if params[:splines].any? { |s| s.key? :obj }
end
end

get '/', splines: '{"x":1,"ints":[1,2,3],"obj":{"y":"woof"}}'
expect(last_response.status).to eq(200)
expect(last_response.body).to eq('woof')

get '/', splines: '[{"x":2,"ints":[]},{"x":3,"ints":[4],"obj":{"y":"quack"}}]'
expect(last_response.status).to eq(200)
expect(last_response.body).to eq('arrays work')

get '/', splines: '{"x":4,"ints":[2]}'
expect(last_response.status).to eq(400)
expect(last_response.body).to eq('splines[x] does not have a valid value')

get '/', splines: '[{"x":1,"ints":[]},{"x":4,"ints":[]}]'
expect(last_response.status).to eq(400)
expect(last_response.body).to eq('splines[x] does not have a valid value')
end

it 'accepts Array[JSON] shorthand' do
subject.params do
requires :splines, type: Array[JSON] do
requires :x, type: Integer, values: [1, 2, 3]
requires :y
end
end
subject.get '/' do
params[:splines].first[:y].class.to_s
spline = params[:splines].first
"#{spline[:x].class}.#{spline[:y].class}"
end

get '/', splines: '{"x":"1","y":"woof"}'
expect(last_response.status).to eq(200)
expect(last_response.body).to eq('Fixnum.String')

get '/', splines: '[{"x":1,"y":2},{"x":1,"y":"quack"}]'
expect(last_response.status).to eq(200)
expect(last_response.body).to eq('Fixnum.Fixnum')

get '/', splines: '{"x":"4","y":"woof"}'
expect(last_response.status).to eq(400)
expect(last_response.body).to eq('splines[x] does not have a valid value')

get '/', splines: '[{"x":"4","y":"woof"}]'
expect(last_response.status).to eq(400)
expect(last_response.body).to eq('splines[x] does not have a valid value')
end

it "doesn't make sense using coerce_with" do
expect do
subject.params do
requires :bad, type: JSON, coerce_with: JSON do
requires :x
end
end
end.to raise_error(ArgumentError)

expect do
subject.params do
requires :bad, type: Array[JSON], coerce_with: JSON do
requires :x
end
end
end.to raise_error(ArgumentError)
end
end

context 'converter' do
it 'does not build Virtus::Attribute multiple times' do
subject.params do
Expand Down