decoding 0.3.0 → 0.5.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 +4 -4
- data/.rubocop.yml +12 -0
- data/CHANGELOG.md +27 -0
- data/CLAUDE.md +6 -0
- data/README.md +230 -3
- data/lib/decoding/decoders/any.rb +5 -1
- data/lib/decoding/decoders/array.rb +1 -1
- data/lib/decoding/decoders/big_decimal.rb +37 -0
- data/lib/decoding/decoders/date.rb +57 -0
- data/lib/decoding/decoders/enum.rb +47 -0
- data/lib/decoding/decoders/field.rb +2 -2
- data/lib/decoding/decoders/hash.rb +2 -2
- data/lib/decoding/decoders/index.rb +1 -4
- data/lib/decoding/decoders/lazy.rb +31 -0
- data/lib/decoding/decoders/map_err.rb +31 -0
- data/lib/decoding/decoders/match.rb +1 -1
- data/lib/decoding/decoders/optional.rb +33 -0
- data/lib/decoding/decoders/optional_field.rb +35 -0
- data/lib/decoding/decoders/succeed.rb +8 -3
- data/lib/decoding/decoders/time.rb +143 -0
- data/lib/decoding/decoders/uri.rb +27 -0
- data/lib/decoding/decoders.rb +149 -5
- data/lib/decoding/env.rb +70 -0
- data/lib/decoding/error.rb +6 -0
- data/lib/decoding/failure.rb +42 -4
- data/lib/decoding/matcher_helpers.rb +50 -0
- data/lib/decoding/result.rb +3 -1
- data/lib/decoding/rspec/decode_value.rb +87 -0
- data/lib/decoding/rspec/fail_with.rb +48 -0
- data/lib/decoding/rspec/succeed_with.rb +31 -0
- data/lib/decoding/rspec.rb +44 -0
- data/lib/decoding/version.rb +1 -1
- data/lib/decoding.rb +15 -2
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 27eee73e0aa7f430b7fb6aac6aea19c872dd5d825633d1f0e54362668ae355e5
|
|
4
|
+
data.tar.gz: b6ceb7aa0bd5cda5812949c165a4f8614dd0da97f5e05a8cbbf77cefa3e10e4d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8119c1c00072ccbe6393ba6265d8eb5bd18e4f3a6b0c821a66eac6f1a9d52481b4cdf6b7e70656545c08cecbbb365f7736a505b3df25a9bd03c1b2e9ec486e9e
|
|
7
|
+
data.tar.gz: 5a76a8b7c125c47e2afd5f8a37bd0290ca5e2fb6de03a42cc2dabeeba4121f4601a27bd804e62a088aad8e165e2f85aca7ac8a07f6fc537be3019d9a610baae7
|
data/.rubocop.yml
CHANGED
|
@@ -29,6 +29,8 @@ Metrics/AbcSize:
|
|
|
29
29
|
Metrics/BlockLength:
|
|
30
30
|
Exclude:
|
|
31
31
|
- spec/**/*_spec.rb
|
|
32
|
+
# The RSpec matcher DSL puts a matcher's whole definition inside one block.
|
|
33
|
+
- lib/decoding/rspec/*.rb
|
|
32
34
|
|
|
33
35
|
Metrics/MethodLength:
|
|
34
36
|
Max: 17
|
|
@@ -53,3 +55,13 @@ RSpec/IdenticalEqualityAssertion:
|
|
|
53
55
|
|
|
54
56
|
RSpec/ExampleLength:
|
|
55
57
|
Enabled: false
|
|
58
|
+
|
|
59
|
+
# The matcher specs deliberately pass literal values to `expect` to assert on
|
|
60
|
+
# the messages the matchers themselves produce.
|
|
61
|
+
RSpec/DescribeClass:
|
|
62
|
+
Exclude:
|
|
63
|
+
- spec/matchers/*_spec.rb
|
|
64
|
+
|
|
65
|
+
RSpec/ExpectActual:
|
|
66
|
+
Exclude:
|
|
67
|
+
- spec/matchers/*_spec.rb
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.5.0]
|
|
4
|
+
|
|
5
|
+
* Add a `zone:` argument to the `time` and `unix_time` decoders, for resolving times somewhere other than the system's time zone.
|
|
6
|
+
* Add `decoding/rspec` module with matchers to help with testing custom decoders
|
|
7
|
+
|
|
8
|
+
## [0.4.0]
|
|
9
|
+
|
|
10
|
+
* Add `uri` decoder for URI objects and strings that can be parsed as one, available after `require "decoding/decoders/uri"`
|
|
11
|
+
* Add `map_err` decoder for replacing the error message of a failed decoder
|
|
12
|
+
* Add `Decoding.decode!` for decoding a value, raising `Decoding::UnwrapError` when decoding fails
|
|
13
|
+
* Add `lazy` decoder, making recursive decoders possible
|
|
14
|
+
* Add `match` decoder for matching a value against any pattern using `===`
|
|
15
|
+
* Add `enum` decoder for a value that must be one of a fixed set of values
|
|
16
|
+
* Add `optional_field` decoder for a key that may be absent from a hash
|
|
17
|
+
* Add `time` and `date` decoders, available after `require "decoding/decoders/time"` and `require "decoding/decoders/date"`
|
|
18
|
+
* Add `big_decimal` decoder, available after `require "decoding/decoders/big_decimal"`
|
|
19
|
+
* Add `unix_time` decoder for timestamps, alongside the `time` decoder
|
|
20
|
+
* Add `parsed_integer`, `parsed_float` and `parsed_boolean` decoders for reading typed values out of strings
|
|
21
|
+
* Add `Decoding.env` for decoding a single environment variable, available after `require "decoding/env"`
|
|
22
|
+
* Raise `Decoding::UnwrapError` as a `Decoding::Error`
|
|
23
|
+
* Remove the unused `Decoding::Decoders::Index::Err` constant
|
|
24
|
+
* Report the location of errors nested inside a `hash` decoder as a path, like `field` and `array` do, rather than flattening it into the error message.
|
|
25
|
+
* Give the `boolean` decoder its own error message, rather than reporting the failures of the decoders it is built from
|
|
26
|
+
* Use consistent phrasing for the error messages of the `match`, `field`, `array` and `index` decoders
|
|
27
|
+
* Report the failure of the given decoder from the `optional` decoder, rather than also reporting that the value was not `nil`
|
|
28
|
+
* Report the location shared by all failures of an `any` decoder once, instead of repeating it in every collected message
|
|
29
|
+
|
|
3
30
|
## [0.3.0]
|
|
4
31
|
|
|
5
32
|
* Add `Decoding::Data` for creating decodable data classes
|
data/CLAUDE.md
CHANGED
|
@@ -19,6 +19,12 @@ Ruby gem for decoding dynamic/external data into known structures. Functional-st
|
|
|
19
19
|
- Test file: `spec/<module>/<class>_spec.rb`
|
|
20
20
|
- Run with `COVERAGE=true bundle exec rspec`
|
|
21
21
|
|
|
22
|
+
**Custom matchers** (defined in `spec/support/matchers/`, tested in `spec/matchers/`):
|
|
23
|
+
- `expect(decoder).to decode_value(input).to(value)` - assert what a decoder decodes an input to
|
|
24
|
+
- `expect(decoder).to decode_value(input).failing_with(msg).at(*path)` - assert how it fails, path outermost first
|
|
25
|
+
- `expect(result).to succeed_with(value)` / `expect(result).to fail_with(msg).at(*path)` - assert on a `Result`
|
|
26
|
+
- Expected values compare strictly (`eql?`) unless you pass a matcher, so `1` does not match `1.0`
|
|
27
|
+
|
|
22
28
|
## Architecture
|
|
23
29
|
|
|
24
30
|
**Core concepts:**
|
data/README.md
CHANGED
|
@@ -31,7 +31,7 @@ Assume the response body, parsed as JSON, results in a value like this:
|
|
|
31
31
|
"orderID" => "7EBWXB5",
|
|
32
32
|
"orderDate" => "1595674680",
|
|
33
33
|
"estimatedDeliveryDate" => "1596365935",
|
|
34
|
-
"deliveryDate" =>
|
|
34
|
+
"deliveryDate" => nil,
|
|
35
35
|
"delayed" => false,
|
|
36
36
|
"status" => {
|
|
37
37
|
"orderPlaced" => true,
|
|
@@ -54,8 +54,8 @@ time_decoder = D.map(D.string) { Time.at(_1.to_i) }
|
|
|
54
54
|
order_decoder = D.map(
|
|
55
55
|
D.field("orderID", D.string),
|
|
56
56
|
D.field("orderDate", time_decoder),
|
|
57
|
-
D.hash(D.string, D.boolean)
|
|
58
|
-
) { Order.new(*args) }
|
|
57
|
+
D.field("status", D.hash(D.string, D.boolean))
|
|
58
|
+
) { |*args| Order.new(*args) }
|
|
59
59
|
|
|
60
60
|
Decoding.decode(order_decoder, body)
|
|
61
61
|
# => Decoding::Ok(#<data Order
|
|
@@ -84,6 +84,8 @@ You can use the base decoders along with `map` to write more complex decoder. Fo
|
|
|
84
84
|
time_decoder = D.map(D.string) { Time.at(_1.to_i) }
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
+
(For unix timestamps specifically there is a `unix_time` decoder, described under optional decoders below; the point here is that `map` lets you build one yourself.)
|
|
88
|
+
|
|
87
89
|
When the shape of the incoming data is unknown, you can try out various decoders in a row to find the first that succeeds using `any`:
|
|
88
90
|
|
|
89
91
|
```ruby
|
|
@@ -92,6 +94,31 @@ Decoding.decode(string_or_integer, 1) # => Decoding::Ok(1)
|
|
|
92
94
|
Decoding.decode(string_or_integer, '1') # => Decoding::Ok('1')
|
|
93
95
|
```
|
|
94
96
|
|
|
97
|
+
A value that might be absent is decoded with `optional`. It hands the value to the given decoder first, so that decoder can give `nil` a meaning of its own, and only falls back to `nil` when the decoder cannot handle it:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
optional_name = D.optional(D.string)
|
|
101
|
+
Decoding.decode(optional_name, "John") # => Decoding::Ok("John")
|
|
102
|
+
Decoding.decode(optional_name, nil) # => Decoding::Ok(nil)
|
|
103
|
+
Decoding.decode(optional_name, 123) # => Decoding::Err("expected String, got Integer")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Note how the failure is the one reported by the given decoder: since you asked for an optional string, being told the value was not `nil` either adds nothing.
|
|
107
|
+
|
|
108
|
+
Decoders that refer to themselves need `lazy`, which defers building the decoder until there is a value to decode. Without it, building the decoder would recurse endlessly:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
def tree
|
|
112
|
+
D.decode_hash(
|
|
113
|
+
name: D.field("name", D.string),
|
|
114
|
+
children: D.field("children", D.array(D.lazy { tree }))
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
Decoding.decode(tree, { "name" => "a", "children" => [{ "name" => "b", "children" => [] }] })
|
|
119
|
+
# => Decoding::Ok({ name: "a", children: [{ name: "b", children: [] }] })
|
|
120
|
+
```
|
|
121
|
+
|
|
95
122
|
You can also base one decoder on a previously decoded value. For example, a payload might contain a version number describing its format. Use `and_then` to decode one value and then construct a new decoder to run against the same input using that value:
|
|
96
123
|
|
|
97
124
|
```ruby
|
|
@@ -115,6 +142,49 @@ Decoding.decode(multiple_version_decoder, "version" => "2", "fullName" => "Paul"
|
|
|
115
142
|
|
|
116
143
|
The return values of decoding are `Decoding::Result` values, which come in `Ok` and `Err` subclasses. These describe how the decoding either succeeded or failed. The `Ok` values contain the decoded result, while the `Err` values always contain a string error message. It is up to you, as a developer, to decide how to deal with unsuccessful decoding.
|
|
117
144
|
|
|
145
|
+
When you would rather not handle failure explicitly, `decode!` returns the decoded value itself and raises `Decoding::UnwrapError` when decoding fails:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
Decoding.decode!(D.string, "foo") # => "foo"
|
|
149
|
+
Decoding.decode!(D.field("name", D.string), { "name" => 123 })
|
|
150
|
+
# raises Decoding::UnwrapError: Error at .name: expected String, got Integer
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Error messages
|
|
154
|
+
|
|
155
|
+
Decoders that reach into a value -- `field`, `at`, `array`, `index` and `hash` -- record where in the input the error occurred, so a failure deep inside a nested structure still tells you how to find it:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
Decoding.decode(D.at("a", "b", D.string), { "a" => { "b" => 1 } })
|
|
159
|
+
# => Decoding::Err("Error at .a.b: expected String, got Integer")
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
A decoder composed of other decoders reports the failures of the decoders it is built from, which is not always what a caller needs to know:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
id = D.any(D.integer, D.map(D.string, &:to_i))
|
|
166
|
+
Decoding.decode(id, true)
|
|
167
|
+
# => Decoding::Err("None of the decoders matched:\n - expected Integer, got TrueClass\n - expected String, got TrueClass")
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Use `map_err` to give such a decoder a single error message of its own. The block receives the original message and the value being decoded, and returns the message to use instead:
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
id = D.map_err(D.any(D.integer, D.map(D.string, &:to_i))) do |_message, value|
|
|
174
|
+
"expected an ID, got #{value.inspect}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
Decoding.decode(id, true)
|
|
178
|
+
# => Decoding::Err("expected an ID, got true")
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Replacing the message does not discard where the error occurred, so a decoder built this way still composes:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
Decoding.decode(D.field("id", id), { "id" => true })
|
|
185
|
+
# => Decoding::Err("Error at .id: expected an ID, got true")
|
|
186
|
+
```
|
|
187
|
+
|
|
118
188
|
## Available decoders
|
|
119
189
|
|
|
120
190
|
The following decoders are included:
|
|
@@ -129,23 +199,180 @@ The following decoders are included:
|
|
|
129
199
|
* `false`
|
|
130
200
|
* `boolean`
|
|
131
201
|
* `symbol`
|
|
202
|
+
* `parsed_integer`
|
|
203
|
+
* `parsed_float`
|
|
204
|
+
* `parsed_boolean`
|
|
132
205
|
* `regexp`
|
|
206
|
+
* `match`
|
|
207
|
+
* `enum`
|
|
133
208
|
* Utility decoders
|
|
134
209
|
* `succeed`
|
|
135
210
|
* `fail`
|
|
136
211
|
* `original`
|
|
137
212
|
* `map`
|
|
213
|
+
* `map_err`
|
|
138
214
|
* `decode_hash`
|
|
139
215
|
* `and_then`
|
|
216
|
+
* `lazy`
|
|
140
217
|
* Compound decoders
|
|
141
218
|
* `any`
|
|
142
219
|
* `optional`
|
|
143
220
|
* `field`
|
|
221
|
+
* `optional_field`
|
|
144
222
|
* `array`
|
|
145
223
|
* `index`
|
|
146
224
|
* `hash`
|
|
147
225
|
* `at`
|
|
148
226
|
|
|
227
|
+
### A note on `include`
|
|
228
|
+
|
|
229
|
+
Refer to the decoders through a short alias, as the examples above do:
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
D = Decoding::Decoders
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Do not `include Decoding::Decoders`. Several decoders are named after methods every object already has: `hash` would override `Object#hash`, leaving instances unusable as hash keys, and `fail` would shadow `Kernel#fail`, so raising an exception would silently build a decoder instead. In specs, `match` would shadow RSpec's `match` matcher. The decoders `nil`, `true` and `false` are unreachable as bare words in any case, since those are keywords.
|
|
236
|
+
|
|
237
|
+
### Optional decoders
|
|
238
|
+
|
|
239
|
+
Some decoders depend on parts of the standard library that not every application needs, so they are not loaded by default. Require them explicitly to make them available:
|
|
240
|
+
|
|
241
|
+
* `uri` -- decodes a `URI` object, or a string that can be parsed as one:
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
require "decoding/decoders/uri"
|
|
245
|
+
|
|
246
|
+
Decoding.decode(D.uri, "https://example.com") # => Decoding::Ok(URI("https://example.com"))
|
|
247
|
+
Decoding.decode(D.uri, 123) # => Decoding::Err("expected a URI, got 123")
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
* `time` and `date` -- decode a `Time` or `Date` object, or a string in a given format. The format is either the name of a standard format or a `strptime` pattern:
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
require "decoding/decoders/time"
|
|
254
|
+
require "decoding/decoders/date"
|
|
255
|
+
|
|
256
|
+
Decoding.decode(D.time(:iso8601), "2020-01-01T10:00:00Z") # => Decoding::Ok(2020-01-01 10:00:00 UTC)
|
|
257
|
+
Decoding.decode(D.date("%Y|%m"), "2020|01") # => Decoding::Ok(#<Date: 2020-01-01>)
|
|
258
|
+
Decoding.decode(D.date(:iso8601), "3rd Feb") # => Decoding::Err("expected a date in iso8601 format, got \"3rd Feb\"")
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
`time` accepts `:iso8601`, `:xmlschema`, `:rfc2822`, `:rfc822`, `:httpdate` and `:parse`; `date` also accepts `:rfc3339` and `:jisx0301`. The same file provides `unix_time`, which reads a number, or a string describing one, as a number of seconds since the epoch:
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
Decoding.decode(D.unix_time, 1_595_674_680) # => Decoding::Ok(2020-07-25 10:58:00 UTC)
|
|
265
|
+
Decoding.decode(D.unix_time, "1595674680") # => Decoding::Ok(2020-07-25 10:58:00 UTC)
|
|
266
|
+
Decoding.decode(D.unix_time(:milliseconds), 1_595_674_680_123) # => Decoding::Ok(2020-07-25 10:58:00.123 UTC)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
A timestamp read in the wrong unit is not an error but a wildly different point in time, so a source reporting milliseconds has to say so. There is deliberately no default: `:parse` is lenient and fills in whatever the input leaves out from the current time, so it has to be asked for by name.
|
|
270
|
+
|
|
271
|
+
Both `time` and `unix_time` resolve times through `Time` itself, and so in the system's time zone. That is rarely the zone a Rails application means: its containers usually run UTC while the application runs somewhere else, and taking `to_date` of the two answers differs by a day for a late-evening timestamp. Pass anything that answers the format you name as `zone:` to decide it yourself:
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
Decoding.decode(D.time(:parse, zone: Time.zone), "2020-01-01 10:00:00")
|
|
275
|
+
# => Decoding::Ok(2020-01-01 10:00:00 +0100)
|
|
276
|
+
Decoding.decode(D.map(D.unix_time(zone: Time.zone), &:to_date), 1_710_113_400)
|
|
277
|
+
# => Decoding::Ok(#<Date: 2024-03-11>)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
An `ActiveSupport::TimeZone` answers only `iso8601`, `rfc3339` and `parse` of the format names above, plus `strptime` patterns, so a zone that cannot parse the format you asked for is refused when the decoder is built:
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
D.time(:httpdate, zone: Time.zone)
|
|
284
|
+
# raises ArgumentError: cannot decode a time in httpdate format: the given zone does not respond to httpdate
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Note `ActiveSupport::TimeZone#parse` answers a value it cannot parse with `nil` rather than by raising, unlike every method of `Time`. Both are read as a failure to decode.
|
|
288
|
+
|
|
289
|
+
* `big_decimal` -- decode a `BigDecimal` object, or a number or string describing one. Only finite numbers are accepted:
|
|
290
|
+
|
|
291
|
+
```ruby
|
|
292
|
+
require "decoding/decoders/big_decimal"
|
|
293
|
+
|
|
294
|
+
Decoding.decode(D.big_decimal, "1.23") # => Decoding::Ok(BigDecimal("1.23"))
|
|
295
|
+
Decoding.decode(D.big_decimal, 42) # => Decoding::Ok(BigDecimal("42"))
|
|
296
|
+
Decoding.decode(D.big_decimal, "abc") # => Decoding::Err("expected a decimal number, got \"abc\"")
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
This decoder needs the `bigdecimal` gem, which is no longer one of Ruby's default gems. Add `gem "bigdecimal"` to your Gemfile to use it.
|
|
300
|
+
|
|
301
|
+
## Reading configuration from the environment
|
|
302
|
+
|
|
303
|
+
Environment variables are always strings, and an application that is misconfigured should refuse to boot rather than fail later. `Decoding.env` reads a single variable, decodes it, and raises when it is missing or its value does not make sense:
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
require "decoding/env"
|
|
307
|
+
|
|
308
|
+
Decoding.env("DATABASE_URL") # => "postgres://localhost/app"
|
|
309
|
+
Decoding.env("PORT", :integer) # => 8080
|
|
310
|
+
Decoding.env("DEBUG", :boolean) # => true
|
|
311
|
+
Decoding.env("PORT", :integer, default: 3000) # => 3000 when PORT is not set
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
The named types are `:string` (the default), `:symbol`, `:integer`, `:float` and `:boolean`. Any decoder is accepted too, which is how you read the types that live behind their own require:
|
|
315
|
+
|
|
316
|
+
```ruby
|
|
317
|
+
require "decoding/decoders/uri"
|
|
318
|
+
|
|
319
|
+
Decoding.env("DATABASE_URL", D.uri) # => #<URI::Generic postgres://localhost/app>
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Failures name the variable, so the reason an application would not start is clear:
|
|
323
|
+
|
|
324
|
+
```
|
|
325
|
+
Decoding::UnwrapError: ENV["PORT"] is not set
|
|
326
|
+
Decoding::UnwrapError: ENV["PORT"]: expected an integer, got "abc"
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
A variable set to an empty string counts as set, so it still has to decode rather than quietly falling back to the default. To allow a variable to be absent without a default, give it a decoder that accepts `nil`:
|
|
330
|
+
|
|
331
|
+
```ruby
|
|
332
|
+
Decoding.env("SENTRY_DSN", D.optional(D.string)) # => nil when not set
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Pass `from:` to read from somewhere other than `ENV`, which is useful in tests.
|
|
336
|
+
|
|
337
|
+
## Testing your own decoders
|
|
338
|
+
|
|
339
|
+
The matchers this gem tests itself with are available to applications that write decoders of their own. They need `rspec-expectations`, which is not a dependency of this gem, so require them from your spec helper:
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
# spec/spec_helper.rb
|
|
343
|
+
require "decoding/rspec"
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
`decode_value` describes a decoder, running it for you, so an example says what a decoder does rather than how to call it:
|
|
347
|
+
|
|
348
|
+
```ruby
|
|
349
|
+
expect(my_decoder).to decode_value({ "name" => "John" }).to({ name: "John" })
|
|
350
|
+
expect(my_decoder).to decode_value({}).failing_with(%(expected Hash with key "name"))
|
|
351
|
+
expect(my_decoder).to decode_value(nil) # decodes; the value is not asserted
|
|
352
|
+
expect(my_decoder).not_to decode_value(nil) # fails to decode
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
An error nested in a structure is asserted with its location, outermost segment first, rather than by matching the rendered `Error at .` prefix:
|
|
356
|
+
|
|
357
|
+
```ruby
|
|
358
|
+
expect(D.field("a", D.field("b", D.string)))
|
|
359
|
+
.to decode_value({ "a" => { "b" => 1 } })
|
|
360
|
+
.failing_with("expected String, got Integer").at("a", "b")
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
`succeed_with` and `fail_with` describe a result you already have, for when making the call is part of what the example tests:
|
|
364
|
+
|
|
365
|
+
```ruby
|
|
366
|
+
expect(Decoding.decode(my_decoder, input)).to succeed_with({ name: "John" })
|
|
367
|
+
expect(Decoding.decode(my_decoder, input)).to fail_with("expected String, got Integer").at("name")
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Expected values are compared strictly, so `1` does not match `1.0` and a decoder answering with the wrong type cannot pass. Pass a matcher where that is too strict:
|
|
371
|
+
|
|
372
|
+
```ruby
|
|
373
|
+
expect(D.unix_time).to decode_value("1595674680.5").to(an_object_having_attributes(usec: 500_000))
|
|
374
|
+
```
|
|
375
|
+
|
|
149
376
|
## Development
|
|
150
377
|
|
|
151
378
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
@@ -31,7 +31,11 @@ module Decoding
|
|
|
31
31
|
|
|
32
32
|
failures << result.unwrap_err(nil)
|
|
33
33
|
end
|
|
34
|
-
err(
|
|
34
|
+
err(
|
|
35
|
+
failures.first.combine(failures.drop(1)) do |messages|
|
|
36
|
+
"None of the decoders matched:\n#{messages.map { " - #{_1}" }.join("\n")}"
|
|
37
|
+
end
|
|
38
|
+
)
|
|
35
39
|
end
|
|
36
40
|
end
|
|
37
41
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Decoding
|
|
6
|
+
# Decoders are composable functions for deconstructing unknown input values
|
|
7
|
+
# into known output values.
|
|
8
|
+
module Decoders
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Decode a `BigDecimal` object, or a number or string describing one.
|
|
12
|
+
#
|
|
13
|
+
# Only finite numbers are accepted: `NaN` and `Infinity` are errors, as a
|
|
14
|
+
# decimal is usually reached for when a value has to be exact.
|
|
15
|
+
#
|
|
16
|
+
# Note this decoder needs the `bigdecimal` gem, which is no longer part of
|
|
17
|
+
# Ruby's default gems. Add it to your Gemfile to use this decoder.
|
|
18
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# decode(big_decimal, "1.23") # => Decoding::Ok(BigDecimal("1.23"))
|
|
21
|
+
# decode(big_decimal, 42) # => Decoding::Ok(BigDecimal("42"))
|
|
22
|
+
# decode(big_decimal, "abc")
|
|
23
|
+
# # => Decoding::Err(%(expected a decimal number, got "abc"))
|
|
24
|
+
# @return [Decoding::Decoder<BigDecimal>]
|
|
25
|
+
# @see Decoding::Decoders::MapErr
|
|
26
|
+
def big_decimal
|
|
27
|
+
Decoders.map_err(
|
|
28
|
+
Decoders.and_then(
|
|
29
|
+
Decoders.any(
|
|
30
|
+
Decoders.match(::BigDecimal),
|
|
31
|
+
Decoders.map(Decoders.any(Decoders.integer, Decoders.float, Decoders.string)) { BigDecimal(_1) }
|
|
32
|
+
)
|
|
33
|
+
) { |number| number.finite? ? Decoders.succeed(number) : Decoders.fail("not a finite number") }
|
|
34
|
+
) { |_message, value| "expected a decimal number, got #{value.inspect}" }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Decoding
|
|
6
|
+
# Decoders are composable functions for deconstructing unknown input values
|
|
7
|
+
# into known output values.
|
|
8
|
+
module Decoders
|
|
9
|
+
# The standard formats {Decoding::Decoders.date} can parse, each named after
|
|
10
|
+
# the `Date` method that parses it.
|
|
11
|
+
DATE_FORMATS = %i[iso8601 xmlschema rfc2822 rfc822 rfc3339 httpdate jisx0301 parse].freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Decode a `Date` object, or a string describing a date in a given format.
|
|
16
|
+
#
|
|
17
|
+
# The format is either the name of one of {DATE_FORMATS}, or a string with a
|
|
18
|
+
# `strptime` pattern. Note that the `:parse` format is lenient: it fills in
|
|
19
|
+
# any components the input value leaves out from the current date.
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# decode(date(:iso8601), "2020-01-01")
|
|
23
|
+
# # => Decoding::Ok(#<Date: 2020-01-01>)
|
|
24
|
+
# decode(date("%Y|%m"), "nope")
|
|
25
|
+
# # => Decoding::Err("expected a date matching \"%Y|%m\", got \"nope\"")
|
|
26
|
+
# @param format [Symbol, String]
|
|
27
|
+
# @raise [ArgumentError] when the format is not a known name or a pattern.
|
|
28
|
+
# This is raised when the decoder is built, not when it is used.
|
|
29
|
+
# @return [Decoding::Decoder<Date>]
|
|
30
|
+
# @see Decoding::Decoders::MapErr
|
|
31
|
+
def date(format)
|
|
32
|
+
parse, description = date_format(format)
|
|
33
|
+
Decoders.map_err(
|
|
34
|
+
Decoders.any(
|
|
35
|
+
Decoders.match(::Date),
|
|
36
|
+
Decoders.map(Decoders.string) { parse.call(_1) }
|
|
37
|
+
)
|
|
38
|
+
) { |_message, value| "expected #{description}, got #{value.inspect}" }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @private
|
|
42
|
+
def date_format(format)
|
|
43
|
+
case format
|
|
44
|
+
when ::String
|
|
45
|
+
[->(str) { ::Date.strptime(str, format) }, "a date matching #{format.inspect}"]
|
|
46
|
+
when ::Symbol
|
|
47
|
+
raise ArgumentError, "unknown date format: #{format.inspect}" unless DATE_FORMATS.include?(format)
|
|
48
|
+
|
|
49
|
+
[->(str) { ::Date.public_send(format, str) }, format == :parse ? "a date" : "a date in #{format} format"]
|
|
50
|
+
else
|
|
51
|
+
raise ArgumentError, "expected a date format name or a strptime pattern, got #{format.inspect}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private_class_method :date_format
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../decoder"
|
|
4
|
+
|
|
5
|
+
module Decoding
|
|
6
|
+
module Decoders
|
|
7
|
+
# A decoder for a value that must be one of a fixed set of values.
|
|
8
|
+
#
|
|
9
|
+
# Values are compared for equality, not with the `===` operator the {Match}
|
|
10
|
+
# decoder uses, so the values are read as themselves rather than as
|
|
11
|
+
# patterns.
|
|
12
|
+
#
|
|
13
|
+
# @see Decoding::Decoders.enum
|
|
14
|
+
class Enum < Decoder
|
|
15
|
+
# @overload initialize(value, *values)
|
|
16
|
+
# @param value [Object]
|
|
17
|
+
# @param values [Object]
|
|
18
|
+
# @overload initialize(values)
|
|
19
|
+
# @param values [Array<Object>] the values as a single array
|
|
20
|
+
# @raise [ArgumentError] when no values are given, or one is repeated.
|
|
21
|
+
def initialize(value, *values)
|
|
22
|
+
@values = collect(value, values)
|
|
23
|
+
super()
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param value [Object]
|
|
27
|
+
# @return [Decoding::Result<Object>]
|
|
28
|
+
def call(value)
|
|
29
|
+
return ok(value) if @values.include?(value)
|
|
30
|
+
|
|
31
|
+
err(failure("expected one of #{@values.map(&:inspect).join(", ")}, got #{value.inspect}"))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def collect(value, values)
|
|
37
|
+
values = (values.empty? && value.is_a?(::Array) ? value.dup : [value, *values]).freeze
|
|
38
|
+
raise ArgumentError, "expected at least one value to decode" if values.empty?
|
|
39
|
+
|
|
40
|
+
duplicates = values.tally.select { |_, count| count > 1 }.keys
|
|
41
|
+
raise ArgumentError, "duplicate values: #{duplicates.map(&:inspect).join(", ")}" if duplicates.any?
|
|
42
|
+
|
|
43
|
+
values
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -23,10 +23,10 @@ module Decoding
|
|
|
23
23
|
if value.key?(@key)
|
|
24
24
|
@decoder.call(value.fetch(@key)).map_err { _1.push(@key) }
|
|
25
25
|
else
|
|
26
|
-
err(failure("expected
|
|
26
|
+
err(failure("expected Hash with key #{@key.inspect}"))
|
|
27
27
|
end
|
|
28
28
|
else
|
|
29
|
-
err(failure("expected
|
|
29
|
+
err(failure("expected Hash, got #{value.class}"))
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
end
|
|
@@ -26,11 +26,11 @@ module Decoding
|
|
|
26
26
|
[
|
|
27
27
|
@key_decoder
|
|
28
28
|
.call(k)
|
|
29
|
-
.map_err { |
|
|
29
|
+
.map_err { _1.map { |msg| "invalid key: #{msg}" }.push(k) },
|
|
30
30
|
|
|
31
31
|
@value_decoder
|
|
32
32
|
.call(v)
|
|
33
|
-
.map_err {
|
|
33
|
+
.map_err { _1.push(k) }
|
|
34
34
|
]
|
|
35
35
|
)
|
|
36
36
|
end
|
|
@@ -9,9 +9,6 @@ module Decoding
|
|
|
9
9
|
#
|
|
10
10
|
# @see Decoding::Decoders.index
|
|
11
11
|
class Index < Decoder
|
|
12
|
-
# @private
|
|
13
|
-
Err = Result.err("error decoding array: index is out of bounds")
|
|
14
|
-
|
|
15
12
|
# @param index [Integer]
|
|
16
13
|
# @param decoder [Decoding::Decoder<Object>]
|
|
17
14
|
def initialize(index, decoder)
|
|
@@ -23,7 +20,7 @@ module Decoding
|
|
|
23
20
|
# @param value [Object]
|
|
24
21
|
# @return [Decoding::Decoder<Object>]
|
|
25
22
|
def call(value)
|
|
26
|
-
return err(failure("expected
|
|
23
|
+
return err(failure("expected Array, got #{value.class}")) unless value.is_a?(::Array)
|
|
27
24
|
|
|
28
25
|
@decoder
|
|
29
26
|
.call(value.fetch(@index))
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../decoder"
|
|
4
|
+
|
|
5
|
+
module Decoding
|
|
6
|
+
module Decoders
|
|
7
|
+
# A decoder that builds the decoder it delegates to on first use, rather
|
|
8
|
+
# than when it is created.
|
|
9
|
+
#
|
|
10
|
+
# This is what makes recursive decoders possible: a decoder for a tree can
|
|
11
|
+
# refer to itself, since the reference is only resolved once there is a
|
|
12
|
+
# value to decode.
|
|
13
|
+
#
|
|
14
|
+
# @see Decoding::Decoders.lazy
|
|
15
|
+
class Lazy < Decoder
|
|
16
|
+
# @yieldreturn [Decoding::Decoder<a>]
|
|
17
|
+
def initialize(&block)
|
|
18
|
+
@block = block
|
|
19
|
+
super()
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param value [Object]
|
|
23
|
+
# @return [Decoding::Result<a>]
|
|
24
|
+
def call(value) = decoder.call(value)
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def decoder = @decoder ||= @block.call.to_decoder
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../decoder"
|
|
4
|
+
|
|
5
|
+
module Decoding
|
|
6
|
+
module Decoders
|
|
7
|
+
# A decoder that replaces the error message of a decoder that failed,
|
|
8
|
+
# leaving successful results untouched.
|
|
9
|
+
#
|
|
10
|
+
# @see Decoding::Decoders.map_err
|
|
11
|
+
class MapErr < Decoder
|
|
12
|
+
# @param decoder [Decoding::Decoder<a>]
|
|
13
|
+
# @yieldparam msg [String]
|
|
14
|
+
# @yieldparam value [Object]
|
|
15
|
+
# @yieldreturn [String]
|
|
16
|
+
def initialize(decoder, &block)
|
|
17
|
+
@decoder = decoder.to_decoder
|
|
18
|
+
@block = block
|
|
19
|
+
super()
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param value [Object]
|
|
23
|
+
# @return [Decoding::Result<a>]
|
|
24
|
+
def call(value)
|
|
25
|
+
@decoder.call(value).map_err { |f| f.map { @block.call(_1, value) } }
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
err(failure("error in map_err block: #{e.message}"))
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -21,7 +21,7 @@ module Decoding
|
|
|
21
21
|
elsif @pattern.is_a?(Class)
|
|
22
22
|
err(failure("expected #{@pattern}, got #{value.class}"))
|
|
23
23
|
else
|
|
24
|
-
err(failure("expected value matching #{@pattern.inspect}, got
|
|
24
|
+
err(failure("expected value matching #{@pattern.inspect}, got #{value.inspect}"))
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
end
|