cuprum 0.11.0.rc.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3938b7e4b35db9560c46f0c4233c4ee50972e34e66145a238ba8415af3998b80
4
- data.tar.gz: b78ca19367cfb764110b32849a191bcd0519fbf4668c554b3af15b81313990b2
3
+ metadata.gz: 3b663519affd2c388cb23aa243406afbda3134d4c7f49276653cc8d64fbdc98d
4
+ data.tar.gz: 1c81bae1c21fb82ca7ecb75afd084569a3f14f640f305c9aec37fb445f4e116f
5
5
  SHA512:
6
- metadata.gz: e5593d6900fb03df0c53681922e180722491d56d3b60281b881e7e9e04118d8cbea5b8e590df80b205f44fd21bcdfacdc2a79c83f8657e09b3e0fa2128e9f742
7
- data.tar.gz: 18a2d36932b71428dc3cf93ffd767844c87eddf105666838907372d1276e873aaffc988d1d6543e413896670d0bde4a51ca7f9a4cd3bfa6088f565faca85db1d
6
+ metadata.gz: 9e6b772398e7064e89d4ee89f781883d6812b54f28fd697b740b2672dd292e59a3acde636ff6693fd6e3d914b123aef8071ffd969bf4456c1b2be4596585280c
7
+ data.tar.gz: 12949825ee493e942c97083983e3e96a13cc7c3f0cfc0a21a8441d5a6488dfb8d0e9ae0ceb670bd55f367c3f69201bfcbf38454ea06b4ef4a004081c893ba71c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0
4
+
5
+ The "Look On My Works, Ye Mighty, and Despair" Update
6
+
7
+ #### Steps
8
+
9
+ Removed calling `#step` with a method name.
10
+
3
11
  ## 0.11.0
4
12
 
5
13
  The "One Giant Leap" Update
@@ -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/DEVELOPMENT.md CHANGED
@@ -1,24 +1,5 @@
1
1
  # Development
2
2
 
3
- ## Version 1.0.0
4
-
5
- The "Look On My Works, Ye Mighty, and Despair" Update
6
-
7
- - Documentation pass.
8
-
9
- Steps Case Study: |
10
-
11
- CMS application - creating a new post.
12
- Directory has many Posts
13
- Post has a Content
14
- Post has many Tags
15
-
16
- Find Directory
17
- Create Post
18
- Create Content
19
- Tags.each { FindOrCreate Tag }
20
- Publish Post # Requires that post have content
21
-
22
3
  ## Future Versions
23
4
 
24
5
  Add `.rbs` files
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
- ### Alternatives
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
- If you want to extract your logic but Cuprum is not the right solution for you, there are a number of alternatives, including
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
- <a id="Commands"></a>
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
- ## Commands
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
- ### Defining Commands
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
- ### Result Values
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
- ### Success, Failure, and Errors
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
- ### Command Currying
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
- #### Currying Arguments
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
- #### Currying Keywords
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
- ### Composing Commands
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
- #### Commands As Arguments
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
- #### Commands As Returned Values
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
- ### Command Steps
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
- #### Using Steps Outside Of Commands
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
- ### Handling Exceptions
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
- <a id="Results"></a>
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
- <a id="Errors"></a>
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
- ### Comparing Errors
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
- ### Serializing Errors
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
- <a id="Middleware"></a>
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
- <a id="Operations"></a>
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
- ### The Operation Mixin
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
- <a id="Matchers"></a>
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
- ### Matching Values And Errors
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
- ### Using Matcher Classes
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
- ### Match Contexts
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
- ### Matcher Lists
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
- ## Command Factories
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
- ### The ::command Method And A Command Class
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
- ### The ::command Method And A Block
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
- ### The ::command_class Method
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
- ## Built In Commands
1783
+ ### Built In Commands
1489
1784
 
1490
1785
  Cuprum includes a small number of predefined commands and their equivalent operations.
1491
1786
 
1492
- ### IdentityCommand
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
- ### IdentityOperation
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
- ### NullCommand
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
- ### NullOperation
1825
+ #### NullOperation
1531
1826
 
1532
1827
  require 'cuprum/built_in/null_operation'
1533
1828
 
data/lib/cuprum/steps.rb CHANGED
@@ -63,56 +63,6 @@ module Cuprum
63
63
  module Steps
64
64
  include Cuprum::ResultHelpers
65
65
 
66
- UNDEFINED = Object.new.freeze
67
- private_constant :UNDEFINED
68
-
69
- class << self
70
- # @!visibility private
71
- def execute_method(receiver, method_name, *args, **kwargs, &block)
72
- if block_given? && kwargs.empty?
73
- receiver.send(method_name, *args, &block)
74
- elsif block_given?
75
- receiver.send(method_name, *args, **kwargs, &block)
76
- elsif kwargs.empty?
77
- receiver.send(method_name, *args)
78
- else
79
- receiver.send(method_name, *args, **kwargs)
80
- end
81
- end
82
-
83
- # @!visibility private
84
- def extract_result_value(result)
85
- return result unless result.respond_to?(:to_cuprum_result)
86
-
87
- result = result.to_cuprum_result
88
-
89
- return result.value if result.success?
90
-
91
- throw :cuprum_failed_step, result
92
- end
93
-
94
- # rubocop:disable Metrics/MethodLength
95
- # @!visibility private
96
- def validate_method_name(method_name)
97
- if method_name.nil?
98
- raise ArgumentError,
99
- 'expected a block or a method name',
100
- caller(1..-1)
101
- end
102
-
103
- unless method_name.is_a?(String) || method_name.is_a?(Symbol)
104
- raise ArgumentError,
105
- 'expected method name to be a String or Symbol',
106
- caller(1..-1)
107
- end
108
-
109
- return unless method_name.empty?
110
-
111
- raise ArgumentError, "method name can't be blank", caller(1..-1)
112
- end
113
- # rubocop:enable Metrics/MethodLength
114
- end
115
-
116
66
  # Executes the block and returns the value, or halts on a failure.
117
67
  #
118
68
  # @yield Called with no parameters.
@@ -155,25 +105,18 @@ module Cuprum
155
105
  # @example Calling a Step with a Failing Result
156
106
  # # The #do_something_wrong method returns a failing Cuprum result.
157
107
  # step { do_something_wrong() } # Throws the :cuprum_failed_step symbol.
158
- def step(method_name = UNDEFINED, *args, **kwargs, &block) # rubocop:disable Metrics/MethodLength
159
- result =
160
- if method_name != UNDEFINED || !args.empty? || !kwargs.empty?
161
- SleepingKingStudios::Tools::CoreTools.deprecate(
162
- "#{self.class}#step(method_name)",
163
- message: 'Use the block form: step { method_name(*args, **kwargs) }'
164
- )
108
+ def step
109
+ raise ArgumentError, 'expected a block' unless block_given?
110
+
111
+ result = yield
112
+
113
+ return result unless result.respond_to?(:to_cuprum_result)
165
114
 
166
- Cuprum::Steps.validate_method_name(method_name)
115
+ result = result.to_cuprum_result
167
116
 
168
- Cuprum::Steps
169
- .execute_method(self, method_name, *args, **kwargs, &block)
170
- elsif !block_given?
171
- raise ArgumentError, 'expected a block'
172
- else
173
- block.call
174
- end
117
+ return result.value if result.success?
175
118
 
176
- Cuprum::Steps.extract_result_value(result)
119
+ throw :cuprum_failed_step, result
177
120
  end
178
121
 
179
122
  # Returns the first failing #step result, or the final result if none fail.
@@ -8,15 +8,15 @@ module Cuprum
8
8
  # @see http://semver.org/
9
9
  module Version
10
10
  # Major version.
11
- MAJOR = 0
11
+ MAJOR = 1
12
12
  # Minor version.
13
- MINOR = 11
13
+ MINOR = 0
14
14
  # Patch version.
15
15
  PATCH = 0
16
16
  # Prerelease version.
17
- PRERELEASE = :rc
17
+ PRERELEASE = nil
18
18
  # Build metadata.
19
- BUILD = 0
19
+ BUILD = nil
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.11.0.rc.0
4
+ version: 1.0.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-07-16 00:00:00.000000000 Z
11
+ date: 2021-11-16 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
@@ -148,6 +149,7 @@ licenses:
148
149
  metadata:
149
150
  bug_tracker_uri: https://github.com/sleepingkingstudios/cuprum/issues
150
151
  source_code_uri: https://github.com/sleepingkingstudios/cuprum
152
+ rubygems_mfa_required: 'true'
151
153
  post_install_message:
152
154
  rdoc_options: []
153
155
  require_paths:
@@ -159,9 +161,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
159
161
  version: 2.5.0
160
162
  required_rubygems_version: !ruby/object:Gem::Requirement
161
163
  requirements:
162
- - - ">"
164
+ - - ">="
163
165
  - !ruby/object:Gem::Version
164
- version: 1.3.1
166
+ version: '0'
165
167
  requirements: []
166
168
  rubygems_version: 3.1.4
167
169
  signing_key: