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
data/README.md
CHANGED
@@ -4,45 +4,41 @@ An opinionated implementation of the Command pattern for Ruby applications. Cupr
|
|
4
4
|
|
5
5
|
It defines the following concepts:
|
6
6
|
|
7
|
-
- [Commands](#
|
8
|
-
- [Operations](#
|
9
|
-
- [Results](#
|
10
|
-
- [Errors](#
|
7
|
+
- [Commands](#Commands) - A function-like object that responds to `#call` and returns a `Result`.
|
8
|
+
- [Operations](#Operations) - A stateful `Command` that wraps and delegates to its most recent `Result`.
|
9
|
+
- [Results](#Results) - An immutable data object with a status (either `:success` or `:failure`), and optional `#value` and/or `#error` objects.
|
10
|
+
- [Errors](#Errors) - Encapsulates a failure state of a command.
|
11
|
+
- [Matchers](#Matchers) - Define handling for results based on status, error, and value.
|
11
12
|
|
12
13
|
## About
|
13
14
|
|
14
|
-
[comment]: # "Status Badges will go here."
|
15
|
-
|
16
15
|
Traditional frameworks such as Rails focus on the objects of your application - the "nouns" such as User, Post, or Item. Using Cuprum or a similar library allows you the developer to make your business logic - the "verbs" such as Create User, Update Post or Ship Item - a first-class citizen of your project. This provides several advantages:
|
17
16
|
|
18
17
|
- **Consistency:** Use the same Commands to underlie controller actions, worker processes and test factories.
|
19
18
|
- **Encapsulation:** Each Command is defined and run in isolation, and dependencies must be explicitly provided to the command when it is initialized or run. This makes it easier to reason about the command's behavior and keep it insulated from changes elsewhere in the code.
|
20
19
|
- **Testability:** Because the logic is extracted from unnecessary context, testing its behavior is much cleaner and easier.
|
21
|
-
- **Composability:** Complex logic such as "find the object with this ID, update it with these attributes, and log the transaction to the reporting service" can be extracted into a series of simple Commands and composed together. The [
|
20
|
+
- **Composability:** Complex logic such as "find the object with this ID, update it with these attributes, and log the transaction to the reporting service" can be extracted into a series of simple Commands and composed together. The [step](#label-Command+Steps) feature allows for complex control flows.
|
22
21
|
- **Reusability:** Logic common to multiple data models or instances in your code, such as "persist an object to the database" or "find all records with a given user and created in a date range" can be refactored into parameterized commands.
|
23
22
|
|
24
|
-
###
|
23
|
+
### Why Cuprum?
|
24
|
+
|
25
|
+
Cuprum allows you to define or extract business logic from models, controllers, jobs or freeform services, and to control the flow of that logic by composing together atomic commands. At its heart, Cuprum relies on three features: commands, results, and control flow using steps.
|
25
26
|
|
26
|
-
|
27
|
-
|
28
|
-
[Dry::Monads](https://dry-rb.org/gems/dry-monads/),
|
29
|
-
[Interactor](https://github.com/collectiveidea/interactor),
|
30
|
-
[Trailblazer](http://trailblazer.to/) Operations,
|
31
|
-
and [Waterfall](https://github.com/apneadiving/waterfall).
|
27
|
+
There are a number of other Ruby libraries and frameworks that provide similar solutions, such as [ActiveInteraction](https://github.com/AaronLasseigne/active_interaction), [Interactor](https://github.com/collectiveidea/interactor), and [Waterfall](https://github.com/apneadiving/waterfall). These libraries may focus on only one aspect (e.g. defining commands or control flow), or include features deliberately omitted from Cuprum such as hooks or callbacks.
|
28
|
+
|
29
|
+
On the opposite end of the scale, frameworks such as [Dry::Monads](https://dry-rb.org/gems/dry-monads/) or [Trailblazer](http://trailblazer.to/) can also provide similar functionality to Cuprum. These frameworks require a larger commitment to use, particularly for a smaller team or on a smaller project, and often use idiosyncratic syntax that requires a steep learning curve. Cuprum is designed to offer a lightweight alternative that should be much more accessible to new developers.
|
32
30
|
|
33
31
|
### Compatibility
|
34
32
|
|
35
|
-
Cuprum is tested against Ruby (MRI) 2.
|
33
|
+
Cuprum is tested against Ruby (MRI) 2.6 through 3.0.
|
36
34
|
|
37
35
|
### Documentation
|
38
36
|
|
39
|
-
Method and class documentation is available courtesy of [RubyDoc](http://www.rubydoc.info/github/sleepingkingstudios/cuprum/master).
|
40
|
-
|
41
37
|
Documentation is generated using [YARD](https://yardoc.org/), and can be generated locally using the `yard` gem.
|
42
38
|
|
43
39
|
### License
|
44
40
|
|
45
|
-
Copyright (c) 2019 Rob Smith
|
41
|
+
Copyright (c) 2019-2021 Rob Smith
|
46
42
|
|
47
43
|
Cuprum is released under the [MIT License](https://opensource.org/licenses/MIT).
|
48
44
|
|
@@ -54,11 +50,315 @@ To report a bug or submit a feature request, please use the [Issue Tracker](http
|
|
54
50
|
|
55
51
|
To contribute code, please fork the repository, make the desired updates, and then provide a [Pull Request](https://github.com/sleepingkingstudios/cuprum/pulls). Pull requests must include appropriate tests for consideration, and all code must be properly formatted.
|
56
52
|
|
57
|
-
###
|
53
|
+
### Code of Conduct
|
54
|
+
|
55
|
+
Please note that the `Cuprum` project is released with a [Contributor Code of Conduct](https://github.com/sleepingkingstudios/cuprum/blob/master/CODE_OF_CONDUCT.md). By contributing to this project, you agree to abide by its terms.
|
56
|
+
|
57
|
+
## Getting Started
|
58
|
+
|
59
|
+
Let's take a look at using Cuprum to define some business logic. Consider the following case study: we are defining an API for a lending library. We'll start by looking at our core models:
|
60
|
+
|
61
|
+
- A `Patron` is a user who can borrow books from the library.
|
62
|
+
- A `Title` represents a book, of which the library may have one or many copies.
|
63
|
+
- A `PhysicalBook` represents one specific copy of a book. Each `PhysicalBook` belongs to a `Title`, and each `Title` can have zero, one, or many `PhysicalBook`s. A given `PhysicalBook` may or may not be available to lend out (borrowed by a patron, missing, or damaged).
|
64
|
+
- A `BookLoan` indicates that a specific `PhysicalBook` is either being held for or checked out by a `Patron`.
|
65
|
+
|
66
|
+
Some books are more popular than others, so library patrons have asked for a way to reserve a book so they can borrow it when a copy becomes available. We could build this feature in the traditional Rails fashion, but the logic is a bit more complicated and our controller will get kind of messy. Let's try building the logic using commands instead. We've already built our new model:
|
67
|
+
|
68
|
+
- A `BookReservation` indicates that a `Patron` is waiting for the next available copy of a `Title`. Whenever the next `PhysicalBook` is available, then the oldest `BookReservation` will convert into a `BookLoan`.
|
69
|
+
|
70
|
+
Here is the logic required to fulfill a reserve book request:
|
71
|
+
|
72
|
+
- Validate the `Patron` making the request, based on the `patron_id` API parameter.
|
73
|
+
- Does the patron exist?
|
74
|
+
- Is the patron active?
|
75
|
+
- Does the patron have unpaid fines?
|
76
|
+
- Validate the `Title` requested, based on the `title_id` API parameter.
|
77
|
+
- Does the book exist in the system?
|
78
|
+
- Are there any physical copies of the book in the system?
|
79
|
+
- Are all of the physical books checked out?
|
80
|
+
- If so, we create a `BookReservation` for the `Title` and the `Patron`.
|
81
|
+
- If not, we create a `BookLoan` for a `PhysicalBook` and the `Patron`.
|
82
|
+
|
83
|
+
Let's get started by handling the `Patron` validation.
|
84
|
+
|
85
|
+
```ruby
|
86
|
+
class FindValidPatron < Cuprum::Command
|
87
|
+
private
|
88
|
+
|
89
|
+
def check_active(patron)
|
90
|
+
return if patron.active?
|
91
|
+
|
92
|
+
failure(Cuprum::Error.new(message: "Patron #{patron.id} is not active"))
|
93
|
+
end
|
94
|
+
|
95
|
+
def check_unpaid_fines(patron)
|
96
|
+
return unless patron.unpaid_fines.empty?
|
97
|
+
|
98
|
+
failure(Cuprum::Error.new(message: "Patron #{patron_id} has unpaid fines"))
|
99
|
+
end
|
100
|
+
|
101
|
+
def find_patron(patron_id)
|
102
|
+
Patron.find(patron_id)
|
103
|
+
rescue ActiveRecord::RecordNotFound
|
104
|
+
failure(Cuprum::Error.new(message: "Unable to find patron #{patron_id}"))
|
105
|
+
end
|
106
|
+
|
107
|
+
def process(patron_id)
|
108
|
+
patron = step { find_patron(patron_id) }
|
109
|
+
|
110
|
+
step { check_active(patron) }
|
111
|
+
|
112
|
+
step { check_unpaid_fines(patron) }
|
113
|
+
|
114
|
+
success(patron)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
```
|
118
|
+
|
119
|
+
There's a lot going on there, so let's dig in. We start by defining a subclass of `Cuprum::Command`. Each command must define a `#process` method, which implements the business logic of the command. In our case, `#process` is a method that takes one argument (the `patron_id`) and defines a series of steps.
|
120
|
+
|
121
|
+
Steps are a key feature of Cuprum that allows managing control flow through a command. Each `step` has a code block, which can return either a `Cuprum::Result` (either passing or failing) or any Ruby object. If the block returns an object or a passing result, the step passes and returns the object or the result value. However, if the block returns a failing result, then the step fails and halts execution of the command, which immediately returns the failing result.
|
122
|
+
|
123
|
+
In our `FindValidPatron` command, we are defining three steps to run in sequence. This allows us to eschew conditional logic - we don't need to assert that a Patron exists before checking whether they are active, because the `step` flow handles that automatically. Looking at the first line in `#process`, we also see that a passing `step` returns the *value* of the result, rather than the result itself - there's no need for an explicit call to `result.value`.
|
124
|
+
|
125
|
+
Finally, `Cuprum::Command` defines some helper methods. Each of our three methods includes a `failure()` call. This is a helper method that wraps the given error in a `Cuprum::Result` with status: `:failure`. Likewise, the final line in `#process` has a `success()` call, which wraps the value in a result with status: `:success`.
|
126
|
+
|
127
|
+
Let's move on to finding and validating the `Title`.
|
128
|
+
|
129
|
+
```ruby
|
130
|
+
class FindValidTitle < Cuprum::Command
|
131
|
+
private
|
132
|
+
|
133
|
+
def find_title(title_id)
|
134
|
+
Title.find(title_id)
|
135
|
+
rescue ActiveRecord::RecordNotFound
|
136
|
+
failure(Cuprum::Error.new(message: "Unable to find title #{title_id}"))
|
137
|
+
end
|
138
|
+
|
139
|
+
def has_physical_copies?(title)
|
140
|
+
return unless title.physical_books.empty?
|
141
|
+
|
142
|
+
failure(Cuprum::Error.new(message: "No copies of title #{title_id}"))
|
143
|
+
end
|
144
|
+
|
145
|
+
def process(title_id)
|
146
|
+
title = step { find_title(title_id) }
|
147
|
+
|
148
|
+
step { has_physical_copies?(title) }
|
149
|
+
|
150
|
+
success(title)
|
151
|
+
end
|
152
|
+
end
|
153
|
+
```
|
154
|
+
|
155
|
+
This command is pretty similar to the `FindValidPatron` command. We define a `#process` method that has a few steps, each of which delegates to a helper method. Note that we have a couple of different interaction types here. The `#find_title` method captures exception handling and translates it into a Cuprum result, while the `#has_physical_copies?` method handles conditional logic. We can also see using the first `step` in the `#process` method to easily transition from Cuprum into plain Ruby.
|
156
|
+
|
157
|
+
We've captured some of our logic in sub-commands - let's see what it looks like putting it all together.
|
158
|
+
|
159
|
+
```ruby
|
160
|
+
class LoanOrReserveTitle < Cuprum::Command
|
161
|
+
private
|
162
|
+
|
163
|
+
def available_copies?(title)
|
164
|
+
title.physical_books.any?(&:available?)
|
165
|
+
end
|
166
|
+
|
167
|
+
def loan_book(patron:, title:)
|
168
|
+
physical_book = title.physical_books.select(&:available?).first
|
169
|
+
loan = BookLoan.new(loanable: physical_book, patron: patron)
|
170
|
+
|
171
|
+
if loan.valid?
|
172
|
+
loan.save
|
173
|
+
|
174
|
+
success(loan)
|
175
|
+
else
|
176
|
+
message = "Unable to loan title #{title.id}:" \
|
177
|
+
" #{reservation.errors.full_messages.join(' ')}"
|
178
|
+
error = Cuprum::Error.new(message: message)
|
179
|
+
|
180
|
+
failure(error)
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
def process(title_id:, patron_id:)
|
185
|
+
patron = step { FindValidPatron.new.call(patron_id) }
|
186
|
+
title = step { FindValidTitle.new.call(title_id) }
|
187
|
+
|
188
|
+
if available_copies?(title)
|
189
|
+
loan_book(patron: patron, title: title)
|
190
|
+
else
|
191
|
+
reserve_title(patron: patron, title: title)
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
def reserve_title(patron:, title:)
|
196
|
+
reservation = BookReservation.new(patron: patron, title: title)
|
197
|
+
|
198
|
+
if reservation.valid?
|
199
|
+
reservation.save
|
200
|
+
|
201
|
+
success(reservation)
|
202
|
+
else
|
203
|
+
message = "Unable to reserve title #{title.id}:" \
|
204
|
+
" #{reservation.errors.full_messages.join(' ')}"
|
205
|
+
error = Cuprum::Error.new(message: message)
|
206
|
+
|
207
|
+
failure(error)
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
211
|
+
```
|
212
|
+
|
213
|
+
This command pulls everything together. Instead of using helper methods to power our steps, we are instead using our previously defined commands.
|
214
|
+
|
215
|
+
Through the magic of composition, each of the checks we defined in our prior commands is used to gate the control flow - the patron must exist, be active and have no unpaid fines, and the book must exist and have physical copies. If any of those steps fail, the command will halt execution and return the relevant error. Conversely, we're able to encapsulate that logic - reading through `ReserveBook`, we don't need to know the details of what makes a valid patron or book (but if we do need to look into things, we know right where that logic lives and how it was structured).
|
216
|
+
|
217
|
+
Finally, we're using plain old Ruby conditionals to determine whether to reserve the book or add the patron to a wait list. Cuprum is a powerful tool, but you don't have to use it for everything - it's specifically designed to be easy to move back and forth between Cuprum and plain Ruby. We could absolutely define a `HasAvailableCopies` command, but we don't have to.
|
218
|
+
|
219
|
+
### Using The Command
|
220
|
+
|
221
|
+
We've defined our `LoanOrReserveTitle` command. How can we put it to work?
|
222
|
+
|
223
|
+
```ruby
|
224
|
+
command = LoanOrReserveTitle.new
|
225
|
+
|
226
|
+
# With invalid parameters.
|
227
|
+
result = command.call(patron_id: 1_000, title_id: 0)
|
228
|
+
result.status #=> :failure
|
229
|
+
result.success? #=> false
|
230
|
+
result.error #=> A Cuprum::Error with message "Unable to find patron 1000"
|
231
|
+
|
232
|
+
# With valid parameters.
|
233
|
+
result = command.call(patron_id: 0, title_id: 0)
|
234
|
+
result.status #=> :success
|
235
|
+
result.success? #=> true
|
236
|
+
result.value #=> An instance of BookReservation or WaitingListReservation.
|
237
|
+
```
|
238
|
+
|
239
|
+
Using a `Cuprum` command is simple:
|
240
|
+
|
241
|
+
First, instantiate the command. In our case, we haven't defined any constructor parameters, but other commands might. For example, a `SearchRecords` command might take a `record_class` parameter to specify which model class to search.
|
242
|
+
|
243
|
+
Second, call the command using the `#call` method. Here, we are passing in `book_id` and `patron_id` keywords. Internally, the command is delegating to the `#process` method we defined (with some additional logic around handling `step`s and ensuring that a result object is returned).
|
244
|
+
|
245
|
+
The return value of `#call` will always be a `Cuprum::Result`. Each result has the following properties:
|
246
|
+
|
247
|
+
- A `#status`, either `:success` or `:failure`. Also defines corresponding helper methods `#success?` and `#failure?`.
|
248
|
+
- A `#value`. By convention, most successful results will have a non-`nil` value, such as the records returned by a query.
|
249
|
+
- An `#error`. Each failing result should have a non-`nil` error. Using an instance of `Cuprum::Error` or a subclass is strongly recommended, but a result error could be a simple message or other errors object.
|
58
250
|
|
59
|
-
|
251
|
+
In rare cases, a result may have both a value and an error, such as the result for a partial query.
|
60
252
|
|
61
|
-
|
253
|
+
Now that we know how to use a command, how can we integrate it into our application? Our original use case is defining an API, so let's build a controller action.
|
254
|
+
|
255
|
+
```ruby
|
256
|
+
class ReservationsController
|
257
|
+
def create
|
258
|
+
command = LoanOrReserveTitle.new
|
259
|
+
result = command.call(patron_id: patron_id, title_id: title_id)
|
260
|
+
|
261
|
+
if result.failure?
|
262
|
+
render json: { ok: false, message: result.error.message }
|
263
|
+
elsif result.value.is_a?(BookReservation)
|
264
|
+
render json: {
|
265
|
+
ok: true,
|
266
|
+
message: "You've been added to the wait list."
|
267
|
+
}
|
268
|
+
else
|
269
|
+
render json: {
|
270
|
+
ok: true,
|
271
|
+
message: 'Your book is waiting at your local library!'
|
272
|
+
}
|
273
|
+
end
|
274
|
+
end
|
275
|
+
|
276
|
+
private
|
277
|
+
|
278
|
+
def patron_id
|
279
|
+
params.require(:patron_id)
|
280
|
+
end
|
281
|
+
|
282
|
+
def title_id
|
283
|
+
params.require(:title_id)
|
284
|
+
end
|
285
|
+
end
|
286
|
+
```
|
287
|
+
|
288
|
+
All of the complexity of the business logic is encapsulated in the command definition - all the controller needs to do is call the command and check the result.
|
289
|
+
|
290
|
+
### Next Steps
|
291
|
+
|
292
|
+
We've defined a command to encapsulate our business logic, and we've incorporated that command into our application. Where can we go from here?
|
293
|
+
|
294
|
+
One path forward is extracting out more of the logic into commands. Looking back over our code, we're relying heavily on some of the pre-existing methods on our models. Extracting this logic lets us simplify our models.
|
295
|
+
|
296
|
+
We can also use Cuprum to reduce redundancy. Take another look at `LoanOrReserveTitle` - the `#loan_book` and `#reserve_title` helper methods look pretty similar. Both methods take a set of attributes, build a record, validate the record, and then save the record to the database. We can build a command that implements this behavior for any record class.
|
297
|
+
|
298
|
+
```ruby
|
299
|
+
class InvalidRecordError < Cuprum::Error
|
300
|
+
def initialize(errors:, message: nil)
|
301
|
+
@errors = errors
|
302
|
+
|
303
|
+
super(message: generate_message(message))
|
304
|
+
end
|
305
|
+
|
306
|
+
attr_reader :errors
|
307
|
+
|
308
|
+
private
|
309
|
+
|
310
|
+
def generate_message(message)
|
311
|
+
"#{message}: #{errors.full_messages.join(' ')}"
|
312
|
+
end
|
313
|
+
end
|
314
|
+
|
315
|
+
class CreateRecord
|
316
|
+
def initialize(record_class:, short_message: nil)
|
317
|
+
@record_class = record_class
|
318
|
+
@short_message = short_message
|
319
|
+
end
|
320
|
+
|
321
|
+
attr_reader :record_class
|
322
|
+
|
323
|
+
def short_message
|
324
|
+
@short_message ||= "create #{record_class_name}"
|
325
|
+
end
|
326
|
+
|
327
|
+
private
|
328
|
+
|
329
|
+
def process(attributes:)
|
330
|
+
record = record_class.new(attributes)
|
331
|
+
|
332
|
+
step { validate_record(record) }
|
333
|
+
|
334
|
+
record.save
|
335
|
+
|
336
|
+
success(record)
|
337
|
+
end
|
338
|
+
|
339
|
+
def record_class_name
|
340
|
+
record_class.name.split('::').last.underscore.tr('_', ' ')
|
341
|
+
end
|
342
|
+
|
343
|
+
def validate_record(record)
|
344
|
+
return if record.valid?
|
345
|
+
|
346
|
+
error = InvalidRecordError.new(
|
347
|
+
errors: record.errors,
|
348
|
+
message: "Unable to #{short_message}"
|
349
|
+
)
|
350
|
+
failure(error)
|
351
|
+
end
|
352
|
+
end
|
353
|
+
```
|
354
|
+
|
355
|
+
This command is a little more advanced than the ones we've built previously. We start by defining a constructor for the command. This allows us to customize the behavior of the command for each use case, in this case specifying what type of record we are building. We continue using steps to manage control flow and handle errors, and helper methods to keep the `#process` method clean and readable. In a production-ready version of this command, we would probably add additional steps to encompass building the record (which can fail given invalid attribute names) and persisting the record to the database (which can fail even for valid records due to database constraints or unavailable connections).
|
356
|
+
|
357
|
+
We're also defining a custom error class, which gives us three benefits. First, it allows us to move some of our presentation logic (the error message) out of the command itself. Second, it lets us pass additional context with the error, in this case the `errors` object for the invalid record object. Third, an error class gives us a method to identify what kind of error occurred.
|
358
|
+
|
359
|
+
The latter two are particularly important when handling errors returned by a failing command. For example, an API response for a failed validation might include a JSON object serializing the validation errors. Likewise, the application should have different responses to an `InvalidSession` error (redirect to a login page) compared to a `BookNotFound` error (display a message and return to book selection) or a `PatronUnpaidFines` error (show a link to pay outstanding fines). Using custom error classes allows the application to adapt its behavior based on the type of failure, either with a conventional Ruby conditional or `case` statement, or by using a `Cuprum::Matcher`.
|
360
|
+
|
361
|
+
## Reference
|
62
362
|
|
63
363
|
### Commands
|
64
364
|
|
@@ -68,8 +368,6 @@ Commands are the core feature of Cuprum. In a nutshell, each `Cuprum::Command` i
|
|
68
368
|
|
69
369
|
Each Command implements a `#call` method that wraps your defined business logic and returns an instance of `Cuprum::Result`. The result has a status (either `:success` or `:failure`), and may have a `#value` and/or an `#error` object. For more details about Cuprum::Result, [see below](#label-Results).
|
70
370
|
|
71
|
-
[Class Documentation](http://www.rubydoc.info/github/sleepingkingstudios/cuprum/master/Cuprum%2FCommand)
|
72
|
-
|
73
371
|
#### Defining Commands
|
74
372
|
|
75
373
|
The recommended way to define commands is to create a subclass of `Cuprum::Command` and override the `#process` method.
|
@@ -267,7 +565,7 @@ multiply_command.call(operands: [3, 3])
|
|
267
565
|
#=> returns a result with value 9
|
268
566
|
```
|
269
567
|
|
270
|
-
|
568
|
+
#### Composing Commands
|
271
569
|
|
272
570
|
Because Cuprum::Command instances are proper objects, they can be composed like any other object. For example, we could define some basic mathematical operations by composing commands:
|
273
571
|
|
@@ -328,7 +626,7 @@ add_two_command.call(8).value #=> 10
|
|
328
626
|
|
329
627
|
You can achieve even more powerful composition by passing in a command as an argument to a method, or by creating a method that returns a command.
|
330
628
|
|
331
|
-
|
629
|
+
##### Commands As Arguments
|
332
630
|
|
333
631
|
Since commands are objects, they can be passed in as arguments to a method or to another command. For example, consider a command that calls another command a given number of times:
|
334
632
|
|
@@ -380,7 +678,7 @@ end
|
|
380
678
|
|
381
679
|
This pattern is also useful for testing. When writing specs for the FulfillOrder command, simply pass in a mock double as the delivery command. This removes any need to stub out the implementation of whatever shipping method is used (or worse, calls to external services).
|
382
680
|
|
383
|
-
|
681
|
+
##### Commands As Returned Values
|
384
682
|
|
385
683
|
We can also return commands as an object from a method call or from another command. One use case for this is the Abstract Factory pattern.
|
386
684
|
|
@@ -410,7 +708,7 @@ Notice that our factory includes error handling - if the user does not have a va
|
|
410
708
|
|
411
709
|
The [Command Factory](#label-Command+Factories) defined by Cuprum is another example of using the Abstract Factory pattern to return command instances. One use case for a command factory would be defining CRUD operations for data records. Depending on the class or the type of record passed in, the factory could return a generic command or a specific command tied to that specific record type.
|
412
710
|
|
413
|
-
|
711
|
+
#### Command Steps
|
414
712
|
|
415
713
|
Separating out business logic into commands is a powerful tool, but it does come with some overhead, particularly when checking whether a result is passing, or when converting between results and values. When a process has many steps, each of which can fail or return a value, this can result in a lot of boilerplate.
|
416
714
|
|
@@ -526,43 +824,7 @@ result.success? #=> true
|
|
526
824
|
result.value #=> an instance of BookReservation
|
527
825
|
```
|
528
826
|
|
529
|
-
|
530
|
-
|
531
|
-
Steps can also be defined as method calls. Instead of providing a block to `#step`, provide the name of the method as the first argument, either as a symbol or as a string. Any subsequent arguments, keywords, or a block is passed to the method when it is called.
|
532
|
-
|
533
|
-
A step defined with a method behaves the same as a step defined with a block. If the method returns a successful result, then `#step` will return the value of the result. If the method returns a failing result, then `#step` will throw `:cuprum_failed_result` and the result, to be caught by the `#process` method or the containing `#steps` block.
|
534
|
-
|
535
|
-
We can use this to rewrite our `ReserveBookByTitle` command to use methods:
|
536
|
-
|
537
|
-
```ruby
|
538
|
-
class ReserveBookByTitle < Cuprum::Result
|
539
|
-
private
|
540
|
-
|
541
|
-
def check_user_status(user)
|
542
|
-
CheckUserStatus.new(user)
|
543
|
-
end
|
544
|
-
|
545
|
-
def create_book_reservation(book:, user:)
|
546
|
-
CreateBookReservation.new(book: book, user: user)
|
547
|
-
end
|
548
|
-
|
549
|
-
def find_book_by_title(title)
|
550
|
-
FindBookByTitle.new.call(title)
|
551
|
-
end
|
552
|
-
|
553
|
-
def process(title:, user:)
|
554
|
-
step :check_user_status, user
|
555
|
-
|
556
|
-
book = step :find_book_by_title, title
|
557
|
-
|
558
|
-
create_book_reservation, book: book, user: user
|
559
|
-
end
|
560
|
-
end
|
561
|
-
```
|
562
|
-
|
563
|
-
In this case, our methods simply delegate to our previously defined commands. However, a more complex example could include other logic in each method, or even a sequence of steps defining subtasks for the method. The only requirement is that the method returns a result. You can use the `#success` helpers to wrap a non-result value, or the `#failure` helper to generate a failing result.
|
564
|
-
|
565
|
-
#### Using Steps Outside Of Commands
|
827
|
+
##### Using Steps Outside Of Commands
|
566
828
|
|
567
829
|
Steps can also be used outside of a command. For example, a controller action might define a sequence of steps to run when the corresponding endpoint is called.
|
568
830
|
|
@@ -625,12 +887,43 @@ A few things to note about this example. First, we have a couple of examples of
|
|
625
887
|
|
626
888
|
You can define even more complex logic by defining multiple `#steps` blocks. Each block represents a series of tasks that will terminate on the first failure. Steps blocks can even be nested in one another, or inside a `#process` method.
|
627
889
|
|
890
|
+
#### Handling Exceptions
|
891
|
+
|
892
|
+
require 'cuprum/exception_handling'
|
893
|
+
|
894
|
+
Cuprum defines a utility module to rescue uncaught exceptions when calling a command.
|
895
|
+
|
896
|
+
```ruby
|
897
|
+
class UnsafeCommand < Cuprum::Command
|
898
|
+
private
|
899
|
+
|
900
|
+
def process
|
901
|
+
raise 'Something went wrong.'
|
902
|
+
end
|
903
|
+
end
|
904
|
+
|
905
|
+
class SafeCommand < UnsafeCommand
|
906
|
+
include Cuprum::ExceptionHandling
|
907
|
+
end
|
908
|
+
|
909
|
+
UnsafeCommand.new.call
|
910
|
+
#=> raises a StandardError
|
911
|
+
|
912
|
+
result = SafeCommand.new.call
|
913
|
+
#=> a Cuprum::Result
|
914
|
+
result.error
|
915
|
+
#=> a Cuprum::Errors::UncaughtException error.
|
916
|
+
result.error.message
|
917
|
+
#=> 'uncaught exception in SafeCommand -' \
|
918
|
+
' StandardError: Something went wrong.'
|
919
|
+
```
|
920
|
+
|
921
|
+
Exception handling is *not* included by default - add `include Cuprum::ExceptionHandling` to your command classes to use this feature.
|
922
|
+
|
628
923
|
### Results
|
629
924
|
|
630
925
|
require 'cuprum'
|
631
926
|
|
632
|
-
[Class Documentation](http://www.rubydoc.info/github/sleepingkingstudios/cuprum/master/Cuprum%2FResult)
|
633
|
-
|
634
927
|
A `Cuprum::Result` is a data object that encapsulates the result of calling a Cuprum command. Each result has a `#value`, an `#error` object (defaults to `nil`), and a `#status` (either `:success` or `:failure`, and accessible via the `#success?` and `#failure?` predicates).
|
635
928
|
|
636
929
|
```ruby
|
@@ -700,38 +993,52 @@ result.failure? #=> false
|
|
700
993
|
|
701
994
|
require 'cuprum/error'
|
702
995
|
|
703
|
-
|
704
|
-
|
705
|
-
A `Cuprum::Error` encapsulates a specific failure state of a Command. Each Error has a `#message` property, which defaults to nil.
|
996
|
+
A `Cuprum::Error` encapsulates a specific failure state of a Command. Each Error has a `#message` property which defaults to nil. Each Error also has a `#type` property which is determined by the Error class or subclass, although it can be overridden by passing a `:type` parameter to the constructor.
|
706
997
|
|
707
998
|
```ruby
|
708
999
|
error = Cuprum::Error.new
|
709
1000
|
error.message => # nil
|
1001
|
+
error.type => 'cuprum.error'
|
710
1002
|
|
711
1003
|
error = Cuprum::Error.new(message: 'Something went wrong.')
|
712
1004
|
error.message => # 'Something went wrong.'
|
1005
|
+
|
1006
|
+
error = Cuprum::Error.new(type: 'example.custom_type')
|
1007
|
+
error.type => 'example.custom_type'
|
713
1008
|
```
|
714
1009
|
|
715
1010
|
Each application should define its own failure states as errors. For example, a typical web application might define the following errors:
|
716
1011
|
|
717
1012
|
```ruby
|
718
1013
|
class NotFoundError < Cuprum::Error
|
1014
|
+
TYPE = 'example.errors.not_found'
|
1015
|
+
|
719
1016
|
def initialize(resource:, resource_id:)
|
720
1017
|
@resource = resource
|
721
1018
|
@resource_id = resource_id
|
722
1019
|
|
723
|
-
super(
|
1020
|
+
super(
|
1021
|
+
message: "#{resource} not found with id #{resource_id}",
|
1022
|
+
resource: resource,
|
1023
|
+
resource_id: resource_id
|
1024
|
+
)
|
724
1025
|
end
|
725
1026
|
|
726
1027
|
attr_reader :resource, :resource_id
|
727
1028
|
end
|
728
1029
|
|
729
1030
|
class ValidationError < Cuprum::Error
|
1031
|
+
TYPE = 'example.errors.validation'
|
1032
|
+
|
730
1033
|
def initialize(resource:, errors:)
|
731
1034
|
@resource = resource
|
732
1035
|
@errors = errors
|
733
1036
|
|
734
|
-
super(
|
1037
|
+
super(
|
1038
|
+
errors: errors,
|
1039
|
+
message: "#{resource} was invalid",
|
1040
|
+
resource: resource
|
1041
|
+
)
|
735
1042
|
end
|
736
1043
|
|
737
1044
|
attr_reader :resource, :errors
|
@@ -740,12 +1047,182 @@ end
|
|
740
1047
|
|
741
1048
|
It is optional but recommended to use a `Cuprum::Error` when returning a failed result from a command.
|
742
1049
|
|
1050
|
+
#### Comparing Errors
|
1051
|
+
|
1052
|
+
There are circumstances when it is useful to compare Error objects, such as when writing tests to specify the failure states of a command. To accommodate this, you can pass additional properties to `Cuprum::Error.new` (or to `super` when defining a subclass). These "comparable properties", plus the type and message (if any), are used to compare the errors.
|
1053
|
+
|
1054
|
+
An instance of `Cuprum::Error` is equal to another (using the `#==` equality comparison) if and only if the two errors have the same `class` and the two errors have the same comparable properties.
|
1055
|
+
|
1056
|
+
```ruby
|
1057
|
+
red = Cuprum::Error.new(message: 'wrong color', color: 'red')
|
1058
|
+
blue = Cuprum::Error.new(message: 'wrong color', color: 'blue')
|
1059
|
+
crimson = Cuprum::Error.new(message: 'wrong color', color: 'red')
|
1060
|
+
|
1061
|
+
red == blue
|
1062
|
+
#=> false
|
1063
|
+
|
1064
|
+
red == crimson
|
1065
|
+
#=> true
|
1066
|
+
```
|
1067
|
+
|
1068
|
+
This can be particularly important when defining Error subclasses. By passing the constructor parameters to `super`, below, we will be able to compare different instances of the `NotFoundError`. The errors will only be equal if they have the same message, resource, and resource_id properties.
|
1069
|
+
|
1070
|
+
```ruby
|
1071
|
+
class NotFoundError < Cuprum::Error
|
1072
|
+
def initialize(resource:, resource_id:)
|
1073
|
+
@resource = resource
|
1074
|
+
@resource_id = resource_id
|
1075
|
+
|
1076
|
+
super(
|
1077
|
+
message: "#{resource} not found with id #{resource_id}",
|
1078
|
+
resource: resource,
|
1079
|
+
resource_id: resource_id,
|
1080
|
+
)
|
1081
|
+
end
|
1082
|
+
|
1083
|
+
attr_reader :resource, :resource_id
|
1084
|
+
end
|
1085
|
+
```
|
1086
|
+
|
1087
|
+
Finally, by overriding the `#comparable_properties` method, you can customize how Error instances are compared.
|
1088
|
+
|
1089
|
+
```ruby
|
1090
|
+
class WrongColorError < Cuprum::Error
|
1091
|
+
def initialize(color:, shape:)
|
1092
|
+
super(message: "the #{shape} is the wrong color")
|
1093
|
+
|
1094
|
+
@color = color
|
1095
|
+
@shape = shape
|
1096
|
+
end
|
1097
|
+
|
1098
|
+
attr_reader :color
|
1099
|
+
|
1100
|
+
protected
|
1101
|
+
|
1102
|
+
def comparable_properties
|
1103
|
+
{ color: color }
|
1104
|
+
end
|
1105
|
+
end
|
1106
|
+
```
|
1107
|
+
|
1108
|
+
#### Serializing Errors
|
1109
|
+
|
1110
|
+
Some use cases require serializing error objects - for example, rendering an error response as JSON. To handle this, `Cuprum::Error` defines an `#as_json` method, which generates a representation of the error as a `Hash` with `String` keys. By default, this includes the `#type` and `#message` (if any) as well as an empty `:data` Hash.
|
1111
|
+
|
1112
|
+
Subclasses can override this behavior to include additional information in the `:data` Hash, which should always use `String` keys and have values composed of basic types and data structures. For example, if an error is passed a `Class`, consider serializing the name of the class to `:data`.
|
1113
|
+
|
1114
|
+
```ruby
|
1115
|
+
error = Cuprum::Error.new
|
1116
|
+
error.as_json #=> { data: {}, message: nil, type: 'cuprum.error' }
|
1117
|
+
|
1118
|
+
error = Cuprum::Error.new(message: 'Something went wrong.')
|
1119
|
+
error.as_json #=> { data: {}, message: 'Something went wrong.', type: 'cuprum.error' }
|
1120
|
+
|
1121
|
+
error = Cuprum::Error.new(type: 'example.custom_error')
|
1122
|
+
error.as_json #=> { data: {}, message: nil, type: 'example.custom_error' }
|
1123
|
+
|
1124
|
+
class ModuleError < Cuprum::Error
|
1125
|
+
TYPE = 'example.module_error'
|
1126
|
+
|
1127
|
+
def initialize(actual:)
|
1128
|
+
@actual = actual
|
1129
|
+
message = "Expected a Module, but #{actual.name} is a Class"
|
1130
|
+
|
1131
|
+
super(actual: actual, message: message)
|
1132
|
+
end
|
1133
|
+
|
1134
|
+
attr_reader :actual
|
1135
|
+
|
1136
|
+
private
|
1137
|
+
|
1138
|
+
def as_json_data
|
1139
|
+
{ actual: actual.name }
|
1140
|
+
end
|
1141
|
+
end
|
1142
|
+
|
1143
|
+
error = ModuleError.new(actual: String)
|
1144
|
+
error.as_json #=>
|
1145
|
+
# {
|
1146
|
+
# data: { actual: 'String' },
|
1147
|
+
# message: 'Expected a Module, but String is a Class',
|
1148
|
+
# type: 'example.module_error'
|
1149
|
+
# }
|
1150
|
+
```
|
1151
|
+
|
1152
|
+
**Important Note:** Be careful when serializing error data - this may expose sensitive information or internal details about your system that you don't want to display to users. Recommended practice is to have a whitelist of serializable errors; all other errors will display a generic error message instead.
|
1153
|
+
|
1154
|
+
### Middleware
|
1155
|
+
|
1156
|
+
```ruby
|
1157
|
+
require 'cuprum/middleware'
|
1158
|
+
```
|
1159
|
+
|
1160
|
+
A middleware command wraps the execution of another command, allowing the developer to compose functionality without an explicit wrapper command. Because the middleware is responsible for calling the wrapped command, it has control over when that command is called, with what parameters, and how the command result is handled.
|
1161
|
+
|
1162
|
+
To use middleware, start by defining a middleware command. This can either be a class that includes Cuprum::Middleware, or a command instance that extends Cuprum::Middleware. Each middleware command's #process method takes as its first argument the wrapped command. By convention, any additional arguments and any keywords or a block are passed to the wrapped command, but some middleware will override ths behavior.
|
1163
|
+
|
1164
|
+
```ruby
|
1165
|
+
class ExampleCommand < Cuprum::Command
|
1166
|
+
private def process(**options)
|
1167
|
+
return failure(options[:error]) if options[:error]
|
1168
|
+
|
1169
|
+
"Options: #{options.inspect}"
|
1170
|
+
end
|
1171
|
+
end
|
1172
|
+
|
1173
|
+
class LoggingMiddleware < Cuprum::Command
|
1174
|
+
include Cuprum::Middleware
|
1175
|
+
|
1176
|
+
# The middleware injects a logging step before the wrapped command is
|
1177
|
+
# called. Notice that this middleware is generic, and can be used with
|
1178
|
+
# virtually any other command.
|
1179
|
+
private def process(next_command, *args, **kwargs)
|
1180
|
+
Logger.info("Calling command #{next_command.class}")
|
1181
|
+
|
1182
|
+
super
|
1183
|
+
end
|
1184
|
+
end
|
1185
|
+
|
1186
|
+
command = Command.new { |**opts| "Called with #{opts.inspect}" }
|
1187
|
+
middleware = LoggingMiddleware.new
|
1188
|
+
result = middleware.call(command, { id: 0 })
|
1189
|
+
#=> logs "Calling command ExampleCommand"
|
1190
|
+
result.value
|
1191
|
+
#=> "Options: { id: 0 }"
|
1192
|
+
```
|
1193
|
+
|
1194
|
+
When defining #process, make sure to either call super or call the wrapped command directly, unless the middleware is specifically intended not to call the wrapped command under those circumstances.
|
1195
|
+
|
1196
|
+
Middleware is powerful because it allows the developer to manipulate the parameters passed to a command, add handling to a result, or even intercept or override the command execution. These are some of the possible use cases for middleware:
|
1197
|
+
|
1198
|
+
- Injecting code before or after a command.
|
1199
|
+
- Changing the parameters passed to a command.
|
1200
|
+
- Adding behavior based on the command result.
|
1201
|
+
- Overriding the command behavior based on the parameters.
|
1202
|
+
|
1203
|
+
```ruby
|
1204
|
+
class AuthenticationMiddleware < Cuprum::Command
|
1205
|
+
include Cuprum::Middleware
|
1206
|
+
|
1207
|
+
# The middleware finds the current user based on the given keywords. If
|
1208
|
+
# a valid user is found, the user is then passed on to the command.
|
1209
|
+
# If a user is not found, then the middleware will immediately halt (due
|
1210
|
+
# to #step) and return the failing result from the authentication
|
1211
|
+
# command.
|
1212
|
+
private def process(next_command, *args, **kwargs)
|
1213
|
+
current_user = step { AuthenticateUser.new.call(**kwargs) }
|
1214
|
+
|
1215
|
+
super(next_command, *args, current_user: current_user, **kwargs)
|
1216
|
+
end
|
1217
|
+
end
|
1218
|
+
```
|
1219
|
+
|
1220
|
+
Middleware is loosely coupled, meaning that one middleware command can wrap any number of other commands. One example would be logging middleware, which could record when a command is called and with what parameters. For a more involved example, consider authorization in a web application. If individual actions are defined as commands, then a single authorization middleware class could wrap each individual action, reducing both the testing burden and the amount of code that must be maintained.
|
1221
|
+
|
743
1222
|
### Operations
|
744
1223
|
|
745
1224
|
require 'cuprum'
|
746
1225
|
|
747
|
-
[Class Documentation](http://www.rubydoc.info/github/sleepingkingstudios/cuprum/master/Cuprum%2FOperation)
|
748
|
-
|
749
1226
|
An Operation is like a Command, but with two key differences. First, an Operation retains a reference to the result object from the most recent time the operation was called, and delegates the methods defined by `Cuprum::Result` to the most recent result. This allows a called Operation to replace a `Cuprum::Result` in any code that expects or returns a result. Second, the `#call` method returns the operation instance, rather than the result itself.
|
750
1227
|
|
751
1228
|
These two features allow developers to simplify logic around calling and using the results of operations, and reduce the need for boilerplate code (particularly when using an operation as part of an existing framework, such as inside of an asynchronous worker or a Rails controller action).
|
@@ -777,13 +1254,235 @@ An operation inherits the `#call` method from Cuprum::Command (see above), and d
|
|
777
1254
|
|
778
1255
|
#### The Operation Mixin
|
779
1256
|
|
780
|
-
[Module Documentation](http://www.rubydoc.info/github/sleepingkingstudios/cuprum/master/Cuprum%2FOperation%2FMixin)
|
781
|
-
|
782
1257
|
The implementation of `Cuprum::Operation` is defined by the `Cuprum::Operation::Mixin` module, which provides the methods defined above. Any command class or instance can be converted to an operation by including (for a class) or extending (for an instance) the operation mixin.
|
783
1258
|
|
784
|
-
###
|
1259
|
+
### Matchers
|
1260
|
+
|
1261
|
+
require 'cuprum/matcher'
|
1262
|
+
|
1263
|
+
A Matcher provides a simple DSL for defining behavior based on a Cuprum result object.
|
1264
|
+
|
1265
|
+
```ruby
|
1266
|
+
matcher = Cuprum::Matcher.new do
|
1267
|
+
match(:failure) { 'Something went wrong' }
|
1268
|
+
|
1269
|
+
match(:success) { 'Ok' }
|
1270
|
+
end
|
1271
|
+
|
1272
|
+
matcher.call(Cuprum::Result.new(status: :failure))
|
1273
|
+
#=> 'Something went wrong'
|
1274
|
+
|
1275
|
+
matcher.call(Cuprum::Result.new(status: :success))
|
1276
|
+
#=> 'Ok'
|
1277
|
+
```
|
1278
|
+
|
1279
|
+
First, the matcher defines possible matches using the `.match` method. This can either be called on a subclass of `Cuprum::Matcher` or by passing a block to the constructor, as above. Each match clause must have the matching status, and a block that is executed when a result matches that clause. The clause can also filter by the result value or error (see Matching Values And Errors, below).
|
1280
|
+
|
1281
|
+
Once the matcher has found a matching clause, it then calls the block in the clause definition. If the block accepts an argument, the result is passed to the block; otherwise, the block is called with no arguments. This allows the match clause to use the error or value of the result.
|
1282
|
+
|
1283
|
+
```ruby
|
1284
|
+
matcher = Cuprum::Matcher.new do
|
1285
|
+
match(:failure) { |result| result.error.message }
|
1286
|
+
end
|
1287
|
+
|
1288
|
+
error = Cuprum::Error.new(message: 'An error has occurred.')
|
1289
|
+
matcher.call(Cuprum::Result.new(error: error))
|
1290
|
+
#=> 'An error has occurred.'
|
1291
|
+
```
|
1292
|
+
|
1293
|
+
If the result does not match any of the clauses, a `Cuprum::Matching::NoMatchError` is raised.
|
1294
|
+
|
1295
|
+
```ruby
|
1296
|
+
matcher = Cuprum::Matcher.new do
|
1297
|
+
match(:success) { :ok }
|
1298
|
+
end
|
1299
|
+
|
1300
|
+
matcher.call(Cuprum::Result.new(status: :failure))
|
1301
|
+
#=> raises Cuprum::Matching::NoMatchError
|
1302
|
+
```
|
1303
|
+
|
1304
|
+
#### Matching Values And Errors
|
1305
|
+
|
1306
|
+
In addition to a status, match clauses can specify the type of the value or error of a matching result. The error or value must be a Class or Module, and the clause will then match only results whose error or value is an instance of the specified Class or Module (or a subclass of the Class).
|
1307
|
+
|
1308
|
+
```ruby
|
1309
|
+
class MagicSmokeError < Cuprum::Error; end
|
1310
|
+
|
1311
|
+
matcher = Cuprum::Matcher.new do
|
1312
|
+
match(:failure) { 'Something went wrong.' }
|
1313
|
+
|
1314
|
+
match(:failure, error: Cuprum::Error) do |result|
|
1315
|
+
"ERROR: #{result.error.message}"
|
1316
|
+
end
|
1317
|
+
|
1318
|
+
match(:failure, error: MagicSmokeError) do
|
1319
|
+
"PANIC: #{result.error.message}"
|
1320
|
+
end
|
1321
|
+
end
|
1322
|
+
|
1323
|
+
matcher.call(Cuprum::Result.new(status: :failure))
|
1324
|
+
#=> 'Something went wrong.'
|
1325
|
+
|
1326
|
+
error = Cuprum::Error.new(message: 'An error has occurred.')
|
1327
|
+
matcher.call(Cuprum::Result.new(error: error)
|
1328
|
+
#=> 'ERROR: An error has occurred.'
|
1329
|
+
|
1330
|
+
error = MagicSmokeError.new(message: 'The magic smoke is escaping.')
|
1331
|
+
matcher.call(Cuprum::Result.new(error: error))
|
1332
|
+
#=> 'PANIC: The magic smoke is escaping.'
|
1333
|
+
```
|
1334
|
+
|
1335
|
+
The matcher will always apply the most specific match clause. In the example above, the result with a `MagicSmokeError` matches all three clauses, but only the final clause is executed.
|
1336
|
+
|
1337
|
+
You can also specify the value of a matching result:
|
1338
|
+
|
1339
|
+
```ruby
|
1340
|
+
matcher = Cuprum::Matcher.new do
|
1341
|
+
match(:success, value: String) { 'a String' }
|
1342
|
+
|
1343
|
+
match(:success, value: Symbol) { 'a Symbol' }
|
1344
|
+
end
|
1345
|
+
|
1346
|
+
matcher.call(Cuprum::Result.new(value: 'Greetings, programs!'))
|
1347
|
+
#=> 'a String'
|
1348
|
+
|
1349
|
+
matcher.call(Cuprum::Result.new(value: :greetings_starfighter))
|
1350
|
+
#=> 'a Symbol'
|
1351
|
+
```
|
1352
|
+
|
1353
|
+
#### Using Matcher Classes
|
1354
|
+
|
1355
|
+
Matcher classes allow you to define custom behavior that can be called as part of the defined match clauses.
|
1356
|
+
|
1357
|
+
```ruby
|
1358
|
+
class LogMatcher < Cuprum::Matcher
|
1359
|
+
match(:failure) { |result| log(:error, result.error.message) }
|
1360
|
+
|
1361
|
+
match(:success) { log(:info, 'Ok') }
|
1362
|
+
|
1363
|
+
def log(level, message)
|
1364
|
+
puts "#{level.upcase}: #{message}"
|
1365
|
+
end
|
1366
|
+
end
|
1367
|
+
|
1368
|
+
matcher = LogMatcher.new
|
1369
|
+
matcher.call(Cuprum::Result.new(status: :success))
|
1370
|
+
#=> prints "INFO: Ok" to STDOUT
|
1371
|
+
```
|
1372
|
+
|
1373
|
+
Match clauses are also inherited by matcher subclasses. Inherited clauses are sorted the same as clauses defined on the matcher directly - the most specific clause is matched first, followed by less specific clauses and finally the generic clause (if any) for that result status.
|
1374
|
+
|
1375
|
+
```ruby
|
1376
|
+
class CustomLogMatcher < Cuprum::Matcher
|
1377
|
+
match(:failure, error: ReallyBadError) do |result|
|
1378
|
+
log(:fatal, result.error.message)
|
1379
|
+
end
|
1380
|
+
end
|
785
1381
|
|
786
|
-
|
1382
|
+
matcher = CustomLogMatcher.new
|
1383
|
+
result = Cuprum::Result.new(error: Cuprum::Error.new('Something went wrong.'))
|
1384
|
+
matcher.call(result)
|
1385
|
+
#=> prints "ERROR: Something went wrong." to STDOUT
|
1386
|
+
|
1387
|
+
result = Cuprum::Result.new(error: ReallyBadError.new('Computer on fire.'))
|
1388
|
+
matcher.call(result)
|
1389
|
+
#=> prints "FATAL: Computer on fire." to STDOUT
|
1390
|
+
```
|
1391
|
+
|
1392
|
+
#### Match Contexts
|
1393
|
+
|
1394
|
+
Match contexts provide an alternative to defining custom matcher classes - instead of defining custom behavior in the matcher itself, the match clauses can be executed in the context of another object.
|
1395
|
+
|
1396
|
+
```ruby
|
1397
|
+
class Inflector
|
1398
|
+
def capitalize(message)
|
1399
|
+
message.split(' ').map(&:capitalize).join(' ')
|
1400
|
+
end
|
1401
|
+
end
|
1402
|
+
|
1403
|
+
matcher = Cuprum::Matcher.new(inflector) do
|
1404
|
+
match(:success) { |result| capitalize(result.value) }
|
1405
|
+
end
|
1406
|
+
matcher.call(Cuprum::Result.new(value: 'greetings starfighter'))
|
1407
|
+
#=> 'Greetings Starfighter'
|
1408
|
+
```
|
1409
|
+
|
1410
|
+
For example, a controller in a web framework might need to define behavior for handling different success and error cases for business logic that is defined as Commands. The controller itself defines methods such as `#render` and `#redirect` - by creating a matcher using the controller as the match context, the matcher can call upon those methods to generate a response.
|
1411
|
+
|
1412
|
+
You can also call an existing matcher with a new context. The `#with_context` method returns a copy of the matcher with the given object set as the match context.
|
1413
|
+
|
1414
|
+
```ruby
|
1415
|
+
matcher = Cuprum::Matcher.new do
|
1416
|
+
match(:success) { |result| capitalize(result.value) }
|
1417
|
+
end
|
1418
|
+
matcher
|
1419
|
+
.with_context(inflector)
|
1420
|
+
.call(Cuprum::Result.new(value: 'greetings starfighter'))
|
1421
|
+
#=> 'Greetings Starfighter'
|
1422
|
+
```
|
1423
|
+
|
1424
|
+
#### Matcher Lists
|
1425
|
+
|
1426
|
+
Matcher lists handle matching a result against an ordered group of matchers.
|
1427
|
+
|
1428
|
+
When given a result, a matcher list will check for the most specific matching clause in each of the matchers. A clause matching both the value and error will match first, followed by a clause matching only the result value or error, and finally a clause matching only the result status will match.
|
1429
|
+
|
1430
|
+
If none of the matchers have a clause that matches the result, a `Cuprum::Matching::NoMatchError` will be raised.
|
1431
|
+
|
1432
|
+
```ruby
|
1433
|
+
generic_matcher = Cuprum::Matcher.new do
|
1434
|
+
match(:failure) { 'generic failure' }
|
1435
|
+
#
|
1436
|
+
match(:failure, error: CustomError) { 'custom failure' }
|
1437
|
+
end
|
1438
|
+
specific_matcher = Cuprum::Matcher.new do
|
1439
|
+
match(:failure, error: Cuprum::Error) { 'specific failure' }
|
1440
|
+
end
|
1441
|
+
matcher_list = Cuprum::MatcherList.new(
|
1442
|
+
[
|
1443
|
+
specific_matcher,
|
1444
|
+
generic_matcher
|
1445
|
+
]
|
1446
|
+
)
|
1447
|
+
|
1448
|
+
generic_matcher = Cuprum::Matcher.new do
|
1449
|
+
match(:failure) { 'generic failure' }
|
1450
|
+
|
1451
|
+
match(:failure, error: CustomError) { 'custom failure' }
|
1452
|
+
end
|
1453
|
+
specific_matcher = Cuprum::Matcher.new do
|
1454
|
+
match(:failure, error: Cuprum::Error) { 'specific failure' }
|
1455
|
+
end
|
1456
|
+
matcher_list = Cuprum::MatcherList.new(
|
1457
|
+
[
|
1458
|
+
specific_matcher,
|
1459
|
+
generic_matcher
|
1460
|
+
]
|
1461
|
+
)
|
1462
|
+
|
1463
|
+
# A failure without an error does not match the first matcher, so the
|
1464
|
+
# matcher list continues on to the next matcher in the list.
|
1465
|
+
result = Cuprum::Result.new(status: :failure)
|
1466
|
+
matcher_list.call(result)
|
1467
|
+
#=> 'generic failure'
|
1468
|
+
|
1469
|
+
# A failure with an error matches the first matcher.
|
1470
|
+
error = Cuprum::Error.new(message: 'Something went wrong.')
|
1471
|
+
result = Cuprum::Result.new(error: error)
|
1472
|
+
matcher_list.call(result)
|
1473
|
+
#=> 'specific failure'
|
1474
|
+
|
1475
|
+
# A failure with an error subclass still matches the first matcher, even
|
1476
|
+
# though the second matcher has a more exact match.
|
1477
|
+
error = CustomError.new(message: 'The magic smoke is escaping.')
|
1478
|
+
result = Cuprum::Result.new(error: error)
|
1479
|
+
matcher_list.call(result)
|
1480
|
+
#=> 'specific failure'
|
1481
|
+
```
|
1482
|
+
|
1483
|
+
One use case for matcher lists would be in defining hierarchies of classes or objects that have matching functionality. For example, a generic controller class might define default success and failure behavior, an included mixin might provide handling for a particular scope of errors, and a specific controller might override the default behavior for a given action. Using a matcher list allows each class or module to define its own behavior as independent matchers, which the matcher list then composes together.
|
1484
|
+
|
1485
|
+
### Command Factories
|
787
1486
|
|
788
1487
|
Commands are powerful and flexible objects, but they do have a few disadvantages compared to traditional service objects which allow the developer to group together related functionality and shared implementation details. To bridge this gap, Cuprum implements the CommandFactory class. Command factories provide a DSL to quickly group together related commands and create context-specific command classes or instances.
|
789
1488
|
|
@@ -1089,8 +1788,6 @@ Cuprum includes a small number of predefined commands and their equivalent opera
|
|
1089
1788
|
|
1090
1789
|
require 'cuprum/built_in/identity_command'
|
1091
1790
|
|
1092
|
-
[Class Documentation](http://www.rubydoc.info/github/sleepingkingstudios/cuprum/master/Cuprum%2FBuiltIn%2FIdentityCommand)
|
1093
|
-
|
1094
1791
|
A pregenerated command that returns the value or result with which it was called.
|
1095
1792
|
|
1096
1793
|
```ruby
|
@@ -1104,8 +1801,6 @@ result.success? #=> true
|
|
1104
1801
|
|
1105
1802
|
require 'cuprum/built_in/identity_operation'
|
1106
1803
|
|
1107
|
-
[Class Documentation](http://www.rubydoc.info/github/sleepingkingstudios/cuprum/master/Cuprum%2FBuiltIn%2FIdentityOperation)
|
1108
|
-
|
1109
1804
|
A pregenerated operation that sets its result to the value or result with which it was called.
|
1110
1805
|
|
1111
1806
|
```ruby
|
@@ -1118,8 +1813,6 @@ operation.success? #=> true
|
|
1118
1813
|
|
1119
1814
|
require 'cuprum/built_in/null_command'
|
1120
1815
|
|
1121
|
-
[Class Documentation](http://www.rubydoc.info/github/sleepingkingstudios/cuprum/master/Cuprum%2FBuiltIn%2FNullCommand)
|
1122
|
-
|
1123
1816
|
A pregenerated command that does nothing when called. Accepts any arguments.
|
1124
1817
|
|
1125
1818
|
```ruby
|
@@ -1133,8 +1826,6 @@ result.success? #=> true
|
|
1133
1826
|
|
1134
1827
|
require 'cuprum/built_in/null_operation'
|
1135
1828
|
|
1136
|
-
[Class Documentation](http://www.rubydoc.info/github/sleepingkingstudios/cuprum/master/Cuprum%2FBuiltIn%2FNullOperation)
|
1137
|
-
|
1138
1829
|
A pregenerated operation that does nothing when called. Accepts any arguments.
|
1139
1830
|
|
1140
1831
|
```ruby
|
@@ -1142,7 +1833,3 @@ operation = Cuprum::BuiltIn::NullOperation.new.call
|
|
1142
1833
|
operation.value #=> nil
|
1143
1834
|
operation.success? #=> true
|
1144
1835
|
```
|
1145
|
-
|
1146
|
-
## Reference
|
1147
|
-
|
1148
|
-
Method and class documentation is available courtesy of [RubyDoc](http://www.rubydoc.info/github/sleepingkingstudios/cuprum/master).
|