Skip to content

Commit

Permalink
Merge pull request #1163 from dslh/first_class_json
Browse files Browse the repository at this point in the history
First-class JSON parameter type #1135
  • Loading branch information
dblock committed Sep 24, 2015
2 parents 7722cd1 + 80a46ba commit b559a5a
Show file tree
Hide file tree
Showing 7 changed files with 189 additions and 20 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
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
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
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
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
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
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,
# 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
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

0 comments on commit b559a5a

Please sign in to comment.