coercive 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +201 -0
- data/lib/coercive.rb +289 -0
- data/lib/coercive/uri.rb +103 -0
- data/test/coercive.rb +336 -0
- data/test/uri.rb +196 -0
- metadata +51 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: d1b6a873b4a86ca962a22d60630a4de5355b278b529fccbd2615a794a7a94448
|
4
|
+
data.tar.gz: 62f47d1f35c9d931935138872dace70a7c39d3eb090529f1f2886448dca2e4ad
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: d2d5f401306d119e14a761aa1937f6c5777f69458d9db1367d35bd2a02fcea8a2d41876db4331a0086af8ec49db00b3f892d3c76dbcbacd6d297edc836cfb3db
|
7
|
+
data.tar.gz: e5e4ffc4e9e7874807a2b3af33fb3f846752acce0e9748d06c79247e54f7120ffb38ac5a764c33a629ee05009c62154864f41f975a7fa19985f77b96caa5d53e
|
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2020 Theorem
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,201 @@
|
|
1
|
+
# coercive
|
2
|
+
|
3
|
+
`Coercive` is a Ruby library to validate and coerce user input.
|
4
|
+
|
5
|
+
Define your coercion modules like this:
|
6
|
+
|
7
|
+
```ruby
|
8
|
+
module CoerceFoo
|
9
|
+
extend Coercive
|
10
|
+
|
11
|
+
attribute :foo, string(min: 1, max: 10), required
|
12
|
+
end
|
13
|
+
```
|
14
|
+
|
15
|
+
Pass in your user input and you'll get back validated and coerced attributes:
|
16
|
+
|
17
|
+
```ruby
|
18
|
+
attributes = CoerceFoo.call("foo" => "bar")
|
19
|
+
|
20
|
+
attributes["foo"]
|
21
|
+
# => "bar"
|
22
|
+
|
23
|
+
CoerceFoo.call("foo" => "more than 10 chars long")
|
24
|
+
# => Coercive::Error: {"foo"=>"too_long"}
|
25
|
+
|
26
|
+
CoerceFoo.call("bar" => "foo is not here")
|
27
|
+
# => Coercive::Error: {"foo"=>"not_present", "bar"=>"unknown"}
|
28
|
+
```
|
29
|
+
|
30
|
+
`Coercive`'s single entry-point is the `call` method that receives a `Hash`. It will compare each key-value pair against the definitions provided by the `attribute` method.
|
31
|
+
|
32
|
+
The `attribute` functions takes three arguments:
|
33
|
+
* The first one is the name of the attribute.
|
34
|
+
* The second one is a coerce function. Coercive comes with many available, and you can always write your own.
|
35
|
+
* The third one is a fetch function, used to look up the attribute in the input `Hash`.
|
36
|
+
|
37
|
+
## Fetch functions
|
38
|
+
|
39
|
+
As you saw in the example above, `required` is one of the three fetch functions available. Let's get into each of them and how they work.
|
40
|
+
|
41
|
+
### `required`
|
42
|
+
|
43
|
+
As the name says, `Coercive` will raise an error if the input lacks the attribute, and add the `"not_present"` error code.
|
44
|
+
|
45
|
+
```ruby
|
46
|
+
CoerceFoo.call("bar" => "foo is not here")
|
47
|
+
# => Coercive::Error: {"foo"=>"not_present", "bar"=>"unknown"}
|
48
|
+
```
|
49
|
+
|
50
|
+
### `optional`
|
51
|
+
|
52
|
+
The `optional` fetch function will grab an attribute from the input, but do nothing if it's not there. Let's look again at the example above:
|
53
|
+
|
54
|
+
```ruby
|
55
|
+
module CoerceFoo
|
56
|
+
extend Coercive
|
57
|
+
|
58
|
+
attribute :foo, string(min: 1, max: 10), required
|
59
|
+
end
|
60
|
+
|
61
|
+
CoerceFoo.call("bar" => "foo is not here")
|
62
|
+
# => Coercive::Error: {"foo"=>"not_present", "bar"=>"unknown"}
|
63
|
+
```
|
64
|
+
|
65
|
+
The `"bar"` attribute raises an error because it's unexpected. `Coercive` is thorough when it comes to the input. To make this go away, we have to add `"bar"` as optional:
|
66
|
+
|
67
|
+
```ruby
|
68
|
+
module CoerceFoo
|
69
|
+
extend Coercive
|
70
|
+
|
71
|
+
attribute :foo, string(min: 1, max: 10), required
|
72
|
+
attribute :bar, any, optional
|
73
|
+
end
|
74
|
+
|
75
|
+
CoerceFoo.call("bar" => "foo is not here")
|
76
|
+
# => Coercive::Error: {"foo"=>"not_present"}
|
77
|
+
```
|
78
|
+
|
79
|
+
### `implicit`
|
80
|
+
|
81
|
+
The last fetch function `Coercive` has is a handy way to set a default value when an attribute is not present in the input.
|
82
|
+
|
83
|
+
```ruby
|
84
|
+
module CoerceFoo
|
85
|
+
extend Coercive
|
86
|
+
|
87
|
+
attribute :foo, string(min: 1, max: 10), implicit("default")
|
88
|
+
attribute :bar, any, optional
|
89
|
+
end
|
90
|
+
|
91
|
+
CoerceFoo.call("bar" => "any")
|
92
|
+
# => {"foo"=>"default", "bar"=>"any"}
|
93
|
+
```
|
94
|
+
|
95
|
+
Keep in mind that your default must comply with the declared type and restrictions. In this case, `implicit("very long default value")` will raise an error because it's longer than 10 characters.
|
96
|
+
|
97
|
+
## Coercion functions
|
98
|
+
|
99
|
+
We already got a taste for the coercion functions with `string(min: 1, max:10)` and there are many more! but let's start there.
|
100
|
+
|
101
|
+
### `string(min:, max:, pattern:)`
|
102
|
+
|
103
|
+
The `string` coercion function will enforce a minimum and maximum character length, throwing `"too_short"` and `"too_long"` errors respectively if the input is not within the declared bounds.
|
104
|
+
|
105
|
+
Additionally, you can also verify your String matches a regular expression with the `pattern:` option.
|
106
|
+
|
107
|
+
```ruby
|
108
|
+
module CoerceFoo
|
109
|
+
extend Coercive
|
110
|
+
|
111
|
+
attribute :foo, string(pattern: /\A\h+\z/), optional
|
112
|
+
end
|
113
|
+
|
114
|
+
CoerceFoo.call("foo" => "REDBEETS")
|
115
|
+
# => Coercive::Error: {"foo"=>"not_valid"}
|
116
|
+
|
117
|
+
CoerceFoo.call("foo" => "DEADBEEF")
|
118
|
+
# => {"foo"=>"DEADBEEF"}
|
119
|
+
```
|
120
|
+
|
121
|
+
### `any`
|
122
|
+
|
123
|
+
The `any` coercion function lets anything pass through. It's commonly used with the `optional` fetch function when an attribute may or many not be a part of the input.
|
124
|
+
|
125
|
+
### `member`
|
126
|
+
|
127
|
+
`member` will check that the value is one of the values of the given array.
|
128
|
+
|
129
|
+
```ruby
|
130
|
+
module CoerceFoo
|
131
|
+
extend Coercive
|
132
|
+
|
133
|
+
attribute :foo, member(["one", "two", "three"]), optional
|
134
|
+
end
|
135
|
+
|
136
|
+
CoerceFoo.call("foo" => 4)
|
137
|
+
# => Coercive::Error: {"foo"=>"not_valid"}
|
138
|
+
```
|
139
|
+
|
140
|
+
### `float`
|
141
|
+
|
142
|
+
`float` expects, well, a float value.
|
143
|
+
|
144
|
+
```ruby
|
145
|
+
module CoerceFoo
|
146
|
+
extend Coercive
|
147
|
+
|
148
|
+
attribute :foo, float, optional
|
149
|
+
end
|
150
|
+
|
151
|
+
CoerceFoo.call("foo" => "bar")
|
152
|
+
# => Coercive::Error: {"foo"=>"not_valid"}
|
153
|
+
|
154
|
+
CoerceFoo.call("foo" => "0.1")
|
155
|
+
# => {"foo"=>0.1}
|
156
|
+
|
157
|
+
CoerceFoo.call("foo" => "0.1e5")
|
158
|
+
# => {"foo"=>10000.0}
|
159
|
+
```
|
160
|
+
|
161
|
+
### `array`
|
162
|
+
|
163
|
+
The `array` coercion is interesting because it's where `Coercive` starts to shine, by letting you compose coercion functions together. Let's see:
|
164
|
+
|
165
|
+
```ruby
|
166
|
+
module CoerceFoo
|
167
|
+
extend Coercive
|
168
|
+
|
169
|
+
attribute :foo, array(string), optional
|
170
|
+
end
|
171
|
+
|
172
|
+
CoerceFoo.call("foo" => ["one", "two", "three"])
|
173
|
+
# => {"foo"=>["one", "two", "three"]}
|
174
|
+
|
175
|
+
CoerceFoo.call("foo" => [1, 2, 3])
|
176
|
+
# => {"foo"=>["1", "2", "3"]}
|
177
|
+
|
178
|
+
CoerceFoo.call("foo" => [nil, true])
|
179
|
+
# => {"foo"=>["", "true"]}
|
180
|
+
|
181
|
+
CoerceFoo.call("foo" => [BasicObject.new])
|
182
|
+
# => Coercive::Error: {"foo"=>["not_valid"]}
|
183
|
+
```
|
184
|
+
|
185
|
+
### `hash`
|
186
|
+
|
187
|
+
`hash` coercion let's you manipulate the key and values, similarly to how `array` does
|
188
|
+
|
189
|
+
```ruby
|
190
|
+
module CoerceFoo
|
191
|
+
extend Coercive
|
192
|
+
|
193
|
+
attribute :foo, hash(string(max: 3), float), optional
|
194
|
+
end
|
195
|
+
|
196
|
+
CoerceFoo.call("foo" => {"bar" => "0.1"})
|
197
|
+
# => {"foo"=>{"bar"=>0.1}}
|
198
|
+
|
199
|
+
CoerceFoo.call("foo" => {"barrrr" => "0.1"})
|
200
|
+
# => Coercive::Error: {"foo"=>{"barrrr"=>"too_long"}}
|
201
|
+
```
|
data/lib/coercive.rb
ADDED
@@ -0,0 +1,289 @@
|
|
1
|
+
require_relative "coercive/uri"
|
2
|
+
|
3
|
+
# Public: The Coercive module implements a succinct DSL for declaring callable
|
4
|
+
# modules that will validate and coerce input data to an expected format.
|
5
|
+
module Coercive
|
6
|
+
# Public: An error raised when a coercion cannot produce a suitable result.
|
7
|
+
class Error < ArgumentError
|
8
|
+
# Public: The error or errors encountered in coercing the input.
|
9
|
+
attr_accessor :errors
|
10
|
+
|
11
|
+
def initialize(errors)
|
12
|
+
@errors = errors
|
13
|
+
super(errors.inspect)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
# Public: Coercive the given input using the declared attribute coercions.
|
18
|
+
#
|
19
|
+
# input - input Hash with string keys that correspond to declared attributes.
|
20
|
+
#
|
21
|
+
# Returns a Hash with known attributes as string keys and coerced values.
|
22
|
+
# Raises a Coercive::Error if the given input is not a Hash, or if there are
|
23
|
+
# any unknown string keys in the Hash, or if the values for the known keys
|
24
|
+
# do not pass the inner coercions for the associated declared attributes.
|
25
|
+
def call(input)
|
26
|
+
fail Coercive::Error.new("not_valid") unless input.is_a?(Hash)
|
27
|
+
|
28
|
+
errors = {}
|
29
|
+
|
30
|
+
# Fetch attributes from the input Hash into the fetched_attrs Hash.
|
31
|
+
#
|
32
|
+
# Each fetch function is responsible for fetching its associated attribute
|
33
|
+
# into the fetched_attrs Hash, or choosing not to fetch it, or choosing to
|
34
|
+
# raise a Coercive::Error.
|
35
|
+
#
|
36
|
+
# These fetch functions encapsulate the respective strategies for dealing
|
37
|
+
# with required, optional, or implicit attributes appropriately.
|
38
|
+
fetched_attrs = {}
|
39
|
+
attr_fetch_fns.each do |name, fetch_fn|
|
40
|
+
begin
|
41
|
+
fetch_fn.call(input, fetched_attrs)
|
42
|
+
rescue Coercive::Error => e
|
43
|
+
errors[name] = e.errors
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# Check for unknown names in the input (not declared, and thus not fetched).
|
48
|
+
input.each_key do |name|
|
49
|
+
errors[name] = "unknown" unless fetched_attrs.key?(name)
|
50
|
+
end
|
51
|
+
|
52
|
+
# Coercive fetched attributes into the coerced_attrs Hash.
|
53
|
+
#
|
54
|
+
# Each coerce function will coerce the given input value for that attribute
|
55
|
+
# to an acceptable output value, or choose to raise a Coercive::Error.
|
56
|
+
coerced_attrs = {}
|
57
|
+
fetched_attrs.each do |name, value|
|
58
|
+
coerce_fn = attr_coerce_fns.fetch(name)
|
59
|
+
begin
|
60
|
+
coerced_attrs[name] = coerce_fn.call(value)
|
61
|
+
rescue Coercive::Error => e
|
62
|
+
errors[name] = e.errors
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Fail if fetching or coercion caused any errors.
|
67
|
+
fail Coercive::Error.new(errors) unless errors.empty?
|
68
|
+
|
69
|
+
coerced_attrs
|
70
|
+
end
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
# Private: Hash with String attribute names as keys and fetch function values.
|
75
|
+
#
|
76
|
+
# Each coerce function will be called with one argument: the input to coerce.
|
77
|
+
#
|
78
|
+
# The coerce function can use any logic to convert the given input value
|
79
|
+
# to an acceptable output value, or raise a Coercive::Error for failure.
|
80
|
+
#
|
81
|
+
# In practice, it is most common to use one of the builtin generator methods
|
82
|
+
# (for example, string, or array(string)), or to use a module that was
|
83
|
+
# declared using the Coercive DSL functions, though any custom coerce function
|
84
|
+
# may be created and used for other behaviour, provided that it conforms to
|
85
|
+
# the same interface.
|
86
|
+
def attr_coerce_fns
|
87
|
+
@attr_coerce_fns ||= {}
|
88
|
+
end
|
89
|
+
|
90
|
+
# Private: Hash with String attribute names as keys and fetch function values.
|
91
|
+
#
|
92
|
+
# Each fetch function will be called with two arguments:
|
93
|
+
# 1 - input Hash of input attributes with String keys.
|
94
|
+
# 2 - output Hash in which the fetched attribute should be stored (if at all).
|
95
|
+
#
|
96
|
+
# The fetch function can use any logic to determine whether the attribute is
|
97
|
+
# present, whether it should be stored, whether to use an implicit default
|
98
|
+
# value, or whether to raise a Coercive::Error to propagate failure upward.
|
99
|
+
#
|
100
|
+
# In practice, it is most common to use one of the builtin generator methods,
|
101
|
+
# (required, optional, or implicit) to create the fetch function, though
|
102
|
+
# any custom fetch function could also be used for other behaviour.
|
103
|
+
#
|
104
|
+
# The return value of the fetch function will be ignored.
|
105
|
+
def attr_fetch_fns
|
106
|
+
@attr_fetch_fns ||= {}
|
107
|
+
end
|
108
|
+
|
109
|
+
# Public DSL: Declare a named attribute with a coercion and fetcher mechanism.
|
110
|
+
#
|
111
|
+
# name - a Symbol name for this attribute.
|
112
|
+
# coerce_fn - a coerce function which may be any callable object
|
113
|
+
# that accepts a single argument as the input data and
|
114
|
+
# returns the coerced output (or raises a Coercive::Error).
|
115
|
+
# See documentation for the attr_coerce_fns method.
|
116
|
+
# fetch_fn_generator - a callable generator that returns a fetch function when
|
117
|
+
# given the String name of the attribute to be fetched.
|
118
|
+
# See documentation for the attr_fetch_fns method.
|
119
|
+
#
|
120
|
+
# Returns the given name.
|
121
|
+
def attribute(name, coerce_fn, fetch_fn_generator)
|
122
|
+
str_name = name.to_s
|
123
|
+
|
124
|
+
attr_coerce_fns[str_name] = coerce_fn
|
125
|
+
attr_fetch_fns[str_name] = fetch_fn_generator.call(str_name)
|
126
|
+
|
127
|
+
name
|
128
|
+
end
|
129
|
+
|
130
|
+
# Public DSL: Return a coerce function that doesn't change or reject anything.
|
131
|
+
# Used when declaring an attribute. See documentation for attr_coerce_fns.
|
132
|
+
def any
|
133
|
+
->(input) do
|
134
|
+
input
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
# Public DSL: Return a coerce function to validate that the input is a
|
139
|
+
# member of the given set. That is, the input must be equal to at least
|
140
|
+
# one member of the given set, or a Coercive::Error will be raised.
|
141
|
+
# Used when declaring an attribute. See documentation for attr_coerce_fns.
|
142
|
+
#
|
143
|
+
# set - the Array of objects to use as the set for checking membership.
|
144
|
+
def member(set)
|
145
|
+
->(input) do
|
146
|
+
fail Coercive::Error.new("not_valid") unless set.include?(input)
|
147
|
+
|
148
|
+
input
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
# Public DSL: Return a coerce function to coerce input to a Float.
|
153
|
+
# Used when declaring an attribute. See documentation for attr_coerce_fns.
|
154
|
+
def float
|
155
|
+
->(input) do
|
156
|
+
begin
|
157
|
+
Float(input)
|
158
|
+
rescue TypeError, ArgumentError
|
159
|
+
fail Coercive::Error.new("not_numeric")
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
# Public DSL: Return a coerce function to coerce input to a String.
|
165
|
+
# Used when declaring an attribute. See documentation for attr_coerce_fns.
|
166
|
+
#
|
167
|
+
# min - if given, restrict the minimum size of the input String.
|
168
|
+
# max - if given, restrict the maximum size of the input String.
|
169
|
+
# pattern - if given, enforce that the input String matches the pattern.
|
170
|
+
def string(min: nil, max: nil, pattern: nil)
|
171
|
+
->(input) do
|
172
|
+
input = begin
|
173
|
+
String(input)
|
174
|
+
rescue TypeError
|
175
|
+
fail Coercive::Error.new("not_valid")
|
176
|
+
end
|
177
|
+
|
178
|
+
if min && min > 0
|
179
|
+
fail Coercive::Error.new("is_empty") if input.empty?
|
180
|
+
fail Coercive::Error.new("too_short") if input.bytesize < min
|
181
|
+
end
|
182
|
+
|
183
|
+
if max && input.bytesize > max
|
184
|
+
fail Coercive::Error.new("too_long")
|
185
|
+
end
|
186
|
+
|
187
|
+
if pattern && !pattern.match(input)
|
188
|
+
fail Coercive::Error.new("not_valid")
|
189
|
+
end
|
190
|
+
|
191
|
+
input
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
# Public DSL: Return a coercion function to coerce input to an Array.
|
196
|
+
# Used when declaring an attribute. See documentation for attr_coerce_fns.
|
197
|
+
#
|
198
|
+
# inner_coerce_fn - the coerce function to use on each element of the Array.
|
199
|
+
def array(inner_coerce_fn)
|
200
|
+
->(input) do
|
201
|
+
output = []
|
202
|
+
errors = []
|
203
|
+
Array(input).each do |value|
|
204
|
+
begin
|
205
|
+
output << inner_coerce_fn.call(value)
|
206
|
+
errors << nil # pad the errors array with a nil element so that any
|
207
|
+
# errors that follow will be in the right position
|
208
|
+
rescue Coercive::Error => e
|
209
|
+
errors << e.errors
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
fail Coercive::Error.new(errors) if errors.any?
|
214
|
+
|
215
|
+
output
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
# Public DSL: Return a coercion function to coerce input to a Hash.
|
220
|
+
# Used when declaring an attribute. See documentation for attr_coerce_fns.
|
221
|
+
#
|
222
|
+
# key_coerce_fn - the coerce function to use on each key of the Hash.
|
223
|
+
# val_coerce_fn - the coerce function to use on each value of the Hash.
|
224
|
+
def hash(key_coerce_fn, val_coerce_fn)
|
225
|
+
->(input) do
|
226
|
+
fail Coercive::Error.new("not_valid") unless input.is_a?(Hash)
|
227
|
+
|
228
|
+
output = {}
|
229
|
+
errors = {}
|
230
|
+
input.each do |key, value|
|
231
|
+
begin
|
232
|
+
key = key_coerce_fn.call(key)
|
233
|
+
output[key] = val_coerce_fn.call(value)
|
234
|
+
rescue Coercive::Error => e
|
235
|
+
errors[key] = e.errors
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
fail Coercive::Error.new(errors) if errors.any?
|
240
|
+
|
241
|
+
output
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
# Public DSL: See Coercive::URI.coerce_fn
|
246
|
+
def uri(*args)
|
247
|
+
Coercive::URI.coerce_fn(*args)
|
248
|
+
end
|
249
|
+
|
250
|
+
# Public DSL: Return a generator function for a "required" fetch function.
|
251
|
+
# Used when declaring an attribute. See documentation for attr_fetch_fns.
|
252
|
+
#
|
253
|
+
# The fetcher will store the present attribute or raise a Coercive::Error.
|
254
|
+
def required
|
255
|
+
->(name) do
|
256
|
+
->(input, fetched) do
|
257
|
+
fail Coercive::Error.new("not_present") unless input.key?(name)
|
258
|
+
|
259
|
+
fetched[name] = input[name]
|
260
|
+
end
|
261
|
+
end
|
262
|
+
end
|
263
|
+
|
264
|
+
# Public DSL: Return a generator function for a "optional" fetch function.
|
265
|
+
# Used when declaring an attribute. See documentation for attr_fetch_fns.
|
266
|
+
#
|
267
|
+
# The fetcher will store the attribute if it is present.
|
268
|
+
def optional
|
269
|
+
->(name) do
|
270
|
+
->(input, fetched) do
|
271
|
+
fetched[name] = input[name] if input.key?(name)
|
272
|
+
end
|
273
|
+
end
|
274
|
+
end
|
275
|
+
|
276
|
+
# Public DSL: Return a generator function for an "implicit" fetch function.
|
277
|
+
# Used when declaring an attribute. See documentation for attr_fetch_fns.
|
278
|
+
#
|
279
|
+
# The fetcher will store either the present attribute or the given default.
|
280
|
+
#
|
281
|
+
# default - the implicit value to use if the attribute is not present.
|
282
|
+
def implicit(default)
|
283
|
+
->(name) do
|
284
|
+
->(attrs, fetched) do
|
285
|
+
fetched[name] = attrs.key?(name) ? attrs[name] : default
|
286
|
+
end
|
287
|
+
end
|
288
|
+
end
|
289
|
+
end
|
data/lib/coercive/uri.rb
ADDED
@@ -0,0 +1,103 @@
|
|
1
|
+
require "ipaddr"
|
2
|
+
require "uri"
|
3
|
+
|
4
|
+
module Coercion
|
5
|
+
module URI
|
6
|
+
# Setting this `true` allows outbound connections to private IP addresses,
|
7
|
+
# bypassing the security check that the IP address is public. This is designed
|
8
|
+
# to be used in devlopment so that the tests can connect to local services.
|
9
|
+
#
|
10
|
+
# This SHOULD NOT be set in PRODUCTION.
|
11
|
+
ALLOW_PRIVATE_IP_CONNECTIONS =
|
12
|
+
ENV.fetch("ALLOW_PRIVATE_IP_CONNECTIONS", "").downcase == "true"
|
13
|
+
|
14
|
+
PRIVATE_IP_RANGES = [
|
15
|
+
IPAddr.new("0.0.0.0/8"), # Broadcasting to the current network. RFC 1700.
|
16
|
+
IPAddr.new("10.0.0.0/8"), # Local private network. RFC 1918.
|
17
|
+
IPAddr.new("127.0.0.0/8"), # Loopback addresses to the localhost. RFC 990.
|
18
|
+
IPAddr.new("169.254.0.0/16"), # link-local addresses between two hosts on a single link. RFC 3927.
|
19
|
+
IPAddr.new("172.16.0.0/12"), # Local private network. RFC 1918.
|
20
|
+
IPAddr.new("192.168.0.0/16"), # Local private network. RFC 1918.
|
21
|
+
IPAddr.new("198.18.0.0/15"), # Testing of inter-network communications between two separate subnets. RFC 2544.
|
22
|
+
IPAddr.new("198.51.100.0/24"), # Assigned as "TEST-NET-2" in RFC 5737.
|
23
|
+
IPAddr.new("203.0.113.0/24"), # Assigned as "TEST-NET-3" in RFC 5737.
|
24
|
+
IPAddr.new("240.0.0.0/4"), # Reserved for future use, as specified by RFC 6890
|
25
|
+
IPAddr.new("::1/128"), # Loopback addresses to the localhost. RFC 5156.
|
26
|
+
IPAddr.new("2001:20::/28"), # Non-routed IPv6 addresses used for Cryptographic Hash Identifiers. RFC 7343.
|
27
|
+
IPAddr.new("fc00::/7"), # Unique Local Addresses (ULAs). RFC 1918.
|
28
|
+
IPAddr.new("fe80::/10"), # link-local addresses between two hosts on a single link. RFC 3927.
|
29
|
+
].freeze
|
30
|
+
|
31
|
+
# Public DSL: Return a coercion function to coerce input to a URI.
|
32
|
+
# Used when declaring an attribute. See documentation for attr_coerce_fns.
|
33
|
+
#
|
34
|
+
# string_coerce_fn - the string coerce function used to coerce the URI
|
35
|
+
# schema_fn - the optional function used to coerce the schema
|
36
|
+
# require_path - set true to make the URI path a required element
|
37
|
+
# require_port - set true to make the URI port a required element
|
38
|
+
# require_user - set true to make the URI user a required element
|
39
|
+
# require_password - set true to make the URI password a required element
|
40
|
+
def self.coerce_fn(string_coerce_fn, schema_fn: nil, require_path: false,
|
41
|
+
require_port: false, require_user: false, require_password: false)
|
42
|
+
->(input) do
|
43
|
+
uri = begin
|
44
|
+
::URI.parse(string_coerce_fn.call(input))
|
45
|
+
rescue ::URI::InvalidURIError
|
46
|
+
fail Coercion::Error.new("not_valid")
|
47
|
+
end
|
48
|
+
|
49
|
+
fail Coercion::Error.new("no_host") unless uri.host
|
50
|
+
fail Coercion::Error.new("not_resolvable") unless resolvable_public_ip?(uri) || ALLOW_PRIVATE_IP_CONNECTIONS
|
51
|
+
fail Coercion::Error.new("no_path") if require_path && uri.path.empty?
|
52
|
+
fail Coercion::Error.new("no_port") if require_port && !uri.port
|
53
|
+
fail Coercion::Error.new("no_user") if require_user && !uri.user
|
54
|
+
fail Coercion::Error.new("no_password") if require_password && !uri.password
|
55
|
+
|
56
|
+
if schema_fn
|
57
|
+
begin
|
58
|
+
schema_fn.call(uri.scheme)
|
59
|
+
rescue Coercion::Error
|
60
|
+
fail Coercion::Error.new("unsupported_schema")
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
uri.to_s
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# Internal: Return true if the given URI is resolvable to a non-private IP.
|
69
|
+
#
|
70
|
+
# uri - the URI to check.
|
71
|
+
def self.resolvable_public_ip?(uri)
|
72
|
+
begin
|
73
|
+
_, _, _, *resolved_addresses = Socket.gethostbyname(uri.host)
|
74
|
+
rescue SocketError
|
75
|
+
return false
|
76
|
+
end
|
77
|
+
|
78
|
+
resolved_addresses.none? do |bytes|
|
79
|
+
ip = ip_from_bytes(bytes)
|
80
|
+
|
81
|
+
ip.nil? || PRIVATE_IP_RANGES.any? { |range| range.include?(ip) }
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# Internal: Return an IPAddr built from the given address bytes.
|
86
|
+
#
|
87
|
+
# bytes - the binary-encoded String returned by Socket.gethostbyname.
|
88
|
+
def self.ip_from_bytes(bytes)
|
89
|
+
octets = bytes.unpack("C*")
|
90
|
+
|
91
|
+
string =
|
92
|
+
if octets.length == 4 # IPv4
|
93
|
+
octets.join(".")
|
94
|
+
else # IPv6
|
95
|
+
octets.map { |i| "%02x" % i }.each_slice(2).map(&:join).join(":")
|
96
|
+
end
|
97
|
+
|
98
|
+
IPAddr.new(string)
|
99
|
+
rescue IPAddr::InvalidAddressError
|
100
|
+
nil
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
data/test/coercive.rb
ADDED
@@ -0,0 +1,336 @@
|
|
1
|
+
require_relative "../lib/coercive"
|
2
|
+
require "minitest/autorun"
|
3
|
+
require "bigdecimal"
|
4
|
+
|
5
|
+
describe "Coercive" do
|
6
|
+
def assert_coercion_error(errors)
|
7
|
+
yield
|
8
|
+
assert false, "should have raised a Coercive::Error"
|
9
|
+
rescue Coercive::Error => e
|
10
|
+
assert_equal errors, e.errors
|
11
|
+
end
|
12
|
+
|
13
|
+
describe "required" do
|
14
|
+
it "errors when the attribute isn't present" do
|
15
|
+
coercion = Module.new do
|
16
|
+
extend Coercive
|
17
|
+
|
18
|
+
attribute :foo, any, required
|
19
|
+
attribute :bar, any, required
|
20
|
+
attribute :baz, any, required
|
21
|
+
end
|
22
|
+
|
23
|
+
expected_errors = { "foo" => "not_present", "baz" => "not_present" }
|
24
|
+
|
25
|
+
assert_coercion_error(expected_errors) { coercion.call("bar" => "red") }
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
describe "implicit" do
|
30
|
+
it "uses a default value when the attribute isn't present" do
|
31
|
+
coercion = Module.new do
|
32
|
+
extend Coercive
|
33
|
+
|
34
|
+
attribute :foo, any, implicit("black")
|
35
|
+
attribute :bar, any, implicit("grey")
|
36
|
+
attribute :baz, any, implicit("blue")
|
37
|
+
end
|
38
|
+
|
39
|
+
expected = { "foo" => "black", "bar" => "red", "baz" => "blue" }
|
40
|
+
|
41
|
+
assert_equal expected, coercion.call("bar" => "red")
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
describe "optional" do
|
46
|
+
it "omits the attribute in the output when not present in the input" do
|
47
|
+
coercion = Module.new do
|
48
|
+
extend Coercive
|
49
|
+
|
50
|
+
attribute :foo, any, optional
|
51
|
+
attribute :bar, any, optional
|
52
|
+
attribute :baz, any, optional
|
53
|
+
end
|
54
|
+
|
55
|
+
expected = { "bar" => "red" }
|
56
|
+
|
57
|
+
assert_equal expected, coercion.call("bar" => "red")
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
describe "any" do
|
62
|
+
it "accepts any input" do
|
63
|
+
coercion = Module.new do
|
64
|
+
extend Coercive
|
65
|
+
|
66
|
+
attribute :foo, any, required
|
67
|
+
end
|
68
|
+
|
69
|
+
[true, nil, "red", 88, [1, 2, 3]].each do |value|
|
70
|
+
expected = { "foo" => value }
|
71
|
+
|
72
|
+
assert_equal expected, coercion.call("foo" => value)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
describe "member" do
|
78
|
+
before do
|
79
|
+
@coercion = Module.new do
|
80
|
+
extend Coercive
|
81
|
+
|
82
|
+
attribute :foo, member([nil, "red", "black"]), required
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
it "accepts any input in the set" do
|
87
|
+
[nil, "red", "black"].each do |value|
|
88
|
+
expected = { "foo" => value }
|
89
|
+
|
90
|
+
assert_equal expected, @coercion.call("foo" => value)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
it "errors on any other input in the set" do
|
95
|
+
[true, "blue", 88, [1, 2, 3]].each do |bad|
|
96
|
+
expected_errors = { "foo" => "not_valid" }
|
97
|
+
|
98
|
+
assert_coercion_error(expected_errors) { @coercion.call("foo" => bad) }
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
describe "float" do
|
104
|
+
before do
|
105
|
+
@coercion = Module.new do
|
106
|
+
extend Coercive
|
107
|
+
|
108
|
+
attribute :foo, float, required
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
it "coerces the input value to a float" do
|
113
|
+
fixnum = 2
|
114
|
+
rational = 2 ** -2
|
115
|
+
bignum = 2 ** 64
|
116
|
+
bigdecimal = BigDecimal.new("0.1")
|
117
|
+
|
118
|
+
[fixnum, rational, bignum, bigdecimal].each do |value|
|
119
|
+
attributes = { "foo" => value }
|
120
|
+
|
121
|
+
expected = { "foo" => Float(value) }
|
122
|
+
|
123
|
+
assert_equal expected, @coercion.call(attributes)
|
124
|
+
assert_equal Float, @coercion.call(attributes)["foo"].class
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
it "errors when the input value can't be coerced to a float" do
|
129
|
+
[true, nil, "red", [1, 2, 3]].each do |bad|
|
130
|
+
expected_errors = { "foo" => "not_numeric" }
|
131
|
+
|
132
|
+
assert_coercion_error(expected_errors) { @coercion.call("foo" => bad) }
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
describe "string" do
|
138
|
+
before do
|
139
|
+
@coercion = Module.new do
|
140
|
+
extend Coercive
|
141
|
+
|
142
|
+
attribute :foo, string, optional
|
143
|
+
attribute :bar, string, optional
|
144
|
+
attribute :baz, string, optional
|
145
|
+
attribute :min, string(min: 4), optional
|
146
|
+
attribute :max, string(max: 6), optional
|
147
|
+
attribute :sized, string(min: 4, max: 6), optional
|
148
|
+
attribute :hex_a, string(pattern: /\A\h+\z/), optional
|
149
|
+
attribute :hex_b, string(pattern: /\A\h+\z/), optional
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
it "coerces the input value to a string" do
|
154
|
+
attributes = { "foo" => false, "bar" => 88, "baz" => "string" }
|
155
|
+
|
156
|
+
expected = { "foo" => "false", "bar" => "88", "baz" => "string" }
|
157
|
+
|
158
|
+
assert_equal expected, @coercion.call(attributes)
|
159
|
+
end
|
160
|
+
|
161
|
+
it "errors if the input is longer than the declared maximum size" do
|
162
|
+
attributes = {
|
163
|
+
"min" => "this will be okay",
|
164
|
+
"max" => "this is too long",
|
165
|
+
"sized" => "this also",
|
166
|
+
}
|
167
|
+
|
168
|
+
expected_errors = { "max" => "too_long", "sized" => "too_long" }
|
169
|
+
|
170
|
+
assert_coercion_error(expected_errors) { @coercion.call(attributes) }
|
171
|
+
end
|
172
|
+
|
173
|
+
it "errors if the input is shorter than the declared minimum size" do
|
174
|
+
attributes = {
|
175
|
+
"min" => "???",
|
176
|
+
"max" => "???",
|
177
|
+
"sized" => "???",
|
178
|
+
}
|
179
|
+
|
180
|
+
expected_errors = { "min" => "too_short", "sized" => "too_short" }
|
181
|
+
|
182
|
+
assert_coercion_error(expected_errors) { @coercion.call(attributes) }
|
183
|
+
end
|
184
|
+
|
185
|
+
it "errors if the input does not match the declared pattern" do
|
186
|
+
attributes = { "hex_a" => "DEADBEEF", "hex_b" => "REDBEETS" }
|
187
|
+
|
188
|
+
expected_errors = { "hex_b" => "not_valid" }
|
189
|
+
|
190
|
+
assert_coercion_error(expected_errors) { @coercion.call(attributes) }
|
191
|
+
end
|
192
|
+
|
193
|
+
it "checks size of the input after coercing to a string" do
|
194
|
+
attributes = { "max" => 1234567, "min" => 89 }
|
195
|
+
|
196
|
+
expected_errors = { "max" => "too_long", "min" => "too_short" }
|
197
|
+
|
198
|
+
assert_coercion_error(expected_errors) { @coercion.call(attributes) }
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
describe "array" do
|
203
|
+
before do
|
204
|
+
@coercion = Module.new do
|
205
|
+
extend Coercive
|
206
|
+
|
207
|
+
attribute :strings, array(string), required
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
it "coerces a array attribute input value to an array" do
|
212
|
+
attributes = { "strings" => "foo" }
|
213
|
+
|
214
|
+
expected = { "strings" => ["foo"] }
|
215
|
+
|
216
|
+
assert_equal expected, @coercion.call(attributes)
|
217
|
+
end
|
218
|
+
|
219
|
+
it "coerces a array attribute input's elements with the inner coercion" do
|
220
|
+
attributes = { "strings" => ["", 88, true] }
|
221
|
+
|
222
|
+
expected = { "strings" => ["", "88", "true"] }
|
223
|
+
|
224
|
+
assert_equal expected, @coercion.call(attributes)
|
225
|
+
end
|
226
|
+
|
227
|
+
it "collects errors from an array attribute input's elements" do
|
228
|
+
bad = BasicObject.new
|
229
|
+
attributes = { "strings" => ["ok", bad, "ok"] }
|
230
|
+
|
231
|
+
expected_errors = { "strings" => [nil, "not_valid", nil] }
|
232
|
+
|
233
|
+
assert_coercion_error(expected_errors) { @coercion.call(attributes) }
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
describe "hash" do
|
238
|
+
before do
|
239
|
+
@coercion = Module.new do
|
240
|
+
extend Coercive
|
241
|
+
|
242
|
+
attribute :strings, hash(string(max: 6), string), required
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
it "errors when a hash attribute input value isn't a hash" do
|
247
|
+
[nil, true, "foo", []].each do |invalid|
|
248
|
+
attributes = { "strings" => invalid }
|
249
|
+
|
250
|
+
expected_errors = { "strings" => "not_valid" }
|
251
|
+
|
252
|
+
assert_coercion_error(expected_errors) { @coercion.call(attributes) }
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
it "coerces a hash attribute keys and values with the inner coercions" do
|
257
|
+
attributes = { "strings" => { false => nil } }
|
258
|
+
|
259
|
+
expected = { "strings" => { "false" => "" } }
|
260
|
+
|
261
|
+
assert_equal expected, @coercion.call(attributes)
|
262
|
+
end
|
263
|
+
|
264
|
+
it "collects errors from a hash attribute input's keys and values" do
|
265
|
+
bad = BasicObject.new
|
266
|
+
attributes = { "strings" => { "foo" => bad, "food_truck" => "ok" } }
|
267
|
+
|
268
|
+
expected_errors = {
|
269
|
+
"strings" => { "foo" => "not_valid", "food_truck" => "too_long" }
|
270
|
+
}
|
271
|
+
|
272
|
+
assert_coercion_error(expected_errors) { @coercion.call(attributes) }
|
273
|
+
end
|
274
|
+
end
|
275
|
+
|
276
|
+
describe "with various declared attributes" do
|
277
|
+
before do
|
278
|
+
@coercion = Module.new do
|
279
|
+
extend Coercive
|
280
|
+
|
281
|
+
attribute :req_hash,
|
282
|
+
hash(string(max: 6), string),
|
283
|
+
required
|
284
|
+
|
285
|
+
attribute :opt_string,
|
286
|
+
string(min: 4, max: 6),
|
287
|
+
optional
|
288
|
+
|
289
|
+
attribute :imp_array,
|
290
|
+
array(string),
|
291
|
+
implicit(["default"])
|
292
|
+
end
|
293
|
+
|
294
|
+
@valid_attributes = {
|
295
|
+
"req_hash" => { "one" => "red", "two" => "blue" },
|
296
|
+
"opt_string" => "apple",
|
297
|
+
"imp_array" => ["foo", "bar", "baz"],
|
298
|
+
}
|
299
|
+
end
|
300
|
+
|
301
|
+
it "returns valid attributes without changing them" do
|
302
|
+
assert_equal @valid_attributes, @coercion.call(@valid_attributes)
|
303
|
+
end
|
304
|
+
|
305
|
+
it "errors when given an undeclared attribute" do
|
306
|
+
attributes = @valid_attributes.merge("bogus" => true)
|
307
|
+
|
308
|
+
expected_errors = { "bogus" => "unknown" }
|
309
|
+
|
310
|
+
assert_coercion_error(expected_errors) { @coercion.call(attributes) }
|
311
|
+
end
|
312
|
+
|
313
|
+
it "collects errors from all fetchers and coercions before reporting" do
|
314
|
+
attributes = {
|
315
|
+
"bogus" => "bogus",
|
316
|
+
"opt_string" => "bar",
|
317
|
+
"imp_array" => ["ok", BasicObject.new, "ok"],
|
318
|
+
}
|
319
|
+
|
320
|
+
expected_errors = {
|
321
|
+
"bogus" => "unknown",
|
322
|
+
"req_hash" => "not_present",
|
323
|
+
"opt_string" => "too_short",
|
324
|
+
"imp_array" => [nil, "not_valid", nil],
|
325
|
+
}
|
326
|
+
|
327
|
+
assert_coercion_error(expected_errors) { @coercion.call(attributes) }
|
328
|
+
end
|
329
|
+
|
330
|
+
it "errors if given input that is not a Hash" do
|
331
|
+
assert_coercion_error("not_valid") { @coercion.call(nil) }
|
332
|
+
assert_coercion_error("not_valid") { @coercion.call(88) }
|
333
|
+
assert_coercion_error("not_valid") { @coercion.call([]) }
|
334
|
+
end
|
335
|
+
end
|
336
|
+
end
|
data/test/uri.rb
ADDED
@@ -0,0 +1,196 @@
|
|
1
|
+
require "minitest/autorun"
|
2
|
+
|
3
|
+
require_relative "../lib/coercive"
|
4
|
+
require_relative "../lib/coercive/uri"
|
5
|
+
|
6
|
+
describe "Coercive::URI" do
|
7
|
+
def assert_coercion_error(errors)
|
8
|
+
yield
|
9
|
+
assert false, "should have raised a Coercive::Error"
|
10
|
+
rescue Coercive::Error => e
|
11
|
+
assert_equal errors, e.errors
|
12
|
+
end
|
13
|
+
|
14
|
+
setup do
|
15
|
+
@coercion = Module.new do
|
16
|
+
extend Coercive
|
17
|
+
|
18
|
+
attribute :any, uri(string), optional
|
19
|
+
attribute :min, uri(string(min: 13)), optional
|
20
|
+
attribute :max, uri(string(max: 17)), optional
|
21
|
+
attribute :sized, uri(string(min: 13, max: 17)), optional
|
22
|
+
|
23
|
+
attribute :schema,
|
24
|
+
uri(string(min: 1, max: 255), schema_fn: member(%w{http})),
|
25
|
+
optional
|
26
|
+
|
27
|
+
attribute :require_path,
|
28
|
+
uri(string(min: 1, max: 255), require_path: true),
|
29
|
+
optional
|
30
|
+
|
31
|
+
attribute :require_port,
|
32
|
+
uri(string(min: 1, max: 255), require_port: true),
|
33
|
+
optional
|
34
|
+
|
35
|
+
attribute :require_user,
|
36
|
+
uri(string(min: 1, max: 255), require_user: true),
|
37
|
+
optional
|
38
|
+
|
39
|
+
attribute :require_password,
|
40
|
+
uri(string(min: 1, max: 255), require_password: true),
|
41
|
+
optional
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
test "coerces a valid string to a URI" do
|
46
|
+
attributes = {
|
47
|
+
"any" => "http://user:pass@www.example.com:1234/path"
|
48
|
+
}
|
49
|
+
|
50
|
+
assert_equal attributes, @coercion.call(attributes)
|
51
|
+
end
|
52
|
+
|
53
|
+
test "errors if input is an invalid URI" do
|
54
|
+
attributes = { "any" => "%" }
|
55
|
+
|
56
|
+
expected_errors = { "any" => "not_valid" }
|
57
|
+
|
58
|
+
assert_coercion_error(expected_errors) { @coercion.call(attributes) }
|
59
|
+
end
|
60
|
+
|
61
|
+
test "errors if the input is longer than the declared maximum size" do
|
62
|
+
attributes = {
|
63
|
+
"min" => "http://foo.com",
|
64
|
+
"max" => "http://long.url.com",
|
65
|
+
"sized" => "http://way.too.long.com",
|
66
|
+
}
|
67
|
+
|
68
|
+
expected_errors = { "max" => "too_long", "sized" => "too_long" }
|
69
|
+
|
70
|
+
assert_coercion_error(expected_errors) { @coercion.call(attributes) }
|
71
|
+
end
|
72
|
+
|
73
|
+
test "errors if the input is shorter than the declared minimum size" do
|
74
|
+
attributes = {
|
75
|
+
"min" => "http://a.com",
|
76
|
+
"max" => "http://bar.com",
|
77
|
+
"sized" => "http://c.com"
|
78
|
+
}
|
79
|
+
|
80
|
+
expected_errors = { "min" => "too_short", "sized" => "too_short" }
|
81
|
+
|
82
|
+
assert_coercion_error(expected_errors) { @coercion.call(attributes) }
|
83
|
+
end
|
84
|
+
|
85
|
+
test "errors if the URI is an empty string" do
|
86
|
+
attributes = { "schema" => "" }
|
87
|
+
expected_errors = { "schema" => "is_empty" }
|
88
|
+
|
89
|
+
assert_coercion_error(expected_errors) { @coercion.call(attributes) }
|
90
|
+
end
|
91
|
+
|
92
|
+
test "errors if no host" do
|
93
|
+
attributes = { "any" => "http://" }
|
94
|
+
|
95
|
+
expected_errors = { "any" => "no_host" }
|
96
|
+
|
97
|
+
assert_coercion_error(expected_errors) { @coercion.call(attributes) }
|
98
|
+
end
|
99
|
+
|
100
|
+
test "errors if schema is not supported" do
|
101
|
+
attributes = { "schema" => "foo://example.com" }
|
102
|
+
|
103
|
+
expected_errors = { "schema" => "unsupported_schema" }
|
104
|
+
|
105
|
+
assert_coercion_error(expected_errors) { @coercion.call(attributes) }
|
106
|
+
end
|
107
|
+
|
108
|
+
test "errors if required elements are not provided" do
|
109
|
+
attributes = {
|
110
|
+
"require_path" => "foo://example.com",
|
111
|
+
"require_port" => "foo://example.com",
|
112
|
+
"require_user" => "foo://example.com",
|
113
|
+
"require_password" => "foo://example.com",
|
114
|
+
}
|
115
|
+
|
116
|
+
expected_errors = {
|
117
|
+
"require_path" => "no_path",
|
118
|
+
"require_port" => "no_port",
|
119
|
+
"require_user" => "no_user",
|
120
|
+
"require_password" => "no_password",
|
121
|
+
}
|
122
|
+
|
123
|
+
assert_coercion_error(expected_errors) { @coercion.call(attributes) }
|
124
|
+
end
|
125
|
+
|
126
|
+
Coercive::URI::PRIVATE_IP_RANGES.each do |range|
|
127
|
+
range = range.to_range
|
128
|
+
first = range.first
|
129
|
+
last = range.last
|
130
|
+
first = first.ipv6? ? "[#{first}]" : first.to_s
|
131
|
+
last = last.ipv6? ? "[#{last}]" : last.to_s
|
132
|
+
|
133
|
+
test "errors when the URI host is an IP in the range #{first}..#{last}" do
|
134
|
+
attributes_first = { "schema" => "http://#{first}/path" }
|
135
|
+
attributes_last = { "schema" => "http://#{last}/path" }
|
136
|
+
expected_errors = { "schema" => "not_resolvable" }
|
137
|
+
|
138
|
+
assert_coercion_error(expected_errors) { @coercion.call(attributes_first) }
|
139
|
+
assert_coercion_error(expected_errors) { @coercion.call(attributes_last) }
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
test "errors when the URI host is not resolvable" do
|
144
|
+
attributes = {
|
145
|
+
"schema" => "http://bogus-host-that-cant-possibly-exist-here/path"
|
146
|
+
}
|
147
|
+
|
148
|
+
expected_errors = { "schema" => "not_resolvable" }
|
149
|
+
|
150
|
+
assert_coercion_error(expected_errors) { @coercion.call(attributes) }
|
151
|
+
end
|
152
|
+
|
153
|
+
test "errors when the URI host resolves to an IP in a private range" do
|
154
|
+
attributes = { "schema" => "http://localhost/path" }
|
155
|
+
|
156
|
+
expected_errors = { "schema" => "not_resolvable" }
|
157
|
+
|
158
|
+
assert_coercion_error(expected_errors) { @coercion.call(attributes) }
|
159
|
+
end
|
160
|
+
|
161
|
+
test "allows a URI host to be IP that isn't in a private range" do
|
162
|
+
attributes = { "schema" => "http://8.8.8.8/path" }
|
163
|
+
|
164
|
+
assert_equal attributes, @coercion.call(attributes)
|
165
|
+
end
|
166
|
+
|
167
|
+
test "allows a URI host that resolves to an IP not in a private range" do
|
168
|
+
attributes = { "schema" => "http://www.example.com/path" }
|
169
|
+
|
170
|
+
assert_equal attributes, @coercion.call(attributes)
|
171
|
+
end
|
172
|
+
|
173
|
+
test "allows a URI with no explicit path component" do
|
174
|
+
attributes = { "schema" => "http://www.example.com" }
|
175
|
+
|
176
|
+
assert_equal attributes, @coercion.call(attributes)
|
177
|
+
end
|
178
|
+
|
179
|
+
test "errors for a string that does not pass URI.parse" do
|
180
|
+
attributes = { "schema" => "\\" }
|
181
|
+
expected_errors = { "schema" => "not_valid" }
|
182
|
+
|
183
|
+
assert_coercion_error(expected_errors) { @coercion.call(attributes) }
|
184
|
+
end
|
185
|
+
|
186
|
+
test "errors for a URL that passes URI.parse, but is ill-formed" do
|
187
|
+
attributes = { "schema" => "http:example.com/path" }
|
188
|
+
|
189
|
+
begin
|
190
|
+
@coercion.call(attributes)
|
191
|
+
assert false, "should have raised a Coercive::Error"
|
192
|
+
rescue Coercive::Error => e
|
193
|
+
assert !e.errors["schema"].empty?, "should have a schema error"
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
metadata
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: coercive
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Joe McIlvain
|
8
|
+
- Lucas Tolchinsky
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2020-08-04 00:00:00.000000000 Z
|
13
|
+
dependencies: []
|
14
|
+
description: Coercive is a library to validate and coerce user input
|
15
|
+
email:
|
16
|
+
- joe.eli.mac@gmail.com
|
17
|
+
- tonchis@protonmail.com
|
18
|
+
executables: []
|
19
|
+
extensions: []
|
20
|
+
extra_rdoc_files: []
|
21
|
+
files:
|
22
|
+
- LICENSE
|
23
|
+
- README.md
|
24
|
+
- lib/coercive.rb
|
25
|
+
- lib/coercive/uri.rb
|
26
|
+
- test/coercive.rb
|
27
|
+
- test/uri.rb
|
28
|
+
homepage: https://github.com/Theorem/coercive
|
29
|
+
licenses:
|
30
|
+
- MIT
|
31
|
+
metadata: {}
|
32
|
+
post_install_message:
|
33
|
+
rdoc_options: []
|
34
|
+
require_paths:
|
35
|
+
- lib
|
36
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
42
|
+
requirements:
|
43
|
+
- - ">="
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: '0'
|
46
|
+
requirements: []
|
47
|
+
rubygems_version: 3.0.3
|
48
|
+
signing_key:
|
49
|
+
specification_version: 4
|
50
|
+
summary: Coercive is a library to validate and coerce user input
|
51
|
+
test_files: []
|