decoding 0.2.6 → 0.4.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: 5d613b53b493863ecae1fcbc778f5684acbbc628ad51f88fca0953889cd138d0
4
- data.tar.gz: cfc4039a5bfcf182c174b0d1f037d2cbd3b610fa746904906a2209b680fb4cad
3
+ metadata.gz: 0af816a4ee858fb01b7affa44921ddb05b38199a2fbdee33f9439342d8c5dee7
4
+ data.tar.gz: 18511340412ca51970844209ed9d878a2f46b32ffec15f1c9862c97a540c8921
5
5
  SHA512:
6
- metadata.gz: cbe2df98d6a2b6b9588cc5ecfe5543cae21575488a09d99768f10791fdb66332f8cda35483b6e61045a8bbdb5949f900cdd69ab94cfb602e456fb05f5dbfa14e
7
- data.tar.gz: e0eaddb3e027ba796f126aa500564507481847c196d139b16a51c390cb28990706be3d959bde8700857abbf2c18fc69765f79c03cd7cd16cbfd5e7a7be03be92
6
+ metadata.gz: 044227d25ff8af0830443a267b86455de69ba14c72fa37bed1b5c84080d5f1156aeb533cceb46d5e8cce7b57a6d2a55a85729fb652b36bb747dc6b9f704ef6ef
7
+ data.tar.gz: f957565332b96ca5fa607fb12ecebfc0bc3379beb6aa154760b70f14f92f59b4c8fbb39e235621e020473f49d68e20fe7909c8a0f6bcef1e091cd7d6f02b6a99
data/.rubocop.yml CHANGED
@@ -1,5 +1,5 @@
1
1
  ---
2
- require:
2
+ plugins:
3
3
  - rubocop-rspec
4
4
  - rubocop-performance
5
5
  - rubocop-rake
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0]
4
+
5
+ * Add `uri` decoder for URI objects and strings that can be parsed as one, available after `require "decoding/decoders/uri"`
6
+ * Add `map_err` decoder for replacing the error message of a failed decoder
7
+ * Add `Decoding.decode!` for decoding a value, raising `Decoding::UnwrapError` when decoding fails
8
+ * Add `lazy` decoder, making recursive decoders possible
9
+ * Add `match` decoder for matching a value against any pattern using `===`
10
+ * Add `enum` decoder for a value that must be one of a fixed set of values
11
+ * Add `optional_field` decoder for a key that may be absent from a hash
12
+ * Add `time` and `date` decoders, available after `require "decoding/decoders/time"` and `require "decoding/decoders/date"`
13
+ * Add `big_decimal` decoder, available after `require "decoding/decoders/big_decimal"`
14
+ * Add `unix_time` decoder for timestamps, alongside the `time` decoder
15
+ * Add `parsed_integer`, `parsed_float` and `parsed_boolean` decoders for reading typed values out of strings
16
+ * Add `Decoding.env` for decoding a single environment variable, available after `require "decoding/env"`
17
+ * Raise `Decoding::UnwrapError` as a `Decoding::Error`
18
+ * Remove the unused `Decoding::Decoders::Index::Err` constant
19
+ * 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.
20
+ * Give the `boolean` decoder its own error message, rather than reporting the failures of the decoders it is built from
21
+ * Use consistent phrasing for the error messages of the `match`, `field`, `array` and `index` decoders
22
+ * Report the failure of the given decoder from the `optional` decoder, rather than also reporting that the value was not `nil`
23
+ * Report the location shared by all failures of an `any` decoder once, instead of repeating it in every collected message
24
+
25
+ ## [0.3.0]
26
+
27
+ * Add `Decoding::Data` for creating decodable data classes
28
+
3
29
  ## [0.2.6]
4
30
 
5
31
  * Add `Decoding::Result#unwrap!`
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,123 @@ 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
+ * `big_decimal` -- decode a `BigDecimal` object, or a number or string describing one. Only finite numbers are accepted:
272
+
273
+ ```ruby
274
+ require "decoding/decoders/big_decimal"
275
+
276
+ Decoding.decode(D.big_decimal, "1.23") # => Decoding::Ok(BigDecimal("1.23"))
277
+ Decoding.decode(D.big_decimal, 42) # => Decoding::Ok(BigDecimal("42"))
278
+ Decoding.decode(D.big_decimal, "abc") # => Decoding::Err("expected a decimal number, got \"abc\"")
279
+ ```
280
+
281
+ 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.
282
+
283
+ ## Reading configuration from the environment
284
+
285
+ 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:
286
+
287
+ ```ruby
288
+ require "decoding/env"
289
+
290
+ Decoding.env("DATABASE_URL") # => "postgres://localhost/app"
291
+ Decoding.env("PORT", :integer) # => 8080
292
+ Decoding.env("DEBUG", :boolean) # => true
293
+ Decoding.env("PORT", :integer, default: 3000) # => 3000 when PORT is not set
294
+ ```
295
+
296
+ 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:
297
+
298
+ ```ruby
299
+ require "decoding/decoders/uri"
300
+
301
+ Decoding.env("DATABASE_URL", D.uri) # => #<URI::Generic postgres://localhost/app>
302
+ ```
303
+
304
+ Failures name the variable, so the reason an application would not start is clear:
305
+
306
+ ```
307
+ Decoding::UnwrapError: ENV["PORT"] is not set
308
+ Decoding::UnwrapError: ENV["PORT"]: expected an integer, got "abc"
309
+ ```
310
+
311
+ 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`:
312
+
313
+ ```ruby
314
+ Decoding.env("SENTRY_DSN", D.optional(D.string)) # => nil when not set
315
+ ```
316
+
317
+ Pass `from:` to read from somewhere other than `ENV`, which is useful in tests.
318
+
149
319
  ## Development
150
320
 
151
321
  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.
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "decoders"
4
+
5
+ module Decoding
6
+ # Wrapper around Ruby's `Data` class, providing a way to both define a new
7
+ # data class as usual but including a decoder to decode a value into such a
8
+ # data value.
9
+ #
10
+ # @example
11
+ # User = Decoding::Data.define(
12
+ # name: field("postName", string),
13
+ # age: field("yearsOld", integer)
14
+ # ) do
15
+ # def greet
16
+ # "Hello, my name is #{name} and I am #{age} years old."
17
+ # end
18
+ # end
19
+ # payload = { "postName" => "John", "yearsOld" => 30 }
20
+ # Decoding.decode(User.decoder, payload) => Decoding::Ok(user)
21
+ # user.greet # => "Hello, my name is John and I am 30 years old."
22
+ # @return [Class]
23
+ module Data
24
+ # @param attributes [Hash<Symbol, Decoding::Decoder>] A hash of attribute
25
+ # names and decoders
26
+ # @see ::Data.define
27
+ # @return [Class] A new data class with a `decoder` method.
28
+ def self.define(attributes, &)
29
+ # First ensure we have a hash of attribute names and decoders
30
+ attributes = attributes.to_h do |key, value|
31
+ [key.to_sym, value.to_decoder]
32
+ end
33
+
34
+ # Define the data class as usual
35
+ data_class = ::Data.define(*attributes.keys, &)
36
+
37
+ # Add a special `decode` method to use the provided decoders to decode a
38
+ # value as hash and build a new instance with it
39
+ data_class.define_singleton_method(:decoder) do
40
+ Decoding::Decoders.map(
41
+ Decoding::Decoders.decode_hash(attributes)
42
+ ) { |attrs| new(**attrs) }
43
+ end
44
+
45
+ # Adapt to `to_decoder` protocol
46
+ data_class.define_singleton_method(:to_decoder) { decoder }
47
+
48
+ # Return the data class so regular assignment like
49
+ # `User = ::Data.define(...)` works.
50
+ data_class
51
+ end
52
+ end
53
+ end
@@ -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
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../decoder"
4
+
5
+ module Decoding
6
+ module Decoders
7
+ # A decoder for a value that may or may not be `nil`, decoding any other
8
+ # value with a given decoder.
9
+ #
10
+ # The given decoder gets to decode a `nil` value first, so it can give it a
11
+ # meaning of its own. Only when it fails to do so does this decoder treat
12
+ # `nil` as an absent value.
13
+ #
14
+ # @see Decoding::Decoders.optional
15
+ class Optional < Decoder
16
+ # @param decoder [Decoding::Decoder<a>]
17
+ def initialize(decoder)
18
+ @decoder = decoder.to_decoder
19
+ super()
20
+ end
21
+
22
+ # @param value [Object]
23
+ # @return [Decoding::Result<a, nil>]
24
+ def call(value)
25
+ result = @decoder.call(value)
26
+ return result if result.ok?
27
+ return ok(nil) if value.nil?
28
+
29
+ result
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../decoder"
4
+ require_relative "field"
5
+
6
+ module Decoding
7
+ module Decoders
8
+ # Decode a value from a key that may be absent from a hash.
9
+ #
10
+ # This differs from wrapping a {Field} decoder in an {Optional} decoder,
11
+ # which describes a key that is present but may hold `nil`. Here the key
12
+ # itself may be missing; when it is present, its value must still decode.
13
+ #
14
+ # @see Decoding::Decoders.optional_field
15
+ class OptionalField < Decoder
16
+ # @param key [Object]
17
+ # @param decoder [Decoding::Decoder<a>]
18
+ # @param default [Object] used when the key is absent.
19
+ def initialize(key, decoder, default: nil)
20
+ @key = String(key)
21
+ @field = Field.new(@key, decoder)
22
+ @default = default
23
+ super()
24
+ end
25
+
26
+ # @param value [Object]
27
+ # @return [Decoding::Result<a>]
28
+ def call(value)
29
+ return ok(@default) if value.is_a?(::Hash) && !value.key?(@key)
30
+
31
+ @field.call(value)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -11,9 +11,14 @@ module Decoding
11
11
  # @example Always return a fixed value
12
12
  # decode(Succeed.new(5), "anything") # => Decoding::Ok(5)
13
13
  #
14
- # @example Use with field to provide defaults
15
- # decode(any(field("x", integer), Succeed.new(0)), {})
16
- # # => Decoding::Ok(0)
14
+ # @example Use in conditional decoding
15
+ # and_then(field("kind", string)) do |kind|
16
+ # kind == "none" ? Succeed.new(nil) : field("value", integer)
17
+ # end
18
+ #
19
+ # Note this is not the way to give a key a default value: combining it with
20
+ # {Any} would also swallow the failure of a key that is present but holds a
21
+ # value of the wrong type. Use {OptionalField} for that instead.
17
22
  class Succeed < Decoder
18
23
  # @param value [Object] the value to always return
19
24
  def initialize(value)
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
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.time} can parse, each named after
10
+ # the `Time` method that parses it.
11
+ TIME_FORMATS = %i[iso8601 xmlschema rfc2822 rfc822 httpdate parse].freeze
12
+
13
+ # The units {Decoding::Decoders.unix_time} can read a timestamp in, mapped
14
+ # to the number of them that make up a second.
15
+ UNIX_TIME_UNITS = { seconds: 1, milliseconds: 1000 }.freeze
16
+
17
+ module_function
18
+
19
+ # Decode a `Time` object, or a string describing a time in a given format.
20
+ #
21
+ # The format is either the name of one of {TIME_FORMATS}, or a string with a
22
+ # `strptime` pattern. Note that the `:parse` format is lenient: it fills in
23
+ # any components the input value leaves out from the current time.
24
+ #
25
+ # @example
26
+ # decode(time(:iso8601), "2020-01-01T10:00:00Z")
27
+ # # => Decoding::Ok(2020-01-01 10:00:00 UTC)
28
+ # decode(time("%Y|%m"), "nope")
29
+ # # => Decoding::Err("expected a time matching \"%Y|%m\", got \"nope\"")
30
+ # @param format [Symbol, String]
31
+ # @raise [ArgumentError] when the format is not a known name or a pattern.
32
+ # This is raised when the decoder is built, not when it is used.
33
+ # @return [Decoding::Decoder<Time>]
34
+ # @see Decoding::Decoders::MapErr
35
+ def time(format)
36
+ parse, description = time_format(format)
37
+ Decoders.map_err(
38
+ Decoders.any(
39
+ Decoders.match(::Time),
40
+ Decoders.map(Decoders.string) { parse.call(_1) }
41
+ )
42
+ ) { |_message, value| "expected #{description}, got #{value.inspect}" }
43
+ end
44
+
45
+ # @private
46
+ def time_format(format)
47
+ case format
48
+ when ::String
49
+ [->(str) { ::Time.strptime(str, format) }, "a time matching #{format.inspect}"]
50
+ when ::Symbol
51
+ raise ArgumentError, "unknown time format: #{format.inspect}" unless TIME_FORMATS.include?(format)
52
+
53
+ [->(str) { ::Time.public_send(format, str) }, format == :parse ? "a time" : "a time in #{format} format"]
54
+ else
55
+ raise ArgumentError, "expected a time format name or a strptime pattern, got #{format.inspect}"
56
+ end
57
+ end
58
+
59
+ # Decode a unix timestamp, given as a number or as a string describing one.
60
+ #
61
+ # Timestamps are read as a number of seconds since the epoch unless another
62
+ # unit is given. Note that reading a timestamp in the wrong unit is not an
63
+ # error but a wildly different point in time, so a source that reports
64
+ # milliseconds has to say so.
65
+ #
66
+ # @example
67
+ # decode(unix_time, 1_595_674_680) # => Decoding::Ok(2020-07-25 10:58:00 UTC)
68
+ # decode(unix_time(:milliseconds), 1_595_674_680_123)
69
+ # # => Decoding::Ok(2020-07-25 10:58:00.123 UTC)
70
+ # @param unit [Symbol] one of the keys of {UNIX_TIME_UNITS}.
71
+ # @raise [ArgumentError] when the unit is not a known one. This is raised
72
+ # when the decoder is built, not when it is used.
73
+ # @return [Decoding::Decoder<Time>]
74
+ # @see Decoding::Decoders::MapErr
75
+ def unix_time(unit = :seconds)
76
+ raise ArgumentError, "unknown unit: #{unit.inspect}" unless UNIX_TIME_UNITS.key?(unit)
77
+
78
+ per_second = UNIX_TIME_UNITS.fetch(unit)
79
+ Decoders.map_err(
80
+ Decoders.any(
81
+ Decoders.match(::Time),
82
+ Decoders.map(
83
+ Decoders.any(Decoders.integer, Decoders.float, Decoders.parsed_integer, Decoders.parsed_float)
84
+ ) { ::Time.at(per_second == 1 ? _1 : _1 / Rational(per_second)) }
85
+ )
86
+ ) { |_message, value| "expected a unix timestamp, got #{value.inspect}" }
87
+ end
88
+
89
+ private_class_method :time_format
90
+ end
91
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
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 URI object, or a string that can be parsed as one.
12
+ #
13
+ # @example
14
+ # decode(uri, "https://example.com") # => Decoding::Ok(URI("https://example.com"))
15
+ # decode(uri, 123) # => Decoding::Err("expected a URI, got 123")
16
+ # @return [Decoding::Decoder<URI::Generic>]
17
+ # @see Decoding::Decoders::MapErr
18
+ def uri
19
+ Decoders.map_err(
20
+ Decoders.any(
21
+ Decoders::Match.new(::URI::Generic),
22
+ Decoders.map(Decoders.string) { ::URI.parse(_1) }
23
+ )
24
+ ) { |_msg, value| "expected a URI, got #{value.inspect}" }
25
+ end
26
+ end
27
+ end
@@ -2,8 +2,13 @@
2
2
 
3
3
  require_relative "decoders/match"
4
4
  require_relative "decoders/map"
5
+ require_relative "decoders/map_err"
6
+ require_relative "decoders/optional"
7
+ require_relative "decoders/lazy"
8
+ require_relative "decoders/enum"
5
9
  require_relative "decoders/any"
6
10
  require_relative "decoders/field"
11
+ require_relative "decoders/optional_field"
7
12
  require_relative "decoders/array"
8
13
  require_relative "decoders/index"
9
14
  require_relative "decoders/hash"
@@ -30,6 +35,37 @@ module Decoding
30
35
  # @see Decoding::Decoders::Match
31
36
  def string = Decoders::Match.new(String)
32
37
 
38
+ # Decode any value matching the given pattern, using the `===` operator.
39
+ # This works with anything that can be used in a `case` statement, such as
40
+ # classes, ranges and regular expressions.
41
+ #
42
+ # @example
43
+ # decode(match(Symbol), :foo) # => Decoding::Ok(:foo)
44
+ # decode(match(1..5), 3) # => Decoding::Ok(3)
45
+ # @param pattern [#===]
46
+ # @return [Decoding::Decoder<Object>]
47
+ # @see Decoding::Decoders::Match
48
+ def match(pattern) = Decoders::Match.new(pattern)
49
+
50
+ # Decode a value that must be one of a fixed set of values.
51
+ #
52
+ # The values are compared for equality, and can be given either as separate
53
+ # arguments or as a single array.
54
+ #
55
+ # @example
56
+ # decode(enum("active", "archived"), "active") # => Decoding::Ok("active")
57
+ # decode(enum("active", "archived"), "nope")
58
+ # # => Decoding::Err(%(expected one of "active", "archived", got "nope"))
59
+ # @overload enum(value, *values)
60
+ # @param value [Object]
61
+ # @param values [Object]
62
+ # @overload enum(values)
63
+ # @param values [Array<Object>]
64
+ # @raise [ArgumentError] when no values are given, or one is repeated.
65
+ # @return [Decoding::Decoder<Object>]
66
+ # @see Decoding::Decoders::Enum
67
+ def enum(...) = Decoders::Enum.new(...)
68
+
33
69
  # Decode any string value that matches a regular expression.
34
70
  #
35
71
  # @param regex [Regexp, String]
@@ -92,7 +128,7 @@ module Decoding
92
128
  # decode(boolean, true) # => Decoding::Ok(true)
93
129
  # decode(boolean, false) # => Decoding::Ok(false)
94
130
  # @return [Decoding::Decoder<Boolean>]
95
- def boolean = any(self.true, self.false)
131
+ def boolean = map_err(any(self.true, self.false)) { |_msg, value| "expected true or false, got #{value.class}" }
96
132
 
97
133
  # Decode a String value into a symbol.
98
134
  #
@@ -101,6 +137,46 @@ module Decoding
101
137
  # @return [Decoding::Decoder<Symbol>]
102
138
  def symbol = map(string, &:to_sym)
103
139
 
140
+ # @!group Parsing decoders
141
+
142
+ # Decode a string describing an integer into that integer.
143
+ #
144
+ # The string is always read as a decimal number, so `"08"` decodes to `8`
145
+ # and `"0x1f"` is not a valid integer.
146
+ #
147
+ # @example
148
+ # decode(parsed_integer, "8080") # => Decoding::Ok(8080)
149
+ # decode(parsed_integer, "abc") # => Decoding::Err(%(expected an integer, got "abc"))
150
+ # @return [Decoding::Decoder<Integer>]
151
+ def parsed_integer
152
+ map_err(map(string) { Integer(_1, 10) }) { |_message, value| "expected an integer, got #{value.inspect}" }
153
+ end
154
+
155
+ # Decode a string describing a number into a float.
156
+ #
157
+ # @example
158
+ # decode(parsed_float, "1.5") # => Decoding::Ok(1.5)
159
+ # decode(parsed_float, "abc") # => Decoding::Err(%(expected a number, got "abc"))
160
+ # @return [Decoding::Decoder<Float>]
161
+ def parsed_float
162
+ map_err(map(string) { Float(_1) }) { |_message, value| "expected a number, got #{value.inspect}" }
163
+ end
164
+
165
+ # Decode the string `"true"` or `"false"` into the matching boolean.
166
+ #
167
+ # Only those two values are accepted: anything else, such as `"1"` or
168
+ # `"yes"`, is an error rather than a guess at what was meant.
169
+ #
170
+ # @example
171
+ # decode(parsed_boolean, "true") # => Decoding::Ok(true)
172
+ # decode(parsed_boolean, "1") # => Decoding::Err(%(expected "true" or "false", got "1"))
173
+ # @return [Decoding::Decoder<Boolean>]
174
+ def parsed_boolean
175
+ map_err(map(enum("true", "false")) { _1 == "true" }) do |_message, value|
176
+ %(expected "true" or "false", got #{value.inspect})
177
+ end
178
+ end
179
+
104
180
  # @!group Utility decoders
105
181
 
106
182
  # A decoder that always succeeds with the given value.
@@ -151,6 +227,28 @@ module Decoding
151
227
  # @see Decoding::Decoders::Map
152
228
  def map(...) = Decoders::Map.new(...)
153
229
 
230
+ # Decode a value with the given decoder and, if it failed, replace its
231
+ # error message with the result of the given block.
232
+ #
233
+ # This is useful for giving a compound decoder a single, fitting error
234
+ # message rather than exposing the messages of the decoders it is built
235
+ # from. The location of the original error, if any, is retained.
236
+ #
237
+ # @example
238
+ # decoder = map_err(any(string, integer)) { |_msg, value|
239
+ # "expected a string or integer, got #{value.inspect}"
240
+ # }
241
+ # decode(decoder, nil)
242
+ # # => Decoding::Err("expected a string or integer, got nil")
243
+ # @overload map_err(decoder)
244
+ # @param decoder [Decoding::Decoder<a>]
245
+ # @yieldparam msg [String] the original error message
246
+ # @yieldparam value [Object] the value being decoded
247
+ # @yieldreturn [String]
248
+ # @return [Decoding::Decoder<a>]
249
+ # @see Decoding::Decoders::MapErr
250
+ def map_err(...) = Decoders::MapErr.new(...)
251
+
154
252
  # Decode a value by trying many different decoders in order, using the first
155
253
  # matching result -- or a failure when none of the given decoders succeed.
156
254
  #
@@ -168,9 +266,15 @@ module Decoding
168
266
  # @example
169
267
  # decode(string, "foo") # => Decoding::Ok("foo")
170
268
  # decode(string, nil) # => Decoding::Ok(nil)
171
- # @param decoder [Decoding::Decoder<a>]
172
- # @return [Decoding::Decoder<a, nil>]
173
- def optional(decoder) = any(decoder, self.nil)
269
+ # The given decoder gets to decode a `nil` value first, so it can give it a
270
+ # meaning of its own. Only when it fails to do so is `nil` treated as an
271
+ # absent value.
272
+ #
273
+ # @overload optional(decoder)
274
+ # @param decoder [Decoding::Decoder<a>]
275
+ # @return [Decoding::Decoder<a, nil>]
276
+ # @see Decoding::Decoders::Optional
277
+ def optional(...) = Decoders::Optional.new(...)
174
278
 
175
279
  # Decode a value from a given key in a hash.
176
280
  #
@@ -183,6 +287,26 @@ module Decoding
183
287
  # @see Decoding::Decoders::Field
184
288
  def field(...) = Decoders::Field.new(...)
185
289
 
290
+ # Decode a value from a key that may be absent from a hash.
291
+ #
292
+ # When the key is absent, the given default is used. When it is present, its
293
+ # value must still decode: a key holding a value of the wrong type is an
294
+ # error rather than a reason to fall back to the default.
295
+ #
296
+ # @example
297
+ # decoder = optional_field("count", integer, default: 0)
298
+ # decode(decoder, {}) # => Decoding::Ok(0)
299
+ # decode(decoder, { "count" => 5 }) # => Decoding::Ok(5)
300
+ # decode(decoder, { "count" => "x" })
301
+ # # => Decoding::Err("Error at .count: expected Integer, got String")
302
+ # @overload optional_field(key, decoder, default: nil)
303
+ # @param key [Object]
304
+ # @param decoder [Decoding::Decoder<a>]
305
+ # @param default [Object]
306
+ # @return [Decoding::Decoder<a>]
307
+ # @see Decoding::Decoders::OptionalField
308
+ def optional_field(...) = Decoders::OptionalField.new(...)
309
+
186
310
  # Decode an array of values using a given decoder.
187
311
  #
188
312
  # @example
@@ -233,6 +357,8 @@ module Decoding
233
357
  # # => Decode::Ok({ id: 1 })
234
358
  # @return Decoding::Decoder
235
359
  def decode_hash(decoders)
360
+ return succeed({}) if decoders.empty?
361
+
236
362
  map(*decoders.values) do |*values|
237
363
  decoders.keys.zip(values).to_h
238
364
  end
@@ -260,6 +386,26 @@ module Decoding
260
386
  # @see Decoding::Decoders::AndThen
261
387
  def and_then(...) = Decoders::AndThen.new(...)
262
388
 
389
+ # Create a decoder that is only built when it is used.
390
+ #
391
+ # This makes recursive decoders possible: without it, a decoder that refers
392
+ # to itself would recurse endlessly while being built.
393
+ #
394
+ # @example
395
+ # def tree
396
+ # decode_hash(
397
+ # name: field("name", string),
398
+ # children: field("children", array(lazy { tree }))
399
+ # )
400
+ # end
401
+ # decode(tree, { "name" => "a", "children" => [] })
402
+ # # => Decoding::Ok({ name: "a", children: [] })
403
+ # @overload lazy
404
+ # @yieldreturn [Decoding::Decoder<a>]
405
+ # @return [Decoding::Decoder<a>]
406
+ # @see Decoding::Decoders::Lazy
407
+ def lazy(...) = Decoders::Lazy.new(...)
408
+
263
409
  # Decode deeply-nested fields.
264
410
  #
265
411
  # @example
@@ -267,7 +413,7 @@ module Decoding
267
413
  # decode(decoder, { "a" => { "b" => { "c" => "d" } } })
268
414
  # # => Decoding::Ok("d")
269
415
  # decode(decoder, { "a" => { "b" => "d" } })
270
- # # => Decoding::Err("Error at .a.b: expected a Hash, got String")
416
+ # # => Decoding::Err("Error at .a.b: expected Hash, got String")
271
417
  # @overload at(*fields, decoder)
272
418
  # @param fields [String]
273
419
  # @param decoder [Decoding::Decoder<a>]
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../decoding"
4
+
5
+ # Decoding is a library to help transform unknown external data into neat values
6
+ # with known shapes.
7
+ module Decoding
8
+ # The types {Decoding.env} accepts by name, mapped to the decoder that reads
9
+ # them.
10
+ #
11
+ # Environment variables are always strings, so these are all decoders that
12
+ # parse a string. Pass a decoder directly for anything else.
13
+ ENV_TYPES = {
14
+ string: :string,
15
+ symbol: :symbol,
16
+ integer: :parsed_integer,
17
+ float: :parsed_float,
18
+ boolean: :parsed_boolean
19
+ }.freeze
20
+
21
+ # Marks the absence of a default value, so that `nil` can be used as one.
22
+ NO_DEFAULT = Object.new.freeze
23
+ private_constant :NO_DEFAULT
24
+
25
+ module_function
26
+
27
+ # Read a single environment variable, decoding its value.
28
+ #
29
+ # This is meant for configuration read at boot time, such as a Rails
30
+ # initializer: it returns the decoded value itself and raises when the
31
+ # variable is missing or its value cannot be decoded, rather than letting a
32
+ # misconfigured application start.
33
+ #
34
+ # @example
35
+ # Decoding.env("DATABASE_URL") # => "postgres://localhost/app"
36
+ # Decoding.env("PORT", :integer) # => 8080
37
+ # Decoding.env("PORT", :integer, default: 3000) # => 3000 when unset
38
+ # Decoding.env("DATABASE_URL", Decoders.uri) # => #<URI::Generic ...>
39
+ # @param name [String] the name of the environment variable.
40
+ # @param type [Symbol, Decoding::Decoder] one of the keys of {ENV_TYPES}, or
41
+ # any decoder to run against the value.
42
+ # @param default [Object] returned, undecoded, when the variable is not set.
43
+ # @param from [#key?, #fetch] where to read the variable from.
44
+ # @raise [ArgumentError] when the type is not a known name or a decoder.
45
+ # @raise [Decoding::UnwrapError] when the variable is missing or its value
46
+ # cannot be decoded.
47
+ # @return [Object]
48
+ def env(name, type = :string, default: NO_DEFAULT, from: ENV)
49
+ decoder = env_decoder(type)
50
+ return default if !from.key?(name) && !default.equal?(NO_DEFAULT)
51
+
52
+ decode!(
53
+ Decoders.map_err(decoder) do |message, value|
54
+ value.nil? ? "ENV[#{name.inspect}] is not set" : "ENV[#{name.inspect}]: #{message}"
55
+ end,
56
+ from.fetch(name, nil)
57
+ )
58
+ end
59
+
60
+ # @private
61
+ def env_decoder(type)
62
+ return type.to_decoder if type.respond_to?(:to_decoder)
63
+ raise ArgumentError, "expected a type name or a decoder, got #{type.inspect}" unless type.is_a?(Symbol)
64
+ raise ArgumentError, "unknown type: #{type.inspect}" unless ENV_TYPES.key?(type)
65
+
66
+ Decoders.public_send(ENV_TYPES.fetch(type))
67
+ end
68
+
69
+ private_class_method :env_decoder
70
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decoding
4
+ # Base class for all errors raised by this library.
5
+ class Error < StandardError; end
6
+ end
@@ -9,6 +9,17 @@ module Decoding
9
9
  # error, the `array` decoder can push `3` to the stack to indicate that
10
10
  # happened at index 3 in its input value.
11
11
  class Failure
12
+ # The error message, without the location it occurred at.
13
+ #
14
+ # @return [String]
15
+ attr_reader :msg
16
+
17
+ # The stack of segments describing where the error occurred, innermost
18
+ # first.
19
+ #
20
+ # @return [Array]
21
+ attr_reader :path
22
+
12
23
  # @param msg [String]
13
24
  # @param path [Array] Internal parameter for creating copies with updated paths
14
25
  def initialize(msg, path = [])
@@ -33,6 +44,37 @@ module Decoding
33
44
  self.class.new(@msg, @path + [segment])
34
45
  end
35
46
 
47
+ # Create a copy of this failure with a transformed error message, retaining
48
+ # the current stack of errors.
49
+ #
50
+ # This is useful for decoders that want to replace the error message of a
51
+ # nested decoder with something more fitting, without losing the location
52
+ # of the error.
53
+ #
54
+ # @yieldparam msg [String]
55
+ # @yieldreturn [String]
56
+ # @return [Decoding::Failure]
57
+ def map = self.class.new(yield(@msg), @path)
58
+
59
+ # Combine this failure with others into a single failure, using the given
60
+ # block to build a single message from all of their messages.
61
+ #
62
+ # When all failures occurred at the same location, that location is kept for
63
+ # the combined failure and left out of the individual messages, since the
64
+ # combined failure already describes it. Otherwise each message describes
65
+ # its own location.
66
+ #
67
+ # @param others [Array<Decoding::Failure>]
68
+ # @yieldparam messages [Array<String>]
69
+ # @yieldreturn [String]
70
+ # @return [Decoding::Failure]
71
+ def combine(others)
72
+ failures = [self, *others]
73
+ return self.class.new(yield(failures.map(&:to_s))) unless failures.map(&:path).uniq.size == 1
74
+
75
+ self.class.new(yield(failures.map(&:msg)), @path)
76
+ end
77
+
36
78
  def to_s
37
79
  if @path.any?
38
80
  "Error at .#{@path.reverse.join(".")}: #{@msg}"
@@ -40,9 +82,5 @@ module Decoding
40
82
  @msg
41
83
  end
42
84
  end
43
-
44
- protected
45
-
46
- attr_reader :msg, :path
47
85
  end
48
86
  end
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "error"
4
+
3
5
  module Decoding
4
6
  # Raised when calling {Result#unwrap!} on an `Err` value.
5
- class UnwrapError < StandardError; end
7
+ class UnwrapError < Error; end
6
8
 
7
9
  # A result represent the outcome of some computation that can succeed or fail.
8
10
  # The results are represented with two subclasses of `Result`: `Ok` and `Err`.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Decoding
4
- VERSION = "0.2.6"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/decoding.rb CHANGED
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "decoding/version"
4
+ require_relative "decoding/error"
4
5
  require_relative "decoding/decoders"
6
+ require_relative "decoding/data"
5
7
 
6
8
  # Decoding is a library to help transform unknown external data into neat values
7
9
  # with known shapes. Consider calling an HTTP API: you might pull in whatever
@@ -109,8 +111,6 @@ require_relative "decoding/decoders"
109
111
  # always contain a string error message. It is up to you, as a developer, to
110
112
  # decide how to deal with unsuccessful decoding.
111
113
  module Decoding
112
- class Error < StandardError; end
113
-
114
114
  module_function
115
115
 
116
116
  # Run a given `decoder` on the given input `value`.
@@ -119,4 +119,18 @@ module Decoding
119
119
  # @param value [Object]
120
120
  # @return [Decoding::Result<a>]
121
121
  def decode(decoder, value) = decoder.call(value).map_err(&:to_s)
122
+
123
+ # Run a given `decoder` on the given input `value`, returning the decoded
124
+ # value or raising an error when decoding failed.
125
+ #
126
+ # @example
127
+ # decode!(string, "foo") # => "foo"
128
+ # decode!(string, 123) # raises Decoding::UnwrapError
129
+ # @overload decode!(decoder, value)
130
+ # @param decoder [Decoding::Decoder<a>]
131
+ # @param value [Object]
132
+ # @raise [Decoding::UnwrapError] when decoding failed.
133
+ # @return [Object]
134
+ # @see Decoding::Result#unwrap!
135
+ def decode!(...) = decode(...).unwrap!
122
136
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: decoding
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.6
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Arjan van der Gaag
@@ -28,20 +28,32 @@ files:
28
28
  - README.md
29
29
  - Rakefile
30
30
  - lib/decoding.rb
31
+ - lib/decoding/data.rb
31
32
  - lib/decoding/decoder.rb
32
33
  - lib/decoding/decoders.rb
33
34
  - lib/decoding/decoders/and_then.rb
34
35
  - lib/decoding/decoders/any.rb
35
36
  - lib/decoding/decoders/array.rb
36
37
  - lib/decoding/decoders/at.rb
38
+ - lib/decoding/decoders/big_decimal.rb
39
+ - lib/decoding/decoders/date.rb
40
+ - lib/decoding/decoders/enum.rb
37
41
  - lib/decoding/decoders/fail.rb
38
42
  - lib/decoding/decoders/field.rb
39
43
  - lib/decoding/decoders/hash.rb
40
44
  - lib/decoding/decoders/index.rb
45
+ - lib/decoding/decoders/lazy.rb
41
46
  - lib/decoding/decoders/map.rb
47
+ - lib/decoding/decoders/map_err.rb
42
48
  - lib/decoding/decoders/match.rb
49
+ - lib/decoding/decoders/optional.rb
50
+ - lib/decoding/decoders/optional_field.rb
43
51
  - lib/decoding/decoders/pass.rb
44
52
  - lib/decoding/decoders/succeed.rb
53
+ - lib/decoding/decoders/time.rb
54
+ - lib/decoding/decoders/uri.rb
55
+ - lib/decoding/env.rb
56
+ - lib/decoding/error.rb
45
57
  - lib/decoding/failure.rb
46
58
  - lib/decoding/result.rb
47
59
  - lib/decoding/version.rb