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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e42aa2335c51607c8da6110d0bec04cfef6b2af82365e003b51d54db365e091a
4
- data.tar.gz: 50d36892d40a7a3ca152125a0265cd7c4ac2f87da78c236f7ca98844f83bd81a
3
+ metadata.gz: 27eee73e0aa7f430b7fb6aac6aea19c872dd5d825633d1f0e54362668ae355e5
4
+ data.tar.gz: b6ceb7aa0bd5cda5812949c165a4f8614dd0da97f5e05a8cbbf77cefa3e10e4d
5
5
  SHA512:
6
- metadata.gz: 561b13185e19d9402fc8b416e8cb851f8dea4eb0bf0ad337151e5e43eb6eb97fe76211b2d85aebbbb44ce7111aa852a1bb61015585dab1af68fc4afe250d055d
7
- data.tar.gz: 356c32052eba205c3ff07395d20afd87d5b4c6c564ce50e7727a2bffba6cb1a43f537e1a92ca33bd628ab819ad01ac643ad30f700387f78914d056e9b8246ce7
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" => null,
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(failure("None of the decoders matched:\n#{failures.map { " - #{_1}" }.join("\n")}"))
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
@@ -24,7 +24,7 @@ module Decoding
24
24
  .map { |v, i| @decoder.call(v).map_err { _1.push(i) } }
25
25
  .then { all _1 }
26
26
  else
27
- err(failure("expected an Array, got: #{value.class}"))
27
+ err(failure("expected Array, got #{value.class}"))
28
28
  end
29
29
  end
30
30
  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 a Hash with key #{@key}"))
26
+ err(failure("expected Hash with key #{@key.inspect}"))
27
27
  end
28
28
  else
29
- err(failure("expected a Hash, got: #{value.inspect}"))
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 { |e| failure("error decoding key #{k.inspect}: #{e}") },
29
+ .map_err { _1.map { |msg| "invalid key: #{msg}" }.push(k) },
30
30
 
31
31
  @value_decoder
32
32
  .call(v)
33
- .map_err { |e| failure("error decoding value for key #{k.inspect}: #{e}") }
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 an Array, got: #{value.class}")) unless value.is_a?(::Array)
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: #{value.inspect}"))
24
+ err(failure("expected value matching #{@pattern.inspect}, got #{value.inspect}"))
25
25
  end
26
26
  end
27
27
  end