cuprum 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +70 -0
- data/DEVELOPMENT.md +42 -53
- data/README.md +728 -536
- data/lib/cuprum.rb +12 -6
- data/lib/cuprum/built_in.rb +3 -1
- 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/command.rb +37 -59
- data/lib/cuprum/command_factory.rb +50 -24
- data/lib/cuprum/currying.rb +79 -0
- data/lib/cuprum/currying/curried_command.rb +116 -0
- data/lib/cuprum/error.rb +44 -10
- data/lib/cuprum/errors.rb +2 -0
- 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/exception_handling.rb +50 -0
- data/lib/cuprum/matcher.rb +90 -0
- data/lib/cuprum/matcher_list.rb +150 -0
- data/lib/cuprum/matching.rb +232 -0
- data/lib/cuprum/matching/match_clause.rb +65 -0
- data/lib/cuprum/middleware.rb +210 -0
- data/lib/cuprum/operation.rb +17 -15
- data/lib/cuprum/processing.rb +10 -14
- data/lib/cuprum/result.rb +2 -4
- data/lib/cuprum/result_helpers.rb +22 -0
- data/lib/cuprum/rspec/be_a_result.rb +10 -1
- data/lib/cuprum/rspec/be_a_result_matcher.rb +5 -7
- data/lib/cuprum/rspec/be_callable.rb +14 -0
- data/lib/cuprum/steps.rb +233 -0
- data/lib/cuprum/utils.rb +3 -1
- data/lib/cuprum/utils/instance_spy.rb +37 -30
- data/lib/cuprum/version.rb +13 -10
- metadata +34 -19
- data/lib/cuprum/chaining.rb +0 -420
@@ -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,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,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,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
|