decoding 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +12 -0
- data/CHANGELOG.md +27 -0
- data/CLAUDE.md +6 -0
- data/README.md +230 -3
- data/lib/decoding/decoders/any.rb +5 -1
- data/lib/decoding/decoders/array.rb +1 -1
- data/lib/decoding/decoders/big_decimal.rb +37 -0
- data/lib/decoding/decoders/date.rb +57 -0
- data/lib/decoding/decoders/enum.rb +47 -0
- data/lib/decoding/decoders/field.rb +2 -2
- data/lib/decoding/decoders/hash.rb +2 -2
- data/lib/decoding/decoders/index.rb +1 -4
- data/lib/decoding/decoders/lazy.rb +31 -0
- data/lib/decoding/decoders/map_err.rb +31 -0
- data/lib/decoding/decoders/match.rb +1 -1
- data/lib/decoding/decoders/optional.rb +33 -0
- data/lib/decoding/decoders/optional_field.rb +35 -0
- data/lib/decoding/decoders/succeed.rb +8 -3
- data/lib/decoding/decoders/time.rb +143 -0
- data/lib/decoding/decoders/uri.rb +27 -0
- data/lib/decoding/decoders.rb +149 -5
- data/lib/decoding/env.rb +70 -0
- data/lib/decoding/error.rb +6 -0
- data/lib/decoding/failure.rb +42 -4
- data/lib/decoding/matcher_helpers.rb +50 -0
- data/lib/decoding/result.rb +3 -1
- data/lib/decoding/rspec/decode_value.rb +87 -0
- data/lib/decoding/rspec/fail_with.rb +48 -0
- data/lib/decoding/rspec/succeed_with.rb +31 -0
- data/lib/decoding/rspec.rb +44 -0
- data/lib/decoding/version.rb +1 -1
- data/lib/decoding.rb +15 -2
- metadata +17 -1
|
@@ -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
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
|
data/lib/decoding/decoders.rb
CHANGED
|
@@ -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
|
-
#
|
|
172
|
-
#
|
|
173
|
-
|
|
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
|
|
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>]
|
data/lib/decoding/env.rb
ADDED
|
@@ -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
|
data/lib/decoding/failure.rb
CHANGED
|
@@ -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
|
data/lib/decoding/result.rb
CHANGED
|
@@ -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 <
|
|
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`.
|