Skip to content

Commit 4f5dcf1

Browse files
rnubelRobert Nubel
authored andcommitted
Support custom types.
Any class can now be used as a type for a parameter, so long as it defines a class-level `parse` method. This method should raise on invalid values and otherwise return the coerced value. Also, I added the Grape::ParameterTypes module, which serves right now only as a basis to determine if a given type is a custom type or not, but in the future could be used to actually validate the types that are given via the params DSL.
1 parent b11a93a commit 4f5dcf1

File tree

7 files changed

+204
-3
lines changed

7 files changed

+204
-3
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ Next Release
33

44
#### Features
55

6-
* Your contribution here.
6+
* [#1039](https://github.com/intridea/grape/pull/1039): Added support for custom parameter types - [@rnubel](https://github.com/rnubel).
7+
* Your contribution here!
78

89
#### Fixes
910

README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
- [Declared](#declared)
3030
- [Include Missing](#include-missing)
3131
- [Parameter Validation and Coercion](#parameter-validation-and-coercion)
32+
- [Supported Parameter Types](#supported-parameter-types)
33+
- [Custom Types](#custom-types)
3234
- [Built-in Validators](#built-in-validators)
3335
- [Namespace Validation and Coercion](#namespace-validation-and-coercion)
3436
- [Custom Validators](#custom-validators)
@@ -730,6 +732,54 @@ params do
730732
end
731733
```
732734

735+
#### Supported Parameter Types
736+
737+
The following are all valid types, supported out of the box by Grape:
738+
739+
* Integer
740+
* Float
741+
* BigDecimal
742+
* Numeric
743+
* Date
744+
* DateTime
745+
* Time
746+
* Boolean
747+
* String
748+
* Symbol
749+
* Rack::Multipart::UploadedFile
750+
751+
#### Custom Types
752+
753+
Aside from the default set of supported types listed above, any class can be
754+
used as a type so long as it defines a class-level `parse` method. This method
755+
must take one string argument and return an instance of the correct type, or
756+
raise an exception to indicate the value was invalid. E.g.,
757+
758+
```ruby
759+
class Color
760+
attr_reader :value
761+
def initialize(color)
762+
@value = color
763+
end
764+
765+
def self.parse(value)
766+
fail 'Invalid color' unless %w(blue red green).include?(value)
767+
new(value)
768+
end
769+
end
770+
771+
# ...
772+
773+
params do
774+
requires :color, type: Color, default: Color.new('blue')
775+
end
776+
777+
get '/stuff' do
778+
# params[:color] is already a Color.
779+
params[:color].value
780+
end
781+
```
782+
733783
#### Validation of Nested Parameters
734784

735785
Parameters can be nested using `group` or by calling `requires` or `optional` with a block.

lib/grape.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
require 'active_support/core_ext/array/extract_options'
2323
require 'active_support/core_ext/hash/deep_merge'
2424
require 'active_support/dependencies/autoload'
25-
require 'grape/util/content_types'
2625
require 'multi_json'
2726
require 'multi_xml'
2827
require 'virtus'
@@ -160,6 +159,9 @@ module Presenters
160159
end
161160
end
162161

162+
require 'grape/util/content_types'
163+
require 'grape/util/parameter_types'
164+
163165
require 'grape/validations/validators/base'
164166
require 'grape/validations/attributes_iterator'
165167
require 'grape/validations/validators/allow_blank'

lib/grape/util/parameter_types.rb

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
module Grape
2+
module ParameterTypes
3+
# Types representing a single value, which are coerced through Virtus
4+
# or special logic in Grape.
5+
PRIMITIVES = [
6+
# Numerical
7+
Integer,
8+
Float,
9+
BigDecimal,
10+
Numeric,
11+
12+
# Date/time
13+
Date,
14+
DateTime,
15+
Time,
16+
17+
# Misc
18+
Virtus::Attribute::Boolean,
19+
String,
20+
Symbol,
21+
Rack::Multipart::UploadedFile
22+
]
23+
24+
# Types representing data structures.
25+
STRUCTURES = [
26+
Hash,
27+
Array,
28+
Set
29+
]
30+
31+
# @param type [Class] type to check
32+
# @returns [Boolean] whether or not the type is known by Grape as a valid
33+
# type for a single value
34+
def self.primitive?(type)
35+
PRIMITIVES.include?(type)
36+
end
37+
38+
# @param type [Class] type to check
39+
# @returns [Boolean] whether or not the type is known by Grape as a valid
40+
# data structure type
41+
# @note This method does not yet consider 'complex types', which inherit
42+
# Virtus.model.
43+
def self.structure?(type)
44+
STRUCTURES.include?(type)
45+
end
46+
47+
# A valid custom type must implement a class-level `parse` method, taking
48+
# one String argument and returning the parsed value in its correct type.
49+
# @param type [Class] type to check
50+
# @returns [Boolean] whether or not the type can be used as a custom type
51+
def self.custom_type?(type)
52+
!primitive?(type) &&
53+
!structure?(type) &&
54+
type.respond_to?(:parse) &&
55+
type.method(:parse).arity == 1
56+
end
57+
end
58+
end

lib/grape/validations/validators/coerce.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,14 @@ def coerce_value(type, val)
5656
return val || Set.new if type == Set
5757
return val || {} if type == Hash
5858

59-
converter = Virtus::Attribute.build(type)
59+
# To support custom types that Virtus can't easily coerce, pass in an
60+
# explicit coercer. Custom types must implement a `parse` class method.
61+
converter_options = {}
62+
if ParameterTypes.custom_type?(type)
63+
converter_options[:coercer] = type.method(:parse)
64+
end
65+
66+
converter = Virtus::Attribute.build(type, converter_options)
6067
converter.coerce(val)
6168

6269
# not the prettiest but some invalid coercion can currently trigger
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
require 'spec_helper'
2+
3+
describe Grape::ParameterTypes do
4+
class FooType
5+
def self.parse(_)
6+
end
7+
end
8+
9+
class BarType
10+
def self.parse
11+
end
12+
end
13+
14+
describe '::primitive?' do
15+
[
16+
Integer, Float, Numeric, BigDecimal,
17+
Virtus::Attribute::Boolean, String, Symbol,
18+
Date, DateTime, Time, Rack::Multipart::UploadedFile
19+
].each do |type|
20+
it "recognizes #{type} as a primitive" do
21+
expect(described_class.primitive?(type)).to be_truthy
22+
end
23+
end
24+
25+
it 'identifies unknown types' do
26+
expect(described_class.primitive?(Object)).to be_falsy
27+
expect(described_class.primitive?(FooType)).to be_falsy
28+
end
29+
end
30+
31+
describe '::structure?' do
32+
[
33+
Hash, Array, Set
34+
].each do |type|
35+
it "recognizes #{type} as a structure" do
36+
expect(described_class.structure?(type)).to be_truthy
37+
end
38+
end
39+
end
40+
41+
describe '::custom_type?' do
42+
it 'returns false if the type does not respond to :parse' do
43+
expect(described_class.custom_type?(Object)).to be_falsy
44+
end
45+
46+
it 'returns true if the type responds to :parse with one argument' do
47+
expect(described_class.custom_type?(FooType)).to be_truthy
48+
end
49+
50+
it 'returns false if the type\'s #parse method takes other than one argument' do
51+
expect(described_class.custom_type?(BarType)).to be_falsy
52+
end
53+
end
54+
end

spec/grape/validations/params_scope_spec.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,35 @@ def app
8989
end
9090
end
9191

92+
context 'when using custom types' do
93+
class CustomType
94+
attr_reader :value
95+
def self.parse(value)
96+
fail if value == 'invalid'
97+
new(value)
98+
end
99+
100+
def initialize(value)
101+
@value = value
102+
end
103+
end
104+
105+
it 'coerces the parameter via the type\'s parse method' do
106+
subject.params do
107+
requires :foo, type: CustomType
108+
end
109+
subject.get('/types') { params[:foo].value }
110+
111+
get '/types', foo: 'valid'
112+
expect(last_response.status).to eq(200)
113+
expect(last_response.body).to eq('valid')
114+
115+
get '/types', foo: 'invalid'
116+
expect(last_response.status).to eq(400)
117+
expect(last_response.body).to match(/foo is invalid/)
118+
end
119+
end
120+
92121
context 'array without coerce type explicitly given' do
93122
it 'sets the type based on first element' do
94123
subject.params do

0 commit comments

Comments
 (0)