runger_actions 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/dependabot.yml +13 -0
- data/.github/workflows/ruby.yml +25 -0
- data/.gitignore +11 -0
- data/.release_assistant.yml +3 -0
- data/.rspec +3 -0
- data/.rubocop.yml +10 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +225 -0
- data/Gemfile +29 -0
- data/Gemfile.lock +268 -0
- data/LICENSE.txt +21 -0
- data/README.md +662 -0
- data/RELEASING.md +7 -0
- data/Rakefile +8 -0
- data/bin/_guard-core +28 -0
- data/bin/console +15 -0
- data/bin/guard +28 -0
- data/bin/release +28 -0
- data/bin/rspec +30 -0
- data/bin/rubocop +30 -0
- data/bin/setup +8 -0
- data/lib/generators/runger_actions/action/USAGE +8 -0
- data/lib/generators/runger_actions/action/action_generator.rb +13 -0
- data/lib/generators/runger_actions/action/templates/action.rb +12 -0
- data/lib/runger_actions/base.rb +229 -0
- data/lib/runger_actions/error.rb +3 -0
- data/lib/runger_actions/result.rb +23 -0
- data/lib/runger_actions/version.rb +5 -0
- data/lib/runger_actions.rb +18 -0
- data/runger_actions.gemspec +34 -0
- metadata +131 -0
data/README.md
ADDED
@@ -0,0 +1,662 @@
|
|
1
|
+
[](https://codecov.io/gh/davidrunger/runger_actions)
|
2
|
+

|
3
|
+
|
4
|
+
# RungerActions
|
5
|
+
|
6
|
+
Organize and validate the business logic of your Rails application with this combined form object /
|
7
|
+
command object.
|
8
|
+
|
9
|
+
# Table of Contents
|
10
|
+
|
11
|
+
<!--ts-->
|
12
|
+
* [RungerActions](#rungeractions)
|
13
|
+
* [Table of Contents](#table-of-contents)
|
14
|
+
* [Installation](#installation)
|
15
|
+
* [Usage in general](#usage-in-general)
|
16
|
+
* [Setup](#setup)
|
17
|
+
* [Generate your actions](#generate-your-actions)
|
18
|
+
* [Define your actions](#define-your-actions)
|
19
|
+
* [Invoke your actions](#invoke-your-actions)
|
20
|
+
* [Available methods](#available-methods)
|
21
|
+
* [Usage in specific](#usage-in-specific)
|
22
|
+
* [An #execute instance method is required!](#an-execute-instance-method-is-required)
|
23
|
+
* [Action class methods](#action-class-methods)
|
24
|
+
* [::requires](#requires)
|
25
|
+
* [Specifying the expected shape of a Hash input](#specifying-the-expected-shape-of-a-hash-input)
|
26
|
+
* [Specifying ActiveModel-style validations](#specifying-activemodel-style-validations)
|
27
|
+
* [Specifying arbitrary input "shapes" by providing a callable object](#specifying-arbitrary-input-shapes-by-providing-a-callable-object)
|
28
|
+
* [Specifying validations for ActiveRecord inputs](#specifying-validations-for-activerecord-inputs)
|
29
|
+
* [::returns](#returns)
|
30
|
+
* [The result object](#the-result-object)
|
31
|
+
* [All promised values must be returned](#all-promised-values-must-be-returned)
|
32
|
+
* [Validating the "shape" of returned values](#validating-the-shape-of-returned-values)
|
33
|
+
* [::fails_with](#fails_with)
|
34
|
+
* [Setting an error_message](#setting-an-error_message)
|
35
|
+
* [Alternatives](#alternatives)
|
36
|
+
* [Status / Context](#status--context)
|
37
|
+
* [Development](#development)
|
38
|
+
* [License](#license)
|
39
|
+
|
40
|
+
<!-- Created by https://github.com/ekalinin/github-markdown-toc -->
|
41
|
+
<!-- Added by: david, at: Sat May 20 12:56:10 CDT 2023 -->
|
42
|
+
|
43
|
+
<!--te-->
|
44
|
+
|
45
|
+
# Installation
|
46
|
+
|
47
|
+
Add the gem to your application's `Gemfile`.
|
48
|
+
|
49
|
+
```rb
|
50
|
+
gem 'runger_actions'
|
51
|
+
```
|
52
|
+
|
53
|
+
And then execute:
|
54
|
+
|
55
|
+
```
|
56
|
+
$ bundle install
|
57
|
+
```
|
58
|
+
|
59
|
+
# Usage in general
|
60
|
+
|
61
|
+
## Setup
|
62
|
+
|
63
|
+
Create a new subdirectory within the `app/` directory in your Rails app: `app/actions/`.
|
64
|
+
|
65
|
+
Create an `app/actions/application_action.rb` file with this content:
|
66
|
+
```rb
|
67
|
+
# app/actions/application_action.rb
|
68
|
+
|
69
|
+
class ApplicationAction < RungerActions::Base
|
70
|
+
end
|
71
|
+
```
|
72
|
+
|
73
|
+
## Generate your actions
|
74
|
+
|
75
|
+
This gem provides a Rails generator. For example, running:
|
76
|
+
|
77
|
+
```
|
78
|
+
bin/rails g runger_actions:action Users::Create
|
79
|
+
```
|
80
|
+
|
81
|
+
will create an empty action in `app/actions/users/create.rb`.
|
82
|
+
|
83
|
+
## Define your actions
|
84
|
+
|
85
|
+
Then, you can start defining actions. Here's an example:
|
86
|
+
```rb
|
87
|
+
# app/actions/send_text_message.rb
|
88
|
+
|
89
|
+
class SendTextMessage < ApplicationAction
|
90
|
+
requires :message_body, String, length: { minimum: 3 } # don't send any super short messages
|
91
|
+
requires :user, User do
|
92
|
+
validates :phone, presence: true, format: { with: /[[:digit:]]{11}/ }
|
93
|
+
end
|
94
|
+
|
95
|
+
returns :cost, Float, numericality: { greater_than_or_equal_to: 0 }
|
96
|
+
returns :nexmo_id, String, presence: true
|
97
|
+
|
98
|
+
fails_with :nexmo_request_failed
|
99
|
+
|
100
|
+
def execute
|
101
|
+
nexmo_response = NexmoClient.send_text!(number: user.phone, message: message_body)
|
102
|
+
if nexmo_response.success?
|
103
|
+
nexmo_response_data = nexmo_response.parsed_response
|
104
|
+
result.cost = nexmo_response_data['cost']
|
105
|
+
result.nexmo_id = nexmo_response_data['message-id']
|
106
|
+
else
|
107
|
+
result.nexmo_request_failed!
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
```
|
112
|
+
|
113
|
+
## Invoke your actions
|
114
|
+
|
115
|
+
Once you have defined one or more actions, you can invoke the action(s) anywhere in your code, such
|
116
|
+
as in a controller, as illustrated below.
|
117
|
+
|
118
|
+
```rb
|
119
|
+
# app/controllers/api/text_messages_controller.rb
|
120
|
+
|
121
|
+
class Api::TextMessagesController < ApplicationController
|
122
|
+
def create
|
123
|
+
send_message_action =
|
124
|
+
SendTextMessage.new(
|
125
|
+
user: current_user,
|
126
|
+
message_body: "Hello! This message was generated at #{Time.current}.",
|
127
|
+
)
|
128
|
+
|
129
|
+
if !send_message_action.valid?
|
130
|
+
# We'll enter this block if one of the ActiveRecord inputs (`user`, in this case) for the
|
131
|
+
# action doesn't meet the required validations, e.g. if the user's `phone` is blank.
|
132
|
+
render json: { error: send_message_action.errors.full_messages.join(', ') }, status: 400
|
133
|
+
return
|
134
|
+
end
|
135
|
+
|
136
|
+
result = send_message_action.run
|
137
|
+
if result.success?
|
138
|
+
Rails.logger.info("Sent message with Nexmo id #{result.nexmo_id} at a cost of #{result.cost}")
|
139
|
+
head :created
|
140
|
+
elsif result.nexmo_request_failed?
|
141
|
+
render json: { error: 'An error occurred when sending the text message' }, status: 500
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
```
|
146
|
+
|
147
|
+
You aren't limited to invoking actions from a controller action, though; you can invoke an action
|
148
|
+
from anywhere in your code.
|
149
|
+
|
150
|
+
One good place to invoke an action is from within *another* action. For a complex or multi-step
|
151
|
+
process, you might want to break that process down into several "sub actions" that can be invoked
|
152
|
+
from the `#execute` method of a coordinating "parent action".
|
153
|
+
|
154
|
+
### Available methods
|
155
|
+
|
156
|
+
There are a few different methods that can be used to instantiate and/or run an action:
|
157
|
+
1. `::run!` class method
|
158
|
+
2. `::new!` class method
|
159
|
+
3. `::new` class method
|
160
|
+
4. `#run!` instance method
|
161
|
+
5. `#run` instance method
|
162
|
+
|
163
|
+
#### `::run!` class method
|
164
|
+
|
165
|
+
This will attempt to instantiate an action (via `::new!`) and then attempt to run the action (via
|
166
|
+
`#run!`). If there are any validation errors and/or if any `fails_with` conditions are invoked
|
167
|
+
during execution, then an error will be raised.
|
168
|
+
|
169
|
+
Example:
|
170
|
+
```rb
|
171
|
+
SendTextMessage.run!(user: current_user, message_body: 'Hello!')
|
172
|
+
```
|
173
|
+
|
174
|
+
#### `::new!` class method
|
175
|
+
|
176
|
+
This will attempt to instantiate an action. If there are any validation errors, then an error will
|
177
|
+
be raised.
|
178
|
+
|
179
|
+
Example:
|
180
|
+
```rb
|
181
|
+
action = SendTextMessage.new!(user: current_user, message_body: 'Hi!')
|
182
|
+
```
|
183
|
+
|
184
|
+
#### `::new` class method
|
185
|
+
|
186
|
+
This will instantiate an action. Even if there are ActiveModel validation errors, an error will
|
187
|
+
**not** be raised.
|
188
|
+
|
189
|
+
Example:
|
190
|
+
```rb
|
191
|
+
action = SendTextMessage.new(user: current_user, message_body: 'Hi!')
|
192
|
+
```
|
193
|
+
|
194
|
+
#### `#run!` instance method
|
195
|
+
|
196
|
+
This will attempt to run an action. If any `fails_with` conditions are invoked during execution,
|
197
|
+
then an error will be raised.
|
198
|
+
|
199
|
+
Example:
|
200
|
+
```rb
|
201
|
+
action = SendTextMessage.new!(user: current_user, message_body: 'Hi!')
|
202
|
+
action.run!
|
203
|
+
```
|
204
|
+
|
205
|
+
#### `#run` instance method
|
206
|
+
|
207
|
+
This will run an action. If any `fails_with` conditions are invoked during execution, then an error
|
208
|
+
will **not** be raised. The errors will be registered on the `result` object.
|
209
|
+
|
210
|
+
Example:
|
211
|
+
```rb
|
212
|
+
action = SendTextMessage.new!(user: current_user, message_body: 'Hi!')
|
213
|
+
result = action.run
|
214
|
+
result.nexmo_request_failed? # check if a `fails_with` condition was invoked
|
215
|
+
```
|
216
|
+
|
217
|
+
# Usage in specific
|
218
|
+
|
219
|
+
## An `#execute` instance method is required!
|
220
|
+
|
221
|
+
The only real requirement for an action is that it implements an `#execute` instance method.
|
222
|
+
|
223
|
+
```rb
|
224
|
+
class DoSomething < ApplicationAction
|
225
|
+
def execute
|
226
|
+
# you MUST write an #execute instance method for your action
|
227
|
+
end
|
228
|
+
end
|
229
|
+
```
|
230
|
+
|
231
|
+
Although all actions must implement an `#execute` instance method, you should generally not invoke
|
232
|
+
that method directly in your application code. Instead, call `#run` on an instance of the class:
|
233
|
+
|
234
|
+
```rb
|
235
|
+
# this will run the DoSomething#execute instance method
|
236
|
+
DoSomething.new.run
|
237
|
+
```
|
238
|
+
|
239
|
+
## Action class methods
|
240
|
+
|
241
|
+
When defining an action class, these three class methods are available:
|
242
|
+
1. `requires`
|
243
|
+
2. `returns`
|
244
|
+
3. `fails_with`
|
245
|
+
|
246
|
+
Those class methods are all optional, though. We'll detail/illustrate their usage below.
|
247
|
+
|
248
|
+
### `::requires`
|
249
|
+
|
250
|
+
The `::requires` class method declares the necessary, expected inputs that are needed in order to
|
251
|
+
execute an action.
|
252
|
+
|
253
|
+
An action can have zero, one, or more `requires` statements.
|
254
|
+
|
255
|
+
An action that requires no input values will have no `requires` statements:
|
256
|
+
|
257
|
+
```rb
|
258
|
+
class PrintCurrentTime < ApplicationAction
|
259
|
+
def execute
|
260
|
+
puts("The current time is #{Time.now}.")
|
261
|
+
end
|
262
|
+
end
|
263
|
+
|
264
|
+
PrintCurrentTime.new.run
|
265
|
+
# => prints "The current time is 2020-06-20 03:25:14 -0700."
|
266
|
+
```
|
267
|
+
|
268
|
+
Most actions probably will take one or more inputs, though. Here's an example of an action with one
|
269
|
+
`requires` statement:
|
270
|
+
|
271
|
+
```rb
|
272
|
+
class PrintDoubledNumber < ApplicationAction
|
273
|
+
requires :number, Numeric
|
274
|
+
|
275
|
+
def execute
|
276
|
+
puts("#{number} doubled is #{number * 2}")
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
PrintDoubledNumber.new(number: 8).run
|
281
|
+
# => prints "8 doubled is 16"
|
282
|
+
```
|
283
|
+
|
284
|
+
In the example above, because the `PrintDoubledNumber` action class declares `requires :number`, a
|
285
|
+
`#number` instance method is available for all instances of that action class. This `#number`
|
286
|
+
instance method is used within the `PrintDoubledNumber#execute` action.
|
287
|
+
|
288
|
+
All subsequent arguments given to `requires` are used to define a "shape" via the [`shaped`
|
289
|
+
gem](https://github.com/davidrunger/shaped/).
|
290
|
+
|
291
|
+
The simplest way to define the expected "shape" of a required action parameter is probably to
|
292
|
+
declare its expected class, as illustrated above (where we specified that the `number` input
|
293
|
+
parameter must be an instance of `Numeric`). However, the `shaped` gem supports a wide variety of
|
294
|
+
ways to specify the expected "shape" of an input. A few additional examples are shown below; see the
|
295
|
+
[`shaped` documentation](https://github.com/davidrunger/shaped/) for more possibilities.
|
296
|
+
|
297
|
+
#### Specifying the expected shape of a Hash input
|
298
|
+
|
299
|
+
```rb
|
300
|
+
class PrintNameAndEmail < ApplicationAction
|
301
|
+
# The `{ email: String, phone: String }` argument specifies the expected shape of `user_data`.
|
302
|
+
requires :user_data, { name: String, email: String }
|
303
|
+
|
304
|
+
def execute
|
305
|
+
puts("The email of #{user_data[:name]} is #{user_data[:email]}.")
|
306
|
+
end
|
307
|
+
end
|
308
|
+
|
309
|
+
PrintNameAndEmail.new(user_data: { name: 'Tom', email: 'tommy@example.com' }).run
|
310
|
+
# => prints "The email of Tom is tommy@example.com."
|
311
|
+
|
312
|
+
# The name and email keys are strings; they are supposed to be symbols.
|
313
|
+
PrintNameAndEmail.new(user_data: { 'name' => 'Thomas', 'email' => 'tommy@example.com' })
|
314
|
+
# => raises RungerActions::TypeMismatch
|
315
|
+
|
316
|
+
# The `:name` key is missing in the `user_data` hash.
|
317
|
+
PrintNameAndEmail.new(user_data: { email: 'tommy@example.com' })
|
318
|
+
# => raises RungerActions::TypeMismatch
|
319
|
+
```
|
320
|
+
|
321
|
+
#### Specifying ActiveModel-style validations
|
322
|
+
|
323
|
+
```rb
|
324
|
+
class PrintEmail < ApplicationAction
|
325
|
+
requires :email, String, format: { with: /.+@.+\..+/ }, length: { minimum: 6 }
|
326
|
+
|
327
|
+
def execute
|
328
|
+
puts("The email is '#{email}'.")
|
329
|
+
end
|
330
|
+
end
|
331
|
+
|
332
|
+
PrintEmail.new(email: 'jefferson@example.com').run
|
333
|
+
# => prints "The email is 'jefferson@example.com'."
|
334
|
+
|
335
|
+
# This email doesn't match the specified regex
|
336
|
+
PrintEmail.new(email: 'Thomas Jefferson')
|
337
|
+
# => raises RungerActions::TypeMismatch
|
338
|
+
|
339
|
+
# This email is too short
|
340
|
+
PrintEmail.new(email: 'a@b.c')
|
341
|
+
# => raises RungerActions::TypeMismatch
|
342
|
+
```
|
343
|
+
|
344
|
+
#### Specifying arbitrary input "shapes" by providing a callable object
|
345
|
+
|
346
|
+
You can leverage `shaped`'s [`Callable` shape
|
347
|
+
type](https://github.com/davidrunger/shaped/#shapedshapescallable) by providing any object that
|
348
|
+
responds to `#call` (such as a lambda). This allows you unlimited flexibility to define requirements
|
349
|
+
for the action's input(s).
|
350
|
+
|
351
|
+
```rb
|
352
|
+
class PrintSmallEvenNumber < ApplicationAction
|
353
|
+
requires :small_even_number, ->(number) { (0..6).cover?(number) && number.even? }
|
354
|
+
|
355
|
+
def execute
|
356
|
+
puts("#{small_even_number} is a small, even number.")
|
357
|
+
end
|
358
|
+
end
|
359
|
+
|
360
|
+
PrintSmallEvenNumber.new(small_even_number: 2).run
|
361
|
+
# => prints "2 is a small, even number."
|
362
|
+
|
363
|
+
# This number is not even
|
364
|
+
PrintSmallEvenNumber.new(small_even_number: 3).run
|
365
|
+
# => raises RungerActions::TypeMismatch
|
366
|
+
|
367
|
+
# This number is not small
|
368
|
+
PrintSmallEvenNumber.new(small_even_number: 200).run
|
369
|
+
# => raises RungerActions::TypeMismatch
|
370
|
+
```
|
371
|
+
|
372
|
+
#### Specifying validations for ActiveRecord inputs
|
373
|
+
|
374
|
+
When declaring a `requires` where the input is specified (via the second argument to `requires`) to
|
375
|
+
be a class that inherits from `ActiveRecord::Base`, there are a few special things that happen:
|
376
|
+
1. You can provide a **validation block** for the ActiveRecord object. Within this block, you can
|
377
|
+
specify validations on attributes of that ActiveRecord model.
|
378
|
+
2. You can check, by calling `valid?` on an instance of the action, whether the ActiveRecord
|
379
|
+
object(s) that are inputs for the action meet the **validation block** validations.
|
380
|
+
3. You can access any validation errors (from the **validation block**) via the `#errors` method of
|
381
|
+
the action instance.
|
382
|
+
4. You can execute the action instance via `run!` rather than `run`; this will raise an exception
|
383
|
+
(and not run the `#execute` method) if any of the validations from a **validation block** are not
|
384
|
+
met.
|
385
|
+
|
386
|
+
```rb
|
387
|
+
class PrintFirstAndLastName < ApplicationAction
|
388
|
+
requires :user, User do
|
389
|
+
validates :name, format: { with: /.+ .+/ }
|
390
|
+
end
|
391
|
+
|
392
|
+
def execute
|
393
|
+
name_parts = user.name.split(' ')
|
394
|
+
puts("First name: #{name_parts.first}. Last name: #{name_parts.last}")
|
395
|
+
end
|
396
|
+
end
|
397
|
+
|
398
|
+
user = User.find(1)
|
399
|
+
user.is_a?(ActiveRecord::Base)
|
400
|
+
# => true
|
401
|
+
user.name
|
402
|
+
# => "David Runger"
|
403
|
+
action = PrintFirstAndLastName.new(user: user)
|
404
|
+
action.valid?
|
405
|
+
# => true
|
406
|
+
action.errors.to_hash
|
407
|
+
# => {}
|
408
|
+
action.run!
|
409
|
+
# => prints "First name: David. Last name: Runger"
|
410
|
+
|
411
|
+
user = User.find(2)
|
412
|
+
user.name
|
413
|
+
# => "Cher"
|
414
|
+
action = PrintFirstAndLastName.new(user: user)
|
415
|
+
action.valid?
|
416
|
+
# => false
|
417
|
+
action.errors.to_hash
|
418
|
+
# => {:name=>["is invalid"]}
|
419
|
+
action.run!
|
420
|
+
# => raises RungerActions::InvalidParam
|
421
|
+
```
|
422
|
+
|
423
|
+
### `::returns`
|
424
|
+
|
425
|
+
The `::returns` class method describes the value(s) that an action promises to return (if any).
|
426
|
+
|
427
|
+
As with `requires`, an action can have zero, one, or more `returns` statements.
|
428
|
+
|
429
|
+
An action that is used for its "side effects," such as most of the examples above that use `puts` to
|
430
|
+
print output, will probably not have any `returns` statements.
|
431
|
+
|
432
|
+
However, if you want the action to return object(s)/data to other parts of your code, then you'll
|
433
|
+
need to declare those return values using the `returns` class method.
|
434
|
+
|
435
|
+
Here's an example:
|
436
|
+
|
437
|
+
```rb
|
438
|
+
class MultiplyNumber < ApplicationAction
|
439
|
+
requires :input_number, Numeric
|
440
|
+
|
441
|
+
returns :doubled_number, Numeric
|
442
|
+
returns :tripled_number, Numeric
|
443
|
+
|
444
|
+
def execute
|
445
|
+
result.doubled_number = input_number * 2
|
446
|
+
result.tripled_number = input_number * 3
|
447
|
+
end
|
448
|
+
end
|
449
|
+
|
450
|
+
multiply_result = MultiplyNumber.new(input_number: 1.5).run
|
451
|
+
multiply_result.class
|
452
|
+
# => MultiplyNumber::Result
|
453
|
+
puts("The number doubled is #{multiply_result.doubled_number}")
|
454
|
+
# => prints "The number doubled is 3.0"
|
455
|
+
puts("The number tripled is #{multiply_result.tripled_number}")
|
456
|
+
# => prints "The number tripled is 4.5"
|
457
|
+
```
|
458
|
+
|
459
|
+
#### The `result` object
|
460
|
+
|
461
|
+
We can see in the example above that `MultiplyNumber#execute` references `result`, which is an
|
462
|
+
object provided automatically to action instances. Because the `MultiplyNumber` action declares
|
463
|
+
`returns :doubled_number` and `returns :tripled_number`, the `result` object automatically has
|
464
|
+
`#doubled_number=` and `#tripled_number=` writer methods, which can (and should) be invoked by the
|
465
|
+
action instance in order to set those values on the `result` object.
|
466
|
+
|
467
|
+
When we call `MultiplyNumber.new(input_number: 1.5).run`, the return value of `#run` is the action's
|
468
|
+
`result` object. Outside of the action, we can then access the return values that were set within
|
469
|
+
the action's `#execute` method; we do this via the `#doubled_number` and `#tripled_number` reader
|
470
|
+
methods that are defined on the result object (which we captured in a local variable called
|
471
|
+
`multiply_result`).
|
472
|
+
|
473
|
+
#### All promised values must be returned
|
474
|
+
|
475
|
+
If an action fails to set any promised return values on the `result` object, then an error will be
|
476
|
+
raised when `#run` is called:
|
477
|
+
|
478
|
+
```rb
|
479
|
+
class MultiplyNumber < ApplicationAction
|
480
|
+
requires :input_number, Numeric
|
481
|
+
|
482
|
+
returns :doubled_number, Numeric
|
483
|
+
returns :tripled_number, Numeric
|
484
|
+
|
485
|
+
def execute
|
486
|
+
# PROBLEM BELOW! An error will be raised when this action is executed,
|
487
|
+
# because we fail to set a `doubled_number` return value.
|
488
|
+
|
489
|
+
# result.doubled_number = input_number * 2
|
490
|
+
result.tripled_number = input_number * 3
|
491
|
+
end
|
492
|
+
end
|
493
|
+
|
494
|
+
multiply_result = MultiplyNumber.new(input_number: 10).run
|
495
|
+
# => raises RungerActions::MissingResultValue
|
496
|
+
```
|
497
|
+
|
498
|
+
#### Validating the "shape" of returned values
|
499
|
+
|
500
|
+
As with the `requires` action class method, the "shape" of the promised return values declared via
|
501
|
+
`returns` can be described via the arguments to `returns`, which are passed to the [`shaped`
|
502
|
+
gem](https://github.com/davidrunger/shaped/). Leveraging this functionality allows you to ensure
|
503
|
+
that your action is providing the expected type of return values.
|
504
|
+
|
505
|
+
```rb
|
506
|
+
class UppercaseEmail < ApplicationAction
|
507
|
+
requires :email, String, format: { with: /.+@.+/ }
|
508
|
+
|
509
|
+
returns :uppercased_email, String, format: { with: /[A-Z]+@[A-Z.]+/ }
|
510
|
+
|
511
|
+
def execute
|
512
|
+
result.uppercased_email = email.upcase
|
513
|
+
end
|
514
|
+
end
|
515
|
+
|
516
|
+
UppercaseEmail.new(email: 'david@protonmail.com').run.uppercased_email
|
517
|
+
# => "DAVID@PROTONMAIL.COM"
|
518
|
+
```
|
519
|
+
|
520
|
+
If an action attempts to set a return value that doesn't match the specified "shape" for that return
|
521
|
+
value, then an `RungerActions::TypeMismatch` error will be raised:
|
522
|
+
|
523
|
+
```rb
|
524
|
+
class UppercaseEmail < ApplicationAction
|
525
|
+
requires :email, String, format: { with: /.+@.+/ }
|
526
|
+
|
527
|
+
returns :uppercased_email, String, format: { with: /[A-Z]+@[A-Z.]+/ }
|
528
|
+
|
529
|
+
def execute
|
530
|
+
# PROBLEM BELOW! This action is supposed to _upcase_ the email, not downcase it!
|
531
|
+
result.uppercased_email = email.downcase
|
532
|
+
end
|
533
|
+
end
|
534
|
+
|
535
|
+
UppercaseEmail.new(email: 'david@protonmail.com').run
|
536
|
+
# => raises RungerActions::TypeMismatch
|
537
|
+
```
|
538
|
+
|
539
|
+
### `::fails_with`
|
540
|
+
|
541
|
+
The `::fails_with` class method can be used to enumerate possible "failure modes" for the action.
|
542
|
+
|
543
|
+
As with `requires` and `returns`, an action can have zero, one, or more `fails_with` statements.
|
544
|
+
|
545
|
+
Generally, it's best to try to write actions in a way such that we don't expect any failures, but
|
546
|
+
sometimes there are things outside of our control; in such cases, using `fails_with` to list these
|
547
|
+
possible points of failure is a good idea. For example, a call to an external API might time out or
|
548
|
+
receive a 500 error response.
|
549
|
+
|
550
|
+
Here's a (contrived) example with one `fails_with` declaration:
|
551
|
+
|
552
|
+
```rb
|
553
|
+
class PrintRandomNumberAboveFive < ApplicationAction
|
554
|
+
fails_with :number_was_too_small
|
555
|
+
|
556
|
+
def execute
|
557
|
+
random_number = rand(10)
|
558
|
+
if random_number > 5
|
559
|
+
puts(random_number)
|
560
|
+
else
|
561
|
+
result.number_was_too_small!
|
562
|
+
end
|
563
|
+
end
|
564
|
+
end
|
565
|
+
|
566
|
+
result = PrintRandomNumberAboveFive.new.run
|
567
|
+
# => prints "9" (sometimes)
|
568
|
+
result.success?
|
569
|
+
# => true
|
570
|
+
result.number_was_too_small?
|
571
|
+
# => false
|
572
|
+
```
|
573
|
+
|
574
|
+
In the case above, we didn't encounter the error condition, which we can verify via the `#success?`
|
575
|
+
and `#number_was_too_small?` methods on the result. `#success?` is available on all action results,
|
576
|
+
and `#number_was_too_small?` is available for this particular action result because the action class
|
577
|
+
declares `fails_with :number_was_too_small`.
|
578
|
+
|
579
|
+
And here's what a failure case would look like:
|
580
|
+
|
581
|
+
```rb
|
582
|
+
result = PrintRandomNumberAboveFive.new.run
|
583
|
+
# => [doesn't print anything, if the random number is <= 5]
|
584
|
+
result.success?
|
585
|
+
# => false
|
586
|
+
result.number_was_too_small?
|
587
|
+
# => true
|
588
|
+
```
|
589
|
+
|
590
|
+
In this case, we entered the `else` branch of the action's `#execute` method and called the
|
591
|
+
`result.number_was_too_small!` method (made available automatically because of the class's
|
592
|
+
`fails_with :number_was_too_small` declaration). Since we called the `result.number_was_too_small!`
|
593
|
+
method, indicating that that failure mode occurred when executing the action, `#success?` returns
|
594
|
+
`false` and `#number_was_too_small?` returns `true`.
|
595
|
+
|
596
|
+
#### Setting an `error_message`
|
597
|
+
|
598
|
+
When invoking a `fails_with` error case, the bang method can optionally take an error message as an
|
599
|
+
argument, which will then be made available via a special `error_message` reader on the result
|
600
|
+
object:
|
601
|
+
|
602
|
+
```rb
|
603
|
+
class SellAlcohol < ApplicationAction
|
604
|
+
requires :age, Numeric
|
605
|
+
|
606
|
+
fails_with :too_young
|
607
|
+
|
608
|
+
def execute
|
609
|
+
if age < 21
|
610
|
+
result.too_young!("Age #{age} is too young to buy alcohol.")
|
611
|
+
else
|
612
|
+
puts('Enjoy your alcohol responsibly!')
|
613
|
+
end
|
614
|
+
end
|
615
|
+
end
|
616
|
+
|
617
|
+
result = SellAlcohol.new!(age: 17).run
|
618
|
+
result.success?
|
619
|
+
# => false
|
620
|
+
result.too_young?
|
621
|
+
# => true
|
622
|
+
result.error_message
|
623
|
+
# => "Age 17 is too young to buy alcohol."
|
624
|
+
```
|
625
|
+
|
626
|
+
# Alternatives
|
627
|
+
|
628
|
+
This project is not the first of its kind!
|
629
|
+
|
630
|
+
Here are a few similar projects:
|
631
|
+
* [`interactor`](https://github.com/collectiveidea/interactor)
|
632
|
+
* [`active_interaction`](https://github.com/AaronLasseigne/active_interaction)
|
633
|
+
* [`mutations`](https://github.com/cypriss/mutations)
|
634
|
+
* [`service_actor`](https://github.com/sunny/actor)
|
635
|
+
|
636
|
+
# Status / Context
|
637
|
+
|
638
|
+
I wouldn't recommend using this gem in production. It's very new (i.e. probably rough around the
|
639
|
+
edges, subject to significant changes at a relatively rapid rate, and arguably somewhat feature
|
640
|
+
incomplete) and I am not committed to maintaing the gem.
|
641
|
+
|
642
|
+
I mostly built this gem because I wasn't _quite_ satisfied with any of the above alternatives that I
|
643
|
+
knew about at the time that I decided to start building it. I built this gem mostly to scratch my
|
644
|
+
own itch and for the sake of exploring this problem space a little bit.
|
645
|
+
|
646
|
+
I am actively using this gem in the small Rails application that hosts my personal website and apps;
|
647
|
+
you can check out its [`app/actions/`
|
648
|
+
directory](https://github.com/davidrunger/david_runger/tree/master/app/actions) if you are
|
649
|
+
interested in seeing some real-world use cases.
|
650
|
+
|
651
|
+
# Development
|
652
|
+
|
653
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `bin/rspec` to run
|
654
|
+
the tests. You can also run `bin/console` for an interactive prompt that will allow you to
|
655
|
+
experiment.
|
656
|
+
|
657
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
658
|
+
|
659
|
+
# License
|
660
|
+
|
661
|
+
The gem is available as open source under the terms of the [MIT
|
662
|
+
License](https://opensource.org/licenses/MIT).
|
data/RELEASING.md
ADDED
data/Rakefile
ADDED
data/bin/_guard-core
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application '_guard-core' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
require 'pathname'
|
12
|
+
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', Pathname.new(__FILE__).realpath)
|
13
|
+
|
14
|
+
bundle_binstub = File.expand_path('bundle', __dir__)
|
15
|
+
|
16
|
+
if File.file?(bundle_binstub)
|
17
|
+
if File.read(bundle_binstub, 300).include?('This file was generated by Bundler')
|
18
|
+
load(bundle_binstub)
|
19
|
+
else
|
20
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
21
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
require 'rubygems'
|
26
|
+
require 'bundler/setup'
|
27
|
+
|
28
|
+
load Gem.bin_path('guard', '_guard-core')
|