hubbado-sequence 0.4.0 → 0.5.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 +48 -0
- data/README.md +245 -158
- 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 +4 -4
- data/lib/hubbado/sequence/pipeline.rb +7 -7
- data/lib/hubbado/sequence/result.rb +15 -15
- data/lib/hubbado/sequence/runner.rb +13 -13
- data/lib/hubbado/sequence/sequencer.rb +3 -3
- metadata +6 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7f708335623135a67d05ecdf13e49c97f9d0a1d1a2c21c2fda731f83c69ecc65
|
|
4
|
+
data.tar.gz: 011fcaa92a23f287b8800da473f37452c9305bd08a0e00833811817b50d858db
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 440e563ba5e86174e51c186ca365d30f7160a93ca4d4d79669d23e0461acfc1030261b63d109223e5a37129cefc4c5c0cfd6433219dd0edf3be2d7c9576048e5
|
|
7
|
+
data.tar.gz: c5a884e0a781e0b9794a3997ba1eaadbd03eeceaf02289ee33cab2528686281d89016ef9f82e7e04d6b71be5ce4378dc81336c5c7e1866e32d6b0f0f01cdff54
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,54 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
|
5
5
|
and this project adheres to [Semantic Versioning](http://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [0.5.0] - Result vocabulary renamed: success/failure and successful_steps
|
|
8
|
+
|
|
9
|
+
### Changed (breaking)
|
|
10
|
+
|
|
11
|
+
- **`Result.ok` → `Result.success`** and **`Result.fail` → `Result.failure`**.
|
|
12
|
+
Aligns with the wider Ruby railway-oriented vocabulary (dry-monads,
|
|
13
|
+
dry-transaction) and replaces the asymmetric `ok`/`failure?` pair with a
|
|
14
|
+
consistent `success`/`failure` pair.
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
# before
|
|
18
|
+
Result.ok(ctx)
|
|
19
|
+
Result.fail(ctx, error: { code: :forbidden })
|
|
20
|
+
result.ok?
|
|
21
|
+
|
|
22
|
+
# after
|
|
23
|
+
Result.success(ctx)
|
|
24
|
+
Result.failure(ctx, error: { code: :forbidden })
|
|
25
|
+
result.success?
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`result.failure?` is unchanged.
|
|
29
|
+
|
|
30
|
+
Migration: search-and-replace `Result.ok(` → `Result.success(`,
|
|
31
|
+
`Result.fail(` → `Result.failure(`, and `.ok?` → `.success?`. RSpec
|
|
32
|
+
matchers `be_ok` become `be_success`.
|
|
33
|
+
|
|
34
|
+
- **`Result#trail` → `Result#successful_steps`** (and `with_trail` →
|
|
35
|
+
`with_successful_steps`, `trail:` kwarg → `successful_steps:`). The old
|
|
36
|
+
name was confusing because the failing step is *not* in the list — it
|
|
37
|
+
lives on `error[:step]`. The new name says exactly what's there.
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
# before
|
|
41
|
+
result.trail # => [:find, :build_contract]
|
|
42
|
+
result.with_trail([...])
|
|
43
|
+
Result.ok(ctx, trail: [...])
|
|
44
|
+
|
|
45
|
+
# after
|
|
46
|
+
result.successful_steps # => [:find, :build_contract]
|
|
47
|
+
result.with_successful_steps([...])
|
|
48
|
+
Result.success(ctx, successful_steps: [...])
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Migration: search-and-replace `.trail` → `.successful_steps`,
|
|
52
|
+
`with_trail(` → `with_successful_steps(`, and the keyword argument
|
|
53
|
+
`trail:` → `successful_steps:`.
|
|
54
|
+
|
|
7
55
|
## [0.4.0] - Inline step blocks removed; Pipeline made internal
|
|
8
56
|
|
|
9
57
|
### Removed (breaking)
|
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.
|
|
143
181
|
|
|
144
|
-
|
|
182
|
+
### ActiveRecord macros
|
|
145
183
|
|
|
146
|
-
|
|
184
|
+
Designed to work with [ActiveRecord](https://github.com/rails/rails) models.
|
|
185
|
+
|
|
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
|
|
218
|
+
|
|
219
|
+
Designed to work with [Reform](https://github.com/trailblazer/reform) form
|
|
220
|
+
objects (contracts).
|
|
178
221
|
|
|
179
|
-
|
|
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,9 +291,9 @@ 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 an object with
|
|
296
|
+
`permitted?`.
|
|
249
297
|
|
|
250
298
|
| | |
|
|
251
299
|
|---|---|
|
|
@@ -293,46 +341,46 @@ as part of the same pipeline.
|
|
|
293
341
|
|
|
294
342
|
The "find the record, build the contract, check the policy" shape is shared
|
|
295
343
|
between an edit form and an update action — both need exactly that, and the
|
|
296
|
-
update then validates and persists.
|
|
297
|
-
sequencer
|
|
344
|
+
update then validates and persists. Define Present as a nested class on the
|
|
345
|
+
outer sequencer so the two stay co-located:
|
|
298
346
|
|
|
299
347
|
```ruby
|
|
300
|
-
class Seqs::
|
|
301
|
-
|
|
348
|
+
class Seqs::UpdateUser
|
|
349
|
+
class Present
|
|
350
|
+
include Hubbado::Sequence::Sequencer
|
|
302
351
|
|
|
303
|
-
|
|
352
|
+
configure :present # so a parent can use `Present.configure(instance)`
|
|
304
353
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
354
|
+
dependency :find, Macros::Model::Find
|
|
355
|
+
dependency :build_contract, Macros::Contract::Build
|
|
356
|
+
dependency :check_policy, Macros::Policy::Check
|
|
308
357
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
358
|
+
def self.build
|
|
359
|
+
new.tap do |instance|
|
|
360
|
+
Macros::Model::Find.configure(instance)
|
|
361
|
+
Macros::Contract::Build.configure(instance)
|
|
362
|
+
Macros::Policy::Check.configure(instance)
|
|
363
|
+
end
|
|
314
364
|
end
|
|
315
|
-
end
|
|
316
365
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
366
|
+
def call(ctx)
|
|
367
|
+
pipeline(ctx) do |p|
|
|
368
|
+
p.invoke(:find, User, as: :user)
|
|
369
|
+
p.invoke(:build_contract, Contracts::UpdateUser, :user)
|
|
370
|
+
p.invoke(:check_policy, Policies::User, :user, :update)
|
|
371
|
+
end
|
|
322
372
|
end
|
|
323
373
|
end
|
|
324
|
-
end
|
|
325
374
|
|
|
326
|
-
class Seqs::UpdateUser
|
|
327
375
|
include Hubbado::Sequence::Sequencer
|
|
328
376
|
|
|
329
|
-
dependency :present,
|
|
377
|
+
dependency :present, Present
|
|
330
378
|
dependency :validate, Macros::Contract::Validate
|
|
331
379
|
dependency :persist, Macros::Contract::Persist
|
|
332
380
|
|
|
333
381
|
def self.build
|
|
334
382
|
new.tap do |instance|
|
|
335
|
-
|
|
383
|
+
Present.configure(instance)
|
|
336
384
|
Macros::Contract::Validate.configure(instance)
|
|
337
385
|
Macros::Contract::Persist.configure(instance)
|
|
338
386
|
end
|
|
@@ -359,7 +407,7 @@ class UsersController < ApplicationController
|
|
|
359
407
|
include Hubbado::Sequence::RunSequence
|
|
360
408
|
|
|
361
409
|
def edit
|
|
362
|
-
run_sequence Seqs::
|
|
410
|
+
run_sequence Seqs::UpdateUser::Present, params: params, current_user: current_user do |result|
|
|
363
411
|
result.success { |ctx| render :edit, locals: { contract: ctx[:contract] } }
|
|
364
412
|
result.policy_failed { |_| redirect_to root_path, alert: result.message }
|
|
365
413
|
result.not_found { |_| render_404 }
|
|
@@ -379,15 +427,15 @@ end
|
|
|
379
427
|
|
|
380
428
|
Inner writes (`ctx[:user]`, `ctx[:contract]`) are visible to outer steps —
|
|
381
429
|
Present and Update share the same `Ctx`, so `:validate` and `:persist` see
|
|
382
|
-
exactly what Present built. The outer
|
|
383
|
-
|
|
430
|
+
exactly what Present built. The outer pipeline records `:present` as a single
|
|
431
|
+
entry in `successful_steps`; Present's inner steps stay opaque to the parent.
|
|
384
432
|
|
|
385
433
|
## Result, success, failure
|
|
386
434
|
|
|
387
435
|
A step is **successful unless it explicitly returns a failed `Result`**. Any
|
|
388
|
-
other return value (`nil`, `false`, a model, `Result.
|
|
436
|
+
other return value (`nil`, `false`, a model, `Result.success(...)`) is taken as
|
|
389
437
|
success and the pipeline continues with the same `ctx`. Only
|
|
390
|
-
`Result.
|
|
438
|
+
`Result.failure(...)` or the `failure(ctx, code: ...)` helper short-circuits.
|
|
391
439
|
|
|
392
440
|
```ruby
|
|
393
441
|
def call(ctx)
|
|
@@ -401,7 +449,7 @@ private
|
|
|
401
449
|
|
|
402
450
|
def must_be_premium(ctx)
|
|
403
451
|
return failure(ctx, code: :forbidden) unless ctx[:user].premium?
|
|
404
|
-
# implicit
|
|
452
|
+
# implicit success if we get here
|
|
405
453
|
end
|
|
406
454
|
```
|
|
407
455
|
|
|
@@ -412,46 +460,65 @@ same error attrs as the underlying error hash (`code:`, `i18n_key:`,
|
|
|
412
460
|
|
|
413
461
|
## Testing
|
|
414
462
|
|
|
415
|
-
|
|
416
|
-
substitutes
|
|
417
|
-
|
|
418
|
-
|
|
463
|
+
The gem doesn't prescribe a testing library — sequencers, macros, and
|
|
464
|
+
substitutes are plain Ruby objects that work with whatever you use.
|
|
465
|
+
The examples below are written in
|
|
466
|
+
[TestBench](https://github.com/test-bench/test-bench) (what we use at
|
|
467
|
+
Hubbado), but the same patterns translate directly to RSpec, Minitest,
|
|
468
|
+
or any other framework.
|
|
469
|
+
|
|
470
|
+
`Seqs::UpdateUser.new` returns a sequencer with all dependencies installed
|
|
471
|
+
as substitutes. Tests configure the substitutes for the scenario at hand.
|
|
472
|
+
`Seqs::UpdateUser.build` runs the production wiring (the real macros).
|
|
473
|
+
Substitutes default to pass-through `Result.success(ctx)` so a test only
|
|
419
474
|
configures the ones whose return matters.
|
|
420
475
|
|
|
421
476
|
### Substituting macros directly
|
|
422
477
|
|
|
423
478
|
```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
|
|
479
|
+
context "Seqs::UpdateUser::Present happy path" do
|
|
480
|
+
user = User.new(id: 1, email: "old@example.com")
|
|
481
|
+
contract = Contracts::UpdateUser.new(user)
|
|
482
|
+
|
|
483
|
+
seq = Seqs::UpdateUser::Present.new
|
|
484
|
+
seq.find.succeed_with(user)
|
|
485
|
+
seq.build_contract.succeed_with(contract)
|
|
486
|
+
|
|
487
|
+
result = seq.(params: { id: 1 }, current_user: User.new)
|
|
488
|
+
|
|
489
|
+
test "Is success" do
|
|
490
|
+
assert(result.success?)
|
|
441
491
|
end
|
|
442
492
|
|
|
443
|
-
|
|
444
|
-
seq
|
|
445
|
-
|
|
493
|
+
test "Fetched the user from ctx[:user]" do
|
|
494
|
+
assert(seq.find.fetched?(as: :user))
|
|
495
|
+
end
|
|
446
496
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
))
|
|
497
|
+
test "Built the contract" do
|
|
498
|
+
assert(seq.build_contract.built?)
|
|
499
|
+
end
|
|
451
500
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
501
|
+
test "Checked the policy" do
|
|
502
|
+
assert(seq.check_policy.checked?)
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
context "Seqs::UpdateUser::Present when the user is not found" do
|
|
507
|
+
seq = Seqs::UpdateUser::Present.new
|
|
508
|
+
seq.find.fail_with(code: :not_found)
|
|
509
|
+
|
|
510
|
+
result = seq.(params: { id: 999 }, current_user: User.new)
|
|
511
|
+
|
|
512
|
+
test "Fails with :not_found" do
|
|
513
|
+
assert(result.error[:code] == :not_found)
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
test "Does not build the contract" do
|
|
517
|
+
refute(seq.build_contract.built?)
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
test "Does not check the policy" do
|
|
521
|
+
refute(seq.check_policy.checked?)
|
|
455
522
|
end
|
|
456
523
|
end
|
|
457
524
|
```
|
|
@@ -464,84 +531,104 @@ Every sequencer ships a default `Substitute` module (installed by
|
|
|
464
531
|
short-circuit a nested sequencer without reaching into its inner pieces:
|
|
465
532
|
|
|
466
533
|
```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
|
|
534
|
+
context "Seqs::UpdateUser happy path" do
|
|
535
|
+
user = User.new(id: 1, email: "old@example.com")
|
|
536
|
+
contract = Contracts::UpdateUser.new(user)
|
|
537
|
+
|
|
538
|
+
seq = Seqs::UpdateUser.new
|
|
539
|
+
seq.present.succeed_with(user: user, contract: contract)
|
|
540
|
+
|
|
541
|
+
result = seq.(
|
|
542
|
+
params: { user: { email: "new@example.com" } },
|
|
543
|
+
current_user: User.new
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
test "Is success" do
|
|
547
|
+
assert(result.success?)
|
|
483
548
|
end
|
|
484
549
|
|
|
485
|
-
|
|
486
|
-
seq
|
|
487
|
-
|
|
550
|
+
test "Calls Present" do
|
|
551
|
+
assert(seq.present.called?)
|
|
552
|
+
end
|
|
488
553
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
554
|
+
test "Persists the contract" do
|
|
555
|
+
assert(seq.persist.persisted?)
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
context "Seqs::UpdateUser when Present denies access" do
|
|
560
|
+
seq = Seqs::UpdateUser.new
|
|
561
|
+
seq.present.fail_with(code: :forbidden)
|
|
562
|
+
|
|
563
|
+
result = seq.(params: { user: {} }, current_user: User.new)
|
|
564
|
+
|
|
565
|
+
test "Fails" do
|
|
566
|
+
assert(result.failure?)
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
test "Fails with :forbidden" do
|
|
570
|
+
assert(result.error[:code] == :forbidden)
|
|
571
|
+
end
|
|
493
572
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
expect(seq.validate.validated?).to be false
|
|
497
|
-
expect(seq.persist.persisted?).to be false
|
|
573
|
+
test "Does not validate" do
|
|
574
|
+
refute(seq.validate.validated?)
|
|
498
575
|
end
|
|
499
576
|
|
|
500
|
-
|
|
501
|
-
seq
|
|
502
|
-
|
|
577
|
+
test "Does not persist" do
|
|
578
|
+
refute(seq.persist.persisted?)
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
context "Seqs::UpdateUser when Present cannot find the record" do
|
|
583
|
+
seq = Seqs::UpdateUser.new
|
|
584
|
+
seq.present.fail_with(code: :not_found)
|
|
585
|
+
|
|
586
|
+
result = seq.(params: { id: 999, user: {} }, current_user: User.new)
|
|
503
587
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
588
|
+
test "Fails with :not_found" do
|
|
589
|
+
assert(result.error[:code] == :not_found)
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
test "Does not validate" do
|
|
593
|
+
refute(seq.validate.validated?)
|
|
594
|
+
end
|
|
508
595
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
expect(seq.persist.persisted?).to be false
|
|
596
|
+
test "Does not persist" do
|
|
597
|
+
refute(seq.persist.persisted?)
|
|
512
598
|
end
|
|
513
599
|
end
|
|
514
600
|
```
|
|
515
601
|
|
|
516
602
|
`succeed_with(**ctx_writes)` writes the given keys into `ctx` and returns
|
|
517
|
-
`Result.
|
|
603
|
+
`Result.success(ctx)`, so the outer steps see what the real Present would have
|
|
518
604
|
left behind. `fail_with(**error)` returns a failed `Result` with the given
|
|
519
605
|
error, short-circuiting the outer pipeline. The Update spec doesn't need
|
|
520
606
|
to exercise Find / Build / Policy::Check directly — those live in
|
|
521
|
-
|
|
607
|
+
`Seqs::UpdateUser::Present`'s spec, where they belong.
|
|
522
608
|
|
|
523
609
|
## Observability
|
|
524
610
|
|
|
525
|
-
Every `Result` carries
|
|
526
|
-
successfully, in order. On failure, the failing step is *not* in
|
|
527
|
-
it's tagged on `error[:step]` instead.
|
|
611
|
+
Every `Result` carries **successful_steps** — the list of step names that
|
|
612
|
+
completed successfully, in order. On failure, the failing step is *not* in
|
|
613
|
+
`successful_steps`; it's tagged on `error[:step]` instead.
|
|
528
614
|
|
|
529
615
|
```ruby
|
|
530
|
-
result.
|
|
531
|
-
result.
|
|
532
|
-
result.error[:step]
|
|
616
|
+
result.successful_steps # => [:find, :build_contract, :check_policy, :validate, :persist] # success
|
|
617
|
+
result.successful_steps # => [:find, :build_contract] # failed at :check_policy
|
|
618
|
+
result.error[:step] # => :check_policy
|
|
533
619
|
```
|
|
534
620
|
|
|
535
621
|
When invoked via `run_sequence`, the dispatcher logs a single line per
|
|
536
|
-
invocation summarising the
|
|
622
|
+
invocation summarising the successful steps and (on failure) where it
|
|
623
|
+
stopped:
|
|
537
624
|
|
|
538
625
|
```
|
|
539
626
|
Sequencer Seqs::UpdateUser succeeded: find → build_contract → check_policy → validate → persist
|
|
540
627
|
Sequencer Seqs::UpdateUser failed at :check_policy (forbidden): find → build_contract
|
|
541
628
|
```
|
|
542
629
|
|
|
543
|
-
Nested sequencer
|
|
544
|
-
`:present`
|
|
630
|
+
Nested sequencer steps are opaque to the parent: a parent's `successful_steps`
|
|
631
|
+
lists `:present` once, not Present's inner sub-steps.
|
|
545
632
|
`error[:step]` carries the inner step name when a nested sequencer fails.
|
|
546
633
|
|
|
547
634
|
## Standard error codes
|
|
@@ -563,4 +650,4 @@ Sequencers can mint their own codes for domain-specific failures
|
|
|
563
650
|
|
|
564
651
|
## License
|
|
565
652
|
|
|
566
|
-
|
|
653
|
+
Released under the [MIT License](LICENSE).
|
data/hubbado-sequence.gemspec
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# -*- encoding: utf-8 -*-
|
|
2
2
|
Gem::Specification.new do |s|
|
|
3
3
|
s.name = "hubbado-sequence"
|
|
4
|
-
s.version = "0.
|
|
5
|
-
s.summary = "A small framework for
|
|
6
|
-
s.description = "
|
|
4
|
+
s.version = "0.5.0"
|
|
5
|
+
s.summary = "A small framework for the short sequences of common steps that controller actions usually boil down to"
|
|
6
|
+
s.description = "A sequencer takes input, runs an ordered sequence of steps, and returns a Result carrying a success-or-failure flag, a structured error, and the working context that was built up during execution. Built with Rails in mind but framework-agnostic."
|
|
7
7
|
|
|
8
8
|
s.authors = ["Hubbado Devs"]
|
|
9
9
|
s.email = ["devs@hubbado.com"]
|
|
@@ -13,7 +13,7 @@ module Hubbado
|
|
|
13
13
|
def call(ctx, contract_class, attr_name = nil)
|
|
14
14
|
model = attr_name && Path.resolve(ctx, attr_name)
|
|
15
15
|
ctx[:contract] = contract_class.new(model)
|
|
16
|
-
Result.
|
|
16
|
+
Result.success(ctx)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
module Substitute
|
|
@@ -31,10 +31,10 @@ module Hubbado
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
record def call(ctx, contract_class, attr_name = nil)
|
|
34
|
-
return Result.
|
|
34
|
+
return Result.failure(ctx, error: @configured_error) if @configured_error
|
|
35
35
|
|
|
36
36
|
ctx[:contract] = @return_value if @configured_success
|
|
37
|
-
Result.
|
|
37
|
+
Result.success(ctx)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def built?(**kwargs)
|
|
@@ -15,7 +15,7 @@ module Hubbado
|
|
|
15
15
|
|
|
16
16
|
ctx[:contract].deserialize(params) if params
|
|
17
17
|
|
|
18
|
-
Result.
|
|
18
|
+
Result.success(ctx)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
module Substitute
|
|
@@ -27,9 +27,9 @@ module Hubbado
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
record def call(ctx, from:)
|
|
30
|
-
return Result.
|
|
30
|
+
return Result.failure(ctx, error: @configured_error) if @configured_error
|
|
31
31
|
|
|
32
|
-
Result.
|
|
32
|
+
Result.success(ctx)
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def deserialized?(**kwargs)
|
|
@@ -14,9 +14,9 @@ module Hubbado
|
|
|
14
14
|
contract = ctx[:contract]
|
|
15
15
|
|
|
16
16
|
if contract.save
|
|
17
|
-
Result.
|
|
17
|
+
Result.success(ctx)
|
|
18
18
|
else
|
|
19
|
-
Result.
|
|
19
|
+
Result.failure(ctx, error: { code: :persist_failed })
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
@@ -34,9 +34,9 @@ module Hubbado
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
record def call(ctx)
|
|
37
|
-
return Result.
|
|
37
|
+
return Result.failure(ctx, error: @configured_error) if @configured_error
|
|
38
38
|
|
|
39
|
-
Result.
|
|
39
|
+
Result.success(ctx)
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def persisted?(**kwargs)
|
|
@@ -17,9 +17,9 @@ module Hubbado
|
|
|
17
17
|
contract.validate(params)
|
|
18
18
|
|
|
19
19
|
if contract.errors.empty?
|
|
20
|
-
Result.
|
|
20
|
+
Result.success(ctx)
|
|
21
21
|
else
|
|
22
|
-
Result.
|
|
22
|
+
Result.failure(ctx, error: { code: :validation_failed })
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
@@ -37,9 +37,9 @@ module Hubbado
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
record def call(ctx, from: nil)
|
|
40
|
-
return Result.
|
|
40
|
+
return Result.failure(ctx, error: @configured_error) if @configured_error
|
|
41
41
|
|
|
42
|
-
Result.
|
|
42
|
+
Result.success(ctx)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
def validated?(**kwargs)
|
|
@@ -17,7 +17,7 @@ module Hubbado
|
|
|
17
17
|
else
|
|
18
18
|
model.new(attributes)
|
|
19
19
|
end
|
|
20
|
-
Result.
|
|
20
|
+
Result.success(ctx)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
module Substitute
|
|
@@ -40,10 +40,10 @@ module Hubbado
|
|
|
40
40
|
"Macros::Model::Build substitute: #{model} does not respond to :new"
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
return Result.
|
|
43
|
+
return Result.failure(ctx, error: @configured_error) if @configured_error
|
|
44
44
|
|
|
45
45
|
ctx[as] = @return_value if @configured_success
|
|
46
|
-
Result.
|
|
46
|
+
Result.success(ctx)
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
def built?(**kwargs)
|
|
@@ -16,9 +16,9 @@ module Hubbado
|
|
|
16
16
|
|
|
17
17
|
if record
|
|
18
18
|
ctx[as] = record
|
|
19
|
-
Result.
|
|
19
|
+
Result.success(ctx)
|
|
20
20
|
else
|
|
21
|
-
Result.
|
|
21
|
+
Result.failure(ctx, error: { code: :not_found })
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
24
|
|
|
@@ -42,10 +42,10 @@ module Hubbado
|
|
|
42
42
|
"Macros::Model::Find substitute: #{model} does not respond to :find_by"
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
return Result.
|
|
45
|
+
return Result.failure(ctx, error: @configured_error) if @configured_error
|
|
46
46
|
|
|
47
47
|
ctx[as] = @return_value if @configured_success
|
|
48
|
-
Result.
|
|
48
|
+
Result.success(ctx)
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def fetched?(**kwargs)
|
|
@@ -18,9 +18,9 @@ module Hubbado
|
|
|
18
18
|
policy_result = policy_instance.public_send(action)
|
|
19
19
|
|
|
20
20
|
if policy_result.permitted?
|
|
21
|
-
Result.
|
|
21
|
+
Result.success(ctx)
|
|
22
22
|
else
|
|
23
|
-
Result.
|
|
23
|
+
Result.failure(
|
|
24
24
|
ctx,
|
|
25
25
|
error: {
|
|
26
26
|
code: :forbidden,
|
|
@@ -49,9 +49,9 @@ module Hubbado
|
|
|
49
49
|
"Macros::Policy::Check substitute: #{policy} does not declare action :#{action}"
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
-
return Result.
|
|
52
|
+
return Result.failure(ctx, error: @configured_error) if @configured_error
|
|
53
53
|
|
|
54
|
-
Result.
|
|
54
|
+
Result.success(ctx)
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
def checked?(**kwargs)
|
|
@@ -6,15 +6,15 @@ module Hubbado
|
|
|
6
6
|
class Pipeline
|
|
7
7
|
def initialize(ctx, dispatcher:)
|
|
8
8
|
@ctx = ctx
|
|
9
|
-
@
|
|
9
|
+
@successful_steps = []
|
|
10
10
|
@failed_result = nil
|
|
11
11
|
@dispatcher = dispatcher
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
# `step(:name)` dispatches to `dispatcher.send(name, ctx)`. The method
|
|
15
15
|
# is treated as successful unless it explicitly returns a failed
|
|
16
|
-
# `Result`; any other return value (nil, false, a model, `Result.
|
|
17
|
-
# continues the pipeline with the same ctx. Only `Result.
|
|
16
|
+
# `Result`; any other return value (nil, false, a model, `Result.success`)
|
|
17
|
+
# continues the pipeline with the same ctx. Only `Result.failure(...)` /
|
|
18
18
|
# `failure(ctx, code: ...)` short-circuits.
|
|
19
19
|
def step(name)
|
|
20
20
|
return self if @failed_result
|
|
@@ -25,7 +25,7 @@ module Hubbado
|
|
|
25
25
|
|
|
26
26
|
# `invoke(:name, *args, **kwargs)` calls a declared dependency on the
|
|
27
27
|
# dispatcher: gets it via `dispatcher.send(name)` (the reader), then
|
|
28
|
-
# invokes it with `(ctx, *args, **kwargs)`. Same
|
|
28
|
+
# invokes it with `(ctx, *args, **kwargs)`. Same step recording,
|
|
29
29
|
# failure short-circuiting, and lenient return convention as `step`.
|
|
30
30
|
#
|
|
31
31
|
# Use this for any declared dependency — macros
|
|
@@ -58,7 +58,7 @@ module Hubbado
|
|
|
58
58
|
if @failed_result
|
|
59
59
|
@failed_result
|
|
60
60
|
else
|
|
61
|
-
Result.
|
|
61
|
+
Result.success(@ctx, successful_steps: @successful_steps.dup)
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
64
|
|
|
@@ -86,13 +86,13 @@ module Hubbado
|
|
|
86
86
|
if return_value.is_a?(Result) && return_value.failure?
|
|
87
87
|
@failed_result = tag_failure(return_value, name)
|
|
88
88
|
else
|
|
89
|
-
@
|
|
89
|
+
@successful_steps << name
|
|
90
90
|
end
|
|
91
91
|
end
|
|
92
92
|
|
|
93
93
|
def tag_failure(result, step_name)
|
|
94
94
|
tagged_error = result.error.merge(step: step_name)
|
|
95
|
-
Result.
|
|
95
|
+
Result.failure(result.ctx, error: tagged_error, successful_steps: @successful_steps.dup, i18n_scope: result.i18n_scope)
|
|
96
96
|
end
|
|
97
97
|
end
|
|
98
98
|
end
|
|
@@ -5,49 +5,49 @@ module Hubbado
|
|
|
5
5
|
|
|
6
6
|
attr_reader :ctx
|
|
7
7
|
attr_reader :error
|
|
8
|
-
attr_reader :
|
|
8
|
+
attr_reader :successful_steps
|
|
9
9
|
attr_reader :i18n_scope
|
|
10
10
|
|
|
11
|
-
def self.
|
|
12
|
-
new(:
|
|
11
|
+
def self.success(ctx, successful_steps: [], i18n_scope: nil)
|
|
12
|
+
new(:success, ctx, error: nil, successful_steps: successful_steps, i18n_scope: i18n_scope)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def self.
|
|
15
|
+
def self.failure(ctx, error:, successful_steps: [], i18n_scope: nil)
|
|
16
16
|
unless error.is_a?(Hash) && error[:code]
|
|
17
|
-
raise ArgumentError, "Result.
|
|
17
|
+
raise ArgumentError, "Result.failure requires error: { code: ... }"
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
new(:
|
|
20
|
+
new(:failure, ctx, error: error, successful_steps: successful_steps, i18n_scope: i18n_scope)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
def initialize(status, ctx, error:,
|
|
23
|
+
def initialize(status, ctx, error:, successful_steps:, i18n_scope:)
|
|
24
24
|
@status = status
|
|
25
25
|
@ctx = ctx
|
|
26
26
|
@error = error
|
|
27
|
-
@
|
|
27
|
+
@successful_steps = successful_steps
|
|
28
28
|
@i18n_scope = i18n_scope
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
def
|
|
32
|
-
@status == :
|
|
31
|
+
def success?
|
|
32
|
+
@status == :success
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def failure?
|
|
36
|
-
@status == :
|
|
36
|
+
@status == :failure
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
def
|
|
40
|
-
self.class.new(@status, @ctx, error: @error,
|
|
39
|
+
def with_successful_steps(successful_steps)
|
|
40
|
+
self.class.new(@status, @ctx, error: @error, successful_steps: successful_steps, i18n_scope: @i18n_scope)
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def with_i18n_scope(scope)
|
|
44
44
|
return self unless @i18n_scope.nil?
|
|
45
45
|
|
|
46
|
-
self.class.new(@status, @ctx, error: @error,
|
|
46
|
+
self.class.new(@status, @ctx, error: @error, successful_steps: @successful_steps, i18n_scope: scope)
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
def message
|
|
50
|
-
return nil if
|
|
50
|
+
return nil if success?
|
|
51
51
|
|
|
52
52
|
translation = translate_with_chain
|
|
53
53
|
return translation if translation
|
|
@@ -34,39 +34,39 @@ module Hubbado
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def success
|
|
37
|
-
return unless @result.
|
|
37
|
+
return unless @result.success?
|
|
38
38
|
execute { yield(@result.ctx) }
|
|
39
|
-
logger.info("Sequencer #{@sequencer_class.name} succeeded: #{
|
|
39
|
+
logger.info("Sequencer #{@sequencer_class.name} succeeded: #{steps_summary}")
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def policy_failed
|
|
43
43
|
return unless code == :forbidden
|
|
44
44
|
execute { yield(@result.ctx) }
|
|
45
|
-
logger.info("Sequencer #{@sequencer_class.name} policy failed at #{step_label} (#{code}): #{
|
|
45
|
+
logger.info("Sequencer #{@sequencer_class.name} policy failed at #{step_label} (#{code}): #{steps_summary}")
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
def not_found
|
|
49
49
|
return unless code == :not_found
|
|
50
50
|
execute { yield(@result.ctx) }
|
|
51
|
-
logger.info("Sequencer #{@sequencer_class.name} not found at #{step_label}: #{
|
|
51
|
+
logger.info("Sequencer #{@sequencer_class.name} not found at #{step_label}: #{steps_summary}")
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
def validation_failed
|
|
55
55
|
return unless code == :validation_failed
|
|
56
56
|
execute { yield(@result.ctx) }
|
|
57
|
-
logger.info("Sequencer #{@sequencer_class.name} validation failed at #{step_label}: #{
|
|
57
|
+
logger.info("Sequencer #{@sequencer_class.name} validation failed at #{step_label}: #{steps_summary}")
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
# otherwise deliberately does not catch policy denials or not_found —
|
|
61
61
|
# those have their own required handlers.
|
|
62
62
|
def otherwise
|
|
63
|
-
return if @result.
|
|
63
|
+
return if @result.success?
|
|
64
64
|
return if code == :forbidden
|
|
65
65
|
return if code == :not_found
|
|
66
66
|
return if @handled
|
|
67
67
|
|
|
68
68
|
execute { yield(@result.ctx) }
|
|
69
|
-
logger.info("Sequencer #{@sequencer_class.name} failed at #{step_label} (#{code}): #{
|
|
69
|
+
logger.info("Sequencer #{@sequencer_class.name} failed at #{step_label} (#{code}): #{steps_summary}")
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
def code
|
|
@@ -74,7 +74,7 @@ module Hubbado
|
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
def handled?
|
|
77
|
-
@result.
|
|
77
|
+
@result.success? || @handled
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
def enforce_safety_nets!
|
|
@@ -96,7 +96,7 @@ module Hubbado
|
|
|
96
96
|
end
|
|
97
97
|
|
|
98
98
|
def log_unhandled
|
|
99
|
-
logger.error("Sequencer #{@sequencer_class.name} failed unhandled at #{step_label} (#{code}): #{
|
|
99
|
+
logger.error("Sequencer #{@sequencer_class.name} failed unhandled at #{step_label} (#{code}): #{steps_summary}")
|
|
100
100
|
end
|
|
101
101
|
|
|
102
102
|
private
|
|
@@ -106,8 +106,8 @@ module Hubbado
|
|
|
106
106
|
@returned = yield
|
|
107
107
|
end
|
|
108
108
|
|
|
109
|
-
def
|
|
110
|
-
@result.
|
|
109
|
+
def steps_summary
|
|
110
|
+
@result.successful_steps.empty? ? "(no steps)" : @result.successful_steps.map(&:to_s).join(" → ")
|
|
111
111
|
end
|
|
112
112
|
|
|
113
113
|
def step_label
|
|
@@ -173,9 +173,9 @@ module Hubbado
|
|
|
173
173
|
|
|
174
174
|
if outcome[:kind] == :success
|
|
175
175
|
outcome[:ctx_writes].each { |key, value| ctx[key] = value }
|
|
176
|
-
Hubbado::Sequence::Result.
|
|
176
|
+
Hubbado::Sequence::Result.success(ctx)
|
|
177
177
|
else
|
|
178
|
-
Hubbado::Sequence::Result.
|
|
178
|
+
Hubbado::Sequence::Result.failure(ctx, error: outcome[:error])
|
|
179
179
|
end
|
|
180
180
|
end
|
|
181
181
|
end
|
|
@@ -34,12 +34,12 @@ module Hubbado
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
record def call(ctx)
|
|
37
|
-
return ::Hubbado::Sequence::Result.
|
|
37
|
+
return ::Hubbado::Sequence::Result.failure(ctx, error: @configured_error) if @configured_error
|
|
38
38
|
|
|
39
39
|
if @configured_writes
|
|
40
40
|
@configured_writes.each { |k, v| ctx[k] = v }
|
|
41
41
|
end
|
|
42
|
-
::Hubbado::Sequence::Result.
|
|
42
|
+
::Hubbado::Sequence::Result.success(ctx)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
def called?(**kwargs)
|
|
@@ -83,7 +83,7 @@ module Hubbado
|
|
|
83
83
|
end
|
|
84
84
|
|
|
85
85
|
def failure(ctx, **error_attrs)
|
|
86
|
-
Result.
|
|
86
|
+
Result.failure(ctx, error: error_attrs, i18n_scope: i18n_scope)
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
# Builds a Pipeline that auto-dispatches blockless `step(:foo)` calls to
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hubbado-sequence
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Hubbado Devs
|
|
@@ -164,9 +164,9 @@ dependencies:
|
|
|
164
164
|
- - ">="
|
|
165
165
|
- !ruby/object:Gem::Version
|
|
166
166
|
version: '0'
|
|
167
|
-
description:
|
|
168
|
-
|
|
169
|
-
execution.
|
|
167
|
+
description: A sequencer takes input, runs an ordered sequence of steps, and returns
|
|
168
|
+
a Result carrying a success-or-failure flag, a structured error, and the working
|
|
169
|
+
context that was built up during execution. Built with Rails in mind but framework-agnostic.
|
|
170
170
|
email:
|
|
171
171
|
- devs@hubbado.com
|
|
172
172
|
executables: []
|
|
@@ -223,5 +223,6 @@ requirements: []
|
|
|
223
223
|
rubygems_version: 3.5.22
|
|
224
224
|
signing_key:
|
|
225
225
|
specification_version: 4
|
|
226
|
-
summary: A small framework for
|
|
226
|
+
summary: A small framework for the short sequences of common steps that controller
|
|
227
|
+
actions usually boil down to
|
|
227
228
|
test_files: []
|