re_sorcery 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,8 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
data/.travis.yml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ sudo: false
3
+ language: ruby
4
+ cache: bundler
5
+ rvm:
6
+ - 2.6.3
7
+ before_install: gem install bundler -v 2.0.2
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in re_sorcery.gemspec
4
+ gemspec
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
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList["test/**/*_test.rb"]
8
+ end
9
+
10
+ task :default => :test
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,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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