cuprum 0.11.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/CODE_OF_CONDUCT.md +132 -0
- data/README.md +350 -55
- data/lib/cuprum/version.rb +4 -4
- metadata +5 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d42abc97f91b8693f2ecd931857ad6818a1d818f6eaad1f3f9999bf948751d7d
|
4
|
+
data.tar.gz: 240587d60e6d7607e4a5bf89819014482847705d6f6c70f941172e58790de11c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: '077659f1208f14487834e90aafa19fc828e31d73533012b9eda8751905cc1383298d89674a9ff86e0e6f248013db1610692f08f2ff83b7ba3b2a47f22d847752'
|
7
|
+
data.tar.gz: 71fbc512da8fb324bb3372bde996bd3fb810fc7aa7b758b59747405da815d66dc16edc90850a390ab65bfc5a67fc195b33217163589d3cc51e0d30ca67c3d9c1
|
data/CODE_OF_CONDUCT.md
ADDED
@@ -0,0 +1,132 @@
|
|
1
|
+
# Contributor Covenant Code of Conduct
|
2
|
+
|
3
|
+
## Our Pledge
|
4
|
+
|
5
|
+
We as members, contributors, and leaders pledge to make participation in our
|
6
|
+
community a harassment-free experience for everyone, regardless of age, body
|
7
|
+
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
8
|
+
identity and expression, level of experience, education, socio-economic status,
|
9
|
+
nationality, personal appearance, race, religion, or sexual identity
|
10
|
+
and orientation.
|
11
|
+
|
12
|
+
We pledge to act and interact in ways that contribute to an open, welcoming,
|
13
|
+
diverse, inclusive, and healthy community.
|
14
|
+
|
15
|
+
## Our Standards
|
16
|
+
|
17
|
+
Examples of behavior that contributes to a positive environment for our
|
18
|
+
community include:
|
19
|
+
|
20
|
+
* Demonstrating empathy and kindness toward other people
|
21
|
+
* Being respectful of differing opinions, viewpoints, and experiences
|
22
|
+
* Giving and gracefully accepting constructive feedback
|
23
|
+
* Accepting responsibility and apologizing to those affected by our mistakes,
|
24
|
+
and learning from the experience
|
25
|
+
* Focusing on what is best not just for us as individuals, but for the
|
26
|
+
overall community
|
27
|
+
|
28
|
+
Examples of unacceptable behavior include:
|
29
|
+
|
30
|
+
* The use of sexualized language or imagery, and sexual attention or
|
31
|
+
advances of any kind
|
32
|
+
* Trolling, insulting or derogatory comments, and personal or political attacks
|
33
|
+
* Public or private harassment
|
34
|
+
* Publishing others' private information, such as a physical or email
|
35
|
+
address, without their explicit permission
|
36
|
+
* Other conduct which could reasonably be considered inappropriate in a
|
37
|
+
professional setting
|
38
|
+
|
39
|
+
## Enforcement Responsibilities
|
40
|
+
|
41
|
+
Community leaders are responsible for clarifying and enforcing our standards of
|
42
|
+
acceptable behavior and will take appropriate and fair corrective action in
|
43
|
+
response to any behavior that they deem inappropriate, threatening, offensive,
|
44
|
+
or harmful.
|
45
|
+
|
46
|
+
Community leaders have the right and responsibility to remove, edit, or reject
|
47
|
+
comments, commits, code, wiki edits, issues, and other contributions that are
|
48
|
+
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
49
|
+
decisions when appropriate.
|
50
|
+
|
51
|
+
## Scope
|
52
|
+
|
53
|
+
This Code of Conduct applies within all community spaces, and also applies when
|
54
|
+
an individual is officially representing the community in public spaces.
|
55
|
+
Examples of representing our community include using an official e-mail address,
|
56
|
+
posting via an official social media account, or acting as an appointed
|
57
|
+
representative at an online or offline event.
|
58
|
+
|
59
|
+
## Enforcement
|
60
|
+
|
61
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
62
|
+
reported to the community leaders responsible for enforcement at
|
63
|
+
merlin@sleepingkingstudios.com.
|
64
|
+
All complaints will be reviewed and investigated promptly and fairly.
|
65
|
+
|
66
|
+
All community leaders are obligated to respect the privacy and security of the
|
67
|
+
reporter of any incident.
|
68
|
+
|
69
|
+
## Enforcement Guidelines
|
70
|
+
|
71
|
+
Community leaders will follow these Community Impact Guidelines in determining
|
72
|
+
the consequences for any action they deem in violation of this Code of Conduct:
|
73
|
+
|
74
|
+
### 1. Correction
|
75
|
+
|
76
|
+
**Community Impact**: Use of inappropriate language or other behavior deemed
|
77
|
+
unprofessional or unwelcome in the community.
|
78
|
+
|
79
|
+
**Consequence**: A private, written warning from community leaders, providing
|
80
|
+
clarity around the nature of the violation and an explanation of why the
|
81
|
+
behavior was inappropriate. A public apology may be requested.
|
82
|
+
|
83
|
+
### 2. Warning
|
84
|
+
|
85
|
+
**Community Impact**: A violation through a single incident or series
|
86
|
+
of actions.
|
87
|
+
|
88
|
+
**Consequence**: A warning with consequences for continued behavior. No
|
89
|
+
interaction with the people involved, including unsolicited interaction with
|
90
|
+
those enforcing the Code of Conduct, for a specified period of time. This
|
91
|
+
includes avoiding interactions in community spaces as well as external channels
|
92
|
+
like social media. Violating these terms may lead to a temporary or
|
93
|
+
permanent ban.
|
94
|
+
|
95
|
+
### 3. Temporary Ban
|
96
|
+
|
97
|
+
**Community Impact**: A serious violation of community standards, including
|
98
|
+
sustained inappropriate behavior.
|
99
|
+
|
100
|
+
**Consequence**: A temporary ban from any sort of interaction or public
|
101
|
+
communication with the community for a specified period of time. No public or
|
102
|
+
private interaction with the people involved, including unsolicited interaction
|
103
|
+
with those enforcing the Code of Conduct, is allowed during this period.
|
104
|
+
Violating these terms may lead to a permanent ban.
|
105
|
+
|
106
|
+
### 4. Permanent Ban
|
107
|
+
|
108
|
+
**Community Impact**: Demonstrating a pattern of violation of community
|
109
|
+
standards, including sustained inappropriate behavior, harassment of an
|
110
|
+
individual, or aggression toward or disparagement of classes of individuals.
|
111
|
+
|
112
|
+
**Consequence**: A permanent ban from any sort of public interaction within
|
113
|
+
the community.
|
114
|
+
|
115
|
+
## Attribution
|
116
|
+
|
117
|
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
118
|
+
version 2.0, available at
|
119
|
+
[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
|
120
|
+
|
121
|
+
Community Impact Guidelines were inspired by
|
122
|
+
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
123
|
+
|
124
|
+
For answers to common questions about this code of conduct, see the FAQ at
|
125
|
+
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available
|
126
|
+
at [https://www.contributor-covenant.org/translations][translations].
|
127
|
+
|
128
|
+
[homepage]: https://www.contributor-covenant.org
|
129
|
+
[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
|
130
|
+
[Mozilla CoC]: https://github.com/mozilla/diversity
|
131
|
+
[FAQ]: https://www.contributor-covenant.org/faq
|
132
|
+
[translations]: https://www.contributor-covenant.org/translations
|
data/README.md
CHANGED
@@ -12,8 +12,6 @@ It defines the following concepts:
|
|
12
12
|
|
13
13
|
## About
|
14
14
|
|
15
|
-
[comment]: # "Status Badges will go here."
|
16
|
-
|
17
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:
|
18
16
|
|
19
17
|
- **Consistency:** Use the same Commands to underlie controller actions, worker processes and test factories.
|
@@ -22,14 +20,13 @@ Traditional frameworks such as Rails focus on the objects of your application -
|
|
22
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.
|
23
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.
|
24
22
|
|
25
|
-
###
|
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.
|
26
|
+
|
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.
|
26
28
|
|
27
|
-
|
28
|
-
[ActiveInteraction](https://github.com/AaronLasseigne/active_interaction),
|
29
|
-
[Dry::Monads](https://dry-rb.org/gems/dry-monads/),
|
30
|
-
[Interactor](https://github.com/collectiveidea/interactor),
|
31
|
-
[Trailblazer](http://trailblazer.to/) Operations,
|
32
|
-
and [Waterfall](https://github.com/apneadiving/waterfall).
|
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.
|
33
30
|
|
34
31
|
### Compatibility
|
35
32
|
|
@@ -41,7 +38,7 @@ Documentation is generated using [YARD](https://yardoc.org/), and can be generat
|
|
41
38
|
|
42
39
|
### License
|
43
40
|
|
44
|
-
Copyright (c) 2019 Rob Smith
|
41
|
+
Copyright (c) 2019-2021 Rob Smith
|
45
42
|
|
46
43
|
Cuprum is released under the [MIT License](https://opensource.org/licenses/MIT).
|
47
44
|
|
@@ -53,9 +50,317 @@ To report a bug or submit a feature request, please use the [Issue Tracker](http
|
|
53
50
|
|
54
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.
|
55
52
|
|
56
|
-
|
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.
|
57
214
|
|
58
|
-
|
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.
|
250
|
+
|
251
|
+
In rare cases, a result may have both a value and an error, such as the result for a partial query.
|
252
|
+
|
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
|
362
|
+
|
363
|
+
### Commands
|
59
364
|
|
60
365
|
require 'cuprum'
|
61
366
|
|
@@ -63,7 +368,7 @@ Commands are the core feature of Cuprum. In a nutshell, each `Cuprum::Command` i
|
|
63
368
|
|
64
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).
|
65
370
|
|
66
|
-
|
371
|
+
#### Defining Commands
|
67
372
|
|
68
373
|
The recommended way to define commands is to create a subclass of `Cuprum::Command` and override the `#process` method.
|
69
374
|
|
@@ -134,7 +439,7 @@ inspect_command = Cuprum::Command.new(&:inspect) # Equivalent to above.
|
|
134
439
|
|
135
440
|
Commands defined using `Cuprum::Command.new` are quick to use, but more difficult to read and to reuse. Defining your own command class is recommended if a command definition takes up more than one line, or if the command will be used in more than one place.
|
136
441
|
|
137
|
-
|
442
|
+
#### Result Values
|
138
443
|
|
139
444
|
Calling the `#call` method on a `Cuprum::Command` instance will always return an instance of `Cuprum::Result`. The result's `#value` property is determined by the object returned by the `#process` method (if the command is defined as a class) or the block (if the command is defined by passing a block to `Cuprum::Command.new`).
|
140
445
|
|
@@ -158,7 +463,7 @@ result.class #=> Cuprum::Result
|
|
158
463
|
result.value #=> 'Greetings, programs!'
|
159
464
|
```
|
160
465
|
|
161
|
-
|
466
|
+
#### Success, Failure, and Errors
|
162
467
|
|
163
468
|
Each Result has a status, either `:success` or `:failure`. A Result will have a status of `:failure` when it was created with an error object. Otherwise, a Result will have a status of `:success`. Returning a failing Result from a Command indicates that something went wrong while executing the Command.
|
164
469
|
|
@@ -206,13 +511,13 @@ result.value #=> book
|
|
206
511
|
book.published? #=> false
|
207
512
|
```
|
208
513
|
|
209
|
-
|
514
|
+
#### Command Currying
|
210
515
|
|
211
516
|
Cuprum::Command defines the `#curry` method, which allows for partial application of command objects. Partial application (more commonly referred to, if imprecisely, as currying) refers to fixing some number of arguments to a function, resulting in a function with a smaller number of arguments.
|
212
517
|
|
213
518
|
In Cuprum's case, a curried (partially applied) command takes an original command and pre-defines some of its arguments. When the curried command is called, the predefined arguments and/or keywords will be combined with the arguments passed to #call.
|
214
519
|
|
215
|
-
|
520
|
+
##### Currying Arguments
|
216
521
|
|
217
522
|
We start by defining the base command. In this case, our base command takes two string arguments - a greeting and a person to be greeted.
|
218
523
|
|
@@ -240,7 +545,7 @@ recruit_command.call
|
|
240
545
|
#=> returns a result with value 'Greetings, starfighter!'
|
241
546
|
```
|
242
547
|
|
243
|
-
|
548
|
+
##### Currying Keywords
|
244
549
|
|
245
550
|
We can also pass keywords to `#curry`. Again, we start by defining our base command. In this case, our base command takes a mathematical operation (addition, subtraction, multiplication, etc) and a list of operands.
|
246
551
|
|
@@ -260,7 +565,7 @@ multiply_command.call(operands: [3, 3])
|
|
260
565
|
#=> returns a result with value 9
|
261
566
|
```
|
262
567
|
|
263
|
-
|
568
|
+
#### Composing Commands
|
264
569
|
|
265
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:
|
266
571
|
|
@@ -321,7 +626,7 @@ add_two_command.call(8).value #=> 10
|
|
321
626
|
|
322
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.
|
323
628
|
|
324
|
-
|
629
|
+
##### Commands As Arguments
|
325
630
|
|
326
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:
|
327
632
|
|
@@ -373,7 +678,7 @@ end
|
|
373
678
|
|
374
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).
|
375
680
|
|
376
|
-
|
681
|
+
##### Commands As Returned Values
|
377
682
|
|
378
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.
|
379
684
|
|
@@ -403,7 +708,7 @@ Notice that our factory includes error handling - if the user does not have a va
|
|
403
708
|
|
404
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.
|
405
710
|
|
406
|
-
|
711
|
+
#### Command Steps
|
407
712
|
|
408
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.
|
409
714
|
|
@@ -519,7 +824,7 @@ result.success? #=> true
|
|
519
824
|
result.value #=> an instance of BookReservation
|
520
825
|
```
|
521
826
|
|
522
|
-
|
827
|
+
##### Using Steps Outside Of Commands
|
523
828
|
|
524
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.
|
525
830
|
|
@@ -582,7 +887,7 @@ A few things to note about this example. First, we have a couple of examples of
|
|
582
887
|
|
583
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.
|
584
889
|
|
585
|
-
|
890
|
+
#### Handling Exceptions
|
586
891
|
|
587
892
|
require 'cuprum/exception_handling'
|
588
893
|
|
@@ -615,9 +920,7 @@ result.error.message
|
|
615
920
|
|
616
921
|
Exception handling is *not* included by default - add `include Cuprum::ExceptionHandling` to your command classes to use this feature.
|
617
922
|
|
618
|
-
|
619
|
-
|
620
|
-
## Results
|
923
|
+
### Results
|
621
924
|
|
622
925
|
require 'cuprum'
|
623
926
|
|
@@ -686,9 +989,7 @@ result.success? #=> true
|
|
686
989
|
result.failure? #=> false
|
687
990
|
```
|
688
991
|
|
689
|
-
|
690
|
-
|
691
|
-
## Errors
|
992
|
+
### Errors
|
692
993
|
|
693
994
|
require 'cuprum/error'
|
694
995
|
|
@@ -746,7 +1047,7 @@ end
|
|
746
1047
|
|
747
1048
|
It is optional but recommended to use a `Cuprum::Error` when returning a failed result from a command.
|
748
1049
|
|
749
|
-
|
1050
|
+
#### Comparing Errors
|
750
1051
|
|
751
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.
|
752
1053
|
|
@@ -804,7 +1105,7 @@ class WrongColorError < Cuprum::Error
|
|
804
1105
|
end
|
805
1106
|
```
|
806
1107
|
|
807
|
-
|
1108
|
+
#### Serializing Errors
|
808
1109
|
|
809
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.
|
810
1111
|
|
@@ -850,9 +1151,7 @@ error.as_json #=>
|
|
850
1151
|
|
851
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.
|
852
1153
|
|
853
|
-
|
854
|
-
|
855
|
-
## Middleware
|
1154
|
+
### Middleware
|
856
1155
|
|
857
1156
|
```ruby
|
858
1157
|
require 'cuprum/middleware'
|
@@ -920,9 +1219,7 @@ end
|
|
920
1219
|
|
921
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.
|
922
1221
|
|
923
|
-
|
924
|
-
|
925
|
-
## Operations
|
1222
|
+
### Operations
|
926
1223
|
|
927
1224
|
require 'cuprum'
|
928
1225
|
|
@@ -955,13 +1252,11 @@ Like a Command, an Operation can be defined directly by passing an implementatio
|
|
955
1252
|
|
956
1253
|
An operation inherits the `#call` method from Cuprum::Command (see above), and delegates the `#value`, `#error`, `#success?`, and `#failure` methods to the most recent result. If the operation has not been called, these methods will return default values.
|
957
1254
|
|
958
|
-
|
1255
|
+
#### The Operation Mixin
|
959
1256
|
|
960
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.
|
961
1258
|
|
962
|
-
|
963
|
-
|
964
|
-
## Matchers
|
1259
|
+
### Matchers
|
965
1260
|
|
966
1261
|
require 'cuprum/matcher'
|
967
1262
|
|
@@ -1006,7 +1301,7 @@ matcher.call(Cuprum::Result.new(status: :failure))
|
|
1006
1301
|
#=> raises Cuprum::Matching::NoMatchError
|
1007
1302
|
```
|
1008
1303
|
|
1009
|
-
|
1304
|
+
#### Matching Values And Errors
|
1010
1305
|
|
1011
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).
|
1012
1307
|
|
@@ -1055,7 +1350,7 @@ matcher.call(Cuprum::Result.new(value: :greetings_starfighter))
|
|
1055
1350
|
#=> 'a Symbol'
|
1056
1351
|
```
|
1057
1352
|
|
1058
|
-
|
1353
|
+
#### Using Matcher Classes
|
1059
1354
|
|
1060
1355
|
Matcher classes allow you to define custom behavior that can be called as part of the defined match clauses.
|
1061
1356
|
|
@@ -1094,7 +1389,7 @@ matcher.call(result)
|
|
1094
1389
|
#=> prints "FATAL: Computer on fire." to STDOUT
|
1095
1390
|
```
|
1096
1391
|
|
1097
|
-
|
1392
|
+
#### Match Contexts
|
1098
1393
|
|
1099
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.
|
1100
1395
|
|
@@ -1126,7 +1421,7 @@ matcher
|
|
1126
1421
|
#=> 'Greetings Starfighter'
|
1127
1422
|
```
|
1128
1423
|
|
1129
|
-
|
1424
|
+
#### Matcher Lists
|
1130
1425
|
|
1131
1426
|
Matcher lists handle matching a result against an ordered group of matchers.
|
1132
1427
|
|
@@ -1187,7 +1482,7 @@ matcher_list.call(result)
|
|
1187
1482
|
|
1188
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.
|
1189
1484
|
|
1190
|
-
|
1485
|
+
### Command Factories
|
1191
1486
|
|
1192
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.
|
1193
1488
|
|
@@ -1240,7 +1535,7 @@ book.author #=> 'Ursula K. Le Guin'
|
|
1240
1535
|
book.publisher #=> nil
|
1241
1536
|
```
|
1242
1537
|
|
1243
|
-
|
1538
|
+
#### The ::command Method And A Command Class
|
1244
1539
|
|
1245
1540
|
The first way to define a command for a factory is by calling the `::command` method and passing it the name of the command and a command class:
|
1246
1541
|
|
@@ -1252,7 +1547,7 @@ end
|
|
1252
1547
|
|
1253
1548
|
This makes the command class available on a factory instance as `::Build`, and generates the `#build` method which returns an instance of `BuildBookCommand`.
|
1254
1549
|
|
1255
|
-
|
1550
|
+
#### The ::command Method And A Block
|
1256
1551
|
|
1257
1552
|
By calling the `::command` method with a block, you can define a command with additional control over how the generated command. The block must return an instance of a subclass of Cuprum::Command.
|
1258
1553
|
|
@@ -1361,7 +1656,7 @@ ary = result.value #=> an array with the selected books
|
|
1361
1656
|
ary.count #=> 1
|
1362
1657
|
```
|
1363
1658
|
|
1364
|
-
|
1659
|
+
#### The ::command_class Method
|
1365
1660
|
|
1366
1661
|
The final way to define a command for a factory is calling the `::command_class` method with the command name and a block. The block must return a subclass (not an instance) of Cuprum::Command. This offers a balance between flexibility and power.
|
1367
1662
|
|
@@ -1485,11 +1780,11 @@ books.count #=> 4
|
|
1485
1780
|
books.include?(book) #=> true
|
1486
1781
|
```
|
1487
1782
|
|
1488
|
-
|
1783
|
+
### Built In Commands
|
1489
1784
|
|
1490
1785
|
Cuprum includes a small number of predefined commands and their equivalent operations.
|
1491
1786
|
|
1492
|
-
|
1787
|
+
#### IdentityCommand
|
1493
1788
|
|
1494
1789
|
require 'cuprum/built_in/identity_command'
|
1495
1790
|
|
@@ -1502,7 +1797,7 @@ result.value #=> 'expected value'
|
|
1502
1797
|
result.success? #=> true
|
1503
1798
|
```
|
1504
1799
|
|
1505
|
-
|
1800
|
+
#### IdentityOperation
|
1506
1801
|
|
1507
1802
|
require 'cuprum/built_in/identity_operation'
|
1508
1803
|
|
@@ -1514,7 +1809,7 @@ operation.value #=> 'expected value'
|
|
1514
1809
|
operation.success? #=> true
|
1515
1810
|
```
|
1516
1811
|
|
1517
|
-
|
1812
|
+
#### NullCommand
|
1518
1813
|
|
1519
1814
|
require 'cuprum/built_in/null_command'
|
1520
1815
|
|
@@ -1527,7 +1822,7 @@ result.value #=> nil
|
|
1527
1822
|
result.success? #=> true
|
1528
1823
|
```
|
1529
1824
|
|
1530
|
-
|
1825
|
+
#### NullOperation
|
1531
1826
|
|
1532
1827
|
require 'cuprum/built_in/null_operation'
|
1533
1828
|
|
data/lib/cuprum/version.rb
CHANGED
@@ -8,15 +8,15 @@ module Cuprum
|
|
8
8
|
# @see http://semver.org/
|
9
9
|
module Version
|
10
10
|
# Major version.
|
11
|
-
MAJOR =
|
11
|
+
MAJOR = 1
|
12
12
|
# Minor version.
|
13
|
-
MINOR =
|
13
|
+
MINOR = 0
|
14
14
|
# Patch version.
|
15
15
|
PATCH = 0
|
16
16
|
# Prerelease version.
|
17
|
-
PRERELEASE =
|
17
|
+
PRERELEASE = :rc
|
18
18
|
# Build metadata.
|
19
|
-
BUILD =
|
19
|
+
BUILD = 0
|
20
20
|
|
21
21
|
class << self
|
22
22
|
# Generates the gem version string from the Version constants.
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: cuprum
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 1.0.0.rc.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Rob "Merlin" Smith
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2021-
|
11
|
+
date: 2021-09-20 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: sleeping_king_studios-tools
|
@@ -106,6 +106,7 @@ extensions: []
|
|
106
106
|
extra_rdoc_files: []
|
107
107
|
files:
|
108
108
|
- CHANGELOG.md
|
109
|
+
- CODE_OF_CONDUCT.md
|
109
110
|
- DEVELOPMENT.md
|
110
111
|
- LICENSE
|
111
112
|
- README.md
|
@@ -159,9 +160,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
159
160
|
version: 2.5.0
|
160
161
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
161
162
|
requirements:
|
162
|
-
- - "
|
163
|
+
- - ">"
|
163
164
|
- !ruby/object:Gem::Version
|
164
|
-
version:
|
165
|
+
version: 1.3.1
|
165
166
|
requirements: []
|
166
167
|
rubygems_version: 3.1.4
|
167
168
|
signing_key:
|