cuprum 0.10.0 → 1.0.0.rc.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuprum'
4
+
5
+ module Cuprum
6
+ # Handles matching a result against an ordered list of matchers.
7
+ #
8
+ # A MatcherList should be used when you have a series of matchers with a
9
+ # defined priority ordering. Within that ordering, the list will check for the
10
+ # most specific matching clause in each of the matchers. A clause matching
11
+ # both the value and error will match first, followed by a clause matching
12
+ # only the result value or error, and finally a clause matching only the
13
+ # result status will match. If none of the matchers have a clause that matches
14
+ # the result, a Cuprum::Matching::NoMatchError will be raised.
15
+ #
16
+ # @example Using A MatcherList
17
+ # generic_matcher = Cuprum::Matcher.new do
18
+ # match(:failure) { 'generic failure' }
19
+ #
20
+ # match(:failure, error: CustomError) { 'custom failure' }
21
+ # end
22
+ # specific_matcher = Cuprum::Matcher.new do
23
+ # match(:failure, error: Cuprum::Error) { 'specific failure' }
24
+ # end
25
+ # matcher_list = Cuprum::MatcherList.new(
26
+ # [
27
+ # specific_matcher,
28
+ # generic_matcher
29
+ # ]
30
+ # )
31
+ #
32
+ # # A failure without an error does not match the first matcher, so the
33
+ # # matcher list continues on to the next matcher in the list.
34
+ # result = Cuprum::Result.new(status: :failure)
35
+ # matcher_list.call(result)
36
+ # #=> 'generic failure'
37
+ #
38
+ # # A failure with an error matches the first matcher.
39
+ # error = Cuprum::Error.new(message: 'Something went wrong.')
40
+ # result = Cuprum::Result.new(error: error)
41
+ # matcher_list.call(result)
42
+ # #=> 'specific failure'
43
+ #
44
+ # # A failure with an error subclass still matches the first matcher, even
45
+ # # though the second matcher has a more exact match.
46
+ # error = CustomError.new(message: 'The magic smoke is escaping.')
47
+ # result = Cuprum::Result.new(error: error)
48
+ # matcher_list.call(result)
49
+ # #=> 'specific failure'
50
+ class MatcherList
51
+ # @param matchers [Array<Cuprum::Matching>] The matchers to match against a
52
+ # result, in order of descending priority.
53
+ def initialize(matchers)
54
+ @matchers = matchers
55
+ end
56
+
57
+ # @return [Array<Cuprum::Matching>] the matchers to match against a result.
58
+ attr_reader :matchers
59
+
60
+ # Finds and executes the best matching clause from the ordered matchers.
61
+ #
62
+ # When given a result, the matcher list will check through each of the
63
+ # matchers in the order they were given for match clauses that match the
64
+ # result. Each matcher is checked for a clause that matches the status,
65
+ # error, and value of the result. If no matching clause is found, the
66
+ # matchers are then checked for a clause matching the status and either the
67
+ # error or value of the result. Finally, if there are still no matching
68
+ # clauses, the matchers are checked for a clause that matches the result
69
+ # status.
70
+ #
71
+ # Once a matching clause is found, that clause is then called with the
72
+ # given result.
73
+ #
74
+ # If none of the matchers have a clause that matches the result, a
75
+ # Cuprum::Matching::NoMatchError will be raised.
76
+ #
77
+ # @param result [Cuprum::Result] The result to match.
78
+ #
79
+ # @raise Cuprum::Matching::NoMatchError if none of the matchers match the
80
+ # given result.
81
+ #
82
+ # @see Cuprum::Matcher#call.
83
+ def call(result)
84
+ unless result.respond_to?(:to_cuprum_result)
85
+ raise ArgumentError, 'result must be a Cuprum::Result'
86
+ end
87
+
88
+ result = result.to_cuprum_result
89
+ matcher = matcher_for(result)
90
+
91
+ return matcher.call(result) if matcher
92
+
93
+ raise Cuprum::Matching::NoMatchError,
94
+ "no match found for #{result.inspect}"
95
+ end
96
+
97
+ private
98
+
99
+ def error_match?(matcher:, result:)
100
+ matcher.matches?(
101
+ result.status,
102
+ error: result.error&.class
103
+ )
104
+ end
105
+
106
+ def exact_match?(matcher:, result:)
107
+ matcher.matches?(
108
+ result.status,
109
+ error: result.error&.class,
110
+ value: result.value&.class
111
+ )
112
+ end
113
+
114
+ def find_exact_match(result)
115
+ matchers.find do |matcher|
116
+ exact_match?(matcher: matcher, result: result)
117
+ end
118
+ end
119
+
120
+ def find_generic_match(result)
121
+ matchers.find do |matcher|
122
+ generic_match?(matcher: matcher, result: result)
123
+ end
124
+ end
125
+
126
+ def find_partial_match(result)
127
+ matchers.find do |matcher|
128
+ error_match?(matcher: matcher, result: result) ||
129
+ value_match?(matcher: matcher, result: result)
130
+ end
131
+ end
132
+
133
+ def generic_match?(matcher:, result:)
134
+ matcher.matches?(result.status)
135
+ end
136
+
137
+ def matcher_for(result)
138
+ find_exact_match(result) ||
139
+ find_partial_match(result) ||
140
+ find_generic_match(result)
141
+ end
142
+
143
+ def value_match?(matcher:, result:)
144
+ matcher.matches?(
145
+ result.status,
146
+ value: result.value&.class
147
+ )
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuprum/matching'
4
+
5
+ module Cuprum::Matching
6
+ # @private
7
+ #
8
+ # Value object that represents a potential result match for a Matcher.
9
+ #
10
+ # Should not be instantiated directly; instead, instantiate a Cuprum::Matcher
11
+ # or include Cuprum::Matching in a custom class.
12
+ MatchClause = Struct.new(:block, :error, :status, :value) do
13
+ include Comparable
14
+
15
+ # @param other [Cuprum::Matching::MatchClause] The other result to compare.
16
+ #
17
+ # @return [Integer] the comparison result.
18
+ def <=>(other)
19
+ return nil unless other.is_a?(Cuprum::Matching::MatchClause)
20
+
21
+ cmp = compare(value, other.value)
22
+
23
+ return cmp unless cmp.zero?
24
+
25
+ compare(error, other.error)
26
+ end
27
+
28
+ # Checks if the match clause matches the specified error and value.
29
+ #
30
+ # @return [Boolean] true if the error and value match, otherwise false.
31
+ def matches_details?(error:, value:)
32
+ return false unless matches_detail?(error, self.error)
33
+ return false unless matches_detail?(value, self.value)
34
+
35
+ true
36
+ end
37
+
38
+ # Checks if the match clause matches the given result.
39
+ #
40
+ # @return [Boolean] true if the result matches, otherwise false.
41
+ def matches_result?(result:)
42
+ return false unless error.nil? || result.error.is_a?(error)
43
+ return false unless value.nil? || result.value.is_a?(value)
44
+
45
+ true
46
+ end
47
+
48
+ private
49
+
50
+ def compare(left, right)
51
+ return 0 if left.nil? && right.nil?
52
+ return 1 if left.nil?
53
+ return -1 if right.nil?
54
+
55
+ left <=> right || 0
56
+ end
57
+
58
+ def matches_detail?(actual, expected)
59
+ return true if actual.nil? && expected.nil?
60
+ return false if actual.nil? || expected.nil?
61
+
62
+ actual <= expected
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuprum'
4
+
5
+ module Cuprum
6
+ # Implements result matching based on result status, error, and value.
7
+ #
8
+ # @see Cuprum::Matcher.
9
+ module Matching
10
+ autoload :MatchClause, 'cuprum/matching/match_clause'
11
+
12
+ # Class methods extend-ed into a class when the module is included.
13
+ module ClassMethods
14
+ # Defines a match clause for the matcher.
15
+ #
16
+ # @param status [Symbol] The status to match. The clause will match a
17
+ # result only if the result has the same status as the match clause.
18
+ # @param error [Class] The type of error to match. If given, the clause
19
+ # will match a result only if the result error is an instance of the
20
+ # given class, or an instance of a subclass.
21
+ # @param value [Class] The type of value to match. If given, the clause
22
+ # will match a result only if the result value is an instance of the
23
+ # given class, or an instance of a subclass.
24
+ #
25
+ # @yield The code to execute on a successful match.
26
+ # @yieldparam result [Cuprum::Result] The matched result.
27
+ def match(status, error: nil, value: nil, &block)
28
+ validate_status!(status)
29
+ validate_error!(error)
30
+ validate_value!(value)
31
+
32
+ clause = MatchClause.new(block, error, status, value)
33
+ clauses = match_clauses[status]
34
+ index = clauses.bsearch_index { |item| clause <= item } || -1
35
+
36
+ # Clauses are sorted from most specific to least specific.
37
+ clauses.insert(index, clause)
38
+ end
39
+
40
+ # @private
41
+ def match_result(result:)
42
+ status_clauses(result.status).find do |clause|
43
+ clause.matches_result?(result: result)
44
+ end
45
+ end
46
+
47
+ # @private
48
+ def matches_result?(result:)
49
+ status_clauses(result.status).reverse_each.any? do |clause|
50
+ clause.matches_result?(result: result)
51
+ end
52
+ end
53
+
54
+ # @private
55
+ def matches_status?(error:, status:, value:)
56
+ status_clauses(status).reverse_each.any? do |clause|
57
+ clause.matches_details?(error: error, value: value)
58
+ end
59
+ end
60
+
61
+ protected
62
+
63
+ def match_clauses
64
+ @match_clauses ||= Hash.new { |hsh, key| hsh[key] = [] }
65
+ end
66
+
67
+ private
68
+
69
+ def status_clauses(status)
70
+ ancestors
71
+ .select { |ancestor| ancestor < Cuprum::Matching }
72
+ .map { |ancestor| ancestor.match_clauses[status] }
73
+ .reduce([], &:concat)
74
+ .sort
75
+ end
76
+
77
+ def validate_error!(error)
78
+ return if error.nil? || error.is_a?(Module)
79
+
80
+ raise ArgumentError,
81
+ 'error must be a Class or Module',
82
+ caller(1..-1)
83
+ end
84
+
85
+ def validate_status!(status)
86
+ if status.nil? || status.to_s.empty?
87
+ raise ArgumentError, "status can't be blank", caller(1..-1)
88
+ end
89
+
90
+ return if status.is_a?(Symbol)
91
+
92
+ raise ArgumentError, 'status must be a Symbol', caller(1..-1)
93
+ end
94
+
95
+ def validate_value!(value)
96
+ return if value.nil? || value.is_a?(Module)
97
+
98
+ raise ArgumentError,
99
+ 'value must be a Class or Module',
100
+ caller(1..-1)
101
+ end
102
+ end
103
+
104
+ # Exception raised when the matcher does not match a result.
105
+ class NoMatchError < StandardError; end
106
+
107
+ class << self
108
+ private
109
+
110
+ def included(other)
111
+ super
112
+
113
+ other.extend(ClassMethods)
114
+ end
115
+ end
116
+
117
+ # @return [Object, nil] the execution context for a matching clause.
118
+ attr_reader :match_context
119
+
120
+ # Finds the match clause matching the result and calls the stored block.
121
+ #
122
+ # Match clauses are defined using the .match DSL. When a result is matched,
123
+ # the defined clauses matching the result status are checked in descending
124
+ # order of specificity:
125
+ #
126
+ # - Clauses that expect both a value and an error.
127
+ # - Clauses that expect a value.
128
+ # - Clauses that expect an error.
129
+ # - Clauses that do not expect a value or an error.
130
+ #
131
+ # If there are multiple clauses that expect a value or an error, they are
132
+ # sorted by inheritance - a clause with a subclass value or error is checked
133
+ # before the clause with the parent class.
134
+ #
135
+ # Using that ordering, each potential clause is checked for a match with the
136
+ # result. If the clause defines a value, then the result will match the
137
+ # clause only if the result value is an instance of the expected value (or
138
+ # an instance of a subclass). Likewise, if the clause defines an error, then
139
+ # the result will match the clause only if the result error is an instance
140
+ # of the expected error class (or an instance of a subclass). Clauses that
141
+ # do not define either a value nor an error will match with any result with
142
+ # the same status, but as the least specific are always matched last.
143
+ #
144
+ # Matchers can also inherit clauses from a parent class or from an included
145
+ # module. Inherited or included clauses are checked after clauses defined on
146
+ # the matcher itself, so the matcher can override generic matches with more
147
+ # specific functionality.
148
+ #
149
+ # Finally, once the most specific matching clause is found, #call will
150
+ # call the block used to define the clause. If the block takes at least one
151
+ # argument, the result will be passed to the block; otherwise, it will be
152
+ # called with no parameters. If there is no clause matching the result,
153
+ # #call will instead raise a Cuprum::Matching::NoMatchError.
154
+ #
155
+ # The match clause is executed in the context of the matcher object. This
156
+ # allows instance methods defined for the matcher to be called as part of
157
+ # the match clause block. If the matcher defines a non-nil
158
+ # #matching_context, the block is instead executed in the context of the
159
+ # matching_context using #instance_exec.
160
+ #
161
+ # @param result [Cuprum::Result] The result to match.
162
+ #
163
+ # @return [Object] the value returned by the stored block.
164
+ #
165
+ # @raise [NoMatchError] if there is no clause matching the result.
166
+ #
167
+ # @see ClassMethods::match
168
+ # @see #match_context
169
+ def call(result)
170
+ unless result.respond_to?(:to_cuprum_result)
171
+ raise ArgumentError, 'result must be a Cuprum::Result'
172
+ end
173
+
174
+ result = result.to_cuprum_result
175
+ clause = singleton_class.match_result(result: result)
176
+
177
+ raise NoMatchError, "no match found for #{result.inspect}" if clause.nil?
178
+
179
+ call_match(block: clause.block, result: result)
180
+ end
181
+
182
+ # @return [Boolean] true if an execution context is defined for a matching
183
+ # clause; otherwise false.
184
+ def match_context?
185
+ !match_context.nil?
186
+ end
187
+
188
+ # @overload matches?(result)
189
+ # Checks if the matcher has any match clauses that match the given result.
190
+ #
191
+ # @param result [Cuprum::Result] The result to match.
192
+ #
193
+ # @return [Boolean] true if the matcher has at least one match clause that
194
+ # matches the result; otherwise false.
195
+ #
196
+ # @overload matches?(status, error: nil, value: nil)
197
+ # Checks if the matcher has any clauses matching the status and details.
198
+ #
199
+ # @param status [Symbol] The status to match.
200
+ # @param error [Class, nil] The class of error to match, if any.
201
+ # @param value [Class, nil] The class of value to match, if any.
202
+ #
203
+ # @return [Boolean] true if the matcher has at least one match clause that
204
+ # matches the status and details; otherwise false.
205
+ def matches?(result_or_status, error: nil, value: nil) # rubocop:disable Metrics/MethodLength
206
+ if result_or_status.respond_to?(:to_cuprum_result)
207
+ raise ArgumentError, 'error defined by result' unless error.nil?
208
+ raise ArgumentError, 'value defined by result' unless value.nil?
209
+
210
+ return singleton_class.matches_result?(
211
+ result: result_or_status.to_cuprum_result
212
+ )
213
+ elsif result_or_status.is_a?(Symbol)
214
+ return singleton_class.matches_status?(
215
+ error: error,
216
+ status: result_or_status,
217
+ value: value
218
+ )
219
+ end
220
+
221
+ raise ArgumentError, 'argument must be a result or a status'
222
+ end
223
+
224
+ private
225
+
226
+ def call_match(block:, result:)
227
+ args = block.arity.zero? ? [] : [result]
228
+
229
+ (match_context || self).instance_exec(*args, &block)
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuprum'
4
+
5
+ module Cuprum
6
+ # Implements a wrapper around another command.
7
+ #
8
+ # A middleware command wraps the execution of another command, allowing the
9
+ # developer to compose functionality without an explicit wrapper command.
10
+ # Because the middleware is responsible for calling the wrapped command, it
11
+ # has control over when that command is called, with what parameters, and how
12
+ # the command result is handled.
13
+ #
14
+ # To use middleware, start by defining a middleware command. This can either
15
+ # be a class that includes Cuprum::Middleware, or a command instance that
16
+ # extends Cuprum::Middleware. Each middleware command's #process method takes
17
+ # as its first argument the wrapped command. By convention, any additional
18
+ # arguments and any keywords or a block are passed to the wrapped command, but
19
+ # some middleware will override ths behavior.
20
+ #
21
+ # When defining #process, make sure to either call super or call the wrapped
22
+ # command directly, unless the middleware is specifically intended not to call
23
+ # the wrapped command under those circumstances.
24
+ #
25
+ # Middleware is powerful because it allows the developer to manipulate the
26
+ # parameters passed to a command, add handling to a result, or even intercept
27
+ # or override the command execution. These are some of the possible use cases
28
+ # for middleware:
29
+ #
30
+ # - Injecting code before or after a command.
31
+ # - Changing the parameters passed to a command.
32
+ # - Adding behavior based on the command result.
33
+ # - Overriding the command behavior based on the parameters.
34
+ #
35
+ # Middleware is loosely coupled, meaning that one middleware command can wrap
36
+ # any number of other commands. One example would be logging middleware, which
37
+ # could record when a command is called and with what parameters. For a more
38
+ # involved example, consider authorization in a web application. If individual
39
+ # actions are defined as commands, then a single authorization middleware
40
+ # class could wrap each individual action, reducing both the testing burden
41
+ # and the amount of code that must be maintained.
42
+ #
43
+ # @example Basic Middleware
44
+ # class ExampleCommand < Cuprum::Command
45
+ # private def process(**options)
46
+ # return failure(options[:error]) if options[:error]
47
+ #
48
+ # "Options: #{options.inspect}"
49
+ # end
50
+ # end
51
+ #
52
+ # class LoggingMiddleware < Cuprum::Command
53
+ # include Cuprum::Middleware
54
+ #
55
+ # # The middleware injects a logging step before the wrapped command is
56
+ # # called. Notice that this middleware is generic, and can be used with
57
+ # # virtually any other command.
58
+ # private def process(next_command, *args, **kwargs)
59
+ # Logger.info("Calling command #{next_command.class}")
60
+ #
61
+ # super
62
+ # end
63
+ # end
64
+ #
65
+ # command = Command.new { |**opts| "Called with #{opts.inspect}" }
66
+ # middleware = LoggingMiddleware.new
67
+ # result = middleware.call(command, { id: 0 })
68
+ # #=> logs "Calling command ExampleCommand"
69
+ # result.value
70
+ # #=> "Options: { id: 0 }"
71
+ #
72
+ # @example Injecting Parameters
73
+ # class ApiMiddleware < Cuprum::Command
74
+ # include Cuprum::Middleware
75
+ #
76
+ # # The middleware adds the :api_key to the parameters passed to the
77
+ # # command. If an :api_key keyword is passed, then the passed value will
78
+ # # take precedence.
79
+ # private def process(next_command, *args, **kwargs)
80
+ # super(next_command, *args, api_key: '12345', **kwargs)
81
+ # end
82
+ # end
83
+ #
84
+ # command = Command.new { |**opts| "Called with #{opts.inspect}" }
85
+ # middleware = LoggingMiddleware.new
86
+ # result = middleware.call(command, { id: 0 })
87
+ # result.value
88
+ # #=> "Options: { id: 0, api_key: '12345' }"
89
+ #
90
+ # @example Handling Results
91
+ # class IgnoreFailure < Cuprum::Command
92
+ # include Cuprum::Middleware
93
+ #
94
+ # # The middleware runs the command once. On a failing result, the
95
+ # # middleware discards the failing result and returns a result with a
96
+ # # value of nil.
97
+ # private def process(next_command, *args, **kwargs)
98
+ # result = super
99
+ #
100
+ # return result if result.success?
101
+ #
102
+ # success(nil)
103
+ # end
104
+ # end
105
+ #
106
+ # command = Command.new { |**opts| "Called with #{opts.inspect}" }
107
+ # middleware = LoggingMiddleware.new
108
+ # result = middleware.call(command, { id: 0 })
109
+ # result.success?
110
+ # #=> true
111
+ # result.value
112
+ # #=> "Options: { id: 0, api_key: '12345' }"
113
+ #
114
+ # error = Cuprum::Error.new(message: 'Something went wrong.')
115
+ # result = middleware.call(command, error: error)
116
+ # result.success?
117
+ # #=> true
118
+ # result.value
119
+ # #=> nil
120
+ #
121
+ # @example Flow Control
122
+ # class AuthenticationMiddleware < Cuprum::Command
123
+ # include Cuprum::Middleware
124
+ #
125
+ # # The middleware finds the current user based on the given keywords. If
126
+ # # a valid user is found, the user is then passed on to the command.
127
+ # # If a user is not found, then the middleware will immediately halt (due
128
+ # # to #step) and return the failing result from the authentication
129
+ # # command.
130
+ # private def process(next_command, *args, **kwargs)
131
+ # current_user = step { AuthenticateUser.new.call(**kwargs) }
132
+ #
133
+ # super(next_command, *args, current_user: current_user, **kwargs)
134
+ # end
135
+ # end
136
+ #
137
+ # @example Advanced Command Wrapping
138
+ # class RetryMiddleware < Cuprum::Command
139
+ # include Cuprum::Middleware
140
+ #
141
+ # # The middleware runs the command up to three times. If a result is
142
+ # # passing, that result is returned immediately; otherwise, the last
143
+ # # failing result will be returned by the middleware.
144
+ # private def process(next_command, *args, **kwargs)
145
+ # result = nil
146
+ #
147
+ # 3.times do
148
+ # result = super
149
+ #
150
+ # return result if result.success?
151
+ # end
152
+ #
153
+ # result
154
+ # end
155
+ # end
156
+ module Middleware
157
+ # @!method call(next_command, *arguments, **keywords, &block)
158
+ # Calls the next command with the given arguments, keywords, and block.
159
+ #
160
+ # Subclasses can call super to easily call the next command with the given
161
+ # parameters, or pass explicit parameters into super to call the next
162
+ # command with those parameters.
163
+ #
164
+ # @param next_command [Cuprum::Command] The command to call.
165
+ # @param arguments [Array] The arguments to pass to the command.
166
+ # @param keywords [Hash] The keywords to pass to the command.
167
+ #
168
+ # @yield A block to pass to the command.
169
+ #
170
+ # @return [Cuprum::Result] the result of calling the command.
171
+
172
+ # Helper method for wrapping a command with middleware.
173
+ #
174
+ # This method takes the given command and middleware and returns a command
175
+ # that will call the middleware in order, followed by the given command.
176
+ # This is done via partial application: the last item in the middleware is
177
+ # partially applied with the given command as the middleware's next command
178
+ # parameter. The next to last middleware is then partially applied with the
179
+ # last middleware as the next command and so on. This ensures that the
180
+ # middleware commands will be called in the given order, and that each
181
+ # middleware command wraps the next, down to the given command at the root.
182
+ #
183
+ # @param command [Cuprum::Command] The command to wrap with middleware.
184
+ # @param middleware [Cuprum::Middleware, Array<Cuprum::Middleware>] The
185
+ # middleware to wrap around the command. Will be called in the order they
186
+ # are given.
187
+ #
188
+ # @return [Cuprum::Command] the outermost middleware command, with the next
189
+ # command parameter partially applied.
190
+ def self.apply(command:, middleware:)
191
+ middleware = Array(middleware)
192
+
193
+ return command if middleware.empty?
194
+
195
+ middleware.reverse_each.reduce(command) do |next_command, cmd|
196
+ cmd.curry(next_command)
197
+ end
198
+ end
199
+
200
+ private
201
+
202
+ def process(next_command, *args, **kwargs, &block)
203
+ if kwargs.empty?
204
+ step { next_command.call(*args, &block) }
205
+ else
206
+ step { next_command.call(*args, **kwargs, &block) }
207
+ end
208
+ end
209
+ end
210
+ end