cuprum 0.10.0.rc.0 → 1.0.0.rc.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -2
- data/CODE_OF_CONDUCT.md +132 -0
- data/DEVELOPMENT.md +4 -21
- data/README.md +776 -89
- data/lib/cuprum/built_in/identity_command.rb +6 -4
- data/lib/cuprum/built_in/identity_operation.rb +4 -2
- data/lib/cuprum/built_in/null_command.rb +5 -3
- data/lib/cuprum/built_in/null_operation.rb +4 -2
- data/lib/cuprum/built_in.rb +3 -1
- data/lib/cuprum/command.rb +29 -58
- data/lib/cuprum/command_factory.rb +7 -5
- data/lib/cuprum/currying/curried_command.rb +11 -4
- data/lib/cuprum/currying.rb +3 -2
- data/lib/cuprum/error.rb +44 -10
- data/lib/cuprum/errors/command_not_implemented.rb +6 -3
- data/lib/cuprum/errors/operation_not_called.rb +6 -6
- data/lib/cuprum/errors/uncaught_exception.rb +55 -0
- data/lib/cuprum/errors.rb +2 -0
- data/lib/cuprum/exception_handling.rb +50 -0
- data/lib/cuprum/matcher.rb +90 -0
- data/lib/cuprum/matcher_list.rb +150 -0
- data/lib/cuprum/matching/match_clause.rb +65 -0
- data/lib/cuprum/matching.rb +232 -0
- data/lib/cuprum/middleware.rb +210 -0
- data/lib/cuprum/operation.rb +17 -15
- data/lib/cuprum/result.rb +1 -3
- data/lib/cuprum/rspec/be_a_result.rb +10 -1
- data/lib/cuprum/rspec/be_a_result_matcher.rb +2 -4
- data/lib/cuprum/rspec/be_callable.rb +14 -0
- data/lib/cuprum/steps.rb +47 -89
- data/lib/cuprum/utils/instance_spy.rb +28 -28
- data/lib/cuprum/utils.rb +3 -1
- data/lib/cuprum/version.rb +13 -10
- data/lib/cuprum.rb +12 -7
- metadata +31 -19
- data/lib/cuprum/chaining.rb +0 -441
@@ -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
|