From 07fce881c1a11fa4a7c45fc140d1165c2a6484b4 Mon Sep 17 00:00:00 2001 From: Doug Hammond Date: Mon, 28 Sep 2015 16:21:39 +0200 Subject: [PATCH] Refactor and extend coercion and type validation Addresses #1164, #690, #689, #693. Depends on https://github.com/solnic/virtus/pull/343 `Grape::ParameterTypes` is renamed `Grape::Validations::Types` to reflect that it should probably be bundled with an eventual `grape-validations` gem. It is expanded to include two new categories of types, 'special' and 'recognized' (see 'lib/grape/validations/types.rb'). `CoerceValidator` now makes use of `Virtus::Attribute::value_coerced?`, simplifying its internals. `CustomTypeCoercer` is introduced, attempting to standardize support for custom types by decoupling coercion and type-checking logic from the `type` class supplied to `Grape::Dsl::Parameters::requires`. The process for inferring which logic to use for each type and coercion method is encoded in `lib/grape/validations/types/build_coercer.rb`. `JSON`, `Array[JSON]` and `Rack::Multipart::UploadedFile (a.k.a `File`) are designated 'special' types, for which special implementations of `Virtus::Attribute` are provided. Instances of `Virtus::Attribute` built with `Virtus::Attribute.build` may now also be passed as the `type` parameter for `requires`. A number of pre-rolled attributes are available providing coercion for `Date` and `DateTime` objects from various formats in `lib/grape/validations/formats/date.rb` and `date_time.rb`. --- CHANGELOG.md | 1 + README.md | 27 ++- lib/grape.rb | 8 +- lib/grape/dsl/parameters.rb | 2 +- lib/grape/util/parameter_types.rb | 58 ------ lib/grape/validations/formats.rb | 14 ++ lib/grape/validations/formats/date_times.rb | 47 +++++ lib/grape/validations/formats/dates.rb | 46 +++++ lib/grape/validations/types.rb | 121 ++++++++++++ lib/grape/validations/types/build_coercer.rb | 50 +++++ .../validations/types/custom_type_coercer.rb | 183 ++++++++++++++++++ lib/grape/validations/types/file.rb | 28 +++ lib/grape/validations/types/json.rb | 65 +++++++ lib/grape/validations/validators/coerce.rb | 101 ++-------- lib/virtus/attribute/collection_patch.rb | 16 ++ spec/grape/util/parameter_types_spec.rb | 56 ------ .../validations/formats/date_times_spec.rb | 54 ++++++ spec/grape/validations/formats/dates_spec.rb | 44 +++++ spec/grape/validations/types_spec.rb | 95 +++++++++ .../validations/validators/coerce_spec.rb | 25 ++- 20 files changed, 834 insertions(+), 207 deletions(-) delete mode 100644 lib/grape/util/parameter_types.rb create mode 100644 lib/grape/validations/formats.rb create mode 100644 lib/grape/validations/formats/date_times.rb create mode 100644 lib/grape/validations/formats/dates.rb create mode 100644 lib/grape/validations/types.rb create mode 100644 lib/grape/validations/types/build_coercer.rb create mode 100644 lib/grape/validations/types/custom_type_coercer.rb create mode 100644 lib/grape/validations/types/file.rb create mode 100644 lib/grape/validations/types/json.rb create mode 100644 lib/virtus/attribute/collection_patch.rb delete mode 100644 spec/grape/util/parameter_types_spec.rb create mode 100644 spec/grape/validations/formats/date_times_spec.rb create mode 100644 spec/grape/validations/formats/dates_spec.rb create mode 100644 spec/grape/validations/types_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index deb05ef7e3..8735247bff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * Your contribution here. +* [#1167](https://github.com/ruby-grape/grape/pull/1167): Refactor and extend coercion and type validation system - [@dslh](https://github.com/dslh). * [#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). diff --git a/README.md b/README.md index 4e30889986..cf610ba895 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) + - [Multipart File Parameters](#multipart-file-parameters) - [First-Class `JSON` Types](#first-class-json-types) - [Validation of Nested Parameters](#validation-of-nested-parameters) - [Dependent Parameters](#dependent-parameters) @@ -732,7 +733,8 @@ The following are all valid types, supported out of the box by Grape: * Boolean * String * Symbol -* Rack::Multipart::UploadedFile +* Rack::Multipart::UploadedFile (alias `File`) +* JSON ### Custom Types and Coercions @@ -784,6 +786,23 @@ params do end ``` +### Multipart File Parameters + +Grape makes use of `Rack::Request`'s built-in support for multipart +file parameters. Such parameters can be declared with `type: File`: + +```ruby +params do + requires :avatar, type: File +end +post '/' do + # Parameter will be wrapped using Hashie: + params.avatar.filename # => 'avatar.png' + params.avatar.type # => 'image/png' + params.avatar.tempfile # => # +end +``` + ### First-Class `JSON` Types Grape supports complex parameters given as JSON-formatted strings using the special `type: JSON` @@ -810,9 +829,7 @@ 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`. +of objects. If a single object is supplied it will be wrapped. ```ruby params do @@ -824,6 +841,8 @@ get '/' do params[:json].each { |obj| ... } # always works end ``` +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`. ### Validation of Nested Parameters diff --git a/lib/grape.rb b/lib/grape.rb index b421fed7ea..1c01cec7b4 100644 --- a/lib/grape.rb +++ b/lib/grape.rb @@ -19,10 +19,14 @@ require 'active_support/notifications' require 'multi_json' require 'multi_xml' -require 'virtus' require 'i18n' require 'thread' +require 'virtus' +# Patch for Virtus::Attribute::Collection +# See the file for more details +require_relative 'virtus/attribute/collection_patch' + I18n.load_path << File.expand_path('../grape/locale/en.yml', __FILE__) module Grape @@ -159,7 +163,6 @@ module Presenters end require 'grape/util/content_types' -require 'grape/util/parameter_types' require 'grape/validations/validators/base' require 'grape/validations/attributes_iterator' @@ -174,5 +177,6 @@ module Presenters require 'grape/validations/validators/values' require 'grape/validations/params_scope' require 'grape/validations/validators/all_or_none' +require 'grape/validations/types' require 'grape/version' diff --git a/lib/grape/dsl/parameters.rb b/lib/grape/dsl/parameters.rb index 1d2dc1216f..84c48c0b87 100644 --- a/lib/grape/dsl/parameters.rb +++ b/lib/grape/dsl/parameters.rb @@ -49,7 +49,7 @@ 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 a list of + # passing it to the endpoint. See {Grape::Validations::Types} 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 diff --git a/lib/grape/util/parameter_types.rb b/lib/grape/util/parameter_types.rb deleted file mode 100644 index c07b8e9cdc..0000000000 --- a/lib/grape/util/parameter_types.rb +++ /dev/null @@ -1,58 +0,0 @@ -module Grape - module ParameterTypes - # Types representing a single value, which are coerced through Virtus - # or special logic in Grape. - PRIMITIVES = [ - # Numerical - Integer, - Float, - BigDecimal, - Numeric, - - # Date/time - Date, - DateTime, - Time, - - # Misc - Virtus::Attribute::Boolean, - String, - Symbol, - Rack::Multipart::UploadedFile - ] - - # Types representing data structures. - STRUCTURES = [ - Hash, - Array, - Set - ] - - # @param type [Class] type to check - # @return [Boolean] whether or not the type is known by Grape as a valid - # type for a single value - def self.primitive?(type) - PRIMITIVES.include?(type) - end - - # @param type [Class] type to check - # @return [Boolean] whether or not the type is known by Grape as a valid - # data structure type - # @note This method does not yet consider 'complex types', which inherit - # Virtus.model. - def self.structure?(type) - STRUCTURES.include?(type) - end - - # A valid custom type must implement a class-level `parse` method, taking - # one String argument and returning the parsed value in its correct type. - # @param type [Class] type to check - # @return [Boolean] whether or not the type can be used as a custom type - def self.custom_type?(type) - !primitive?(type) && - !structure?(type) && - type.respond_to?(:parse) && - type.method(:parse).arity == 1 - end - end -end diff --git a/lib/grape/validations/formats.rb b/lib/grape/validations/formats.rb new file mode 100644 index 0000000000..f8269efb9c --- /dev/null +++ b/lib/grape/validations/formats.rb @@ -0,0 +1,14 @@ +require_relative 'formats/dates' +require_relative 'formats/date_times' + +module Grape + module Validations + # Contains collections of constants that may be passed + # as the +type+ parameter of {Grape::Dsl::Parameters#requires} + # or {Grape::Dsl::Parameters#optional}, providing + # parameter coercion from a range of standard formats + # to a number of standard types. + module Formats + end + end +end diff --git a/lib/grape/validations/formats/date_times.rb b/lib/grape/validations/formats/date_times.rb new file mode 100644 index 0000000000..15973f7325 --- /dev/null +++ b/lib/grape/validations/formats/date_times.rb @@ -0,0 +1,47 @@ +require 'time' +require_relative '../types/custom_type_coercer' + +module Grape + module Validations + module Formats + # This module provides a set of ready-made +Virtus::Attribute+ + # constants, suitable for use as the +type+ option for + # {Grape::Dsl::Parameters#requires} and {Grape::Dsl::Parameters#optional}. + # These definitions will coerce input strings to the standard + # ruby +DateTime+ type using the standard parsing methods defined + # on that class. + # + # This module is not required by default. + module DateTimes + # Parses timestamps using +DateTime.httpdate+ + HttpDate = Types::CustomTypeCoercer.build(::DateTime, ::DateTime.method(:httpdate)) + + # Parses timestamps using +DateTime.iso8601+ + Iso8601 = Types::CustomTypeCoercer.build(::DateTime, ::DateTime.method(:iso8601)) + + # Parses timestamps using +DateTime.jisx0301+ + Jisx0301 = Types::CustomTypeCoercer.build(::DateTime, ::DateTime.method(:jisx0301)) + + # Parses julian dates using +DateTime.jd+. + # Time of day is not supported. + JulianDay = Types::CustomTypeCoercer.build(::DateTime, lambda do |val| + fail Grape::Exceptions::Validations, 'julian date must be an integer' unless val =~ /^\d*$/ + + ::DateTime.jd val.to_i + end) + + # Parses timestamps using +DateTime.rfc2822+ + Rfc2822 = Types::CustomTypeCoercer.build(::DateTime, ::DateTime.method(:rfc2822)) + + # Parses timestamps using +DateTime.rfc3339+ + Rfc3339 = Types::CustomTypeCoercer.build(::DateTime, ::DateTime.method(:rfc3339)) + + # Parses timestamps using +DateTime.rfc822+ + Rfc822 = Types::CustomTypeCoercer.build(::DateTime, ::DateTime.method(:rfc822)) + + # Parses timestamps using +DateTime.xmlschema+ + XmlSchema = Types::CustomTypeCoercer.build(::DateTime, ::DateTime.method(:xmlschema)) + end + end + end +end diff --git a/lib/grape/validations/formats/dates.rb b/lib/grape/validations/formats/dates.rb new file mode 100644 index 0000000000..262f57ab77 --- /dev/null +++ b/lib/grape/validations/formats/dates.rb @@ -0,0 +1,46 @@ +require 'date' +require_relative '../types/custom_type_coercer' + +module Grape + module Validations + module Formats + # This module provides a set of ready-made +Virtus::Attribute+ + # constants, suitable for use as the +type+ option for + # {Grape::Dsl::Parameters#requires} and {Grape::Dsl::Parameters#optional}. + # These definitions will coerce input strings to the standard + # ruby +Date+ type using the standard parsing methods defined + # on that class. + # + # This module is not required by default. + module Dates + # Parses dates using +Date.httpdate+ + HttpDate = Types::CustomTypeCoercer.build(::Date, ::Date.method(:httpdate)) + + # Parses dates using +Date.iso8601+ + Iso8601 = Types::CustomTypeCoercer.build(::Date, ::Date.method(:iso8601)) + + # Parses dates using +Date.jisx0301+ + Jisx0301 = Types::CustomTypeCoercer.build(::Date, ::Date.method(:jisx0301)) + + # Parses dates using +Date.jd+ + JulianDay = Types::CustomTypeCoercer.build(::Date, lambda do |val| + fail Grape::Exceptions::Validations, 'julian date must be an integer' unless val =~ /^\d*$/ + + ::Date.jd val.to_i + end) + + # Parses dates using +Date.rfc2822+ + Rfc2822 = Types::CustomTypeCoercer.build(::Date, ::Date.method(:rfc2822)) + + # Parses dates using +Date.rfc3339+ + Rfc3339 = Types::CustomTypeCoercer.build(::Date, ::Date.method(:rfc3339)) + + # Parses dates using +Date.rfc822+ + Rfc822 = Types::CustomTypeCoercer.build(::Date, ::Date.method(:rfc822)) + + # Parses dates using +Date.xmlschema+ + XmlSchema = Types::CustomTypeCoercer.build(::Date, ::Date.method(:xmlschema)) + end + end + end +end diff --git a/lib/grape/validations/types.rb b/lib/grape/validations/types.rb new file mode 100644 index 0000000000..ba8b7ca0f8 --- /dev/null +++ b/lib/grape/validations/types.rb @@ -0,0 +1,121 @@ +require_relative 'types/build_coercer' +require_relative 'types/custom_type_coercer' +require_relative 'types/json' +require_relative 'types/file' + +module Grape + module Validations + # Module for code related to grape's system for + # coercion and type validation of incoming request + # parameters. + # + # Grape uses a number of tests and assertions to + # work out exactly how a parameter should be handled, + # based on the +type+ and +coerce_with+ options that + # may be supplied to {Grape::Dsl::Parameters#requires} + # and {Grape::Dsl::Parameters#optional}. The main + # entry point for this process is {Types.build_coercer}. + module Types + # Types representing a single value, which are coerced through Virtus + # or special logic in Grape. + PRIMITIVES = [ + # Numerical + Integer, + Float, + BigDecimal, + Numeric, + + # Date/time + Date, + DateTime, + Time, + + # Misc + Virtus::Attribute::Boolean, + String, + Symbol, + Rack::Multipart::UploadedFile + ] + + # Types representing data structures. + STRUCTURES = [ + Hash, + Array, + Set + ] + + # Types for which Grape provides special coercion + # and type-checking logic. + SPECIAL = { + JSON => Json, + Array[JSON] => JsonArray, + ::File => File, + Rack::Multipart::UploadedFile => File + } + + # Is the given class a primitive type as recognized by Grape? + # + # @param type [Class] type to check + # @return [Boolean] whether or not the type is known by Grape as a valid + # type for a single value + def self.primitive?(type) + PRIMITIVES.include?(type) + end + + # Is the given class a standard data structure (collection or map) + # as recognized by Grape? + # + # @param type [Class] type to check + # @return [Boolean] whether or not the type is known by Grape as a valid + # data structure type + # @note This method does not yet consider 'complex types', which inherit + # Virtus.model. + def self.structure?(type) + STRUCTURES.include?(type) + end + + # Does the given class implement a type system that Grape + # (i.e. the underlying virtus attribute system) supports + # out-of-the-box? Currently supported are +axiom-types+ + # and +virtus+. + # + # The type will be passed to +Virtus::Attribute.build+, + # and the resulting attribute object will be expected to + # respond correctly to +coerce+ and +value_coerced?+. + # + # @param type [Class] type to check + # @return [Boolean] +true+ where the type is recognized + def self.recognized?(type) + return false if type.is_a?(Array) || type.is_a?(Set) + + type.is_a?(Virtus::Attribute) || + type.ancestors.include?(Axiom::Types::Type) || + type.include?(Virtus::Model::Core) + end + + # Does Grape provide special coercion and validation + # routines for the given class? This does not include + # automatic handling for primitives, structures and + # otherwise recognized types. See {Types::SPECIAL}. + # + # @param type [Class] type to check + # @return [Boolean] +true+ if special routines are available + def self.special?(type) + SPECIAL.key? type + end + + # A valid custom type must implement a class-level `parse` method, taking + # one String argument and returning the parsed value in its correct type. + # @param type [Class] type to check + # @return [Boolean] whether or not the type can be used as a custom type + def self.custom?(type) + !primitive?(type) && + !structure?(type) && + !recognized?(type) && + !special?(type) && + type.respond_to?(:parse) && + type.method(:parse).arity == 1 + end + end + end +end diff --git a/lib/grape/validations/types/build_coercer.rb b/lib/grape/validations/types/build_coercer.rb new file mode 100644 index 0000000000..13945efc48 --- /dev/null +++ b/lib/grape/validations/types/build_coercer.rb @@ -0,0 +1,50 @@ +module Grape + module Validations + module Types + # Work out the +Virtus::Attribute+ object to + # use for coercing strings to the given +type+. + # Coercion +method+ will be inferred if none is + # supplied. + # + # If a +Virtus::Attribute+ object already built + # with +Virtus::Attribute.build+ is supplied as + # the +type+ it will be returned and +method+ + # will be ignored. + # + # See {CustomTypeCoercer} for further details + # about coercion and type-checking inference. + # + # @param type [Class] the type to which input strings + # should be coerced + # @param method [Class,#call] the coercion method to use + # @return [Virtus::Attribute] object to be used + # for coercion and type validation + def self.build_coercer(type, method = nil) + if type.is_a? Virtus::Attribute + # Accept pre-rolled virtus attributes without interference + type + else + converter_options = { + nullify_blank: true + } + conversion_type = type + + # Use a special coercer for custom types and coercion methods. + if method || Types.custom?(type) + converter_options[:coercer] = Types::CustomTypeCoercer.new(type, method) + + # Grape swaps in its own Virtus::Attribute implementations + # for certain special types that merit first-class support + # (but not if a custom coercion method has been supplied). + elsif Types.special?(type) + conversion_type = Types::SPECIAL[type] + end + + # Virtus will infer coercion and validation rules + # for many common ruby types. + Virtus::Attribute.build(conversion_type, converter_options) + end + end + end + end +end diff --git a/lib/grape/validations/types/custom_type_coercer.rb b/lib/grape/validations/types/custom_type_coercer.rb new file mode 100644 index 0000000000..f3bded747f --- /dev/null +++ b/lib/grape/validations/types/custom_type_coercer.rb @@ -0,0 +1,183 @@ +module Grape + module Validations + module Types + # Instances of this class may be passed to + # +Virtus::Attribute.build+ as the +:coercer+ + # option for custom types that do not otherwise + # satisfy the requirements for +Virtus::Attribute::coerce+ + # and +Virtus::Attribute::value_coerced?+ to work + # as expected. + # + # Subclasses of +Virtus::Attribute+ or +Axiom::Types::Type+ + # (or for which an axiom type can be inferred, i.e. the + # primitives, +Date+, +Time+, etc.) do not need any such + # coercer to be passed with them. + # + # Coercion + # -------- + # + # This class will detect type classes that implement + # a class-level +parse+ method. The method should accept one + # +String+ argument and should return the value coerced to + # the appropriate type. The method may raise an exception if + # there are any problems parsing the string. + # + # Alternately an optional +method+ may be supplied (see the + # +coerce_with+ option of {Grape::Dsl::Parameters#requires}). + # This may be any class or object implementing +parse+ or +call+, + # with the same contract as described above. + # + # Type Checking + # ------------- + # + # Calls to +value_coerced?+ will consult this class to check + # that the coerced value produced above is in fact of the + # expected type. By default this class performs a basic check + # against the type supplied, but this behaviour will be + # overridden if the class implements a class-level + # +coerced?+ or +parsed?+ method. This method + # will receive a single parameter that is the coerced value + # and should return +true+ iff the value meets type expectations. + # Arbitrary assertions may be made here but the grape validation + # system should be preferred. + # + # Alternately a proc or other object responding to +call+ may be + # supplied in place of a type. This should implement the same + # contract as +coerced?+, and must be supplied with a coercion + # +method+. + class CustomTypeCoercer + # Uses +Virtus::Attribute.build+ to build a new + # attribute that makes use of this class for + # coercion and type validation logic. + # + # @return [Virtus::Attribute] + def self.build(type, method = nil) + Virtus::Attribute.build(type, coercer: new(type, method)) + end + + # A new coercer for the given type specification + # and coercion method. + # + # @param type [Class,#coerced?,#parsed?,#call?] + # specifier for the target type. See class docs. + # @param method [#parse,#call] + # optional coercion method. See class docs. + def initialize(type, method = nil) + coercion_method = infer_coercion_method type, method + + @method = enforce_symbolized_keys type, coercion_method + + @type_check = infer_type_check(type) + end + + # This method is called from somewhere within + # +Virtus::Attribute::coerce+ in order to coerce + # the given value. + # + # @param value [String] value to be coerced, in grape + # this should always be a string. + # @return [Object] the coerced result + def call(value) + @method.call value + end + + # This method is called from somewhere within + # +Virtus::Attribute::value_coerced?+ in order to + # assert that the value has been coerced successfully. + # + # @param _primitive [Axiom::Types::Type] primitive type + # for the coercion as detected by axiom-types' inference + # system. For custom types this is typically not much use + # (i.e. it is +Axiom::Types::Object+) unless special + # inference rules have been declared for the type. + # @param value [Object] a coerced result returned from {#call} + # @return [true,false] whether or not the coerced value + # satisfies type requirements. + def success?(_primitive, value) + @type_check.call value + end + + private + + # Determine the coercion method we're expected to use + # based on the parameters given. + # + # @param type see #new + # @param method see #new + # @return [#call] coercion method + def infer_coercion_method(type, method) + if method + if method.respond_to? :parse + method.method :parse + else + method + end + else + # Try to use parse() declared on the target type. + # This may raise an exception, but we are out of ideas anyway. + type.method :parse + end + end + + # Determine how the type validity of a coerced + # value should be decided. + # + # @param type see #new + # @return [#call] a procedure which accepts a single parameter + # and returns +true+ if the passed object is of the correct type. + def infer_type_check(type) + # First check for special class methods + if type.respond_to? :coerced? + type.method :coerced? + elsif type.respond_to? :parsed? + type.method :parsed? + elsif type.respond_to? :call + # Arbitrary proc passed for type validation. + # Note that this will fail unless a method is also + # passed, or if the type also implements a parse() method. + type + else + # By default, do a simple type check + ->(value) { value.is_a? type } + end + end + + # Enforce symbolized keys for complex types + # by wrapping the coercion method such that + # any Hash objects in the immediate heirarchy + # are passed through +Hashie.symbolize_keys!+. + # This helps common libs such as JSON to work easily. + # + # @param type see #new + # @param method see #infer_coercion_method + # @return [#call] +method+ wrapped in an additional + # key-conversion step, or just returns +method+ + # itself if no conversion is deemed to be + # necessary. + def enforce_symbolized_keys(type, method) + # Collections have all values processed individually + if type == Array || type == Set + lambda do |val| + method.call(val).tap do |new_value| + new_value.each do |item| + Hashie.symbolize_keys!(item) if item.is_a? Hash + end + end + end + + # Hash objects are processed directly + elsif type == Hash + lambda do |val| + Hashie.symbolize_keys! method.call(val) + end + + # Simple types are not processed. + # This includes Array types. + else + method + end + end + end + end + end +end diff --git a/lib/grape/validations/types/file.rb b/lib/grape/validations/types/file.rb new file mode 100644 index 0000000000..be8b7be636 --- /dev/null +++ b/lib/grape/validations/types/file.rb @@ -0,0 +1,28 @@ +module Grape + module Validations + module Types + # +Virtus::Attribute+ implementation for parameters + # that are multipart file objects. Actual handling + # of these objects is provided by +Rack::Request+; + # this class is here only to assert that rack's + # handling has succeeded, and to prevent virtus + # from interfering. + class File < Virtus::Attribute + def coerce(input) + # Processing of multipart file objects + # is already taken care of by Rack::Request. + # Nothing to do here. + input + end + + def value_coerced?(value) + # Rack::Request creates a Hash with filename, + # content type and an IO object. Grape wraps that + # using hashie for convenience. Do a bit of basic + # duck-typing. + value.is_a?(Hashie::Mash) && value.key?(:tempfile) + end + end + end + end +end diff --git a/lib/grape/validations/types/json.rb b/lib/grape/validations/types/json.rb new file mode 100644 index 0000000000..220d1db6d7 --- /dev/null +++ b/lib/grape/validations/types/json.rb @@ -0,0 +1,65 @@ +require 'json' + +module Grape + module Validations + module Types + # +Virtus::Attribute+ implementation that handles coercion + # and type checking for parameters that are complex types + # given as JSON-encoded strings. It accepts both JSON objects + # and arrays of objects, and will coerce the input to a +Hash+ + # or +Array+ object respectively. In either case the Grape + # validation system will apply nested validation rules to + # all returned objects. + class Json < Virtus::Attribute + # Coerce the input into a JSON-like data structure. + # + # @param input [String] a JSON-encoded parameter value + # @return [Hash,Array,nil] + def coerce(input) + # Allow nulls and blank strings + return if input.nil? || input =~ /^\s*$/ + JSON.parse(input, symbolize_names: true) + end + + # Checks that the input was parsed successfully + # and isn't something odd such as an array of primitives. + # + # @param value [Object] result of {#coerce} + # @return [true,false] + def value_coerced?(value) + value.is_a?(::Hash) || coerced_collection?(value) + end + + protected + + # Is the value an array of JSON-like objects? + # + # @param value [Object] result of {#coerce} + # @return [true,false] + def coerced_collection?(value) + value.is_a?(::Array) && value.all? { |i| i.is_a? ::Hash } + end + end + + # Specialization of the {Json} attribute that is guaranteed + # to return an array of objects. Accepts both JSON-encoded + # objects and arrays of objects, but wraps single objects + # in an Array. + class JsonArray < Json + # See {Json#coerce}. Wraps single objects in an array. + # + # @param input [String] JSON-encoded parameter value + # @return [Array] + def coerce(input) + json = super + Array.wrap(json) unless json.nil? + end + + # See {Json#coerced_collection?} + def value_coerced?(value) + coerced_collection? value + end + end + end + end +end diff --git a/lib/grape/validations/validators/coerce.rb b/lib/grape/validations/validators/coerce.rb index d91d0ccef9..2e2178742b 100644 --- a/lib/grape/validations/validators/coerce.rb +++ b/lib/grape/validations/validators/coerce.rb @@ -19,55 +19,19 @@ class InvalidValue; end private - def _valid_array_type?(type, values) - values.all? do |val| - _valid_single_type?(type, val) - end - end + def valid_type?(val) + # Special value to denote coercion failure + return false if val.instance_of?(InvalidValue) - def _valid_single_type?(klass, val) - # allow nil, to ignore when a parameter is absent + # Allow nil, to ignore when a parameter is absent return true if val.nil? - if klass == Virtus::Attribute::Boolean - val.is_a?(TrueClass) || val.is_a?(FalseClass) || (val.is_a?(String) && val.empty?) - elsif klass == Rack::Multipart::UploadedFile - val.is_a?(Hashie::Mash) && val.key?(:tempfile) - elsif [DateTime, Date, Numeric].any? { |vclass| vclass >= klass } - return true if val.is_a?(String) && val.empty? - val.is_a?(klass) - else - val.is_a?(klass) - end - end - 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 - _valid_single_type?(type, val) - end + converter.value_coerced? 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? + unless (@option[:method] && !val.nil?) || type.is_a?(Virtus::Attribute) return val || [] if type == Array return val || Set.new if type == Set return val || {} if type == Hash @@ -81,53 +45,22 @@ def coerce_value(val) InvalidValue.new end + # Type to which the parameter will be coerced. + # + # @return [Class] def type @option[:type] end + # Create and cache the attribute object + # that will be used for parameter coercion + # and type checking. + # + # See {Types.build_coercer} + # + # @return [Virtus::Attribute] def converter - @converter ||= - begin - # If any custom conversion method has been supplied - # via the coerce_with parameter, pass it on to Virtus. - converter_options = {} - if @option[:method] - # Accept classes implementing parse() - coercer = if @option[:method].respond_to? :parse - @option[:method].method(:parse) - else - # Otherwise expect a lambda function or similar - @option[:method] - end - - # Enforce symbolized keys for complex types - # by wrapping the coercion method. - # This helps common libs such as JSON to work easily. - if type == Array || type == Set - converter_options[:coercer] = lambda do |val| - coercer.call(val).tap do |new_value| - new_value.each do |item| - Hashie.symbolize_keys!(item) if item.is_a? Hash - end - end - end - elsif type == Hash - converter_options[:coercer] = lambda do |val| - Hashie.symbolize_keys! coercer.call(val) - end - else - # Simple types do not need a wrapper - converter_options[:coercer] = coercer - end - - # Custom types may be used without an explicit coercion method - # if they implement a `parse` class method. - elsif ParameterTypes.custom_type?(type) - converter_options[:coercer] = type.method(:parse) - end - - Virtus::Attribute.build(type, converter_options) - end + @converter ||= Types.build_coercer(type, @option[:method]) end end end diff --git a/lib/virtus/attribute/collection_patch.rb b/lib/virtus/attribute/collection_patch.rb new file mode 100644 index 0000000000..eab5208b27 --- /dev/null +++ b/lib/virtus/attribute/collection_patch.rb @@ -0,0 +1,16 @@ +require 'virtus/attribute/collection' + +# See https://github.com/solnic/virtus/pull/343 +# This monkey-patch fixes type validation for collections, +# ensuring that type assertions are applied to collection +# members. +# +# This patch duplicates the code in the above pull request. +# Once the request, or equivalent functionality, has been +# published into the +virtus+ gem this file should be deleted. +Virtus::Attribute::Collection.class_eval do + # @api public + def value_coerced?(value) + super && value.all? { |item| member_type.value_coerced? item } + end +end diff --git a/spec/grape/util/parameter_types_spec.rb b/spec/grape/util/parameter_types_spec.rb deleted file mode 100644 index 4f0c43e012..0000000000 --- a/spec/grape/util/parameter_types_spec.rb +++ /dev/null @@ -1,56 +0,0 @@ -require 'spec_helper' - -describe Grape::ParameterTypes do - module ParameterTypesSpec - class FooType - def self.parse(_) - end - end - - class BarType - def self.parse - end - end - end - - describe '::primitive?' do - [ - Integer, Float, Numeric, BigDecimal, - Virtus::Attribute::Boolean, String, Symbol, - Date, DateTime, Time, Rack::Multipart::UploadedFile - ].each do |type| - it "recognizes #{type} as a primitive" do - expect(described_class.primitive?(type)).to be_truthy - end - end - - it 'identifies unknown types' do - expect(described_class.primitive?(Object)).to be_falsy - expect(described_class.primitive?(ParameterTypesSpec::FooType)).to be_falsy - end - end - - describe '::structure?' do - [ - Hash, Array, Set - ].each do |type| - it "recognizes #{type} as a structure" do - expect(described_class.structure?(type)).to be_truthy - end - end - end - - describe '::custom_type?' do - it 'returns false if the type does not respond to :parse' do - expect(described_class.custom_type?(Object)).to be_falsy - end - - it 'returns true if the type responds to :parse with one argument' do - expect(described_class.custom_type?(ParameterTypesSpec::FooType)).to be_truthy - end - - it 'returns false if the type\'s #parse method takes other than one argument' do - expect(described_class.custom_type?(ParameterTypesSpec::BarType)).to be_falsy - end - end -end diff --git a/spec/grape/validations/formats/date_times_spec.rb b/spec/grape/validations/formats/date_times_spec.rb new file mode 100644 index 0000000000..ccd1227cee --- /dev/null +++ b/spec/grape/validations/formats/date_times_spec.rb @@ -0,0 +1,54 @@ +require 'grape/validations/formats/date_times' + +describe Grape::Validations::Formats::DateTimes do + let(:api) { Class.new(Grape::API) } + + def app + api + end + + DATE_TIME_INPUTS = { + HttpDate: ['Sat, 03 Feb 2001 04:05:06 GMT'], + Iso8601: %w( + 2001-02-03T04:05:06+07:00 + 20010203T040506+0700 + 2001-W05-6T04:05:06+07:00 + ), + Jisx0301: %w(H13.02.03T04:05:06+07:00), + JulianDay: %w(2451944), + Rfc2822: ['Sat, 3 Feb 2001 04:05:06 +0700'], + Rfc3339: %w(2001-02-03T04:05:06+07:00), + Rfc822: ['Sat, 3 Feb 2001 04:05:06 +0700'], + XmlSchema: %w(2001-02-03T04:05:06+07:00) + } + + COERCION_EXPECTATION = Hash.new('DateTime.2001-02-03T04:05:06+07:00') + # Special expectations for formats that don't support + # full timestamp plus timezone. + COERCION_EXPECTATION[:HttpDate] = 'DateTime.2001-02-03T04:05:06+00:00' + COERCION_EXPECTATION[:JulianDay] = 'DateTime.2001-02-03T00:00:00+00:00' + + context 'timestamp coercion' do + described_class.constants.each do |format_name| + it "coerces from #{format_name}" do + format = described_class.const_get format_name + api.params do + requires :time, type: format + end + api.get '/' do + "#{params[:time].class}.#{params[:time]}" + end + + DATE_TIME_INPUTS[format_name].each do |input| + get '/', time: input + expect(last_response.status).to eq(200) + expect(last_response.body).to eq(COERCION_EXPECTATION[format_name]) + + get '/', time: 'definitely not a timestamp' + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('time is invalid') + end + end + end + end +end diff --git a/spec/grape/validations/formats/dates_spec.rb b/spec/grape/validations/formats/dates_spec.rb new file mode 100644 index 0000000000..6e37d25f49 --- /dev/null +++ b/spec/grape/validations/formats/dates_spec.rb @@ -0,0 +1,44 @@ +require 'grape/validations/formats/dates' + +describe Grape::Validations::Formats::Dates do + let(:api) { Class.new(Grape::API) } + + def app + api + end + + DATE_INPUTS = { + HttpDate: ['Sat, 03 Feb 2001 00:00:00 GMT'], + Iso8601: %w(2001-02-03 20010203 2001-W05-6), + Jisx0301: %w(H13.02.03), + JulianDay: %w(2451944), + Rfc2822: ['Sat, 3 Feb 2001 00:00:00 +0000'], + Rfc3339: %w(2001-02-03T04:05:06+07:00), + Rfc822: ['Sat, 3 Feb 2001 00:00:00 +0000'], + XmlSchema: %w(2001-02-03) + } + + context 'date coercion' do + described_class.constants.each do |format_name| + it "coerces from #{format_name}" do + format = described_class.const_get format_name + api.params do + requires :date, type: format + end + api.get '/' do + "#{params[:date].class}.#{params[:date]}" + end + + DATE_INPUTS[format_name].each do |input| + get '/', date: input + expect(last_response.status).to eq(200) + expect(last_response.body).to eq('Date.2001-02-03') + + get '/', date: 'definitely not a date' + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('date is invalid') + end + end + end + end +end diff --git a/spec/grape/validations/types_spec.rb b/spec/grape/validations/types_spec.rb new file mode 100644 index 0000000000..eb2f0ce5f1 --- /dev/null +++ b/spec/grape/validations/types_spec.rb @@ -0,0 +1,95 @@ +require 'spec_helper' + +describe Grape::Validations::Types do + module TypesSpec + class FooType + def self.parse(_) + end + end + + class BarType + def self.parse + end + end + end + + VirtusA = Virtus::Attribute.build(String) + + module VirtusModule + include Virtus.module + end + + class VirtusB + include VirtusModule + end + + class VirtusC + include Virtus.model + end + + MyAxiom = Axiom::Types::String.new do + minimum_length 1 + maximum_length 30 + end + + describe '::primitive?' do + [ + Integer, Float, Numeric, BigDecimal, + Virtus::Attribute::Boolean, String, Symbol, + Date, DateTime, Time, Rack::Multipart::UploadedFile + ].each do |type| + it "recognizes #{type} as a primitive" do + expect(described_class.primitive?(type)).to be_truthy + end + end + + it 'identifies unknown types' do + expect(described_class.primitive?(Object)).to be_falsy + expect(described_class.primitive?(TypesSpec::FooType)).to be_falsy + end + end + + describe '::structure?' do + [ + Hash, Array, Set + ].each do |type| + it "recognizes #{type} as a structure" do + expect(described_class.structure?(type)).to be_truthy + end + end + end + + describe '::recognized?' do + [ + VirtusA, VirtusB, VirtusC, MyAxiom + ].each do |type| + it "recognizes #{type}" do + expect(described_class.recognized?(type)).to be_truthy + end + end + end + + describe '::special?' do + [ + JSON, Array[JSON], File, Rack::Multipart::UploadedFile + ].each do |type| + it "provides special handling for #{type.inspect}" do + expect(described_class.special?(type)).to be_truthy + end + end + end + + describe '::custom?' do + it 'returns false if the type does not respond to :parse' do + expect(described_class.custom?(Object)).to be_falsy + end + + it 'returns true if the type responds to :parse with one argument' do + expect(described_class.custom?(TypesSpec::FooType)).to be_truthy + end + + it 'returns false if the type\'s #parse method takes other than one argument' do + expect(described_class.custom?(TypesSpec::BarType)).to be_falsy + end + end +end diff --git a/spec/grape/validations/validators/coerce_spec.rb b/spec/grape/validations/validators/coerce_spec.rb index 7dea93761d..6e1c132e3e 100644 --- a/spec/grape/validations/validators/coerce_spec.rb +++ b/spec/grape/validations/validators/coerce_spec.rb @@ -224,9 +224,9 @@ class User expect(last_response.body).to eq('TrueClass') end - it 'file' do + it 'Rack::Multipart::UploadedFile' do subject.params do - requires :file, coerce: Rack::Multipart::UploadedFile + requires :file, type: Rack::Multipart::UploadedFile end subject.post '/upload' do params[:file].filename @@ -235,6 +235,27 @@ class User post '/upload', file: Rack::Test::UploadedFile.new(__FILE__) expect(last_response.status).to eq(201) expect(last_response.body).to eq(File.basename(__FILE__).to_s) + + post '/upload', file: 'not a file' + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('file is invalid') + end + + it 'File' do + subject.params do + requires :file, coerce: File + end + subject.post '/upload' do + params[:file].filename + end + + post '/upload', file: Rack::Test::UploadedFile.new(__FILE__) + expect(last_response.status).to eq(201) + expect(last_response.body).to eq(File.basename(__FILE__).to_s) + + post '/upload', file: 'not a file' + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('file is invalid') end it 'Nests integers' do