Skip to content

Commit

Permalink
Allow parameters with more than one type.
Browse files Browse the repository at this point in the history
Adds `types` option for `requires` and `optional` endpoint parameter
declarations, allowing for parameters that have more than one allowed
type. See README.md for usage.
  • Loading branch information
dslh committed Oct 21, 2015
1 parent 9c8713d commit 3195ad2
Show file tree
Hide file tree
Showing 10 changed files with 416 additions and 31 deletions.
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.

* [#1188](https://github.com/ruby-grape/grape/putt/1188): Allow parameters with more than one type - [@dslh](https://github.com/dslh).
* [#1179](https://github.com/ruby-grape/grape/pull/1179): Allow all RFC6838 valid characters in header vendor - [@suan](https://github.com/suan).
* [#1170](https://github.com/ruby-grape/grape/pull/1170): Allow dashes and periods in header vendor - [@suan](https://github.com/suan).
* [#1167](https://github.com/ruby-grape/grape/pull/1167): Convenience wrapper `type: File` for validating multipart file parameters - [@dslh](https://github.com/dslh).
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
- [Custom Types and Coercions](#custom-types-and-coercions)
- [Multipart File Parameters](#multipart-file-parameters)
- [First-Class `JSON` Types](#first-class-json-types)
- [Multiple Allowed Types](#multiple-allowed-types)
- [Validation of Nested Parameters](#validation-of-nested-parameters)
- [Dependent Parameters](#dependent-parameters)
- [Built-in Validators](#built-in-validators)
Expand Down Expand Up @@ -852,6 +853,41 @@ 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`.

### Multiple Allowed Types

Variant-type parameters can be declared using the `types` option rather than `type`:

```ruby
params do
requires :status_code, types: [Integer, String, Array[Integer, String]]
end
get '/' do
params[:status_code].inspect
end

# ...

client.get('/', status_code: 'OK_GOOD') # => "OK_GOOD"
client.get('/', status_code: 300) # => 300
client.get('/', status_code: %w(404 NOT FOUND)) # => [404, "NOT", "FOUND"]
```

As a special case, variant-member-type collections may also be declared, by
passing a `Set` or `Array` with more than one member to `type`:

```ruby
params do
requires :status_codes, type: Array[Integer,String]
end
get '/' do
params[:status_codes].inspect
end

# ...

client.get('/', status_codes: %w(1 two)) # => [1, "two"]
```

### Validation of Nested Parameters

Parameters can be nested using `group` or by calling `requires` or `optional` with a block.
Expand Down
8 changes: 6 additions & 2 deletions lib/grape/dsl/parameters.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ def use(*names)
# 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 :types [Array<Class>] may be supplied in place of +:type+
# to declare an attribute that has multiple allowed types. See
# {Validations::Types::MultipleTypeCoercer} for more details on coercion
# and validation rules for variant-type parameters.
# @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
Expand Down Expand Up @@ -158,7 +162,7 @@ def all_or_none_of(*attrs)
# the given parameter is present. The parameters are not nested.
# @param attr [Symbol] the parameter which, if present, triggers the
# validations
# @throws Grape::Exceptions::UnknownParameter if `attr` has not been
# @raise Grape::Exceptions::UnknownParameter if `attr` has not been
# defined in this scope yet
# @yield a parameter definition DSL
def given(attr, &block)
Expand All @@ -168,7 +172,7 @@ def given(attr, &block)

# Test for whether a certain parameter has been defined in this params
# block yet.
# @returns [Boolean] whether the parameter has been defined
# @return [Boolean] whether the parameter has been defined
def declared_param?(param)
# @declared_params also includes hashes of options and such, but those
# won't be flattened out.
Expand Down
43 changes: 38 additions & 5 deletions lib/grape/validations/params_scope.rb
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,7 @@ def configure_declared_params
def validates(attrs, validations)
doc_attrs = { required: validations.keys.include?(:presence) }

# special case (type = coerce)
validations[:coerce] = validations.delete(:type) if validations.key?(:type)

coerce_type = validations[:coerce]
coerce_type = infer_coercion(validations)

doc_attrs[:type] = coerce_type.to_s if coerce_type

Expand Down Expand Up @@ -212,7 +209,7 @@ def validates(attrs, validations)
validations.delete(:presence)
end

# Before we run the rest of the validators, lets handle
# Before we run the rest of the validators, let's handle
# whatever coercion so that we are working with correctly
# type casted values
coerce_type validations, attrs, doc_attrs
Expand All @@ -222,6 +219,42 @@ def validates(attrs, validations)
end
end

# Validate and comprehend the +:type+, +:types+, and +:coerce_with+
# options that have been supplied to the parameter declaration.
# The +:type+ and +:types+ options will be removed from the
# validations list, replaced appropriately with +:coerce+ and
# +:coerce_with+ options that will later be passed to
# {Validators::CoerceValidator}. The type that is returned may be
# used for documentation and further validation of parameter
# options.
#
# @param validations [Hash] list of validations supplied to the
# parameter declaration
# @return [class-like] type to which the parameter will be coerced
# @raise [ArgumentError] if the given type options are invalid
def infer_coercion(validations)
if validations.key?(:type) && validations.key?(:types)
fail ArgumentError, ':type may not be supplied with :types'
end

validations[:coerce] = validations[:type] if validations.key?(:type)
validations[:coerce] = validations.delete(:types) if validations.key?(:types)

coerce_type = validations[:coerce]

# Special case - when the argument is a single type that is a
# variant-type collection.
if Types.multiple?(coerce_type) && validations.key?(:type)
validations[:coerce] = Types::VariantCollectionCoercer.new(
coerce_type,
validations.delete(:coerce_with)
)
end
validations.delete(:type)

coerce_type
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
Expand Down
19 changes: 19 additions & 0 deletions lib/grape/validations/types.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
require_relative 'types/build_coercer'
require_relative 'types/custom_type_coercer'
require_relative 'types/multiple_type_coercer'
require_relative 'types/variant_collection_coercer'
require_relative 'types/json'
require_relative 'types/file'

Expand All @@ -20,6 +22,10 @@ module Validations
# and {Grape::Dsl::Parameters#optional}. The main
# entry point for this process is {Types.build_coercer}.
module Types
# Instances of this class may be used as tokens to denote that
# a parameter value could not be coerced.
class InvalidValue; end

# Types representing a single value, which are coerced through Virtus
# or special logic in Grape.
PRIMITIVES = [
Expand Down Expand Up @@ -78,6 +84,18 @@ def self.structure?(type)
STRUCTURES.include?(type)
end

# Is the declared type in fact an array of multiple allowed types?
# For example the declaration +types: [Integer,String]+ will attempt
# first to coerce given values to integer, but will also accept any
# other string.
#
# @param type [Array<Class>,Set<Class>] type (or type list!) to check
# @return [Boolean] +true+ if the given value will be treated as
# a list of types.
def self.multiple?(type)
(type.is_a?(Array) || type.is_a?(Set)) && type.size > 1
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+
Expand Down Expand Up @@ -115,6 +133,7 @@ def self.special?(type)
def self.custom?(type)
!primitive?(type) &&
!structure?(type) &&
!multiple?(type) &&
!recognized?(type) &&
!special?(type) &&
type.respond_to?(:parse) &&
Expand Down
43 changes: 23 additions & 20 deletions lib/grape/validations/types/build_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,30 +20,33 @@ module Types
# @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
# Accept pre-rolled virtus attributes without interference
return type if type.is_a? Virtus::Attribute

# Use a special coercer for custom types and coercion methods.
if method || Types.custom?(type)
converter_options[:coercer] = Types::CustomTypeCoercer.new(type, method)
converter_options = {
nullify_blank: true
}
conversion_type = type

# 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
# Use a special coercer for multiply-typed parameters.
if Types.multiple?(type)
converter_options[:coercer] = Types::MultipleTypeCoercer.new(type, method)
conversion_type = Object

# Virtus will infer coercion and validation rules
# for many common ruby types.
Virtus::Attribute.build(conversion_type, converter_options)
# Use a special coercer for custom types and coercion methods.
elsif 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
Expand Down
76 changes: 76 additions & 0 deletions lib/grape/validations/types/multiple_type_coercer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
module Grape
module Validations
module Types
# This class is intended for use with Grape endpoint parameters that
# have been declared to be of variant-type using the +:types+ option.
# +MultipleTypeCoercer+ will build a coercer for each type declared
# in the array passed to +:types+ using {Types.build_coercer}. It will
# apply these coercers to parameter values in the order given to
# +:types+, and will return the value returned by the first coercer
# to successfully coerce the parameter value. Therefore if +String+ is
# an allowed type it should be declared last, since it will always
# successfully "coerce" the value.
class MultipleTypeCoercer
# Construct a new coercer that will attempt to coerce
# values to the given list of types in the given order.
#
# @param types [Array<Class>] list of allowed types
# @param method [#call,#parse] method by which values should be
# coerced. See class docs for default behaviour.
def initialize(types, method = nil)
@method = method.respond_to?(:parse) ? method.method(:parse) : method

@type_coercers = types.map do |type|
if Types.multiple? type
VariantCollectionCoercer.new type
else
Types.build_coercer type
end
end
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,InvalidValue] the coerced result, or an instance
# of {InvalidValue} if the value could not be coerced.
def call(value)
return @method.call(value) if @method

@type_coercers.each do |coercer|
coerced = coercer.coerce(value)

return coerced if coercer.value_coerced? coerced
end

# Declare that we couldn't coerce the value in such a way
# that Grape won't ask us again if the value is valid
InvalidValue.new
end

# This method is called from somewhere within
# +Virtus::Attribute::value_coerced?+ in order to
# assert that the value has been coerced successfully.
# Due to Grape's design this will in fact only be called
# if a custom coercion method is being used, since {#call}
# returns an {InvalidValue} object if the value could not
# be coerced.
#
# @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_coercers.any? { |coercer| coercer.value_coerced? value }
end
end
end
end
end
59 changes: 59 additions & 0 deletions lib/grape/validations/types/variant_collection_coercer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
module Grape
module Validations
module Types
# This class wraps {MultipleTypeCoercer}, for use with collections
# that allow members of more than one type.
class VariantCollectionCoercer < Virtus::Attribute
# Construct a new coercer that will attempt to coerce
# a list of values such that all members are of one of
# the given types. The container may also optionally be
# coerced to a +Set+. An arbitrary coercion +method+ may
# be supplied, which will be passed the entire collection
# as a parameter and should return a new collection, or
# may return the same one if no coercion was required.
#
# @param types [Array<Class>,Set<Class>] list of allowed types,
# also specifying the container type
# @param method [#call,#parse] method by which values should be coerced
def initialize(types, method = nil)
@types = types
@method = method.respond_to?(:parse) ? method.method(:parse) : method

# If we have a coercion method, pass it in here to save
# building another one, even though we call it directly.
@member_coercer = MultipleTypeCoercer.new types, method
end

# Coerce the given value.
#
# @param value [Array<String>] collection of values to be coerced
# @return [Array<Object>,Set<Object>,InvalidValue]
# the coerced result, or an instance
# of {InvalidValue} if the value could not be coerced.
def coerce(value)
return InvalidValue.new unless value.is_a? Array

value =
if @method
@method.call(value)
else
value.map { |v| @member_coercer.call(v) }
end
return Set.new value if @types.is_a? Set

value
end

# Assert that the value has been coerced successfully.
#
# @param value [Object] a coerced result returned from {#coerce}
# @return [true,false] whether or not the coerced value
# satisfies type requirements.
def value_coerced?(value)
value.is_a?(@types.class) &&
value.all? { |v| @member_coercer.success?(@types, v) }
end
end
end
end
end
Loading

0 comments on commit 3195ad2

Please sign in to comment.