hubbado-sequence 0.4.0 → 0.6.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 +4 -4
- data/CHANGELOG.md +139 -0
- data/README.md +315 -162
- data/hubbado-sequence.gemspec +3 -3
- data/lib/hubbado/sequence/macros/contract/build.rb +3 -3
- data/lib/hubbado/sequence/macros/contract/deserialize.rb +3 -3
- data/lib/hubbado/sequence/macros/contract/persist.rb +4 -4
- data/lib/hubbado/sequence/macros/contract/validate.rb +4 -4
- data/lib/hubbado/sequence/macros/model/build.rb +3 -3
- data/lib/hubbado/sequence/macros/model/find.rb +4 -4
- data/lib/hubbado/sequence/macros/policy/check.rb +6 -8
- data/lib/hubbado/sequence/pipeline.rb +9 -12
- data/lib/hubbado/sequence/result.rb +68 -29
- data/lib/hubbado/sequence/runner.rb +47 -28
- data/lib/hubbado/sequence/sequencer.rb +4 -3
- metadata +7 -6
data/README.md
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
# hubbado-sequence
|
|
2
2
|
|
|
3
|
-
A small framework
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
The
|
|
12
|
-
|
|
3
|
+
A small framework (about 200 lines of core code) for orchestrating the
|
|
4
|
+
short sequences of common steps that controller actions usually boil
|
|
5
|
+
down to — find a model, validate a contract, call a domain object, save
|
|
6
|
+
something, redirect. A sequencer takes input, runs an ordered sequence
|
|
7
|
+
of steps, and returns a `Result` carrying a success-or-failure flag, a
|
|
8
|
+
structured error, and the working context that was built up during
|
|
9
|
+
execution.
|
|
10
|
+
|
|
11
|
+
The DSL is deliberately minimal: every step is a regular method, every
|
|
12
|
+
dependency is a regular Ruby object, and control flow uses regular Ruby
|
|
13
|
+
(`if`, `unless`, `case`) rather than a conditional DSL. The framework
|
|
14
|
+
gets out of the way once you've described the high-level sequence; the
|
|
15
|
+
real work lives in the plain-Ruby objects the steps call.
|
|
16
|
+
|
|
17
|
+
Built with Rails in mind but framework-agnostic — the core has no Rails
|
|
18
|
+
dependency, and the included `RunSequence` mixin works in any host
|
|
19
|
+
(controllers, jobs, scripts). The full design rationale lives in
|
|
20
|
+
[`docs/design.md`](docs/design.md). This README is a quick tour.
|
|
13
21
|
|
|
14
22
|
## Installation
|
|
15
23
|
|
|
@@ -24,9 +32,8 @@ Then run `bundle install`.
|
|
|
24
32
|
## Requirements
|
|
25
33
|
|
|
26
34
|
- Ruby >= 3.3
|
|
27
|
-
- [evt-dependency](https://github.com/eventide-project/dependency) —
|
|
28
|
-
|
|
29
|
-
dependency of the gem).
|
|
35
|
+
- [evt-dependency](https://github.com/eventide-project/dependency) —
|
|
36
|
+
dependency injection (declared as a runtime dependency of the gem).
|
|
30
37
|
|
|
31
38
|
Optional, depending on which macros you use:
|
|
32
39
|
|
|
@@ -39,29 +46,59 @@ Optional, depending on which macros you use:
|
|
|
39
46
|
|
|
40
47
|
## Philosophy
|
|
41
48
|
|
|
42
|
-
Sequencers sit at the controller boundary. They receive input
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
49
|
+
Sequencers sit at the controller boundary. They receive input, orchestrate
|
|
50
|
+
the work, and hand a `Result` back. The sequencer's job is **orchestration
|
|
51
|
+
only** — it should not contain business logic itself. Real behaviour lives
|
|
52
|
+
in the models, contracts, policies, and domain objects it calls.
|
|
53
|
+
|
|
54
|
+
The framework is built with Rails in mind but doesn't require it — the core
|
|
55
|
+
of the gem has no Rails dependency, and `Hubbado::Sequence::RunSequence` is
|
|
56
|
+
a plain mixin that works in any host that drives a sequencer from a fixed
|
|
57
|
+
lifecycle (Sinatra actions, Rack handlers, Hanami actions, job workers).
|
|
58
|
+
ActiveRecord and Reform are only needed if you use the macros that wrap
|
|
59
|
+
them.
|
|
60
|
+
|
|
61
|
+
In a Rails context the gem solves a specific pain: a controller action is
|
|
62
|
+
hard to unit-test because the framework owns its lifecycle, and that gets
|
|
63
|
+
worse the moment you want dependency injection. Sequencers lift the
|
|
64
|
+
testable work *out* of the controller into a plain Ruby object that
|
|
65
|
+
exposes its dependencies cleanly, and `run_sequence` keeps the controller
|
|
66
|
+
itself thin — branching to redirect, render, or set a flash based on the
|
|
67
|
+
sequencer's outcome.
|
|
68
|
+
|
|
69
|
+
Most controller actions shouldn't contain much business logic anyway. They're
|
|
70
|
+
a short sequence of common steps — find a model, validate a contract, save
|
|
71
|
+
something, redirect. The sequencer DSL is designed to make that high-level
|
|
72
|
+
sequence compact and easy to scan, without trying to be the home for the
|
|
73
|
+
business logic underneath. Regular Ruby is already excellent at that.
|
|
74
|
+
|
|
75
|
+
The DSL is deliberately minimal. A sequencer's `pipeline(ctx)` block is a
|
|
76
|
+
small set of conventions around how `ctx` flows and what each step
|
|
77
|
+
returns — nothing more. Steps are regular methods on a regular Ruby
|
|
78
|
+
object, dependencies are regular Ruby objects, and the pipeline lets you
|
|
79
|
+
use regular Ruby `if` / `unless` / `case` for control flow rather than
|
|
80
|
+
inventing a conditional DSL. Where the framework can get out of your way,
|
|
81
|
+
it does.
|
|
82
|
+
|
|
83
|
+
The gem doesn't impose a nesting depth, but in practice we keep nesting
|
|
84
|
+
very shallow — typically one level. The only nesting we use is a `Present`
|
|
85
|
+
sequencer inside an `Update` sequencer: Present loads the record, builds
|
|
86
|
+
the contract, and checks the policy; Update calls Present and then
|
|
87
|
+
validates and persists. Anything deeper is a signal that a chunk of the
|
|
88
|
+
work should be a plain Ruby object instead.
|
|
89
|
+
|
|
90
|
+
The framework uses [evt-dependency](https://github.com/eventide-project/dependency)
|
|
91
|
+
for dependency injection. Every macro and every nested sequencer is an
|
|
92
|
+
injected dependency, which means calling `.new` on a sequencer installs
|
|
93
|
+
substitutes for all of them. Unit tests exercise the sequencer's
|
|
94
|
+
orchestration logic — what runs, in what order, what short-circuits —
|
|
95
|
+
without hitting the database, the policy gem, or Reform. The substitutes
|
|
96
|
+
default to pass-through success, so a test only configures the outcomes
|
|
97
|
+
that matter for the scenario it's verifying.
|
|
98
|
+
|
|
99
|
+
Integration coverage (using `.build` to wire real collaborators) is
|
|
100
|
+
reserved for the controller boundary — one happy-path integration test
|
|
101
|
+
per sequencer is usually enough to confirm the wiring is correct.
|
|
65
102
|
|
|
66
103
|
## Quick start
|
|
67
104
|
|
|
@@ -139,11 +176,14 @@ it directly.
|
|
|
139
176
|
## Built-in macros
|
|
140
177
|
|
|
141
178
|
Each macro is a dependency declared on a sequencer with `dependency :name, Macros::...`
|
|
142
|
-
and wired via `.configure(instance)` in `.build`.
|
|
179
|
+
and wired via `.configure(instance)` in `.build`. The built-in macros are
|
|
180
|
+
grouped by the gem they expect to be available.
|
|
181
|
+
|
|
182
|
+
### ActiveRecord macros
|
|
143
183
|
|
|
144
|
-
|
|
184
|
+
Designed to work with [ActiveRecord](https://github.com/rails/rails) models.
|
|
145
185
|
|
|
146
|
-
|
|
186
|
+
#### Model::Find
|
|
147
187
|
|
|
148
188
|
Fetches a record using `model.find_by(id:)` and writes it to `ctx[as]`.
|
|
149
189
|
|
|
@@ -159,7 +199,7 @@ p.invoke(:find, User, as: :user, id_key: %i[params id]) # nested path (default)
|
|
|
159
199
|
| **Writes** | `ctx[as]` — the found record |
|
|
160
200
|
| **Fails** | `:not_found` when `find_by` returns nil |
|
|
161
201
|
|
|
162
|
-
|
|
202
|
+
#### Model::Build
|
|
163
203
|
|
|
164
204
|
Instantiates a new record and writes it to `ctx[as]`.
|
|
165
205
|
|
|
@@ -174,9 +214,12 @@ p.invoke(:build_record, User, as: :user, attributes: { role: :admin })
|
|
|
174
214
|
| **Writes** | `ctx[as]` — the new instance |
|
|
175
215
|
| **Fails** | never |
|
|
176
216
|
|
|
177
|
-
|
|
217
|
+
### Reform macros
|
|
178
218
|
|
|
179
|
-
|
|
219
|
+
Designed to work with [Reform](https://github.com/trailblazer/reform) form
|
|
220
|
+
objects (contracts).
|
|
221
|
+
|
|
222
|
+
#### Contract::Build
|
|
180
223
|
|
|
181
224
|
Wraps a model in a contract and writes it to `ctx[:contract]`.
|
|
182
225
|
|
|
@@ -191,7 +234,7 @@ p.invoke(:build_contract, Contracts::CreateUser) # no model
|
|
|
191
234
|
| **Writes** | `ctx[:contract]` |
|
|
192
235
|
| **Fails** | never |
|
|
193
236
|
|
|
194
|
-
|
|
237
|
+
#### Contract::Deserialize
|
|
195
238
|
|
|
196
239
|
Deserializes params into the contract via `contract.deserialize(params)`.
|
|
197
240
|
|
|
@@ -206,7 +249,7 @@ p.invoke(:deserialize_to_contract, from: :raw_params)
|
|
|
206
249
|
| **Writes** | nothing (mutates the contract in place) |
|
|
207
250
|
| **Fails** | never (no-op when the `from:` path is absent) |
|
|
208
251
|
|
|
209
|
-
|
|
252
|
+
#### Contract::Validate
|
|
210
253
|
|
|
211
254
|
Validates the contract via `contract.validate(params)` and checks `errors`.
|
|
212
255
|
|
|
@@ -221,7 +264,7 @@ p.invoke(:validate) # contract already deserialized; passes empty params
|
|
|
221
264
|
| **Writes** | nothing (populates `contract.errors` on invalid) |
|
|
222
265
|
| **Fails** | `:validation_failed` when `contract.errors` is non-empty |
|
|
223
266
|
|
|
224
|
-
|
|
267
|
+
#### Contract::Persist
|
|
225
268
|
|
|
226
269
|
Saves the contract via `contract.save`.
|
|
227
270
|
|
|
@@ -235,7 +278,12 @@ p.invoke(:persist)
|
|
|
235
278
|
| **Writes** | nothing |
|
|
236
279
|
| **Fails** | `:persist_failed` when `save` returns false |
|
|
237
280
|
|
|
238
|
-
###
|
|
281
|
+
### hubbado-policy macros
|
|
282
|
+
|
|
283
|
+
Designed to work with the
|
|
284
|
+
[hubbado-policy](https://github.com/hubbado/hubbado-policy) gem.
|
|
285
|
+
|
|
286
|
+
#### Policy::Check
|
|
239
287
|
|
|
240
288
|
Builds a policy and calls the named action to authorise the operation.
|
|
241
289
|
|
|
@@ -243,15 +291,31 @@ Builds a policy and calls the named action to authorise the operation.
|
|
|
243
291
|
p.invoke(:check_policy, Policies::User, :user, :update)
|
|
244
292
|
```
|
|
245
293
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
294
|
+
The policy class must respond to `.build(current_user, record)`; the
|
|
295
|
+
instance must respond to the action method and return a
|
|
296
|
+
`Hubbado::Policy::Result`-shaped object (`permitted?`, `denied?`,
|
|
297
|
+
`reason`, `message`).
|
|
249
298
|
|
|
250
299
|
| | |
|
|
251
300
|
|---|---|
|
|
252
301
|
| **Reads** | `ctx[:current_user]`, `ctx[record_key]` |
|
|
253
302
|
| **Writes** | nothing |
|
|
254
|
-
| **Fails** | `:forbidden` when `permitted?` is false; `
|
|
303
|
+
| **Fails** | `:forbidden` when `permitted?` is false; `result.data` carries `{ policy:, policy_result: }` |
|
|
304
|
+
|
|
305
|
+
A controller can branch on the denial reason via `data`:
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
result.policy_failed do |ctx|
|
|
309
|
+
if result.data[:policy_result].reason == :not_open
|
|
310
|
+
redirect_to public_path(ctx[:job])
|
|
311
|
+
else
|
|
312
|
+
result.raise_policy_failed
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
See [Handling specific failure reasons inside an outcome block](#handling-specific-failure-reasons-inside-an-outcome-block)
|
|
318
|
+
for when `raise_policy_failed` and its siblings come in handy.
|
|
255
319
|
|
|
256
320
|
## Transactions
|
|
257
321
|
|
|
@@ -293,46 +357,46 @@ as part of the same pipeline.
|
|
|
293
357
|
|
|
294
358
|
The "find the record, build the contract, check the policy" shape is shared
|
|
295
359
|
between an edit form and an update action — both need exactly that, and the
|
|
296
|
-
update then validates and persists.
|
|
297
|
-
sequencer
|
|
360
|
+
update then validates and persists. Define Present as a nested class on the
|
|
361
|
+
outer sequencer so the two stay co-located:
|
|
298
362
|
|
|
299
363
|
```ruby
|
|
300
|
-
class Seqs::
|
|
301
|
-
|
|
364
|
+
class Seqs::UpdateUser
|
|
365
|
+
class Present
|
|
366
|
+
include Hubbado::Sequence::Sequencer
|
|
302
367
|
|
|
303
|
-
|
|
368
|
+
configure :present # so a parent can use `Present.configure(instance)`
|
|
304
369
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
370
|
+
dependency :find, Macros::Model::Find
|
|
371
|
+
dependency :build_contract, Macros::Contract::Build
|
|
372
|
+
dependency :check_policy, Macros::Policy::Check
|
|
308
373
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
374
|
+
def self.build
|
|
375
|
+
new.tap do |instance|
|
|
376
|
+
Macros::Model::Find.configure(instance)
|
|
377
|
+
Macros::Contract::Build.configure(instance)
|
|
378
|
+
Macros::Policy::Check.configure(instance)
|
|
379
|
+
end
|
|
314
380
|
end
|
|
315
|
-
end
|
|
316
381
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
382
|
+
def call(ctx)
|
|
383
|
+
pipeline(ctx) do |p|
|
|
384
|
+
p.invoke(:find, User, as: :user)
|
|
385
|
+
p.invoke(:build_contract, Contracts::UpdateUser, :user)
|
|
386
|
+
p.invoke(:check_policy, Policies::User, :user, :update)
|
|
387
|
+
end
|
|
322
388
|
end
|
|
323
389
|
end
|
|
324
|
-
end
|
|
325
390
|
|
|
326
|
-
class Seqs::UpdateUser
|
|
327
391
|
include Hubbado::Sequence::Sequencer
|
|
328
392
|
|
|
329
|
-
dependency :present,
|
|
393
|
+
dependency :present, Present
|
|
330
394
|
dependency :validate, Macros::Contract::Validate
|
|
331
395
|
dependency :persist, Macros::Contract::Persist
|
|
332
396
|
|
|
333
397
|
def self.build
|
|
334
398
|
new.tap do |instance|
|
|
335
|
-
|
|
399
|
+
Present.configure(instance)
|
|
336
400
|
Macros::Contract::Validate.configure(instance)
|
|
337
401
|
Macros::Contract::Persist.configure(instance)
|
|
338
402
|
end
|
|
@@ -359,7 +423,7 @@ class UsersController < ApplicationController
|
|
|
359
423
|
include Hubbado::Sequence::RunSequence
|
|
360
424
|
|
|
361
425
|
def edit
|
|
362
|
-
run_sequence Seqs::
|
|
426
|
+
run_sequence Seqs::UpdateUser::Present, params: params, current_user: current_user do |result|
|
|
363
427
|
result.success { |ctx| render :edit, locals: { contract: ctx[:contract] } }
|
|
364
428
|
result.policy_failed { |_| redirect_to root_path, alert: result.message }
|
|
365
429
|
result.not_found { |_| render_404 }
|
|
@@ -379,15 +443,15 @@ end
|
|
|
379
443
|
|
|
380
444
|
Inner writes (`ctx[:user]`, `ctx[:contract]`) are visible to outer steps —
|
|
381
445
|
Present and Update share the same `Ctx`, so `:validate` and `:persist` see
|
|
382
|
-
exactly what Present built. The outer
|
|
383
|
-
|
|
446
|
+
exactly what Present built. The outer pipeline records `:present` as a single
|
|
447
|
+
entry in `successful_steps`; Present's inner steps stay opaque to the parent.
|
|
384
448
|
|
|
385
449
|
## Result, success, failure
|
|
386
450
|
|
|
387
451
|
A step is **successful unless it explicitly returns a failed `Result`**. Any
|
|
388
|
-
other return value (`nil`, `false`, a model, `Result.
|
|
452
|
+
other return value (`nil`, `false`, a model, `Result.success(...)`) is taken as
|
|
389
453
|
success and the pipeline continues with the same `ctx`. Only
|
|
390
|
-
`Result.
|
|
454
|
+
`Result.failure(...)` or the `failure(ctx, code: ...)` helper short-circuits.
|
|
391
455
|
|
|
392
456
|
```ruby
|
|
393
457
|
def call(ctx)
|
|
@@ -401,59 +465,128 @@ private
|
|
|
401
465
|
|
|
402
466
|
def must_be_premium(ctx)
|
|
403
467
|
return failure(ctx, code: :forbidden) unless ctx[:user].premium?
|
|
404
|
-
# implicit
|
|
468
|
+
# implicit success if we get here
|
|
405
469
|
end
|
|
406
470
|
```
|
|
407
471
|
|
|
408
472
|
`failure(ctx, ...)` is a sequencer helper that builds a failed `Result`
|
|
409
|
-
with the sequencer's auto-derived i18n scope already applied. It takes
|
|
410
|
-
same
|
|
411
|
-
`
|
|
473
|
+
with the sequencer's auto-derived i18n scope already applied. It takes
|
|
474
|
+
the same kwargs as `Result.failure` (`code:`, `data:`, `step:`,
|
|
475
|
+
`i18n_scope:`, `i18n_key:`, `i18n_args:`).
|
|
476
|
+
|
|
477
|
+
## Outcome blocks and safety nets
|
|
478
|
+
|
|
479
|
+
`run_sequence` enforces that serious failures are addressed. Forgetting to
|
|
480
|
+
handle them raises rather than silently swallowing:
|
|
481
|
+
|
|
482
|
+
- An unhandled `:forbidden` raises `Hubbado::Sequence::Errors::Unauthorized`.
|
|
483
|
+
- An unhandled `:not_found` raises `Hubbado::Sequence::Errors::NotFound`.
|
|
484
|
+
- An unhandled non-policy / non-not-found failure (and an `otherwise` block
|
|
485
|
+
isn't given) raises `Hubbado::Sequence::Errors::Failed`.
|
|
486
|
+
|
|
487
|
+
`otherwise` deliberately does *not* catch `:forbidden` or `:not_found` —
|
|
488
|
+
that's what prevents a generic `otherwise` accidentally rendering a form
|
|
489
|
+
when the policy denied access.
|
|
490
|
+
|
|
491
|
+
### Handling specific failure reasons inside an outcome block
|
|
492
|
+
|
|
493
|
+
The dispatch object exposes the standard escalation paths as public
|
|
494
|
+
methods, so an outcome block can handle some cases inline and fall back to
|
|
495
|
+
the framework's exception for the rest:
|
|
496
|
+
|
|
497
|
+
```ruby
|
|
498
|
+
result.policy_failed do |ctx|
|
|
499
|
+
if result.data[:policy_result].reason == :not_open
|
|
500
|
+
redirect_to public_path(ctx[:job])
|
|
501
|
+
else
|
|
502
|
+
result.raise_policy_failed
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
The available helpers mirror the safety nets:
|
|
508
|
+
|
|
509
|
+
- `result.raise_policy_failed` — raises `Errors::Unauthorized` with the
|
|
510
|
+
standard message.
|
|
511
|
+
- `result.raise_not_found` — raises `Errors::NotFound`.
|
|
512
|
+
- `result.raise_failed` — raises `Errors::Failed`.
|
|
513
|
+
|
|
514
|
+
Use them when you genuinely want the framework's default escalation;
|
|
515
|
+
prefer plain Ruby control flow (`return`, `if`/`else`) for ordinary
|
|
516
|
+
branching.
|
|
412
517
|
|
|
413
518
|
## Testing
|
|
414
519
|
|
|
415
|
-
|
|
416
|
-
substitutes
|
|
417
|
-
|
|
418
|
-
|
|
520
|
+
The gem doesn't prescribe a testing library — sequencers, macros, and
|
|
521
|
+
substitutes are plain Ruby objects that work with whatever you use.
|
|
522
|
+
The examples below are written in
|
|
523
|
+
[TestBench](https://github.com/test-bench/test-bench) (what we use at
|
|
524
|
+
Hubbado), but the same patterns translate directly to RSpec, Minitest,
|
|
525
|
+
or any other framework.
|
|
526
|
+
|
|
527
|
+
`Seqs::UpdateUser.new` returns a sequencer with all dependencies installed
|
|
528
|
+
as substitutes. Tests configure the substitutes for the scenario at hand.
|
|
529
|
+
`Seqs::UpdateUser.build` runs the production wiring (the real macros).
|
|
530
|
+
Substitutes default to pass-through `Result.success(ctx)` so a test only
|
|
419
531
|
configures the ones whose return matters.
|
|
420
532
|
|
|
421
533
|
### Substituting macros directly
|
|
422
534
|
|
|
423
535
|
```ruby
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
)
|
|
436
|
-
|
|
437
|
-
expect(result).to be_ok
|
|
438
|
-
expect(seq.find.fetched?(as: :user)).to be true
|
|
439
|
-
expect(seq.build_contract.built?).to be true
|
|
440
|
-
expect(seq.check_policy.checked?).to be true
|
|
536
|
+
context "Seqs::UpdateUser::Present happy path" do
|
|
537
|
+
user = User.new(id: 1, email: "old@example.com")
|
|
538
|
+
contract = Contracts::UpdateUser.new(user)
|
|
539
|
+
|
|
540
|
+
seq = Seqs::UpdateUser::Present.new
|
|
541
|
+
seq.find.succeed_with(user)
|
|
542
|
+
seq.build_contract.succeed_with(contract)
|
|
543
|
+
|
|
544
|
+
result = seq.(params: { id: 1 }, current_user: User.new)
|
|
545
|
+
|
|
546
|
+
test "Is success" do
|
|
547
|
+
assert(result.success?)
|
|
441
548
|
end
|
|
442
549
|
|
|
443
|
-
|
|
444
|
-
seq
|
|
445
|
-
|
|
550
|
+
test "Fetched the user from ctx[:user]" do
|
|
551
|
+
assert(seq.find.fetched?(as: :user))
|
|
552
|
+
end
|
|
446
553
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
))
|
|
554
|
+
test "Built the contract" do
|
|
555
|
+
assert(seq.build_contract.built?)
|
|
556
|
+
end
|
|
451
557
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
expect(seq.check_policy.checked?).to be false
|
|
558
|
+
test "Checked the policy" do
|
|
559
|
+
assert(seq.check_policy.checked?)
|
|
455
560
|
end
|
|
456
561
|
end
|
|
562
|
+
|
|
563
|
+
context "Seqs::UpdateUser::Present when the user is not found" do
|
|
564
|
+
seq = Seqs::UpdateUser::Present.new
|
|
565
|
+
seq.find.fail_with(code: :not_found)
|
|
566
|
+
|
|
567
|
+
result = seq.(params: { id: 999 }, current_user: User.new)
|
|
568
|
+
|
|
569
|
+
test "Fails with :not_found" do
|
|
570
|
+
assert(result.code == :not_found)
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
test "Does not build the contract" do
|
|
574
|
+
refute(seq.build_contract.built?)
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
test "Does not check the policy" do
|
|
578
|
+
refute(seq.check_policy.checked?)
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
The `Policy::Check` substitute's `fail_with(**error)` accepts the same
|
|
584
|
+
attributes `Result.failure` does, so a test that needs an outcome block
|
|
585
|
+
to branch on `result.data[:policy_result].reason` can configure the data
|
|
586
|
+
payload directly:
|
|
587
|
+
|
|
588
|
+
```ruby
|
|
589
|
+
seq.check_policy.fail_with(code: :forbidden, data: { policy_result: DeniedResult.new(:not_open) })
|
|
457
590
|
```
|
|
458
591
|
|
|
459
592
|
### Substituting a nested sequencer
|
|
@@ -464,84 +597,104 @@ Every sequencer ships a default `Substitute` module (installed by
|
|
|
464
597
|
short-circuit a nested sequencer without reaching into its inner pieces:
|
|
465
598
|
|
|
466
599
|
```ruby
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
expect(seq.present.called?).to be true
|
|
482
|
-
expect(seq.persist.persisted?).to be true
|
|
600
|
+
context "Seqs::UpdateUser happy path" do
|
|
601
|
+
user = User.new(id: 1, email: "old@example.com")
|
|
602
|
+
contract = Contracts::UpdateUser.new(user)
|
|
603
|
+
|
|
604
|
+
seq = Seqs::UpdateUser.new
|
|
605
|
+
seq.present.succeed_with(user: user, contract: contract)
|
|
606
|
+
|
|
607
|
+
result = seq.(
|
|
608
|
+
params: { user: { email: "new@example.com" } },
|
|
609
|
+
current_user: User.new
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
test "Is success" do
|
|
613
|
+
assert(result.success?)
|
|
483
614
|
end
|
|
484
615
|
|
|
485
|
-
|
|
486
|
-
seq
|
|
487
|
-
|
|
616
|
+
test "Calls Present" do
|
|
617
|
+
assert(seq.present.called?)
|
|
618
|
+
end
|
|
488
619
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
620
|
+
test "Persists the contract" do
|
|
621
|
+
assert(seq.persist.persisted?)
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
context "Seqs::UpdateUser when Present denies access" do
|
|
626
|
+
seq = Seqs::UpdateUser.new
|
|
627
|
+
seq.present.fail_with(code: :forbidden)
|
|
493
628
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
629
|
+
result = seq.(params: { user: {} }, current_user: User.new)
|
|
630
|
+
|
|
631
|
+
test "Fails" do
|
|
632
|
+
assert(result.failure?)
|
|
498
633
|
end
|
|
499
634
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
635
|
+
test "Fails with :forbidden" do
|
|
636
|
+
assert(result.code == :forbidden)
|
|
637
|
+
end
|
|
503
638
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
639
|
+
test "Does not validate" do
|
|
640
|
+
refute(seq.validate.validated?)
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
test "Does not persist" do
|
|
644
|
+
refute(seq.persist.persisted?)
|
|
645
|
+
end
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
context "Seqs::UpdateUser when Present cannot find the record" do
|
|
649
|
+
seq = Seqs::UpdateUser.new
|
|
650
|
+
seq.present.fail_with(code: :not_found)
|
|
651
|
+
|
|
652
|
+
result = seq.(params: { id: 999, user: {} }, current_user: User.new)
|
|
653
|
+
|
|
654
|
+
test "Fails with :not_found" do
|
|
655
|
+
assert(result.code == :not_found)
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
test "Does not validate" do
|
|
659
|
+
refute(seq.validate.validated?)
|
|
660
|
+
end
|
|
508
661
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
expect(seq.persist.persisted?).to be false
|
|
662
|
+
test "Does not persist" do
|
|
663
|
+
refute(seq.persist.persisted?)
|
|
512
664
|
end
|
|
513
665
|
end
|
|
514
666
|
```
|
|
515
667
|
|
|
516
668
|
`succeed_with(**ctx_writes)` writes the given keys into `ctx` and returns
|
|
517
|
-
`Result.
|
|
669
|
+
`Result.success(ctx)`, so the outer steps see what the real Present would have
|
|
518
670
|
left behind. `fail_with(**error)` returns a failed `Result` with the given
|
|
519
671
|
error, short-circuiting the outer pipeline. The Update spec doesn't need
|
|
520
672
|
to exercise Find / Build / Policy::Check directly — those live in
|
|
521
|
-
|
|
673
|
+
`Seqs::UpdateUser::Present`'s spec, where they belong.
|
|
522
674
|
|
|
523
675
|
## Observability
|
|
524
676
|
|
|
525
|
-
Every `Result` carries
|
|
526
|
-
successfully, in order. On failure, the failing step is *not* in
|
|
527
|
-
it's tagged on `
|
|
677
|
+
Every `Result` carries **successful_steps** — the list of step names that
|
|
678
|
+
completed successfully, in order. On failure, the failing step is *not* in
|
|
679
|
+
`successful_steps`; it's tagged on `step` instead.
|
|
528
680
|
|
|
529
681
|
```ruby
|
|
530
|
-
result.
|
|
531
|
-
result.
|
|
532
|
-
result.
|
|
682
|
+
result.successful_steps # => [:find, :build_contract, :check_policy, :validate, :persist] # success
|
|
683
|
+
result.successful_steps # => [:find, :build_contract] # failed at :check_policy
|
|
684
|
+
result.step # => :check_policy
|
|
533
685
|
```
|
|
534
686
|
|
|
535
687
|
When invoked via `run_sequence`, the dispatcher logs a single line per
|
|
536
|
-
invocation summarising the
|
|
688
|
+
invocation summarising the successful steps and (on failure) where it
|
|
689
|
+
stopped:
|
|
537
690
|
|
|
538
691
|
```
|
|
539
692
|
Sequencer Seqs::UpdateUser succeeded: find → build_contract → check_policy → validate → persist
|
|
540
693
|
Sequencer Seqs::UpdateUser failed at :check_policy (forbidden): find → build_contract
|
|
541
694
|
```
|
|
542
695
|
|
|
543
|
-
Nested sequencer
|
|
544
|
-
`:present`
|
|
696
|
+
Nested sequencer steps are opaque to the parent: a parent's `successful_steps`
|
|
697
|
+
lists `:present` once, not Present's inner sub-steps.
|
|
545
698
|
`error[:step]` carries the inner step name when a nested sequencer fails.
|
|
546
699
|
|
|
547
700
|
## Standard error codes
|
|
@@ -563,4 +716,4 @@ Sequencers can mint their own codes for domain-specific failures
|
|
|
563
716
|
|
|
564
717
|
## License
|
|
565
718
|
|
|
566
|
-
|
|
719
|
+
Released under the [MIT License](LICENSE).
|