re_sorcery 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/.travis.yml +7 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +28 -0
- data/LICENSE.txt +21 -0
- data/README.md +120 -0
- data/Rakefile +10 -0
- data/bin/console +98 -0
- data/bin/setup +8 -0
- data/lib/re_sorcery/arg_check.rb +14 -0
- data/lib/re_sorcery/configuration.rb +54 -0
- data/lib/re_sorcery/decoder/builtin_decoders.rb +124 -0
- data/lib/re_sorcery/decoder.rb +126 -0
- data/lib/re_sorcery/error.rb +33 -0
- data/lib/re_sorcery/fielded/expand_internal_fields.rb +49 -0
- data/lib/re_sorcery/fielded.rb +52 -0
- data/lib/re_sorcery/helpers.rb +29 -0
- data/lib/re_sorcery/linked/link_class_factory.rb +62 -0
- data/lib/re_sorcery/linked.rb +73 -0
- data/lib/re_sorcery/maybe/just.rb +50 -0
- data/lib/re_sorcery/maybe/nothing.rb +41 -0
- data/lib/re_sorcery/maybe.rb +6 -0
- data/lib/re_sorcery/result/err.rb +46 -0
- data/lib/re_sorcery/result/ok.rb +49 -0
- data/lib/re_sorcery/result.rb +9 -0
- data/lib/re_sorcery/version.rb +5 -0
- data/lib/re_sorcery.rb +40 -0
- data/re_sorcery.gemspec +34 -0
- metadata +128 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 0b2890e5efcd2dbd01d5f849d9ba9887ac5c78077057b6406ae458ef3c048337
|
4
|
+
data.tar.gz: 3971c5c49f0c6d6bc0ac407b6b79091e1e2e9d16fa7d80c7deab0e12d3a94954
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 6281d8f26287f854baccd61aa8e800dd27720053262b99380c5f2a26b4336b8bef238f7073722f8789d4da9b84f17a021df2e74030b64be995425c6cf5ae9f70
|
7
|
+
data.tar.gz: 546cc421b749cd28393494f10190bd34e9e7ed1ff5170b91a07724fccc94ece8351e03fc3d672e98e3c1e9908aa21abbdf91fc8eb86d0db2426ea9131eb7f0dd
|
data/.gitignore
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
re_sorcery (0.1.0)
|
5
|
+
|
6
|
+
GEM
|
7
|
+
remote: https://rubygems.org/
|
8
|
+
specs:
|
9
|
+
coderay (1.1.3)
|
10
|
+
method_source (1.0.0)
|
11
|
+
minitest (5.14.4)
|
12
|
+
pry (0.14.1)
|
13
|
+
coderay (~> 1.1)
|
14
|
+
method_source (~> 1.0)
|
15
|
+
rake (10.5.0)
|
16
|
+
|
17
|
+
PLATFORMS
|
18
|
+
ruby
|
19
|
+
|
20
|
+
DEPENDENCIES
|
21
|
+
bundler (~> 2.0)
|
22
|
+
re_sorcery!
|
23
|
+
minitest (~> 5.0)
|
24
|
+
pry
|
25
|
+
rake (~> 10.0)
|
26
|
+
|
27
|
+
BUNDLED WITH
|
28
|
+
2.0.2
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2021 Spencer Christiansen
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,120 @@
|
|
1
|
+
# ReSorcery
|
2
|
+
|
3
|
+
> *Create resources with run-time payload type checking and link validation*
|
4
|
+
|
5
|
+
Frontend clients can decode and type check `JSON` responses from their backends using packages like
|
6
|
+
[Elm's Decoders] or [`jsonous` for TypeScript].
|
7
|
+
|
8
|
+
[Elm's Decoders]: https://package.elm-lang.org/packages/elm/json/latest/Json-Decode
|
9
|
+
[`jsonous` for TypeScript]: https://github.com/kofno/festive-possum/tree/main/packages/jsonous
|
10
|
+
|
11
|
+
This is a similar package for the backend, so that Ruby can perform run-time payload type checking
|
12
|
+
and link validation before sending resources to the client.
|
13
|
+
|
14
|
+
- A `<resource>` is a `Hash` with a `:payload` field (a `Hash`) and a `:links` field (an
|
15
|
+
`Array` of `<link>` objects).
|
16
|
+
- Each entry in the `:payload` is type checked using a `Decoder` (inspired by [Elm's Decoders]).
|
17
|
+
- A `<link>` is a `Hash` with four fields:
|
18
|
+
- `:href`, which is either a `URI` or a `String` that parses as a valid `URI`;
|
19
|
+
- `:rel`, which is a white-listed `String`;
|
20
|
+
- `:method`, which is also a white-listed `String`; and
|
21
|
+
- `:type`, which is a `String`.
|
22
|
+
|
23
|
+
Demo:
|
24
|
+
|
25
|
+
```ruby
|
26
|
+
class StaticResource
|
27
|
+
include ReSorcery
|
28
|
+
field :string, is(String), -> { "a string" }
|
29
|
+
field :number, is(Numeric), -> { 42 }
|
30
|
+
links do
|
31
|
+
link 'self', '/here'
|
32
|
+
link 'create', '/here', 'post'
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
StaticResource.new.resource
|
37
|
+
# #<ReSorcery::Result::Ok @value={
|
38
|
+
# :payload=>{:string=>"a string", :number=>42},
|
39
|
+
# :links=>[{:rel=>"self", :href=>"/here", :method=>"get", :type=>"application/json"},
|
40
|
+
# {:rel=>"create", :href=>"/here", :method=>"post", :type=>"application/json"}]
|
41
|
+
# }>
|
42
|
+
```
|
43
|
+
|
44
|
+
## Installation
|
45
|
+
|
46
|
+
Add this line to your application's Gemfile:
|
47
|
+
|
48
|
+
```ruby
|
49
|
+
gem 're_sorcery'
|
50
|
+
```
|
51
|
+
|
52
|
+
And then execute:
|
53
|
+
|
54
|
+
$ bundle
|
55
|
+
|
56
|
+
Or install it yourself as:
|
57
|
+
|
58
|
+
$ gem install re_sorcery
|
59
|
+
|
60
|
+
## Usage
|
61
|
+
|
62
|
+
```ruby
|
63
|
+
class User
|
64
|
+
include ReSorcery
|
65
|
+
|
66
|
+
def initialize(**args)
|
67
|
+
@args = args
|
68
|
+
end
|
69
|
+
|
70
|
+
def admin?
|
71
|
+
@args[:admin] == true
|
72
|
+
end
|
73
|
+
|
74
|
+
field :name, String, -> { @args[:name] }
|
75
|
+
field :id, is(Integer).and { |i| i.positive? || '`id` must be positive' }, -> { @args[:id] }
|
76
|
+
field :admin?, is(true, false)
|
77
|
+
|
78
|
+
links do
|
79
|
+
link 'self', "/users/#{@args[:id]}"
|
80
|
+
link 'destroy', "/users/#{@args[:id]}", 'delete' unless admin? # Don't delete admins
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
User.new(name: "Spencer", id: 1, admin: true).as_json #=> {
|
85
|
+
# :payload=>{:name=>"Spencer", :id=>1, :admin?=>true},
|
86
|
+
# :links=>[
|
87
|
+
# {:rel=>"self", :href=>"/users/1", :method=>"get", :type=>"application/json"}
|
88
|
+
# ]
|
89
|
+
# }
|
90
|
+
|
91
|
+
# An invalid `name` raises an error when calling `as_json`
|
92
|
+
User.new(name: :Invalid, id: 1).as_json
|
93
|
+
# ReSorcery::Error::InvalidResourceError: Error at field `name` of `User`: Expected a(n) String, but
|
94
|
+
# got a(n) Symbol
|
95
|
+
```
|
96
|
+
|
97
|
+
The implementation of `ReSorcery#as_json` raises an exception on invalid data, instead of serving
|
98
|
+
the error message to the user as JSON.
|
99
|
+
|
100
|
+
If you want to serve the error message to the user as JSON, use `ReSorcery#resource.as_json`, which
|
101
|
+
will return a JSON representation of a `Result` object.
|
102
|
+
|
103
|
+
## Development
|
104
|
+
|
105
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run
|
106
|
+
the tests. You can also run `bin/console` for an interactive prompt that will allow you to
|
107
|
+
experiment.
|
108
|
+
|
109
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new
|
110
|
+
version, update the version number in `version.rb`, and then run `bundle exec rake release`, which
|
111
|
+
will create a git tag for the version, push git commits and tags, and push the `.gem` file to
|
112
|
+
[rubygems.org](https://rubygems.org).
|
113
|
+
|
114
|
+
## Contributing
|
115
|
+
|
116
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/spejamchr/re_sorcery.
|
117
|
+
|
118
|
+
## License
|
119
|
+
|
120
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,98 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "re_sorcery"
|
5
|
+
require "pry"
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
class Horse
|
11
|
+
include ReSorcery
|
12
|
+
attr_reader :name, :age, :children
|
13
|
+
|
14
|
+
def initialize(name, age, children)
|
15
|
+
@name = name
|
16
|
+
@age = age
|
17
|
+
@children = children
|
18
|
+
end
|
19
|
+
|
20
|
+
field :name, String
|
21
|
+
field :age, Numeric
|
22
|
+
field :children, array(Horse)
|
23
|
+
|
24
|
+
links do
|
25
|
+
link 'self', "/horses/#{name}"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
class HorseCollection
|
30
|
+
include ReSorcery
|
31
|
+
attr_reader :person, :horses
|
32
|
+
|
33
|
+
def initialize(person, horses)
|
34
|
+
@person = person
|
35
|
+
@horses = horses
|
36
|
+
end
|
37
|
+
|
38
|
+
field :horses, array(Horse)
|
39
|
+
|
40
|
+
links do
|
41
|
+
link 'self', "/person/#{person.name}/horses" if person.kind == 'owner'
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
class Person
|
46
|
+
include ReSorcery
|
47
|
+
attr_reader :name, :age, :kind, :horses
|
48
|
+
|
49
|
+
def initialize(name, age, kind, horses)
|
50
|
+
@name = name
|
51
|
+
@age = age
|
52
|
+
@kind = kind
|
53
|
+
@horses = horses
|
54
|
+
end
|
55
|
+
|
56
|
+
field :name, String
|
57
|
+
field :age, Numeric
|
58
|
+
field :kind, is("owner", "jockey")
|
59
|
+
field :horses, HorseCollection, -> { HorseCollection.new(self, horses) }
|
60
|
+
|
61
|
+
links do
|
62
|
+
link 'self', "/users/#{name}"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def dave
|
67
|
+
Horse.new("Dave", 1, [])
|
68
|
+
end
|
69
|
+
|
70
|
+
def rigby
|
71
|
+
Horse.new("Rigby", 2, [])
|
72
|
+
end
|
73
|
+
|
74
|
+
def ruby
|
75
|
+
Horse.new("Ruby", 6, [dave, rigby])
|
76
|
+
end
|
77
|
+
|
78
|
+
def bad_horse
|
79
|
+
Horse.new("bad horse", 4, [bob])
|
80
|
+
end
|
81
|
+
|
82
|
+
def bob
|
83
|
+
Person.new("Bob", 70, "owner", [dave, rigby, ruby])
|
84
|
+
end
|
85
|
+
|
86
|
+
def jockey
|
87
|
+
Person.new("Jack", 23, "jockey", [dave, ruby])
|
88
|
+
end
|
89
|
+
|
90
|
+
def bab
|
91
|
+
Person.new("Bab", "bad", "owner", [dave, rigby, ruby])
|
92
|
+
end
|
93
|
+
|
94
|
+
def bab2
|
95
|
+
Person.new("Bob", 70, "owner", [dave, rigby, bad_horse])
|
96
|
+
end
|
97
|
+
|
98
|
+
Pry.start
|
data/bin/setup
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ReSorcery
|
4
|
+
module ArgCheck
|
5
|
+
def self.[](name, value, *types)
|
6
|
+
return value if types.any? { |t| value.is_a?(t) }
|
7
|
+
|
8
|
+
fn = caller_locations.first.label
|
9
|
+
s = "`#{fn}` expected `#{name}` to be #{types.join(' or ')}; but got #{value.class}: #{value.inspect}"
|
10
|
+
raise ReSorcery::Error::ArgumentError, s
|
11
|
+
end
|
12
|
+
end
|
13
|
+
private_constant :ArgCheck
|
14
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ReSorcery
|
4
|
+
# Configure `ReSorcery`: All configuration kept in one place
|
5
|
+
#
|
6
|
+
# `ReSorcery` has some values that can be configured by users. To keep such
|
7
|
+
# configuration clear, and to prevent confusing behavior, `#configure` can
|
8
|
+
# only be called once, and must be called before `include`ing `ReSorcery`.
|
9
|
+
#
|
10
|
+
# Example:
|
11
|
+
#
|
12
|
+
# ReSorcery.configure do
|
13
|
+
# link_rels ['self', 'create', 'update']
|
14
|
+
# link_methods ['get', 'post', 'put']
|
15
|
+
# end
|
16
|
+
#
|
17
|
+
# @see `Configuration::CONFIGURABLES` for a list of what can be configured and
|
18
|
+
# what value each configurable takes.
|
19
|
+
#
|
20
|
+
module Configuration
|
21
|
+
extend Decoder::BuiltinDecoders
|
22
|
+
|
23
|
+
CONFIGURABLES = {
|
24
|
+
link_rels: array(String),
|
25
|
+
link_methods: array(String),
|
26
|
+
}.freeze
|
27
|
+
|
28
|
+
def configuration
|
29
|
+
@configuration ||= {}
|
30
|
+
end
|
31
|
+
|
32
|
+
def configure(&block)
|
33
|
+
raise Error::InvalidConfigurationError, @configured if configured?
|
34
|
+
|
35
|
+
@configured = "configured at #{caller_locations.first}"
|
36
|
+
instance_exec(&block)
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def configured?
|
42
|
+
@configured ||= false
|
43
|
+
end
|
44
|
+
|
45
|
+
CONFIGURABLES.each do |name, decoder|
|
46
|
+
define_method(name) do |value|
|
47
|
+
decoder.test(value).cata(
|
48
|
+
ok: ->(v) { configuration[name] = v },
|
49
|
+
err: ->(e) { raise Error::ArgumentError, "Error configuring `#{name}`: #{e}" },
|
50
|
+
)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ReSorcery
|
4
|
+
class Decoder
|
5
|
+
# Common decoders implemented here for convenience
|
6
|
+
module BuiltinDecoders
|
7
|
+
include Helpers
|
8
|
+
|
9
|
+
private
|
10
|
+
|
11
|
+
# A Decoder that always succeeds with a given value
|
12
|
+
def succeed(value)
|
13
|
+
Decoder.new { ok(value) }
|
14
|
+
end
|
15
|
+
|
16
|
+
# A decoder that always fails with some error message
|
17
|
+
def fail(message = '`fail` Decoder always fails')
|
18
|
+
ArgCheck['message', message, String]
|
19
|
+
|
20
|
+
Decoder.new { message }
|
21
|
+
end
|
22
|
+
|
23
|
+
# Test if an object is a thing (or one of a list of things)
|
24
|
+
#
|
25
|
+
# Convenience method for creating common types of `Decoder`s.
|
26
|
+
#
|
27
|
+
# @param [Decoder|Class|Module|unknown] thing
|
28
|
+
# @param [Array<Decoder|Class|Module|unknown>] others
|
29
|
+
#
|
30
|
+
# Accepts any number of arguments greater than 1. Call the list of
|
31
|
+
# arguments `things`.
|
32
|
+
#
|
33
|
+
# For each argument `thing` in `things`:
|
34
|
+
#
|
35
|
+
# - If `thing` is a Decoder, return it unchanged.
|
36
|
+
# - If `thing` is a Class or Module, create a Decoder that tests whether
|
37
|
+
# an object `is_a?(thing)`.
|
38
|
+
# - Otherwise, create a Decoder that tests if an object equals `thing`
|
39
|
+
# (using `==`).
|
40
|
+
#
|
41
|
+
# Then create a decoder that tests each of these decoders one by one
|
42
|
+
# against a given item until one passes or they have all failed.
|
43
|
+
#
|
44
|
+
# @return [Decoder]
|
45
|
+
def is(thing, *others)
|
46
|
+
things = [thing] + others
|
47
|
+
decoders = things.map { |t| make_decoder_from(t) }
|
48
|
+
|
49
|
+
Decoder.new do |instance|
|
50
|
+
test_multiple(decoders, instance).map_error do |errors|
|
51
|
+
errors.count == 1 ? errors[0] : "all decoders in `is` failed: (#{errors.join(' | ')})"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# Test that an object is an array of objects that pass the decoder `is(thing)`
|
57
|
+
#
|
58
|
+
# @param thing @see `is` for details
|
59
|
+
# @param others @see `is` for details
|
60
|
+
# @return [Decoder]
|
61
|
+
def array(thing, *others)
|
62
|
+
decoder = is(thing, *others)
|
63
|
+
Decoder.new do |instance|
|
64
|
+
is(Array).test(instance).and_then do |arr|
|
65
|
+
arr.each_with_index.inject(ok([])) do |result_array, (unknown, index)|
|
66
|
+
result_array.and_then do |ok_array|
|
67
|
+
decoder.test(unknown)
|
68
|
+
.map { |tested| ok_array << tested }
|
69
|
+
.map_error { |error| "Error at index `#{index}` of Array: #{error}" }
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# Test that an object is a Maybe whose `value` passes some `Decoder`
|
77
|
+
#
|
78
|
+
# @param thing @see `is` for details
|
79
|
+
# @param others @see `is` for details
|
80
|
+
# @return [Decoder]
|
81
|
+
def maybe(thing, *others)
|
82
|
+
decoder = is(thing, *others)
|
83
|
+
Decoder.new do |instance|
|
84
|
+
is(Maybe::Just, Maybe::Nothing).test(instance).and_then do |maybe|
|
85
|
+
maybe
|
86
|
+
.map { |v| decoder.test(v).map { |c| just(c) } }
|
87
|
+
.get_or_else { ok(nothing) }
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# Test that an object is a hash and has a field that passes a given decoder
|
93
|
+
def field(key, thing, *others)
|
94
|
+
key_decoder = is(thing, *others).map_error { |e| "Error at key `#{key}`: #{e}" }
|
95
|
+
|
96
|
+
is(Hash)
|
97
|
+
.and_then { Decoder.new { |u| u.key?(key) || "Expected key `#{key}` in: #{u.inspect}" } }
|
98
|
+
.and_then { Decoder.new { |u| key_decoder.test(u.fetch(key)) } }
|
99
|
+
end
|
100
|
+
|
101
|
+
def make_decoder_from(thing)
|
102
|
+
nillish = thing.nil? || thing == NilClass
|
103
|
+
raise ReSorcery::Error::ArgumentError, "Do not use `nil`" if nillish
|
104
|
+
|
105
|
+
case thing
|
106
|
+
when Decoder
|
107
|
+
thing
|
108
|
+
when Class, Module
|
109
|
+
Decoder.new { |n| n.is_a?(thing) || "Expected a(n) #{thing}, but got a(n) #{n.class}" }
|
110
|
+
else
|
111
|
+
Decoder.new { |n| n == thing || "Expected #{thing.inspect}, but got #{n.inspect}" }
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def test_multiple(decoders, instance)
|
116
|
+
decoders.inject(err([])) do |error_array, decoder|
|
117
|
+
error_array.or_else do |errors|
|
118
|
+
decoder.test(instance).map_error { |error| errors << error }
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,126 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 're_sorcery/decoder/builtin_decoders'
|
4
|
+
|
5
|
+
module ReSorcery
|
6
|
+
# Test that an object satisfies some property
|
7
|
+
#
|
8
|
+
# A `Decoder` represents a piece of logic for verifying some property. A
|
9
|
+
# simple example would be a `Decoder` that verifies that an object
|
10
|
+
# `is_a?(String)`. A `ReSorcery::Result` is used to represent the result
|
11
|
+
# of the logic. This can be created like:
|
12
|
+
#
|
13
|
+
# is_string =
|
14
|
+
# Decoder.new { |n| n.is_a?(String) ? ok(n) : err("Expected String, got #{n.class}") }
|
15
|
+
#
|
16
|
+
# And then used like:
|
17
|
+
#
|
18
|
+
# is_string.test("I'm a string") #=> ok("I'm a string")
|
19
|
+
# is_string.test(:symbol) #=> err("Expected String, got Symbol")
|
20
|
+
#
|
21
|
+
# Because returning the original object wrapped in `ok` is the common result
|
22
|
+
# when a `Decoder` passes, a shorthand in the initializer is to return
|
23
|
+
# `true`, and `Decoder` will wrap the object for you.
|
24
|
+
#
|
25
|
+
# is_string =
|
26
|
+
# Decoder.new { |n| n.is_a?(String) || err("Expected String, got #{n.class}") }
|
27
|
+
#
|
28
|
+
# A similar shorthand: Since the alternative is always something wrapped in
|
29
|
+
# `err`, if anything but `true` or a `Result` is returned, it will be wrapped
|
30
|
+
# in `err`.
|
31
|
+
#
|
32
|
+
# is_string =
|
33
|
+
# Decoder.new { |n| n.is_a?(String) || "Expected String, got #{n.class}" }
|
34
|
+
#
|
35
|
+
# All three of these implementations of `is_string` are equivalent.
|
36
|
+
#
|
37
|
+
# Note that this library has strong opinions against using `nil`, so `nil`
|
38
|
+
# will never pass `Decoder#test`.
|
39
|
+
#
|
40
|
+
class Decoder
|
41
|
+
include Helpers
|
42
|
+
|
43
|
+
def initialize(&block)
|
44
|
+
@block = block
|
45
|
+
end
|
46
|
+
|
47
|
+
# Use the decoder to `test` that an `unknown` object satisfies some property
|
48
|
+
#
|
49
|
+
# Note that `ok(nil)` will never be returned.
|
50
|
+
#
|
51
|
+
# @param [unknown] unknown
|
52
|
+
# @return [Result]
|
53
|
+
def test(unknown)
|
54
|
+
result = @block.call(unknown)
|
55
|
+
case result
|
56
|
+
when Result::Ok, Result::Err
|
57
|
+
result
|
58
|
+
when TrueClass
|
59
|
+
ok(unknown)
|
60
|
+
else
|
61
|
+
err(result)
|
62
|
+
end.and_then { |r| r.nil? ? err("`nil` was returned on a successful test!") : ok(r) }
|
63
|
+
end
|
64
|
+
|
65
|
+
# Apply some block within the context of a successful decoder
|
66
|
+
def map(&block)
|
67
|
+
Decoder.new { |unknown| test(unknown).map(&block) }
|
68
|
+
end
|
69
|
+
|
70
|
+
# Apply some block within the context of an unsuccessful decoder
|
71
|
+
def map_error(&block)
|
72
|
+
Decoder.new { |unknown| test(unknown).map_error(&block) }
|
73
|
+
end
|
74
|
+
|
75
|
+
# Chain decoders
|
76
|
+
#
|
77
|
+
# The second decoder can be chosen based on the (successful) result of the
|
78
|
+
# first decoder.
|
79
|
+
#
|
80
|
+
# The block must return a `Decoder`.
|
81
|
+
def and_then(decoder = nil, &block)
|
82
|
+
ArgCheck['decoder', decoder, Decoder] unless decoder.nil?
|
83
|
+
|
84
|
+
Decoder.new do |unknown|
|
85
|
+
test(unknown).and_then do |value|
|
86
|
+
# Don't try to re-assign `decoder` here, because it's captured from outside the Decoder
|
87
|
+
(decoder || ArgCheck['block.call(value)', block.call(value), Decoder]).test(unknown)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# If the `Decoder` failed, try another `Decoder`
|
93
|
+
#
|
94
|
+
# The block must return a `Decoder`.
|
95
|
+
def or_else(decoder = nil, &block)
|
96
|
+
ArgCheck['decoder', decoder, Decoder] unless decoder.nil?
|
97
|
+
|
98
|
+
Decoder.new do |unknown|
|
99
|
+
test(unknown).or_else do |value|
|
100
|
+
decoder ||= ArgCheck['block.call(value)', block.call(value), Decoder]
|
101
|
+
|
102
|
+
decoder.test(unknown)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
# Chain decoders like `and_then`, but always with a specific decoder
|
108
|
+
def and(&block)
|
109
|
+
and_then { Decoder.new(&block) }
|
110
|
+
end
|
111
|
+
|
112
|
+
# Chain decoders like and_then, but use the chain to build an object
|
113
|
+
def assign(key, other)
|
114
|
+
ArgCheck['key', key, Symbol]
|
115
|
+
ArgCheck['other', other, Proc, Decoder]
|
116
|
+
other = ->(_) { other.call } if other.is_a?(Proc) && other.arity.zero?
|
117
|
+
|
118
|
+
and_then do |a|
|
119
|
+
ArgCheck['decoded value', a, Hash]
|
120
|
+
|
121
|
+
decoder = other.is_a?(Decoder) ? other : ArgCheck['other.call', other.call(a), Decoder]
|
122
|
+
decoder.map { |b| a.merge(key => b) }
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ReSorcery
|
4
|
+
module Error
|
5
|
+
class ReSorceryError < StandardError; end
|
6
|
+
|
7
|
+
class ArgumentError < ReSorceryError; end
|
8
|
+
|
9
|
+
class InvalidResourceError < ReSorceryError; end
|
10
|
+
|
11
|
+
class NonHashAssignError < ReSorceryError
|
12
|
+
def initialize(value)
|
13
|
+
super(value)
|
14
|
+
@value = value
|
15
|
+
end
|
16
|
+
|
17
|
+
def message
|
18
|
+
"#assign can only be used when the @value is a Hash, but was a(n) #{@value.class}"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
class InvalidConfigurationError < ReSorceryError
|
23
|
+
def initialize(details)
|
24
|
+
@details = details
|
25
|
+
end
|
26
|
+
|
27
|
+
def message
|
28
|
+
"ReSorcery can only be configured once, and only before `include`ing ReSorcery, but was " +
|
29
|
+
@details
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|