lite-command 2.0.3 → 2.1.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: b21cf35a72b7a37760d250919ee052e3800bfe09083709e9d9f65593612d8826
4
- data.tar.gz: b2b55e457b95ce878aab140537ba1f28aa5310c4e257d0e2b11948ed9ee7f9c2
3
+ metadata.gz: efd37a3a2fcbb8d90fef73088313f18bd281922d46594a4a57ee8dd34fcaf801
4
+ data.tar.gz: 4e88ca10b986095be52c3e454e70a6c8d7b3556bb4770ed51aca80d0b9beb894
5
5
  SHA512:
6
- metadata.gz: f8ab004280e1c00026dacc92a7d652dc2859c68f6648e31210ccfa1c16a1aba9ba31e57843910bf90d4b94677e8cd8c4c651989c2e2f1983556f33ff786e6514
7
- data.tar.gz: aed6da43c95926071114ef9692b17c4fa2b5a3215629e473c8bd70a552fdd624f189095cfa1744da6aa427cbcf80cfb6adb6db81c5b754f4ff81bfee500d046d
6
+ metadata.gz: 4123ef0a3315a8b00796a58ca65b10bc2a51905dc59e663b1ffef2ec7f9becc700ae64216cb896024564993749979cd824087a68bac1e95532311dba29cd7aa0
7
+ data.tar.gz: dd4e22ead3d68f981319a372a0ce305f734f1745a31223daf725d93ff71cccbe1b4708e7b3d7ef4921f98f2e8df6c04939855b8a7e8d0e3292f5e7f3e0ec6c1a
data/.rubocop.yml CHANGED
@@ -51,6 +51,12 @@ RSpec/MultipleExpectations:
51
51
  Enabled: false
52
52
  RSpec/MultipleMemoizedHelpers:
53
53
  Enabled: false
54
+ RSpec/NestedGroups:
55
+ Enabled: false
56
+ RSpec/StringAsInstanceDoubleConstant:
57
+ Enabled: false
58
+ RSpec/VerifiedDoubleReference:
59
+ EnforcedStyle: string
54
60
  Style/ArgumentsForwarding:
55
61
  Enabled: false
56
62
  Style/Documentation:
data/CHANGELOG.md CHANGED
@@ -6,6 +6,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.1.0] - 2024-10-05
10
+ ### Added
11
+ - Added passing metadata to faults
12
+ - Added `on_success` callback
13
+ - Added `on_pending`, `on_executing`, `on_complete`, and `on_interrupted` callbacks
14
+ - Added attributes and attribute validations
15
+ - Added sequences
16
+ ### Changed
17
+ - Check error descendency instead of type
18
+ - Rename internal modules
19
+ - Make execute(!) methods private
20
+ ### Removed
21
+ - Remove predefined callback methods
22
+ - Remove non-bang fault methods
23
+
9
24
  ## [2.0.3] - 2024-09-30
10
25
  ### Changed
11
26
  - Simplify error building
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- lite-command (2.0.3)
4
+ lite-command (2.1.0)
5
5
  ostruct
6
6
 
7
7
  GEM
@@ -114,7 +114,7 @@ GEM
114
114
  rspec-expectations (3.13.3)
115
115
  diff-lcs (>= 1.2.0, < 2.0)
116
116
  rspec-support (~> 3.13.0)
117
- rspec-mocks (3.13.1)
117
+ rspec-mocks (3.13.2)
118
118
  diff-lcs (>= 1.2.0, < 2.0)
119
119
  rspec-support (~> 3.13.0)
120
120
  rspec-support (3.13.1)
@@ -135,7 +135,7 @@ GEM
135
135
  rubocop-ast (>= 1.31.1, < 2.0)
136
136
  rubocop-rake (0.6.0)
137
137
  rubocop (~> 1.0)
138
- rubocop-rspec (3.0.5)
138
+ rubocop-rspec (3.1.0)
139
139
  rubocop (~> 1.61)
140
140
  ruby-progressbar (1.13.0)
141
141
  ruby_parser (3.21.1)
data/README.md CHANGED
@@ -1,7 +1,6 @@
1
1
  # Lite::Command
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/lite-command.svg)](http://badge.fury.io/rb/lite-command)
4
- [![Build Status](https://travis-ci.org/drexed/lite-command.svg?branch=master)](https://travis-ci.org/drexed/lite-command)
5
4
 
6
5
  Lite::Command provides an API for building simple and complex command based service objects.
7
6
 
@@ -9,6 +8,10 @@ Lite::Command provides an API for building simple and complex command based serv
9
8
 
10
9
  Add this line to your application's Gemfile:
11
10
 
11
+ > [!NOTE]
12
+ > Gem versions `2.0.0`, `2.0.1`, `2.0.2`, and `2.0.3` are borked.
13
+ > Version `2.1.0` is the latest working version.
14
+
12
15
  ```ruby
13
16
  gem 'lite-command'
14
17
  ```
@@ -25,50 +28,112 @@ Or install it yourself as:
25
28
 
26
29
  * [Setup](#setup)
27
30
  * [Execution](#execution)
31
+ * [Dynamic Faults](#dynamic-faults)
28
32
  * [Context](#context)
29
- * [Internals](#Internals)
33
+ * [Attributes](#attributes)
34
+ * [States](#states)
35
+ * [Statuses](#statuses)
36
+ * [Callbacks](#callbacks)
37
+ * [State Hooks](#status-hooks)
38
+ * [Execution Hooks](#execution-hooks)
39
+ * [Status Hooks](#status-hooks)
40
+ * [Children](#children)
41
+ * [Throwing Faults](#throwing-faults)
42
+ * [Sequences](#sequences)
43
+ * [Results](#results)
44
+ * [Examples](#examples)
45
+ * [Disable Instance Calls](#disable-instance-calls)
46
+ * [ActiveModel Validations](#activemodel-validations)
30
47
  * [Generator](#generator)
31
48
 
32
49
  ## Setup
33
50
 
34
- Defining a command is as simple as adding a call method.
51
+ Defining a command is as simple as adding a call method to a command object (required).
35
52
 
36
53
  ```ruby
37
54
  class CalculatePower < Lite::Command::Base
38
55
 
39
56
  def call
40
- # TODO: implement calculator
57
+ if all_even_numbers?
58
+ context.result = ctx.a ** ctx.b
59
+ else
60
+ invalid!("All values must be even")
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def all_even_numbers?
67
+ # Some logic...
41
68
  end
42
69
 
43
70
  end
44
71
  ```
45
72
 
73
+ > [!TIP]
74
+ > You should make all of your domain logic private so that only the command API is exposed.
75
+
46
76
  ## Execution
47
77
 
48
78
  Executing a command can be done as an instance or class call.
49
- It returns the command instance in a forzen state.
79
+ It returns the command instance in a frozen state.
50
80
  These will never call will never raise an execption, but will
51
81
  be kept track of in its internal state.
52
82
 
53
- **NOTE:** Class calls is the prefered format due to its readability.
54
-
55
83
  ```ruby
56
- # Class call
57
- CalculatePower.call(..args)
58
-
59
- # Instance call
60
- caculator = CalculatePower.new(..args).call
84
+ CalculatePower.call(...)
85
+ # - or -
86
+ CalculatePower.new(...).call
61
87
 
88
+ # On success, fault and exception:
62
89
  #=> <CalculatePower ...>
63
90
  ```
64
91
 
92
+ > [!TIP]
93
+ > Class calls is the prefered format due to its readability.
94
+
65
95
  Commands can be called with a `!` bang method to raise a
66
96
  `Lite::Command::Fault` based exception or the original
67
97
  `StandardError` based exception.
68
98
 
69
99
  ```ruby
70
- CalculatePower.call!(..args)
100
+ CalculatePower.call!(...)
101
+ # - or -
102
+ CalculatePower.new(...).call!
103
+
104
+ # On success:
105
+ #=> <CalculatePower ...>
106
+
107
+ # On fault:
71
108
  #=> raises Lite::Command::Fault
109
+
110
+ # On exception:
111
+ #=> raises StandardError
112
+ ```
113
+
114
+ ### Dynamic Faults
115
+
116
+ You can enable dynamic faults named after your command. This is
117
+ especially helpful for catching + running custom logic or filtering
118
+ out specific errors from you APM service.
119
+
120
+ ```ruby
121
+ class CalculatePower < Lite::Command::Base
122
+
123
+ def call
124
+ fail!("Some failure")
125
+ end
126
+
127
+ private
128
+
129
+ def raise_dynamic_faults?
130
+ true
131
+ end
132
+
133
+ end
134
+
135
+ CalculatePower.call!(...)
136
+ #=> raises CalculatePower::Fault
72
137
  ```
73
138
 
74
139
  ## Context
@@ -81,7 +146,8 @@ of its children commands.
81
146
  class CalculatePower < Lite::Command::Base
82
147
 
83
148
  def call
84
- context.result = context.a ** context.b
149
+ # `ctx` is an alias to `context`
150
+ context.result = ctx.a ** ctx.b
85
151
  end
86
152
 
87
153
  end
@@ -90,50 +156,425 @@ command = CalculatePower.call(a: 2, b: 3)
90
156
  command.context.result #=> 8
91
157
  ```
92
158
 
93
- ## Internals
94
-
95
- #### States
96
- State represents the state of the executable code. Once `execute`
97
- is ran, it will always `complete` or `interrupted` if a fault is thrown by a
98
- child command.
99
-
100
- - `pending`
101
- - Command objects that have been initialized.
102
- - `executing`
103
- - Command objects actively executing code.
104
- - `complete`
105
- - Command objects that executed to completion.
106
- - `interrupted`
107
- - Command objects that could NOT be executed to completion.
108
- This could be as a result of a fault/exception on the
109
- object itself or one of its children.
110
-
111
- #### Statuses
112
-
113
- Status represents the state of the callable code. If no fault
114
- is thrown then a status of `success` is returned even if `call`
115
- has not been executed. The list of status include (by severity):
116
-
117
- - `success`
118
- - No fault or exception
119
- - `noop`
120
- - Noop represents skipping completion of call execution early
121
- an unsatisfied condition or logic check where there is no
122
- point on proceeding.
123
- - **eg:** account is sample: skip since its a non-alterable record
124
- - `invalid`
125
- - Invalid represents a stoppage of call execution due to
126
- missing, bad, or corrupt data.
127
- - **eg:** user not found: stop since rest of the call cant be executed
128
- - `failure`
129
- - Failure represents a stoppage of call execution due to
130
- an unsatisfied condition or logic check where it blocks
131
- proceeding any further.
132
- - **eg:** record not found: stop since there is nothing todo
133
- - `error`
134
- - Error represents a caught exception for a call execution
135
- that could not complete.
136
- - **eg:** ApiServerError: stop since there was a 3rd party issue
159
+ ### Attributes
160
+
161
+ Delegate methods for a cleaner command setup, type checking and
162
+ argument requirements. Setup a contract by using the `attribute`
163
+ method which automatically delegates to `context`.
164
+
165
+ | Options | Values | Default | Description |
166
+ | ---------- | ------ | ------- | ----------- |
167
+ | `from` | Symbol, String | `:context` | The object containing the attribute. |
168
+ | `types` | Symbol, String, Array, Proc | | The allowed class types of the attribute value. |
169
+ | `required` | Symbol, String, Boolean, Proc | `false` | The attribute must be passed to the context or delegatable (no matter the value). |
170
+ | `filled` | Symbol, String, Boolean, Proc | `false` | The attribute value must be not be `nil`. |
171
+
172
+ > [!NOTE]
173
+ > If optioned with some similar to `filled: true, types: [String, NilClass]`
174
+ > then `NilClass` for the `types` option will be removed automatically.
175
+
176
+ ```ruby
177
+ class CalculatePower < Lite::Command::Base
178
+
179
+ attribute :remote_storage, required: true, filled: true, types: RemoteStorage
180
+
181
+ attribute :a, :b
182
+ attribute :c, :d, from: :remote_storage, types: [Integer, Float]
183
+ attribute :x, :y, from: :local_storage, if: :signed_in?
184
+
185
+ def call
186
+ context.result =
187
+ (a.to_i ** b.to_i) +
188
+ (c.to_i + d.to_i) -
189
+ (x.to_i + y.to_i)
190
+ end
191
+
192
+ private
193
+
194
+ def local_storage
195
+ @local_storage ||= LocalStorage.new(x: 1, y: 1, z: 99)
196
+ end
197
+
198
+ def signed_in?
199
+ ctx.user.signed_in?
200
+ end
201
+
202
+ end
203
+
204
+ # With valid options:
205
+ storage = RemoteStorage.new(c: 2, d: 2, j: 99)
206
+ command = CalculatePower.call(a: 2, b: 2, remote_storage: storage)
207
+ command.status #=> "success"
208
+ command.context.result #=> 6
209
+
210
+ # With invalid options
211
+ command = CalculatePower.call
212
+ command.status #=> "invalid"
213
+ command.reason #=> "Invalid context attributes"
214
+ command.metadata #=> {
215
+ #=> context: ["a is required", "remote_storage must be filled"],
216
+ #=> remote_storage: ["d type invalid"]
217
+ #=> local_storage: ["is not defined or an attribute"]
218
+ #=> }
219
+ ```
220
+
221
+ ## States
222
+ `state` represents the condition of all the code command should execute.
223
+
224
+ | Status | Description |
225
+ | ------------- | ----------- |
226
+ | `pending` | Command objects that have been initialized. |
227
+ | `executing` | Command objects that are actively executing code. |
228
+ | `complete` | Command objects that executed to completion without fault/exception. |
229
+ | `interrupted` | Command objects that could **NOT** be executed to completion due to a fault/exception. |
230
+
231
+ > [!CAUTION]
232
+ > States are automatically transitioned and should **NEVER** be altered manually.
233
+
234
+ ```ruby
235
+ class CalculatePower < Lite::Command::Base
236
+
237
+ def call
238
+ # ...
239
+ end
240
+
241
+ end
242
+
243
+ command = CalculatePower.call(a: 1, b: 3)
244
+ command.state #=> "executed"
245
+ command.pending? #=> false
246
+ command.executed? #=> false
247
+ ```
248
+
249
+ ## Statuses
250
+
251
+ `status` represents the state of the domain logic executed via the `call` method.
252
+ A status of `success` is returned even if the command has **NOT** been executed.
253
+
254
+ | Status | Description |
255
+ | --------- | ----------- |
256
+ | `success` | Call execution completed without fault/exception. |
257
+ | `noop` | **Fault** to skip completion of call execution early for an unsatisfied condition where proceeding is pointless. |
258
+ | `invalid` | **Fault** to stop call execution due to missing, bad, or corrupt data. |
259
+ | `failure` | **Fault** to stop call execution due to an unsatisfied condition where it blocks proceeding any further. |
260
+ | `error` | **Fault** to stop call execution due to a thrown `StandardError` based exception. |
261
+
262
+ > [!IMPORTANT]
263
+ > Each **fault** status has a setter method ending in `!` that invokes a matching fault procedure.
264
+ > Metadata may also be passed to enrich your fault response.
265
+
266
+ ```ruby
267
+ class CalculatePower < Lite::Command::Base
268
+
269
+ def call
270
+ if ctx.a.nil? || ctx.b.nil?
271
+ invalid!("An a and b parameter must be passed")
272
+ elsif ctx.a < 1 || ctx.b < 1
273
+ failure!("Parameters must be >= 1")
274
+ elsif ctx.a == 1 || ctx.b == 1
275
+ noop!(
276
+ "Anything to the power of 1 is 1",
277
+ { i18n: "some.key" }
278
+ )
279
+ else
280
+ ctx.result = ctx.a ** ctx.b
281
+ end
282
+ rescue DivisionError => e
283
+ error!("Cathcing it myself")
284
+ end
285
+
286
+ end
287
+
288
+ command = CalculatePower.call(a: 1, b: 3)
289
+ command.ctx.result #=> nil
290
+ command.status #=> "noop"
291
+ command.reason #=> "Anything to the power of 1 is 1"
292
+ command.metadata #=> { i18n: "some.key" }
293
+ command.invalid? #=> false
294
+ command.noop? #=> true
295
+ command.noop?("Anything to the power of 1 is 1") #=> true
296
+ ```
297
+
298
+ ## Callbacks
299
+
300
+ Use callbacks to run arbituary code at transition points and
301
+ on finalized internals. The following is an example of the hooks
302
+ called for a failed command with a successful child command.
303
+
304
+ ```ruby
305
+ -> 1. FooCommand.on_pending
306
+ -> 2. FooCommand.on_before_execution
307
+ -> 3. FooCommand.on_executing
308
+ ---> 3a. BarCommand.on_pending
309
+ ---> 3b. BarCommand.on_before_execution
310
+ ---> 3c. BarCommand.on_executing
311
+ ---> 3d. BarCommand.on_after_execution
312
+ ---> 3e. BarCommand.on_success
313
+ ---> 3f. BarCommand.on_complete
314
+ -> 4. FooCommand.on_after_execution
315
+ -> 5. FooCommand.on_failure
316
+ -> 6. FooCommand.on_interrupted
317
+ ```
318
+
319
+ ### Status Hooks
320
+
321
+ Define one or more callbacks that are called during transitions
322
+ between states.
323
+
324
+ ```ruby
325
+ class CalculatePower < Lite::Command::Base
326
+
327
+ def call
328
+ # ...
329
+ end
330
+
331
+ private
332
+
333
+ def on_pending
334
+ # eg: Append additional contextual data
335
+ end
336
+
337
+ def on_executing
338
+ # eg: Insert inspection debugger
339
+ end
340
+
341
+ def on_complete
342
+ # eg: Log message for posterity
343
+ end
344
+
345
+ def on_interrupted
346
+ # eg: Report to APM with tags and metadata
347
+ end
348
+
349
+ end
350
+ ```
351
+
352
+ ### Execution Hooks
353
+
354
+ Define before and after callbacks to call around execution.
355
+
356
+ ```ruby
357
+ class CalculatePower < Lite::Command::Base
358
+
359
+ def call
360
+ # ...
361
+ end
362
+
363
+ private
364
+
365
+ def on_before_execution
366
+ # eg: Append additional contextual data
367
+ end
368
+
369
+ def on_after_execution
370
+ # eg: Store results to database
371
+ end
372
+
373
+ end
374
+ ```
375
+
376
+ ### Status Hooks
377
+
378
+ Define one or more callbacks that are called after execution for
379
+ specific statuses.
380
+
381
+ ```ruby
382
+ class CalculatePower < Lite::Command::Base
383
+
384
+ def call
385
+ # ...
386
+ end
387
+
388
+ private
389
+
390
+ def on_success
391
+ # eg: Increment KPI counter
392
+ end
393
+
394
+ def on_noop(fault)
395
+ # eg: Log message for posterity
396
+ end
397
+
398
+ def on_invalid(fault)
399
+ # eg: Send metadata errors to frontend
400
+ end
401
+
402
+ def on_failure(fault)
403
+ # eg: Rollback record changes
404
+ end
405
+
406
+ def on_error(fault_or_exception)
407
+ # eg: Report to APM with tags and metadata
408
+ end
409
+
410
+ end
411
+ ```
412
+
413
+ > [!NOTE]
414
+ > The `on_success` callback does **NOT** take any arguments.
415
+
416
+ ## Children
417
+
418
+ When building complex commands, its best that you pass the
419
+ parents context to the child command (unless neccessary) so
420
+ that it gains automated indexing and the parents `cmd_id`.
421
+
422
+ ```ruby
423
+ class CalculatePower < Lite::Command::Base
424
+
425
+ def call
426
+ CalculateSqrt.call(context.merge!(some_other: "required value"))
427
+ end
428
+
429
+ end
430
+ ```
431
+
432
+ ### Throwing Faults
433
+
434
+ Throwing faults allows you to bubble up child faults up to the parent.
435
+ Use it to create branches within your logic and create clean tracing
436
+ of your command results. You can use `throw!` as a catch-all or any
437
+ of the bang status method `failure!`.
438
+
439
+ ```ruby
440
+ class CalculatePower < Lite::Command::Base
441
+
442
+ def call
443
+ command = CalculateSqrt.call(context.merge!(some_other: "required value"))
444
+
445
+ if command.noop?("Sqrt of 1 is 1")
446
+ # Manually throw any fault you want
447
+ invalid!(command)
448
+ elsif command.fault?
449
+ # Automatically throws a matching fault type
450
+ throw!(command)
451
+ else
452
+ # Success, do nothing
453
+ end
454
+ end
455
+
456
+ end
457
+ ```
458
+
459
+ ## Sequences
460
+
461
+ A sequence is a command that calls commands in a linear fashion.
462
+ This is useful for composing multiple steps into one call.
463
+
464
+ > [!NOTE]
465
+ > Sequences only stop processing on `invalid`, `failure`, and `error`
466
+ > faults. This is due to the the idea the `noop` performs no work,
467
+ > so its no different than just passing the context forward. To change
468
+ > this behavior, just override the `ok?` method with you logic, eg: just `success`
469
+
470
+ ```ruby
471
+ class ProcessCheckout < Lite::Command::Sequence
472
+
473
+ attribute :user, required: true, filled: true
474
+
475
+ step FinalizeInvoice
476
+ step ChargeCard, if: :card_available?
477
+ step SendConfirmationEmail, SendConfirmationText
478
+ step NotifyWarehouse, unless: proc { ctx.invoice.fullfilled_by_amazon? }
479
+
480
+ # Do NOT set a call method.
481
+ # Its defined by Lite::Command::Sequence
482
+
483
+ private
484
+
485
+ def card_available?
486
+ user.has_card?
487
+ end
488
+
489
+ end
490
+
491
+ sequence = ProcessCheckout.call(...)
492
+ # <ProcessCheckout ...>
493
+ ```
494
+
495
+ ## Results
496
+
497
+ During any point in the lifecyle of a command, `to_hash` can be
498
+ called to dump out the current values. The `index` value is
499
+ auto-incremented and the `cmd_id` is static when its passed to
500
+ child commands. This helps with debugging and logging.
501
+
502
+ ```ruby
503
+ command = CalculatePower.call(...)
504
+ command.to_hash #=> {
505
+ #=> index: 1,
506
+ #=> cmd_id: "018c2b95-b764-7615-a924-cc5b910ed1e5",
507
+ #=> command: "FailureCommand",
508
+ #=> outcome: "failure",
509
+ #=> state: "interrupted",
510
+ #=> status: "failure",
511
+ #=> reason: "[!] command stopped due to failure",
512
+ #=> metadata: {
513
+ #=> errors: { name: ["is too short"] },
514
+ #=> i18n_key: "command.failure"
515
+ #=> },
516
+ #=> caused_by: 1,
517
+ #=> thrown_by: 1,
518
+ #=> runtime: 0.0123
519
+ #=> }
520
+ ```
521
+
522
+ ## Examples
523
+
524
+ ### Disable Instance Calls
525
+
526
+ ```ruby
527
+ class CalculatePower < Lite::Command::Base
528
+
529
+ private_class_method :new
530
+
531
+ def call
532
+ # ...
533
+ end
534
+
535
+ end
536
+
537
+ CalculatePower.new(...).call
538
+ #=> raise NoMethodError
539
+ ```
540
+
541
+ ### ActiveModel Validations
542
+
543
+ ```ruby
544
+ class CalculatePower < Lite::Command::Base
545
+ include ActiveModel::Validations
546
+
547
+ validates :a, :b, presence: true
548
+
549
+ def call
550
+ # ...
551
+ end
552
+
553
+ def read_attribute_for_validation(key)
554
+ context.public_send(key)
555
+ end
556
+
557
+ private
558
+
559
+ def on_before_execution
560
+ return if valid?
561
+
562
+ invalid!(
563
+ errors.full_messages.to_sentence,
564
+ errors.to_hash
565
+ )
566
+ end
567
+
568
+ end
569
+
570
+ CalculatePower.call!
571
+
572
+ # With `validate!`
573
+ #=> raise ActiveRecord::RecordInvalid
574
+
575
+ # With `valid?`
576
+ #=> raise Lite::Command::Invalid
577
+ ```
137
578
 
138
579
  ## Generator
139
580