Skip to content

Commit

Permalink
Merge pull request #1161 from dslh/issue/1135
Browse files Browse the repository at this point in the history
Custom parameter coercion using `coerce_with` #1135
  • Loading branch information
dblock committed Sep 21, 2015
2 parents 51ffbdf + 7411e2f commit 7722cd1
Show file tree
Hide file tree
Showing 7 changed files with 147 additions and 16 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.

* [#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).

#### Fixes
Expand Down
28 changes: 23 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
- [Include Missing](#include-missing)
- [Parameter Validation and Coercion](#parameter-validation-and-coercion)
- [Supported Parameter Types](#supported-parameter-types)
- [Custom Types](#custom-types)
- [Custom Types and Coercions](#custom-types-and-coercions)
- [Validation of Nested Parameters](#validation-of-nested-parameters)
- [Dependent Parameters](#dependent-parameters)
- [Built-in Validators](#built-in-validators)
Expand Down Expand Up @@ -733,12 +733,13 @@ The following are all valid types, supported out of the box by Grape:
* Symbol
* Rack::Multipart::UploadedFile

### Custom Types
### Custom Types and Coercions

Aside from the default set of supported types listed above, any class can be
used as a type so long as it defines a class-level `parse` method. This method
must take one string argument and return an instance of the correct type, or
raise an exception to indicate the value was invalid. E.g.,
used as a type so long as an explicit coercion method is supplied. If the type
implements a class-level `parse` method, Grape will use it automatically.
This method must take one string argument and return an instance of the correct
type, or raise an exception to indicate the value was invalid. E.g.,

```ruby
class Color
Expand All @@ -765,6 +766,23 @@ get '/stuff' do
end
```

Alternatively, a custom coercion method may be supplied for any type of parameter
using `coerce_with`. Any class or object may be given that implements a `parse` or
`call` method, in that order of precedence. The method must accept a single string
parameter, and the return value must match the given `type`.

```ruby
params do
requires :passwd, type: String, coerce_with: Base64.method(:decode)
requires :loud_color, type: Color, coerce_with: ->(c) { Color.parse(c.downcase) }

requires :obj, type: Hash, coerce_with: JSON do
requires :words, type: Array[String], coerce_with: ->(val) { val.split(/\s+/) }
optional :time, type: Time, coerce_with: Chronic
end
end
```

### Validation of Nested Parameters

Parameters can be nested using `group` or by calling `requires` or `optional` with a block.
Expand Down
3 changes: 3 additions & 0 deletions lib/grape/dsl/parameters.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ def use(*names)
# 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
# that defines `::parse` or `::call` may be used.
#
# @example
#
Expand Down
12 changes: 11 additions & 1 deletion lib/grape/validations/params_scope.rb
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,11 @@ 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 @@ -216,7 +221,12 @@ def validates(attrs, validations)
# whatever coercion so that we are working with correctly
# type casted values
if validations.key? :coerce
validate('coerce', validations[:coerce], attrs, doc_attrs)
coerce_options = {
type: validations[:coerce],
method: validations[:coerce_with]
}
validate('coerce', coerce_options, attrs, doc_attrs)
validations.delete(:coerce_with)
validations.delete(:coerce)
end

Expand Down
7 changes: 7 additions & 0 deletions lib/grape/validations/validators/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ module Validations
class Base
attr_reader :attrs

# Creates a new Validator from options specified
# by a +requires+ or +optional+ directive during
# parameter definition.
# @param attrs [Array] names of attributes to which the Validator applies
# @param options [Object] implementation-dependent Validator options
# @param required [Boolean] attribute(s) are required or optional
# @param scope [ParamsScope] parent scope for this Validator
def initialize(attrs, options, required, scope)
@attrs = Array(attrs)
@option = options
Expand Down
54 changes: 44 additions & 10 deletions lib/grape/validations/validators/coerce.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,20 @@ def _valid_single_type?(klass, val)
def valid_type?(val)
if val.instance_of?(InvalidValue)
false
elsif @option.is_a?(Array) || @option.is_a?(Set)
_valid_array_type?(@option.first, val)
elsif type.is_a?(Array) || type.is_a?(Set)
_valid_array_type?(type.first, val)
else
_valid_single_type?(@option, val)
_valid_single_type?(type, val)
end
end

def coerce_value(val)
# Don't coerce things other than nil to Arrays or Hashes
return val || [] if type == Array
return val || Set.new if type == Set
return val || {} if type == Hash
unless @option[:method] && !val.nil?
return val || [] if type == Array
return val || Set.new if type == Set
return val || {} if type == Hash
end

converter.coerce(val)

Expand All @@ -65,18 +67,50 @@ def coerce_value(val)
end

def type
@option
@option[:type]
end

def converter
@converter ||=
begin
# To support custom types that Virtus can't easily coerce, pass in an
# explicit coercer. Custom types must implement a `parse` class method.
# If any custom conversion method has been supplied
# via the coerce_with parameter, pass it on to Virtus.
converter_options = {}
if ParameterTypes.custom_type?(type)
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
end
Expand Down
58 changes: 58 additions & 0 deletions spec/grape/validations/validators/coerce_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,64 @@ class User
end
end

context 'using coerce_with' do
it 'uses parse where available' do
subject.params do
requires :ints, type: Array, coerce_with: JSON do
requires :i, type: Integer
requires :j
end
end
subject.get '/ints' do
ints = params[:ints].first
'coercion works' if ints[:i] == 1 && ints[:j] == '2'
end

get '/ints', ints: [{ i: 1, j: '2' }]
expect(last_response.status).to eq(400)
expect(last_response.body).to eq('ints is invalid')

get '/ints', ints: '{"i":1,"j":"2"}'
expect(last_response.status).to eq(400)
expect(last_response.body).to eq('ints[i] is missing, ints[i] is invalid, ints[j] is missing')

get '/ints', ints: '[{"i":"1","j":"2"}]'
expect(last_response.status).to eq(200)
expect(last_response.body).to eq('coercion works')
end

it 'accepts any callable' do
subject.params do
requires :ints, type: Hash, coerce_with: JSON.method(:parse) do
requires :int, type: Integer, coerce_with: ->(val) { val == 'three' ? 3 : val }
end
end
subject.get '/ints' do
params[:ints][:int]
end

get '/ints', ints: '{"int":"3"}'
expect(last_response.status).to eq(400)
expect(last_response.body).to eq('ints[int] is invalid')

get '/ints', ints: '{"int":"three"}'
expect(last_response.status).to eq(200)
expect(last_response.body).to eq('3')

get '/ints', ints: '{"int":3}'
expect(last_response.status).to eq(200)
expect(last_response.body).to eq('3')
end

it 'must be supplied with :type or :coerce' do
expect do
subject.params do
requires :ints, coerce_with: JSON
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 7722cd1

Please sign in to comment.