Skip to content

Commit 3ed8bbe

Browse files
javmorindslh
authored andcommitted
Allow coercion of collections of a type implementing .parse.
1 parent 5fac5fd commit 3ed8bbe

File tree

6 files changed

+165
-1
lines changed

6 files changed

+165
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* [#1686](https://github.com/ruby-grape/grape/pull/1686): Avoid coercion of a value if it is valid - [@timothysu](https://github.com/timothysu).
66
* [#1688](https://github.com/ruby-grape/grape/pull/1688): Removes yard docs - [@ramkumar-kr](https://github.com/ramkumar-kr).
77
* [#1702](https://github.com/ruby-grape/grape/pull/1702): Added danger-toc, verify correct TOC in README - [@dblock](https://github.com/dblock).
8+
* [#1711](https://github.com/ruby-grape/grape/pull/1711): Automatically coerce arrays and sets of types that implement a `parse` method - [@dslh](https://github.com/dslh).
89

910
#### Fixes
1011

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -909,6 +909,8 @@ end
909909

910910
params do
911911
requires :color, type: Color, default: Color.new('blue')
912+
requires :more_colors, type: Array[Color] # Collections work
913+
optional :unique_colors, type: Set[Color] # Duplicates discarded
912914
end
913915

914916
get '/stuff' do
@@ -943,6 +945,26 @@ params do
943945
end
944946
```
945947

948+
Grape will assert that coerced values match the given `type`, and will reject the request
949+
if they do not. To override this behaviour, custom types may implement a `parsed?` method
950+
that should accept a single argument and return `true` if the value passes type validation.
951+
952+
```ruby
953+
class SecureUri
954+
def self.parse(value)
955+
URI.parse value
956+
end
957+
958+
def self.parsed?(value)
959+
value.is_a? URI::HTTPS
960+
end
961+
end
962+
963+
params do
964+
requires :secure_uri, type: SecureUri
965+
end
966+
```
967+
946968
### Multipart File Parameters
947969

948970
Grape makes use of `Rack::Request`'s built-in support for multipart file parameters. Such parameters can be declared with `type: File`:

lib/grape/validations/types.rb

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
require_relative 'types/build_coercer'
22
require_relative 'types/custom_type_coercer'
3+
require_relative 'types/custom_type_collection_coercer'
34
require_relative 'types/multiple_type_coercer'
45
require_relative 'types/variant_collection_coercer'
56
require_relative 'types/json'
@@ -143,7 +144,8 @@ def self.group?(type)
143144
end
144145

145146
# A valid custom type must implement a class-level `parse` method, taking
146-
# one String argument and returning the parsed value in its correct type.
147+
# one String argument and returning the parsed value in its correct type.
148+
#
147149
# @param type [Class] type to check
148150
# @return [Boolean] whether or not the type can be used as a custom type
149151
def self.custom?(type)
@@ -155,6 +157,17 @@ def self.custom?(type)
155157
type.respond_to?(:parse) &&
156158
type.method(:parse).arity == 1
157159
end
160+
161+
# Is the declared type an +Array+ or +Set+ of a {#custom?} type?
162+
#
163+
# @param type [Array<Class>,Class] type to check
164+
# @return [Boolean] true if +type+ is a collection of a type that implements
165+
# its own +#parse+ method.
166+
def self.collection_of_custom?(type)
167+
(type.is_a?(Array) || type.is_a?(Set)) &&
168+
type.length == 1 &&
169+
custom?(type.first)
170+
end
158171
end
159172
end
160173
end

lib/grape/validations/types/build_coercer.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ def self.create_coercer_instance(type, method = nil)
5151
elsif method || Types.custom?(type)
5252
converter_options[:coercer] = Types::CustomTypeCoercer.new(type, method)
5353

54+
# Special coercer for collections of types that implement a parse method.
55+
# CustomTypeCoercer (above) already handles such types when an explicit coercion
56+
# method is supplied.
57+
elsif Types.collection_of_custom?(type)
58+
converter_options[:coercer] = Types::CustomTypeCollectionCoercer.new(
59+
type.first, type.is_a?(Set)
60+
)
61+
5462
# Grape swaps in its own Virtus::Attribute implementations
5563
# for certain special types that merit first-class support
5664
# (but not if a custom coercion method has been supplied).
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
module Grape
2+
module Validations
3+
module Types
4+
# Instances of this class may be passed to
5+
# +Virtus::Attribute.build+ as the +:coercer+
6+
# option, to handle collections of types that
7+
# provide their own parsing (and optionally,
8+
# type-checking) functionality.
9+
#
10+
# See {CustomTypeCoercer} for details on types
11+
# that will be supported by this by this coercer.
12+
# This coercer works in the same way as +CustomTypeCoercer+
13+
# except that it expects to receive an array of strings to
14+
# coerce and will return an array (or optionally, a set)
15+
# of coerced values.
16+
#
17+
# +CustomTypeCoercer+ is already capable of providing type
18+
# checking for arrays where an independent coercion method
19+
# is supplied. As such, +CustomTypeCollectionCoercer+ does
20+
# not allow for such a method to be supplied independently
21+
# of the type.
22+
class CustomTypeCollectionCoercer < CustomTypeCoercer
23+
# A new coercer for collections of the given type.
24+
#
25+
# @param type [Class,#parse]
26+
# type to which items in the array should be coerced.
27+
# Must implement a +parse+ method which accepts a string,
28+
# and for the purposes of type-checking it may either be
29+
# a class, or it may implement a +coerced?+, +parsed?+ or
30+
# +call+ method (in that order of precedence) which
31+
# accepts a single argument and returns true if the given
32+
# array item has been coerced correctly.
33+
# @param set [Boolean]
34+
# when true, a +Set+ will be returned by {#call} instead
35+
# of an +Array+ and duplicate items will be discarded.
36+
def initialize(type, set = false)
37+
super(type)
38+
@set = set
39+
end
40+
41+
# This method is called from somewhere within
42+
# +Virtus::Attribute::coerce+ in order to coerce
43+
# the given value.
44+
#
45+
# @param value [Array<String>] an array of values to be coerced
46+
# @return [Array,Set] the coerced result. May be an +Array+ or a
47+
# +Set+ depending on the setting given to the constructor
48+
def call(value)
49+
coerced = value.map { |item| super(item) }
50+
51+
@set ? Set.new(coerced) : coerced
52+
end
53+
54+
# This method is called from somewhere within
55+
# +Virtus::Attribute::value_coerced?+ in order to assert
56+
# that the all of the values in the array have been coerced
57+
# successfully.
58+
#
59+
# @param primitive [Axiom::Types::Type] primitive type for
60+
# the coercion as deteced by axiom-types' inference system.
61+
# @param value [Enumerable] a coerced result returned from {#call}
62+
# @return [true,false] whether or not all of the coerced values in
63+
# the collection satisfy type requirements.
64+
def success?(primitive, value)
65+
value.is_a?(@set ? Set : Array) &&
66+
value.all? { |item| super(primitive, item) }
67+
end
68+
end
69+
end
70+
end
71+
end

spec/grape/validations/validators/coerce_spec.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,55 @@ class User
225225
expect(last_response.status).to eq(200)
226226
expect(last_response.body).to eq('1')
227227
end
228+
229+
it 'Array of type implementing parse' do
230+
subject.params do
231+
requires :uri, type: Array[URI]
232+
end
233+
subject.get '/uri_array' do
234+
params[:uri][0].class
235+
end
236+
get 'uri_array', uri: ['http://www.google.com']
237+
expect(last_response.status).to eq(200)
238+
expect(last_response.body).to eq('URI::HTTP')
239+
end
240+
241+
it 'Set of type implementing parse' do
242+
subject.params do
243+
requires :uri, type: Set[URI]
244+
end
245+
subject.get '/uri_array' do
246+
"#{params[:uri].class},#{params[:uri].first.class},#{params[:uri].size}"
247+
end
248+
get 'uri_array', uri: Array.new(2) { 'http://www.example.com' }
249+
expect(last_response.status).to eq(200)
250+
expect(last_response.body).to eq('Set,URI::HTTP,1')
251+
end
252+
253+
it 'Array of class implementing parse and parsed?' do
254+
class SecureURIOnly
255+
def self.parse(value)
256+
URI.parse(value)
257+
end
258+
259+
def self.parsed?(value)
260+
value.is_a? URI::HTTPS
261+
end
262+
end
263+
264+
subject.params do
265+
requires :uri, type: Array[SecureURIOnly]
266+
end
267+
subject.get '/secure_uris' do
268+
params[:uri].first.class
269+
end
270+
get 'secure_uris', uri: ['https://www.example.com']
271+
expect(last_response.status).to eq(200)
272+
expect(last_response.body).to eq('URI::HTTPS')
273+
get 'secure_uris', uri: ['https://www.example.com', 'http://www.example.com']
274+
expect(last_response.status).to eq(400)
275+
expect(last_response.body).to eq('uri is invalid')
276+
end
228277
end
229278

230279
context 'Set' do

0 commit comments

Comments
 (0)