diff --git a/CHANGELOG.md b/CHANGELOG.md index 00b0a5fd1a..9542840008 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * Your contribution here. +* 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). diff --git a/README.md b/README.md index 3bba1f1e3e..0271f5cb59 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. diff --git a/lib/grape/dsl/parameters.rb b/lib/grape/dsl/parameters.rb index 140b79edd8..1d2dc1216f 100644 --- a/lib/grape/dsl/parameters.rb +++ b/lib/grape/dsl/parameters.rb @@ -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 diff --git a/lib/grape/locale/en.yml b/lib/grape/locale/en.yml index 358846ec05..0c4cb0d9bd 100644 --- a/lib/grape/locale/en.yml +++ b/lib/grape/locale/en.yml @@ -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: diff --git a/lib/grape/validations/params_scope.rb b/lib/grape/validations/params_scope.rb index 99dfd4b7c4..0791063753 100644 --- a/lib/grape/validations/params_scope.rb +++ b/lib/grape/validations/params_scope.rb @@ -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 } @@ -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 @@ -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?) diff --git a/lib/grape/validations/validators/coerce.rb b/lib/grape/validations/validators/coerce.rb index c134f8556d..d91d0ccef9 100644 --- a/lib/grape/validations/validators/coerce.rb +++ b/lib/grape/validations/validators/coerce.rb @@ -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 @@ -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 diff --git a/spec/grape/validations/validators/coerce_spec.rb b/spec/grape/validations/validators/coerce_spec.rb index beb2156944..7dea93761d 100644 --- a/spec/grape/validations/validators/coerce_spec.rb +++ b/spec/grape/validations/validators/coerce_spec.rb @@ -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