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.
@@ -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.
@@ -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
+ ```
@@ -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
@@ -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
@@ -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
@@ -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: []