decoding 0.4.0 → 0.6.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 +28 -0
- data/CHANGELOG.md +13 -0
- data/CLAUDE.md +6 -0
- data/README.md +57 -0
- data/Rakefile +12 -0
- data/lib/decoding/decoders/array.rb +1 -4
- data/lib/decoding/decoders/big_decimal.rb +9 -6
- data/lib/decoding/decoders/date.rb +2 -2
- data/lib/decoding/decoders/hash.rb +13 -13
- data/lib/decoding/decoders/map.rb +1 -1
- data/lib/decoding/decoders/optional.rb +11 -3
- data/lib/decoding/decoders/time.rb +72 -16
- data/lib/decoding/decoders/uri.rb +2 -2
- data/lib/decoding/decoders.rb +1 -1
- data/lib/decoding/matcher_helpers.rb +50 -0
- data/lib/decoding/result.rb +24 -7
- 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
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3dcd003beb1f65e74c31189b632c9c290d636a20467f5fed1a8c382d1aff634b
|
|
4
|
+
data.tar.gz: 338652cc9d8ea559a31fc9b14adc14a83cdc10c5730a175a1fa3670b87435b3d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1a20f3f88d3970063dd6b3f8890ee8f0646df418e0c157fffe3081f6de6c2c3b7718cd685335c1b7f108e21f110d5823be6bdab3e37a222c2d6b4a728bcb820f
|
|
7
|
+
data.tar.gz: 31edcf5c7f5721d93308269883d271b1cd1336738a1013c18c5d21e071dddfd3de855fb3091b5d2426ddc1e57f19baca471051cbffdddf2cc47e7b157120bd1e
|
data/.rubocop.yml
CHANGED
|
@@ -25,13 +25,21 @@ Metrics/ModuleLength:
|
|
|
25
25
|
|
|
26
26
|
Metrics/AbcSize:
|
|
27
27
|
Max: 18
|
|
28
|
+
Exclude:
|
|
29
|
+
# The benchmarks build test data in long methods.
|
|
30
|
+
- benchmark/support/*.rb
|
|
28
31
|
|
|
29
32
|
Metrics/BlockLength:
|
|
30
33
|
Exclude:
|
|
31
34
|
- spec/**/*_spec.rb
|
|
35
|
+
# The RSpec matcher DSL puts a matcher's whole definition inside one block.
|
|
36
|
+
- lib/decoding/rspec/*.rb
|
|
32
37
|
|
|
33
38
|
Metrics/MethodLength:
|
|
34
39
|
Max: 17
|
|
40
|
+
Exclude:
|
|
41
|
+
# The benchmarks build test data in long methods.
|
|
42
|
+
- benchmark/support/*.rb
|
|
35
43
|
|
|
36
44
|
Style/SignalException:
|
|
37
45
|
Enabled: false
|
|
@@ -53,3 +61,23 @@ RSpec/IdenticalEqualityAssertion:
|
|
|
53
61
|
|
|
54
62
|
RSpec/ExampleLength:
|
|
55
63
|
Enabled: false
|
|
64
|
+
|
|
65
|
+
# The matcher specs deliberately pass literal values to `expect` to assert on
|
|
66
|
+
# the messages the matchers themselves produce.
|
|
67
|
+
RSpec/DescribeClass:
|
|
68
|
+
Exclude:
|
|
69
|
+
- spec/matchers/*_spec.rb
|
|
70
|
+
|
|
71
|
+
RSpec/ExpectActual:
|
|
72
|
+
Exclude:
|
|
73
|
+
- spec/matchers/*_spec.rb
|
|
74
|
+
|
|
75
|
+
# The benchmarks print aligned tables, where plain format strings next to each
|
|
76
|
+
# other read better than annotated ones.
|
|
77
|
+
Style/FormatStringToken:
|
|
78
|
+
Exclude:
|
|
79
|
+
- benchmark/**/*.rb
|
|
80
|
+
|
|
81
|
+
Style/RedundantFormat:
|
|
82
|
+
Exclude:
|
|
83
|
+
- benchmark/**/*.rb
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.6.0]
|
|
4
|
+
|
|
5
|
+
* Decode arrays, hashes and multi-field decoders (such as `decode_hash` and `Decoding::Data`) in linear rather than quadratic time. Decoding 1,600 records is about 29 times faster.
|
|
6
|
+
* Decode valid input to `boolean`, `big_decimal`, `time`, `date`, `uri` and `unix_time` without first trying, and failing, a decoder for another type of input. Strings and numbers decode up to 3 times faster; values that are already a `Time`, `Date` or `URI` are now checked last and take a little longer.
|
|
7
|
+
* Stop decoding an array, hash or `map` at the first failure, as only that one is reported. A document whose first record is invalid fails about 90 times faster. Items after the failure are no longer decoded, so blocks given to `map` for them no longer run.
|
|
8
|
+
* Accept a block in `Result.all` to map each item to a result, stopping at the first `Err`
|
|
9
|
+
* Make `optional` remember when its decoder has failed to decode `nil`, and decode `nil` without asking it again. A `nil` now takes about 0.3 µs instead of up to 5 µs. A decoder that does decode `nil`, such as `optional(succeed(5))`, is still asked every time.
|
|
10
|
+
|
|
11
|
+
## [0.5.0]
|
|
12
|
+
|
|
13
|
+
* Add a `zone:` argument to the `time` and `unix_time` decoders, for resolving times somewhere other than the system's time zone.
|
|
14
|
+
* Add `decoding/rspec` module with matchers to help with testing custom decoders
|
|
15
|
+
|
|
3
16
|
## [0.4.0]
|
|
4
17
|
|
|
5
18
|
* Add `uri` decoder for URI objects and strings that can be parsed as one, available after `require "decoding/decoders/uri"`
|
data/CLAUDE.md
CHANGED
|
@@ -19,6 +19,12 @@ Ruby gem for decoding dynamic/external data into known structures. Functional-st
|
|
|
19
19
|
- Test file: `spec/<module>/<class>_spec.rb`
|
|
20
20
|
- Run with `COVERAGE=true bundle exec rspec`
|
|
21
21
|
|
|
22
|
+
**Custom matchers** (defined in `spec/support/matchers/`, tested in `spec/matchers/`):
|
|
23
|
+
- `expect(decoder).to decode_value(input).to(value)` - assert what a decoder decodes an input to
|
|
24
|
+
- `expect(decoder).to decode_value(input).failing_with(msg).at(*path)` - assert how it fails, path outermost first
|
|
25
|
+
- `expect(result).to succeed_with(value)` / `expect(result).to fail_with(msg).at(*path)` - assert on a `Result`
|
|
26
|
+
- Expected values compare strictly (`eql?`) unless you pass a matcher, so `1` does not match `1.0`
|
|
27
|
+
|
|
22
28
|
## Architecture
|
|
23
29
|
|
|
24
30
|
**Core concepts:**
|
data/README.md
CHANGED
|
@@ -268,6 +268,24 @@ Decoding.decode(D.unix_time(:milliseconds), 1_595_674_680_123) # => Decoding::Ok
|
|
|
268
268
|
|
|
269
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
270
|
|
|
271
|
+
Both `time` and `unix_time` resolve times through `Time` itself, and so in the system's time zone. That is rarely the zone a Rails application means: its containers usually run UTC while the application runs somewhere else, and taking `to_date` of the two answers differs by a day for a late-evening timestamp. Pass anything that answers the format you name as `zone:` to decide it yourself:
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
Decoding.decode(D.time(:parse, zone: Time.zone), "2020-01-01 10:00:00")
|
|
275
|
+
# => Decoding::Ok(2020-01-01 10:00:00 +0100)
|
|
276
|
+
Decoding.decode(D.map(D.unix_time(zone: Time.zone), &:to_date), 1_710_113_400)
|
|
277
|
+
# => Decoding::Ok(#<Date: 2024-03-11>)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
An `ActiveSupport::TimeZone` answers only `iso8601`, `rfc3339` and `parse` of the format names above, plus `strptime` patterns, so a zone that cannot parse the format you asked for is refused when the decoder is built:
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
D.time(:httpdate, zone: Time.zone)
|
|
284
|
+
# raises ArgumentError: cannot decode a time in httpdate format: the given zone does not respond to httpdate
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Note `ActiveSupport::TimeZone#parse` answers a value it cannot parse with `nil` rather than by raising, unlike every method of `Time`. Both are read as a failure to decode.
|
|
288
|
+
|
|
271
289
|
* `big_decimal` -- decode a `BigDecimal` object, or a number or string describing one. Only finite numbers are accepted:
|
|
272
290
|
|
|
273
291
|
```ruby
|
|
@@ -316,6 +334,45 @@ Decoding.env("SENTRY_DSN", D.optional(D.string)) # => nil when not set
|
|
|
316
334
|
|
|
317
335
|
Pass `from:` to read from somewhere other than `ENV`, which is useful in tests.
|
|
318
336
|
|
|
337
|
+
## Testing your own decoders
|
|
338
|
+
|
|
339
|
+
The matchers this gem tests itself with are available to applications that write decoders of their own. They need `rspec-expectations`, which is not a dependency of this gem, so require them from your spec helper:
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
# spec/spec_helper.rb
|
|
343
|
+
require "decoding/rspec"
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
`decode_value` describes a decoder, running it for you, so an example says what a decoder does rather than how to call it:
|
|
347
|
+
|
|
348
|
+
```ruby
|
|
349
|
+
expect(my_decoder).to decode_value({ "name" => "John" }).to({ name: "John" })
|
|
350
|
+
expect(my_decoder).to decode_value({}).failing_with(%(expected Hash with key "name"))
|
|
351
|
+
expect(my_decoder).to decode_value(nil) # decodes; the value is not asserted
|
|
352
|
+
expect(my_decoder).not_to decode_value(nil) # fails to decode
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
An error nested in a structure is asserted with its location, outermost segment first, rather than by matching the rendered `Error at .` prefix:
|
|
356
|
+
|
|
357
|
+
```ruby
|
|
358
|
+
expect(D.field("a", D.field("b", D.string)))
|
|
359
|
+
.to decode_value({ "a" => { "b" => 1 } })
|
|
360
|
+
.failing_with("expected String, got Integer").at("a", "b")
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
`succeed_with` and `fail_with` describe a result you already have, for when making the call is part of what the example tests:
|
|
364
|
+
|
|
365
|
+
```ruby
|
|
366
|
+
expect(Decoding.decode(my_decoder, input)).to succeed_with({ name: "John" })
|
|
367
|
+
expect(Decoding.decode(my_decoder, input)).to fail_with("expected String, got Integer").at("name")
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Expected values are compared strictly, so `1` does not match `1.0` and a decoder answering with the wrong type cannot pass. Pass a matcher where that is too strict:
|
|
371
|
+
|
|
372
|
+
```ruby
|
|
373
|
+
expect(D.unix_time).to decode_value("1595674680.5").to(an_object_having_attributes(usec: 500_000))
|
|
374
|
+
```
|
|
375
|
+
|
|
319
376
|
## Development
|
|
320
377
|
|
|
321
378
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
data/Rakefile
CHANGED
|
@@ -12,3 +12,15 @@ RuboCop::RakeTask.new
|
|
|
12
12
|
YARD::Rake::YardocTask.new
|
|
13
13
|
|
|
14
14
|
task default: %i[spec rubocop yard]
|
|
15
|
+
|
|
16
|
+
BENCHMARKS = %w[realistic scaling invalid micro memory].freeze
|
|
17
|
+
|
|
18
|
+
namespace :bench do
|
|
19
|
+
BENCHMARKS.each do |name|
|
|
20
|
+
desc "Run benchmark/#{name}.rb"
|
|
21
|
+
task(name) { ruby "benchmark/#{name}.rb" }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
desc "Run all benchmarks (see benchmark/README.md)"
|
|
26
|
+
task bench: BENCHMARKS.map { "bench:#{_1}" }
|
|
@@ -19,10 +19,7 @@ module Decoding
|
|
|
19
19
|
# @return [Decoding::Result<a>]
|
|
20
20
|
def call(value)
|
|
21
21
|
if value.is_a?(::Array)
|
|
22
|
-
value
|
|
23
|
-
.each_with_index
|
|
24
|
-
.map { |v, i| @decoder.call(v).map_err { _1.push(i) } }
|
|
25
|
-
.then { all _1 }
|
|
22
|
+
all(value.each_index) { |i| @decoder.call(value[i]).map_err { _1.push(i) } }
|
|
26
23
|
else
|
|
27
24
|
err(failure("expected Array, got #{value.class}"))
|
|
28
25
|
end
|
|
@@ -6,6 +6,12 @@ module Decoding
|
|
|
6
6
|
# Decoders are composable functions for deconstructing unknown input values
|
|
7
7
|
# into known output values.
|
|
8
8
|
module Decoders
|
|
9
|
+
# Matches the values {big_decimal} converts, in one check rather than
|
|
10
|
+
# trying a decoder for each type and discarding their failures.
|
|
11
|
+
DECIMAL_SOURCES = [::BigDecimal, ::Integer, ::Float, ::String].freeze
|
|
12
|
+
DECIMAL_SOURCE = ->(value) { DECIMAL_SOURCES.any? { _1 === value } }
|
|
13
|
+
private_constant :DECIMAL_SOURCES, :DECIMAL_SOURCE
|
|
14
|
+
|
|
9
15
|
module_function
|
|
10
16
|
|
|
11
17
|
# Decode a `BigDecimal` object, or a number or string describing one.
|
|
@@ -25,12 +31,9 @@ module Decoding
|
|
|
25
31
|
# @see Decoding::Decoders::MapErr
|
|
26
32
|
def big_decimal
|
|
27
33
|
Decoders.map_err(
|
|
28
|
-
Decoders.
|
|
29
|
-
|
|
30
|
-
|
|
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
|
+
Decoders.map(Decoders.match(DECIMAL_SOURCE)) do |value|
|
|
35
|
+
BigDecimal(value).tap { |number| raise ArgumentError, "not a finite number" unless number.finite? }
|
|
36
|
+
end
|
|
34
37
|
) { |_message, value| "expected a decimal number, got #{value.inspect}" }
|
|
35
38
|
end
|
|
36
39
|
end
|
|
@@ -32,8 +32,8 @@ module Decoding
|
|
|
32
32
|
parse, description = date_format(format)
|
|
33
33
|
Decoders.map_err(
|
|
34
34
|
Decoders.any(
|
|
35
|
-
Decoders.
|
|
36
|
-
Decoders.
|
|
35
|
+
Decoders.map(Decoders.string) { parse.call(_1) },
|
|
36
|
+
Decoders.match(::Date)
|
|
37
37
|
)
|
|
38
38
|
) { |_message, value| "expected #{description}, got #{value.inspect}" }
|
|
39
39
|
end
|
|
@@ -21,20 +21,20 @@ module Decoding
|
|
|
21
21
|
def call(value)
|
|
22
22
|
return err(failure("expected Hash, got #{value.class}")) unless value.is_a?(::Hash)
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
.call(k)
|
|
29
|
-
.map_err { _1.map { |msg| "invalid key: #{msg}" }.push(k) },
|
|
24
|
+
all(value) { |k, v| decode_entry(k, v) }.map(&:to_h)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
30
28
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
|
|
29
|
+
# Decode a single key and value into a pair, leaving the value alone
|
|
30
|
+
# when the key cannot be decoded.
|
|
31
|
+
def decode_entry(key, value)
|
|
32
|
+
@key_decoder
|
|
33
|
+
.call(key)
|
|
34
|
+
.map_err { _1.map { |msg| "invalid key: #{msg}" }.push(key) }
|
|
35
|
+
.and_then do |decoded_key|
|
|
36
|
+
@value_decoder.call(value).map_err { _1.push(key) }.map { [decoded_key, _1] }
|
|
37
|
+
end
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
40
|
end
|
|
@@ -22,7 +22,7 @@ module Decoding
|
|
|
22
22
|
# @return [Decoding::Result<b>]
|
|
23
23
|
def call(value)
|
|
24
24
|
Result
|
|
25
|
-
.all(@decoders
|
|
25
|
+
.all(@decoders) { _1.call(value) }
|
|
26
26
|
.map { @block.call(*_1) }
|
|
27
27
|
rescue StandardError => e
|
|
28
28
|
err(failure("error in map block: #{e.message}"))
|
|
@@ -11,22 +11,30 @@ module Decoding
|
|
|
11
11
|
# meaning of its own. Only when it fails to do so does this decoder treat
|
|
12
12
|
# `nil` as an absent value.
|
|
13
13
|
#
|
|
14
|
+
# Most decoders cannot decode `nil`, and building the failure that says so
|
|
15
|
+
# costs more than decoding most values. Since a decoder gives the same
|
|
16
|
+
# result for the same input, this decoder remembers when the given decoder
|
|
17
|
+
# has failed to decode `nil`, and stops asking it about `nil` from then on.
|
|
18
|
+
#
|
|
14
19
|
# @see Decoding::Decoders.optional
|
|
15
20
|
class Optional < Decoder
|
|
16
21
|
# @param decoder [Decoding::Decoder<a>]
|
|
17
22
|
def initialize(decoder)
|
|
18
23
|
@decoder = decoder.to_decoder
|
|
24
|
+
@fails_on_nil = false
|
|
19
25
|
super()
|
|
20
26
|
end
|
|
21
27
|
|
|
22
28
|
# @param value [Object]
|
|
23
29
|
# @return [Decoding::Result<a, nil>]
|
|
24
30
|
def call(value)
|
|
31
|
+
return ok(nil) if value.nil? && @fails_on_nil
|
|
32
|
+
|
|
25
33
|
result = @decoder.call(value)
|
|
26
|
-
return result if result.ok?
|
|
27
|
-
return ok(nil) if value.nil?
|
|
34
|
+
return result if result.ok? || !value.nil?
|
|
28
35
|
|
|
29
|
-
|
|
36
|
+
@fails_on_nil = true
|
|
37
|
+
ok(nil)
|
|
30
38
|
end
|
|
31
39
|
end
|
|
32
40
|
end
|
|
@@ -10,6 +10,10 @@ module Decoding
|
|
|
10
10
|
# the `Time` method that parses it.
|
|
11
11
|
TIME_FORMATS = %i[iso8601 xmlschema rfc2822 rfc822 httpdate parse].freeze
|
|
12
12
|
|
|
13
|
+
# Matches the numbers {Decoding::Decoders.unix_time} reads as a timestamp.
|
|
14
|
+
TIMESTAMP_NUMBER = ->(value) { value.is_a?(::Integer) || value.is_a?(::Float) }
|
|
15
|
+
private_constant :TIMESTAMP_NUMBER
|
|
16
|
+
|
|
13
17
|
# The units {Decoding::Decoders.unix_time} can read a timestamp in, mapped
|
|
14
18
|
# to the number of them that make up a second.
|
|
15
19
|
UNIX_TIME_UNITS = { seconds: 1, milliseconds: 1000 }.freeze
|
|
@@ -22,35 +26,50 @@ module Decoding
|
|
|
22
26
|
# `strptime` pattern. Note that the `:parse` format is lenient: it fills in
|
|
23
27
|
# any components the input value leaves out from the current time.
|
|
24
28
|
#
|
|
29
|
+
# Times are resolved by `Time` itself, and so in the system's time zone,
|
|
30
|
+
# unless another zone is given. Anything answering the format you name will
|
|
31
|
+
# do: an `ActiveSupport::TimeZone` resolves in the application's zone, which
|
|
32
|
+
# is rarely the system's, but note it answers only `iso8601`, `rfc3339` and
|
|
33
|
+
# `parse` of the names above.
|
|
34
|
+
#
|
|
25
35
|
# @example
|
|
26
36
|
# decode(time(:iso8601), "2020-01-01T10:00:00Z")
|
|
27
37
|
# # => Decoding::Ok(2020-01-01 10:00:00 UTC)
|
|
28
38
|
# decode(time("%Y|%m"), "nope")
|
|
29
39
|
# # => Decoding::Err("expected a time matching \"%Y|%m\", got \"nope\"")
|
|
40
|
+
# decode(time(:parse, zone: Time.zone), "2020-01-01 10:00:00")
|
|
41
|
+
# # => Decoding::Ok(2020-01-01 10:00:00 +0100)
|
|
30
42
|
# @param format [Symbol, String]
|
|
31
|
-
# @
|
|
32
|
-
#
|
|
43
|
+
# @param zone [Object] anything answering the format you name, such as
|
|
44
|
+
# `Time` or an `ActiveSupport::TimeZone`.
|
|
45
|
+
# @raise [ArgumentError] when the format is not a known name or a pattern,
|
|
46
|
+
# or the zone cannot parse it. This is raised when the decoder is built,
|
|
47
|
+
# not when it is used.
|
|
33
48
|
# @return [Decoding::Decoder<Time>]
|
|
34
49
|
# @see Decoding::Decoders::MapErr
|
|
35
|
-
def time(format)
|
|
36
|
-
parse, description = time_format(format)
|
|
50
|
+
def time(format, zone: ::Time)
|
|
51
|
+
parse, description = time_format(format, zone)
|
|
37
52
|
Decoders.map_err(
|
|
38
53
|
Decoders.any(
|
|
39
|
-
|
|
40
|
-
Decoders.
|
|
54
|
+
parsed_by(parse),
|
|
55
|
+
Decoders.match(::Time)
|
|
41
56
|
)
|
|
42
57
|
) { |_message, value| "expected #{description}, got #{value.inspect}" }
|
|
43
58
|
end
|
|
44
59
|
|
|
45
60
|
# @private
|
|
46
|
-
def time_format(format)
|
|
61
|
+
def time_format(format, zone)
|
|
47
62
|
case format
|
|
48
63
|
when ::String
|
|
49
|
-
|
|
64
|
+
description = "a time matching #{format.inspect}"
|
|
65
|
+
require_parser(zone, :strptime, description)
|
|
66
|
+
[->(str) { zone.strptime(str, format) }, description]
|
|
50
67
|
when ::Symbol
|
|
51
68
|
raise ArgumentError, "unknown time format: #{format.inspect}" unless TIME_FORMATS.include?(format)
|
|
52
69
|
|
|
53
|
-
|
|
70
|
+
description = format == :parse ? "a time" : "a time in #{format} format"
|
|
71
|
+
require_parser(zone, format, description)
|
|
72
|
+
[->(str) { zone.public_send(format, str) }, description]
|
|
54
73
|
else
|
|
55
74
|
raise ArgumentError, "expected a time format name or a strptime pattern, got #{format.inspect}"
|
|
56
75
|
end
|
|
@@ -63,29 +82,66 @@ module Decoding
|
|
|
63
82
|
# error but a wildly different point in time, so a source that reports
|
|
64
83
|
# milliseconds has to say so.
|
|
65
84
|
#
|
|
85
|
+
# A timestamp names a point in time rather than a local one, so the zone
|
|
86
|
+
# only decides how the value describes itself afterwards -- which matters as
|
|
87
|
+
# soon as anything derives a date from it.
|
|
88
|
+
#
|
|
66
89
|
# @example
|
|
67
90
|
# decode(unix_time, 1_595_674_680) # => Decoding::Ok(2020-07-25 10:58:00 UTC)
|
|
68
91
|
# decode(unix_time(:milliseconds), 1_595_674_680_123)
|
|
69
92
|
# # => Decoding::Ok(2020-07-25 10:58:00.123 UTC)
|
|
70
93
|
# @param unit [Symbol] one of the keys of {UNIX_TIME_UNITS}.
|
|
71
|
-
# @
|
|
72
|
-
#
|
|
94
|
+
# @param zone [Object] anything answering `at`, such as `Time` or an
|
|
95
|
+
# `ActiveSupport::TimeZone`.
|
|
96
|
+
# @raise [ArgumentError] when the unit is not a known one, or the zone
|
|
97
|
+
# cannot read a timestamp. This is raised when the decoder is built, not
|
|
98
|
+
# when it is used.
|
|
73
99
|
# @return [Decoding::Decoder<Time>]
|
|
74
100
|
# @see Decoding::Decoders::MapErr
|
|
75
|
-
def unix_time(unit = :seconds)
|
|
101
|
+
def unix_time(unit = :seconds, zone: ::Time)
|
|
76
102
|
raise ArgumentError, "unknown unit: #{unit.inspect}" unless UNIX_TIME_UNITS.key?(unit)
|
|
77
103
|
|
|
104
|
+
require_parser(zone, :at, "a unix timestamp")
|
|
78
105
|
per_second = UNIX_TIME_UNITS.fetch(unit)
|
|
106
|
+
# Cheapest checks first, so the most common inputs fail the fewest
|
|
107
|
+
# decoders on their way to the one that matches.
|
|
79
108
|
Decoders.map_err(
|
|
80
109
|
Decoders.any(
|
|
110
|
+
timestamp_in(Decoders.match(TIMESTAMP_NUMBER), zone, per_second),
|
|
81
111
|
Decoders.match(::Time),
|
|
82
|
-
Decoders.
|
|
83
|
-
Decoders.any(Decoders.integer, Decoders.float, Decoders.parsed_integer, Decoders.parsed_float)
|
|
84
|
-
) { ::Time.at(per_second == 1 ? _1 : _1 / Rational(per_second)) }
|
|
112
|
+
timestamp_in(Decoders.any(Decoders.parsed_integer, Decoders.parsed_float), zone, per_second)
|
|
85
113
|
)
|
|
86
114
|
) { |_message, value| "expected a unix timestamp, got #{value.inspect}" }
|
|
87
115
|
end
|
|
88
116
|
|
|
89
|
-
|
|
117
|
+
# Decode the number given by `decoder` as a point in time.
|
|
118
|
+
#
|
|
119
|
+
# @private
|
|
120
|
+
def timestamp_in(decoder, zone, per_second)
|
|
121
|
+
Decoders.map(decoder) { zone.at(per_second == 1 ? _1 : _1 / Rational(per_second)) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Decode a string with the given parser, treating a nil answer as a failure.
|
|
125
|
+
#
|
|
126
|
+
# Every method of `Time` raises for a value it cannot parse, but
|
|
127
|
+
# `ActiveSupport::TimeZone#parse` answers with nil instead, which would
|
|
128
|
+
# otherwise decode as a successful nil.
|
|
129
|
+
#
|
|
130
|
+
# @private
|
|
131
|
+
def parsed_by(parse)
|
|
132
|
+
Decoders.map(Decoders.string) { parse.call(_1) || raise(ArgumentError, "parsed to nothing") }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Refuse a zone that cannot answer the parsing this decoder will ask of it,
|
|
136
|
+
# while the decoder is being built rather than once it is decoding.
|
|
137
|
+
#
|
|
138
|
+
# @private
|
|
139
|
+
def require_parser(zone, method, description)
|
|
140
|
+
return if zone.respond_to?(method)
|
|
141
|
+
|
|
142
|
+
raise ArgumentError, "cannot decode #{description}: the given zone does not respond to #{method}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private_class_method :time_format, :parsed_by, :timestamp_in, :require_parser
|
|
90
146
|
end
|
|
91
147
|
end
|
|
@@ -18,8 +18,8 @@ module Decoding
|
|
|
18
18
|
def uri
|
|
19
19
|
Decoders.map_err(
|
|
20
20
|
Decoders.any(
|
|
21
|
-
Decoders
|
|
22
|
-
Decoders.
|
|
21
|
+
Decoders.map(Decoders.string) { ::URI.parse(_1) },
|
|
22
|
+
Decoders::Match.new(::URI::Generic)
|
|
23
23
|
)
|
|
24
24
|
) { |_msg, value| "expected a URI, got #{value.inspect}" }
|
|
25
25
|
end
|
data/lib/decoding/decoders.rb
CHANGED
|
@@ -128,7 +128,7 @@ module Decoding
|
|
|
128
128
|
# decode(boolean, true) # => Decoding::Ok(true)
|
|
129
129
|
# decode(boolean, false) # => Decoding::Ok(false)
|
|
130
130
|
# @return [Decoding::Decoder<Boolean>]
|
|
131
|
-
def boolean = map_err(
|
|
131
|
+
def boolean = map_err(enum(true, false)) { |_msg, value| "expected true or false, got #{value.class}" }
|
|
132
132
|
|
|
133
133
|
# Decode a String value into a symbol.
|
|
134
134
|
#
|
|
@@ -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
|
@@ -30,15 +30,29 @@ module Decoding
|
|
|
30
30
|
# Collapse array of result values into a single result, or return the
|
|
31
31
|
# first `Err` value.
|
|
32
32
|
#
|
|
33
|
+
# Given a block, each item is mapped to a result with the block first,
|
|
34
|
+
# one at a time, and items after the first `Err` are not mapped at all.
|
|
35
|
+
#
|
|
33
36
|
# @example
|
|
34
37
|
# all(Ok(1), Ok(2)) # => Ok([1, 2])
|
|
35
38
|
# all(Ok(1), Err("error")) # => Err("error")
|
|
36
|
-
#
|
|
37
|
-
# @
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
# all([1, 2]) { Ok(_1 * 10) } # => Ok([10, 20])
|
|
40
|
+
# @overload all(results)
|
|
41
|
+
# @param results [Enumerable<Result<a>>]
|
|
42
|
+
# @overload all(items)
|
|
43
|
+
# @param items [Enumerable<Object>]
|
|
44
|
+
# @yieldparam item [Object]
|
|
45
|
+
# @yieldreturn [Result<a>]
|
|
46
|
+
# @return [Result<Array<a>>]
|
|
47
|
+
def self.all(items)
|
|
48
|
+
values = []
|
|
49
|
+
items.each_entry do |item|
|
|
50
|
+
result = (block_given? ? yield(item) : item).to_result
|
|
51
|
+
return result if result.err?
|
|
52
|
+
|
|
53
|
+
values << result.unwrap(nil)
|
|
41
54
|
end
|
|
55
|
+
ok(values)
|
|
42
56
|
end
|
|
43
57
|
|
|
44
58
|
private_class_method :new
|
|
@@ -48,12 +62,15 @@ module Decoding
|
|
|
48
62
|
|
|
49
63
|
protected :value
|
|
50
64
|
|
|
65
|
+
# Computed when asked for rather than up front: results are rarely used as
|
|
66
|
+
# hash keys, and hashing a large decoded value for every result created
|
|
67
|
+
# made decoding a collection quadratic.
|
|
68
|
+
#
|
|
51
69
|
# @private
|
|
52
|
-
|
|
70
|
+
def hash = [self.class, value].hash
|
|
53
71
|
|
|
54
72
|
def initialize(value)
|
|
55
73
|
@value = value
|
|
56
|
-
@hash = [self.class, value].hash
|
|
57
74
|
freeze
|
|
58
75
|
end
|
|
59
76
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../matcher_helpers"
|
|
4
|
+
|
|
5
|
+
# Passes when the given decoder decodes the given input. Chain `to` to also
|
|
6
|
+
# assert the decoded value, or `failing_with` (optionally with `at`) to assert
|
|
7
|
+
# that decoding fails with a particular message. Both expected values may
|
|
8
|
+
# themselves be matchers.
|
|
9
|
+
#
|
|
10
|
+
# expect(decoder).to decode_value(["foo"]).to(["foo"])
|
|
11
|
+
# expect(decoder).to decode_value([1]).failing_with("expected String, got Integer").at(0)
|
|
12
|
+
# expect(decoder).not_to decode_value(true)
|
|
13
|
+
RSpec::Matchers.define :decode_value do |input|
|
|
14
|
+
include Decoding::MatcherHelpers
|
|
15
|
+
|
|
16
|
+
match do |actual|
|
|
17
|
+
next false unless actual.respond_to?(:call)
|
|
18
|
+
|
|
19
|
+
result = Decoding.decode(actual, input)
|
|
20
|
+
next matches_failure?(result) if message_expected?
|
|
21
|
+
next result.ok? && matches_value?(@expected_value, result.unwrap!) if value_expected?
|
|
22
|
+
|
|
23
|
+
result.ok?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
match_when_negated do |actual|
|
|
27
|
+
raise ArgumentError, "use `not_to decode_value(input)` without `to` or `failing_with`" if message_expected? || value_expected?
|
|
28
|
+
|
|
29
|
+
actual.respond_to?(:call) && Decoding.decode(actual, input).err?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
chain :to do |value|
|
|
33
|
+
@expected_value = value
|
|
34
|
+
@value_expected = true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
chain :failing_with do |message|
|
|
38
|
+
@expected_message = message
|
|
39
|
+
@message_expected = true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
chain :at do |*segments|
|
|
43
|
+
@expected_path = segments
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
failure_message do |actual|
|
|
47
|
+
next "expected a decoder, got #{description_of(actual)}" unless actual.respond_to?(:call)
|
|
48
|
+
|
|
49
|
+
"expected the decoder to #{description}, but #{describe_outcome(Decoding.decode(actual, input))}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
failure_message_when_negated do |actual|
|
|
53
|
+
next "expected a decoder, got #{description_of(actual)}" unless actual.respond_to?(:call)
|
|
54
|
+
|
|
55
|
+
"expected the decoder not to decode #{description_of(input)}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
description do
|
|
59
|
+
if message_expected?
|
|
60
|
+
next "fail decoding #{description_of(input)} with #{description_of(@expected_message)}#{render_path(expected_path)}"
|
|
61
|
+
end
|
|
62
|
+
next "decode #{description_of(input)} to #{description_of(@expected_value)}" if value_expected?
|
|
63
|
+
|
|
64
|
+
"decode #{description_of(input)}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def message_expected? = @message_expected
|
|
68
|
+
def value_expected? = @value_expected
|
|
69
|
+
|
|
70
|
+
def matches_failure?(result)
|
|
71
|
+
return false unless result.err?
|
|
72
|
+
|
|
73
|
+
msg, path = describe_failure(result)
|
|
74
|
+
matches_value?(@expected_message, msg) && path_matches?(path)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def describe_outcome(result)
|
|
78
|
+
unless result.ok?
|
|
79
|
+
msg, path = describe_failure(result)
|
|
80
|
+
return "it failed with #{description_of(msg)}#{render_path(path)}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
return "it succeeded with #{description_of(result.unwrap!)}" if message_expected?
|
|
84
|
+
|
|
85
|
+
"it decoded to #{description_of(result.unwrap!)}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../matcher_helpers"
|
|
4
|
+
|
|
5
|
+
# Passes when the given {Decoding::Result} is an `Err` that failed with the
|
|
6
|
+
# expected message. Chain `at` to also assert where the failure occurred,
|
|
7
|
+
# naming the path segments outermost first. The expected message may itself be
|
|
8
|
+
# a matcher.
|
|
9
|
+
#
|
|
10
|
+
# expect(Decoding.decode(decoder, [1])).to fail_with("expected String, got Integer").at(0)
|
|
11
|
+
#
|
|
12
|
+
# Failures are recognised both as {Decoding::Failure} values and as the strings
|
|
13
|
+
# they turn into once {Decoding.decode} has rendered them.
|
|
14
|
+
RSpec::Matchers.define :fail_with do |expected|
|
|
15
|
+
include Decoding::MatcherHelpers
|
|
16
|
+
|
|
17
|
+
match do |actual|
|
|
18
|
+
next false unless actual.is_a?(Decoding::Result) && actual.err?
|
|
19
|
+
|
|
20
|
+
msg, path = describe_failure(actual)
|
|
21
|
+
matches_value?(expected, msg) && path_matches?(path)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
chain :at do |*segments|
|
|
25
|
+
@expected_path = segments
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
failure_message do |actual|
|
|
29
|
+
next "expected a Decoding::Result, got #{description_of(actual)}" unless actual.is_a?(Decoding::Result)
|
|
30
|
+
|
|
31
|
+
outcome =
|
|
32
|
+
if actual.ok?
|
|
33
|
+
"it succeeded with #{description_of(actual.unwrap!)}"
|
|
34
|
+
else
|
|
35
|
+
msg, path = describe_failure(actual)
|
|
36
|
+
"it failed with #{description_of(msg)}#{render_path(path)}"
|
|
37
|
+
end
|
|
38
|
+
"expected the result to #{description}, but #{outcome}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
failure_message_when_negated do
|
|
42
|
+
"expected the result not to #{description}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
description do
|
|
46
|
+
"fail with #{description_of(expected)}#{render_path(expected_path)}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../matcher_helpers"
|
|
4
|
+
|
|
5
|
+
# Passes when the given {Decoding::Result} is an `Ok` holding the expected
|
|
6
|
+
# value. The expected value may itself be a matcher.
|
|
7
|
+
#
|
|
8
|
+
# expect(Decoding.decode(decoder, "foo")).to succeed_with("foo")
|
|
9
|
+
RSpec::Matchers.define :succeed_with do |expected|
|
|
10
|
+
include Decoding::MatcherHelpers
|
|
11
|
+
|
|
12
|
+
match do |actual|
|
|
13
|
+
actual.is_a?(Decoding::Result) && actual.ok? && matches_value?(expected, actual.unwrap!)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
failure_message do |actual|
|
|
17
|
+
next "expected a Decoding::Result, got #{description_of(actual)}" unless actual.is_a?(Decoding::Result)
|
|
18
|
+
|
|
19
|
+
outcome =
|
|
20
|
+
if actual.ok?
|
|
21
|
+
"it succeeded with #{description_of(actual.unwrap!)}"
|
|
22
|
+
else
|
|
23
|
+
"it failed with #{description_of(actual.unwrap_err(nil).to_s)}"
|
|
24
|
+
end
|
|
25
|
+
"expected the result to succeed with #{description_of(expected)}, but #{outcome}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
failure_message_when_negated do
|
|
29
|
+
"expected the result not to succeed with #{description_of(expected)}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Matchers for asserting on decoders and their results, for applications that
|
|
4
|
+
# write decoders of their own.
|
|
5
|
+
#
|
|
6
|
+
# These are the matchers this gem tests itself with. Require them from your
|
|
7
|
+
# spec helper; they need `rspec-expectations`, which is not a dependency of
|
|
8
|
+
# this gem, so nothing is loaded unless you ask for it.
|
|
9
|
+
#
|
|
10
|
+
# # spec/spec_helper.rb
|
|
11
|
+
# require "decoding/rspec"
|
|
12
|
+
#
|
|
13
|
+
# `decode_value` describes a decoder, and is what you want most of the time: it
|
|
14
|
+
# runs the decoder for you, so an example says what a decoder does rather than
|
|
15
|
+
# how to call it.
|
|
16
|
+
#
|
|
17
|
+
# expect(my_decoder).to decode_value({ "name" => "John" }).to({ name: "John" })
|
|
18
|
+
# expect(my_decoder).to decode_value({}).failing_with(%(expected Hash with key "name"))
|
|
19
|
+
# expect(my_decoder).to decode_value(nil) # decodes; the value is not asserted
|
|
20
|
+
# expect(my_decoder).not_to decode_value(nil) # fails to decode
|
|
21
|
+
#
|
|
22
|
+
# An error nested in a structure is asserted with its location, outermost
|
|
23
|
+
# segment first, rather than by matching the rendered "Error at ." prefix:
|
|
24
|
+
#
|
|
25
|
+
# expect(field("a", field("b", string)))
|
|
26
|
+
# .to decode_value({ "a" => { "b" => 1 } })
|
|
27
|
+
# .failing_with("expected String, got Integer").at("a", "b")
|
|
28
|
+
#
|
|
29
|
+
# `succeed_with` and `fail_with` describe a result you already have, for when
|
|
30
|
+
# making the call is part of what the example is testing:
|
|
31
|
+
#
|
|
32
|
+
# expect(Decoding.decode(my_decoder, input)).to succeed_with({ name: "John" })
|
|
33
|
+
# expect(Decoding.decode(my_decoder, input)).to fail_with("expected String, got Integer").at("name")
|
|
34
|
+
#
|
|
35
|
+
# Expected values are compared strictly, so `1` does not match `1.0` and a
|
|
36
|
+
# decoder answering with the wrong type cannot pass. Pass a matcher where that
|
|
37
|
+
# is too strict:
|
|
38
|
+
#
|
|
39
|
+
# expect(unix_time).to decode_value("1595674680.5").to(an_object_having_attributes(usec: 500_000))
|
|
40
|
+
require_relative "../decoding"
|
|
41
|
+
require_relative "matcher_helpers"
|
|
42
|
+
require_relative "rspec/decode_value"
|
|
43
|
+
require_relative "rspec/succeed_with"
|
|
44
|
+
require_relative "rspec/fail_with"
|
data/lib/decoding/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Arjan van der Gaag
|
|
@@ -55,7 +55,12 @@ files:
|
|
|
55
55
|
- lib/decoding/env.rb
|
|
56
56
|
- lib/decoding/error.rb
|
|
57
57
|
- lib/decoding/failure.rb
|
|
58
|
+
- lib/decoding/matcher_helpers.rb
|
|
58
59
|
- lib/decoding/result.rb
|
|
60
|
+
- lib/decoding/rspec.rb
|
|
61
|
+
- lib/decoding/rspec/decode_value.rb
|
|
62
|
+
- lib/decoding/rspec/fail_with.rb
|
|
63
|
+
- lib/decoding/rspec/succeed_with.rb
|
|
59
64
|
- lib/decoding/version.rb
|
|
60
65
|
- sig/decoding.rbs
|
|
61
66
|
homepage: https://github.com/avdgaag/decoding
|