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.
@@ -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,143 @@
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
+ # Times are resolved by `Time` itself, and so in the system's time zone,
26
+ # unless another zone is given. Anything answering the format you name will
27
+ # do: an `ActiveSupport::TimeZone` resolves in the application's zone, which
28
+ # is rarely the system's, but note it answers only `iso8601`, `rfc3339` and
29
+ # `parse` of the names above.
30
+ #
31
+ # @example
32
+ # decode(time(:iso8601), "2020-01-01T10:00:00Z")
33
+ # # => Decoding::Ok(2020-01-01 10:00:00 UTC)
34
+ # decode(time("%Y|%m"), "nope")
35
+ # # => Decoding::Err("expected a time matching \"%Y|%m\", got \"nope\"")
36
+ # decode(time(:parse, zone: Time.zone), "2020-01-01 10:00:00")
37
+ # # => Decoding::Ok(2020-01-01 10:00:00 +0100)
38
+ # @param format [Symbol, String]
39
+ # @param zone [Object] anything answering the format you name, such as
40
+ # `Time` or an `ActiveSupport::TimeZone`.
41
+ # @raise [ArgumentError] when the format is not a known name or a pattern,
42
+ # or the zone cannot parse it. This is raised when the decoder is built,
43
+ # not when it is used.
44
+ # @return [Decoding::Decoder<Time>]
45
+ # @see Decoding::Decoders::MapErr
46
+ def time(format, zone: ::Time)
47
+ parse, description = time_format(format, zone)
48
+ Decoders.map_err(
49
+ Decoders.any(
50
+ Decoders.match(::Time),
51
+ parsed_by(parse)
52
+ )
53
+ ) { |_message, value| "expected #{description}, got #{value.inspect}" }
54
+ end
55
+
56
+ # @private
57
+ def time_format(format, zone)
58
+ case format
59
+ when ::String
60
+ description = "a time matching #{format.inspect}"
61
+ require_parser(zone, :strptime, description)
62
+ [->(str) { zone.strptime(str, format) }, description]
63
+ when ::Symbol
64
+ raise ArgumentError, "unknown time format: #{format.inspect}" unless TIME_FORMATS.include?(format)
65
+
66
+ description = format == :parse ? "a time" : "a time in #{format} format"
67
+ require_parser(zone, format, description)
68
+ [->(str) { zone.public_send(format, str) }, description]
69
+ else
70
+ raise ArgumentError, "expected a time format name or a strptime pattern, got #{format.inspect}"
71
+ end
72
+ end
73
+
74
+ # Decode a unix timestamp, given as a number or as a string describing one.
75
+ #
76
+ # Timestamps are read as a number of seconds since the epoch unless another
77
+ # unit is given. Note that reading a timestamp in the wrong unit is not an
78
+ # error but a wildly different point in time, so a source that reports
79
+ # milliseconds has to say so.
80
+ #
81
+ # A timestamp names a point in time rather than a local one, so the zone
82
+ # only decides how the value describes itself afterwards -- which matters as
83
+ # soon as anything derives a date from it.
84
+ #
85
+ # @example
86
+ # decode(unix_time, 1_595_674_680) # => Decoding::Ok(2020-07-25 10:58:00 UTC)
87
+ # decode(unix_time(:milliseconds), 1_595_674_680_123)
88
+ # # => Decoding::Ok(2020-07-25 10:58:00.123 UTC)
89
+ # @param unit [Symbol] one of the keys of {UNIX_TIME_UNITS}.
90
+ # @param zone [Object] anything answering `at`, such as `Time` or an
91
+ # `ActiveSupport::TimeZone`.
92
+ # @raise [ArgumentError] when the unit is not a known one, or the zone
93
+ # cannot read a timestamp. This is raised when the decoder is built, not
94
+ # when it is used.
95
+ # @return [Decoding::Decoder<Time>]
96
+ # @see Decoding::Decoders::MapErr
97
+ def unix_time(unit = :seconds, zone: ::Time)
98
+ raise ArgumentError, "unknown unit: #{unit.inspect}" unless UNIX_TIME_UNITS.key?(unit)
99
+
100
+ require_parser(zone, :at, "a unix timestamp")
101
+ Decoders.map_err(
102
+ Decoders.any(
103
+ Decoders.match(::Time),
104
+ timestamp_in(zone, UNIX_TIME_UNITS.fetch(unit))
105
+ )
106
+ ) { |_message, value| "expected a unix timestamp, got #{value.inspect}" }
107
+ end
108
+
109
+ # Decode a number, or a string describing one, as a point in time.
110
+ #
111
+ # @private
112
+ def timestamp_in(zone, per_second)
113
+ Decoders.map(
114
+ Decoders.any(Decoders.integer, Decoders.float, Decoders.parsed_integer, Decoders.parsed_float)
115
+ ) { zone.at(per_second == 1 ? _1 : _1 / Rational(per_second)) }
116
+ end
117
+
118
+ # Decode a string with the given parser, treating a nil answer as a failure.
119
+ #
120
+ # Every method of `Time` raises for a value it cannot parse, but
121
+ # `ActiveSupport::TimeZone#parse` answers with nil instead, which would
122
+ # otherwise decode as a successful nil.
123
+ #
124
+ # @private
125
+ def parsed_by(parse)
126
+ Decoders.and_then(Decoders.map(Decoders.string) { parse.call(_1) }) do |value|
127
+ value.nil? ? Decoders.fail("parsed to nothing") : Decoders.succeed(value)
128
+ end
129
+ end
130
+
131
+ # Refuse a zone that cannot answer the parsing this decoder will ask of it,
132
+ # while the decoder is being built rather than once it is decoding.
133
+ #
134
+ # @private
135
+ def require_parser(zone, method, description)
136
+ return if zone.respond_to?(method)
137
+
138
+ raise ArgumentError, "cannot decode #{description}: the given zone does not respond to #{method}"
139
+ end
140
+
141
+ private_class_method :time_format, :parsed_by, :timestamp_in, :require_parser
142
+ end
143
+ 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
@@ -262,6 +386,26 @@ module Decoding
262
386
  # @see Decoding::Decoders::AndThen
263
387
  def and_then(...) = Decoders::AndThen.new(...)
264
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
+
265
409
  # Decode deeply-nested fields.
266
410
  #
267
411
  # @example
@@ -269,7 +413,7 @@ module Decoding
269
413
  # decode(decoder, { "a" => { "b" => { "c" => "d" } } })
270
414
  # # => Decoding::Ok("d")
271
415
  # decode(decoder, { "a" => { "b" => "d" } })
272
- # # => Decoding::Err("Error at .a.b: expected a Hash, got String")
416
+ # # => Decoding::Err("Error at .a.b: expected Hash, got String")
273
417
  # @overload at(*fields, decoder)
274
418
  # @param fields [String]
275
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
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decoding
4
+ # Shared logic for the matchers that describe decoding, loaded along with
5
+ # them by `require "decoding/rspec"`.
6
+ #
7
+ # Decoders report failures as {Decoding::Failure} values, but {Decoding.decode}
8
+ # renders those into strings, so matchers have to understand both.
9
+ module MatcherHelpers
10
+ # Split the error value of a result into its message and the path it
11
+ # occurred at, with the outermost path segment first.
12
+ #
13
+ # @param result [Decoding::Result]
14
+ # @return [Array(String, Array)]
15
+ def describe_failure(result)
16
+ error = result.unwrap_err(nil)
17
+ return [error.msg, error.path.reverse] if error.is_a?(Decoding::Failure)
18
+
19
+ match = error.to_s.match(/\AError at \.(?<path>.+?): (?<msg>.*)\z/m)
20
+ match ? [match[:msg], match[:path].split(".")] : [error.to_s, []]
21
+ end
22
+
23
+ # @param path [Array]
24
+ # @return [String]
25
+ def render_path(path) = path.empty? ? "" : " at .#{path.join(".")}"
26
+
27
+ # The path set by the `at` chain, if any.
28
+ #
29
+ # @return [Array]
30
+ def expected_path = @expected_path ||= []
31
+
32
+ # @param path [Array]
33
+ # @return [Boolean]
34
+ def path_matches?(path) = path.map(&:to_s) == expected_path.map(&:to_s)
35
+
36
+ # Compare a decoded value to what the example expects. Values are
37
+ # compared strictly, so that a decoder returning a value of the wrong
38
+ # type is not mistaken for a success, unless the example expects a
39
+ # matcher rather than a value.
40
+ #
41
+ # @param expected [Object]
42
+ # @param actual [Object]
43
+ # @return [Boolean]
44
+ def matches_value?(expected, actual)
45
+ return values_match?(expected, actual) if RSpec::Support.is_a_matcher?(expected)
46
+
47
+ actual.eql?(expected)
48
+ end
49
+ end
50
+ 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`.