hubbado-sequence 0.3.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 +82 -0
- data/README.md +263 -172
- 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 +37 -79
- 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 +7 -6
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,88 @@ 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
|
+
|
|
55
|
+
## [0.4.0] - Inline step blocks removed; Pipeline made internal
|
|
56
|
+
|
|
57
|
+
### Removed (breaking)
|
|
58
|
+
|
|
59
|
+
- **Inline block form of `step` removed.** `p.step(:name) { |ctx| ... }`
|
|
60
|
+
is no longer supported. Every step must be a method on the sequencer
|
|
61
|
+
with the same name:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
# before
|
|
65
|
+
p.step(:notify) { |ctx| UserMailer.updated(ctx[:user]).deliver_later }
|
|
66
|
+
|
|
67
|
+
# after
|
|
68
|
+
p.step(:notify) # dispatches to def notify(ctx)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Migration: extract each inline block to a private method of the same
|
|
72
|
+
name. One method per step; one method per responsibility.
|
|
73
|
+
|
|
74
|
+
- **`Pipeline` is no longer part of the public API.** `Pipeline.(ctx)`
|
|
75
|
+
and `Pipeline.new` are internal to the framework. Sequencers build
|
|
76
|
+
pipelines exclusively through the `pipeline(ctx)` helper.
|
|
77
|
+
|
|
78
|
+
Migration: any direct `Pipeline.(ctx)` or `Pipeline.new(ctx, ...)`
|
|
79
|
+
call sites must be replaced with a sequencer that uses `pipeline(ctx)`.
|
|
80
|
+
|
|
81
|
+
### Changed
|
|
82
|
+
|
|
83
|
+
- **`Pipeline#step` always auto-dispatches.** With inline blocks gone,
|
|
84
|
+
`step(:foo)` unconditionally dispatches to `self.foo(ctx)` on the
|
|
85
|
+
sequencer — no block-versus-dispatch ambiguity. Missing methods raise
|
|
86
|
+
`NoMethodError` with the step name and the sequencer class in the
|
|
87
|
+
message.
|
|
88
|
+
|
|
7
89
|
## [0.3.0] - Contract::Deserialize macro, Runner extraction, Path helper
|
|
8
90
|
|
|
9
91
|
### Added
|
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
|
|
|
@@ -114,38 +151,39 @@ class UsersController < ApplicationController
|
|
|
114
151
|
end
|
|
115
152
|
```
|
|
116
153
|
|
|
117
|
-
## The
|
|
154
|
+
## The two step shapes
|
|
118
155
|
|
|
119
156
|
```ruby
|
|
120
157
|
pipeline(ctx) do |p|
|
|
121
|
-
p.invoke(:find, :user)
|
|
122
|
-
p.step(:scrub_params)
|
|
123
|
-
p.step(:audit) { |c| AuditLog.append(c[:user]) } # inline block
|
|
158
|
+
p.invoke(:find, User, as: :user) # declared dependency (macro or sequencer)
|
|
159
|
+
p.step(:scrub_params) # local method `def scrub_params(ctx)`
|
|
124
160
|
end
|
|
125
161
|
```
|
|
126
162
|
|
|
127
163
|
- `p.invoke(:foo, *args, **kwargs)` — a `dependency :foo, …` declared on the
|
|
128
164
|
sequencer (a macro or a nested sequencer). Calls
|
|
129
165
|
`dispatcher.foo.(ctx, *args, **kwargs)`.
|
|
130
|
-
- `p.step(:foo)` — a local instance method.
|
|
131
|
-
`self.foo(ctx)`.
|
|
132
|
-
- `p.step(:foo) { |ctx| … }` — explicit inline block.
|
|
166
|
+
- `p.step(:foo)` — a local instance method. Dispatches to `self.foo(ctx)`.
|
|
133
167
|
|
|
134
|
-
|
|
135
|
-
`
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
168
|
+
Every `step` is a method on the sequencer with the same name as the step.
|
|
169
|
+
This makes the `call` body a table of contents — scan `p.step(:...)` lines
|
|
170
|
+
to see the sequence shape, jump to the method for details.
|
|
171
|
+
|
|
172
|
+
`pipeline(ctx)` is the only way to build a pipeline. The underlying
|
|
173
|
+
Pipeline class is an implementation detail; sequencers do not construct
|
|
174
|
+
it directly.
|
|
140
175
|
|
|
141
176
|
## Built-in macros
|
|
142
177
|
|
|
143
178
|
Each macro is a dependency declared on a sequencer with `dependency :name, Macros::...`
|
|
144
|
-
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
|
|
145
183
|
|
|
146
|
-
|
|
184
|
+
Designed to work with [ActiveRecord](https://github.com/rails/rails) models.
|
|
147
185
|
|
|
148
|
-
|
|
186
|
+
#### Model::Find
|
|
149
187
|
|
|
150
188
|
Fetches a record using `model.find_by(id:)` and writes it to `ctx[as]`.
|
|
151
189
|
|
|
@@ -161,7 +199,7 @@ p.invoke(:find, User, as: :user, id_key: %i[params id]) # nested path (default)
|
|
|
161
199
|
| **Writes** | `ctx[as]` — the found record |
|
|
162
200
|
| **Fails** | `:not_found` when `find_by` returns nil |
|
|
163
201
|
|
|
164
|
-
|
|
202
|
+
#### Model::Build
|
|
165
203
|
|
|
166
204
|
Instantiates a new record and writes it to `ctx[as]`.
|
|
167
205
|
|
|
@@ -176,9 +214,12 @@ p.invoke(:build_record, User, as: :user, attributes: { role: :admin })
|
|
|
176
214
|
| **Writes** | `ctx[as]` — the new instance |
|
|
177
215
|
| **Fails** | never |
|
|
178
216
|
|
|
179
|
-
|
|
217
|
+
### Reform macros
|
|
180
218
|
|
|
181
|
-
|
|
219
|
+
Designed to work with [Reform](https://github.com/trailblazer/reform) form
|
|
220
|
+
objects (contracts).
|
|
221
|
+
|
|
222
|
+
#### Contract::Build
|
|
182
223
|
|
|
183
224
|
Wraps a model in a contract and writes it to `ctx[:contract]`.
|
|
184
225
|
|
|
@@ -193,7 +234,7 @@ p.invoke(:build_contract, Contracts::CreateUser) # no model
|
|
|
193
234
|
| **Writes** | `ctx[:contract]` |
|
|
194
235
|
| **Fails** | never |
|
|
195
236
|
|
|
196
|
-
|
|
237
|
+
#### Contract::Deserialize
|
|
197
238
|
|
|
198
239
|
Deserializes params into the contract via `contract.deserialize(params)`.
|
|
199
240
|
|
|
@@ -208,7 +249,7 @@ p.invoke(:deserialize_to_contract, from: :raw_params)
|
|
|
208
249
|
| **Writes** | nothing (mutates the contract in place) |
|
|
209
250
|
| **Fails** | never (no-op when the `from:` path is absent) |
|
|
210
251
|
|
|
211
|
-
|
|
252
|
+
#### Contract::Validate
|
|
212
253
|
|
|
213
254
|
Validates the contract via `contract.validate(params)` and checks `errors`.
|
|
214
255
|
|
|
@@ -223,7 +264,7 @@ p.invoke(:validate) # contract already deserialized; passes empty params
|
|
|
223
264
|
| **Writes** | nothing (populates `contract.errors` on invalid) |
|
|
224
265
|
| **Fails** | `:validation_failed` when `contract.errors` is non-empty |
|
|
225
266
|
|
|
226
|
-
|
|
267
|
+
#### Contract::Persist
|
|
227
268
|
|
|
228
269
|
Saves the contract via `contract.save`.
|
|
229
270
|
|
|
@@ -237,7 +278,12 @@ p.invoke(:persist)
|
|
|
237
278
|
| **Writes** | nothing |
|
|
238
279
|
| **Fails** | `:persist_failed` when `save` returns false |
|
|
239
280
|
|
|
240
|
-
###
|
|
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
|
|
241
287
|
|
|
242
288
|
Builds a policy and calls the named action to authorise the operation.
|
|
243
289
|
|
|
@@ -245,9 +291,9 @@ Builds a policy and calls the named action to authorise the operation.
|
|
|
245
291
|
p.invoke(:check_policy, Policies::User, :user, :update)
|
|
246
292
|
```
|
|
247
293
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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?`.
|
|
251
297
|
|
|
252
298
|
| | |
|
|
253
299
|
|---|---|
|
|
@@ -273,9 +319,15 @@ def call(ctx)
|
|
|
273
319
|
t.invoke(:persist)
|
|
274
320
|
end
|
|
275
321
|
|
|
276
|
-
p.step(:notify)
|
|
322
|
+
p.step(:notify)
|
|
277
323
|
end
|
|
278
324
|
end
|
|
325
|
+
|
|
326
|
+
private
|
|
327
|
+
|
|
328
|
+
def notify(ctx)
|
|
329
|
+
UserMailer.updated(ctx[:user]).deliver_later
|
|
330
|
+
end
|
|
279
331
|
```
|
|
280
332
|
|
|
281
333
|
Steps before the transaction run outside it (read-only lookups, policy
|
|
@@ -289,46 +341,46 @@ as part of the same pipeline.
|
|
|
289
341
|
|
|
290
342
|
The "find the record, build the contract, check the policy" shape is shared
|
|
291
343
|
between an edit form and an update action — both need exactly that, and the
|
|
292
|
-
update then validates and persists.
|
|
293
|
-
sequencer
|
|
344
|
+
update then validates and persists. Define Present as a nested class on the
|
|
345
|
+
outer sequencer so the two stay co-located:
|
|
294
346
|
|
|
295
347
|
```ruby
|
|
296
|
-
class Seqs::
|
|
297
|
-
|
|
348
|
+
class Seqs::UpdateUser
|
|
349
|
+
class Present
|
|
350
|
+
include Hubbado::Sequence::Sequencer
|
|
298
351
|
|
|
299
|
-
|
|
352
|
+
configure :present # so a parent can use `Present.configure(instance)`
|
|
300
353
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
354
|
+
dependency :find, Macros::Model::Find
|
|
355
|
+
dependency :build_contract, Macros::Contract::Build
|
|
356
|
+
dependency :check_policy, Macros::Policy::Check
|
|
304
357
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
310
364
|
end
|
|
311
|
-
end
|
|
312
365
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
318
372
|
end
|
|
319
373
|
end
|
|
320
|
-
end
|
|
321
374
|
|
|
322
|
-
class Seqs::UpdateUser
|
|
323
375
|
include Hubbado::Sequence::Sequencer
|
|
324
376
|
|
|
325
|
-
dependency :present,
|
|
377
|
+
dependency :present, Present
|
|
326
378
|
dependency :validate, Macros::Contract::Validate
|
|
327
379
|
dependency :persist, Macros::Contract::Persist
|
|
328
380
|
|
|
329
381
|
def self.build
|
|
330
382
|
new.tap do |instance|
|
|
331
|
-
|
|
383
|
+
Present.configure(instance)
|
|
332
384
|
Macros::Contract::Validate.configure(instance)
|
|
333
385
|
Macros::Contract::Persist.configure(instance)
|
|
334
386
|
end
|
|
@@ -355,7 +407,7 @@ class UsersController < ApplicationController
|
|
|
355
407
|
include Hubbado::Sequence::RunSequence
|
|
356
408
|
|
|
357
409
|
def edit
|
|
358
|
-
run_sequence Seqs::
|
|
410
|
+
run_sequence Seqs::UpdateUser::Present, params: params, current_user: current_user do |result|
|
|
359
411
|
result.success { |ctx| render :edit, locals: { contract: ctx[:contract] } }
|
|
360
412
|
result.policy_failed { |_| redirect_to root_path, alert: result.message }
|
|
361
413
|
result.not_found { |_| render_404 }
|
|
@@ -375,15 +427,15 @@ end
|
|
|
375
427
|
|
|
376
428
|
Inner writes (`ctx[:user]`, `ctx[:contract]`) are visible to outer steps —
|
|
377
429
|
Present and Update share the same `Ctx`, so `:validate` and `:persist` see
|
|
378
|
-
exactly what Present built. The outer
|
|
379
|
-
|
|
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.
|
|
380
432
|
|
|
381
433
|
## Result, success, failure
|
|
382
434
|
|
|
383
435
|
A step is **successful unless it explicitly returns a failed `Result`**. Any
|
|
384
|
-
other return value (`nil`, `false`, a model, `Result.
|
|
436
|
+
other return value (`nil`, `false`, a model, `Result.success(...)`) is taken as
|
|
385
437
|
success and the pipeline continues with the same `ctx`. Only
|
|
386
|
-
`Result.
|
|
438
|
+
`Result.failure(...)` or the `failure(ctx, code: ...)` helper short-circuits.
|
|
387
439
|
|
|
388
440
|
```ruby
|
|
389
441
|
def call(ctx)
|
|
@@ -397,7 +449,7 @@ private
|
|
|
397
449
|
|
|
398
450
|
def must_be_premium(ctx)
|
|
399
451
|
return failure(ctx, code: :forbidden) unless ctx[:user].premium?
|
|
400
|
-
# implicit
|
|
452
|
+
# implicit success if we get here
|
|
401
453
|
end
|
|
402
454
|
```
|
|
403
455
|
|
|
@@ -408,46 +460,65 @@ same error attrs as the underlying error hash (`code:`, `i18n_key:`,
|
|
|
408
460
|
|
|
409
461
|
## Testing
|
|
410
462
|
|
|
411
|
-
|
|
412
|
-
substitutes
|
|
413
|
-
|
|
414
|
-
|
|
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
|
|
415
474
|
configures the ones whose return matters.
|
|
416
475
|
|
|
417
476
|
### Substituting macros directly
|
|
418
477
|
|
|
419
478
|
```ruby
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
)
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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?)
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
test "Fetched the user from ctx[:user]" do
|
|
494
|
+
assert(seq.find.fetched?(as: :user))
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
test "Built the contract" do
|
|
498
|
+
assert(seq.build_contract.built?)
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
test "Checked the policy" do
|
|
502
|
+
assert(seq.check_policy.checked?)
|
|
437
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)
|
|
438
509
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
|
442
515
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
))
|
|
516
|
+
test "Does not build the contract" do
|
|
517
|
+
refute(seq.build_contract.built?)
|
|
518
|
+
end
|
|
447
519
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
expect(seq.check_policy.checked?).to be false
|
|
520
|
+
test "Does not check the policy" do
|
|
521
|
+
refute(seq.check_policy.checked?)
|
|
451
522
|
end
|
|
452
523
|
end
|
|
453
524
|
```
|
|
@@ -460,84 +531,104 @@ Every sequencer ships a default `Substitute` module (installed by
|
|
|
460
531
|
short-circuit a nested sequencer without reaching into its inner pieces:
|
|
461
532
|
|
|
462
533
|
```ruby
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
expect(seq.present.called?).to be true
|
|
478
|
-
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?)
|
|
479
548
|
end
|
|
480
549
|
|
|
481
|
-
|
|
482
|
-
seq
|
|
483
|
-
|
|
550
|
+
test "Calls Present" do
|
|
551
|
+
assert(seq.present.called?)
|
|
552
|
+
end
|
|
553
|
+
|
|
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
|
|
484
568
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
569
|
+
test "Fails with :forbidden" do
|
|
570
|
+
assert(result.error[:code] == :forbidden)
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
test "Does not validate" do
|
|
574
|
+
refute(seq.validate.validated?)
|
|
575
|
+
end
|
|
489
576
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
expect(seq.validate.validated?).to be false
|
|
493
|
-
expect(seq.persist.persisted?).to be false
|
|
577
|
+
test "Does not persist" do
|
|
578
|
+
refute(seq.persist.persisted?)
|
|
494
579
|
end
|
|
580
|
+
end
|
|
495
581
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
582
|
+
context "Seqs::UpdateUser when Present cannot find the record" do
|
|
583
|
+
seq = Seqs::UpdateUser.new
|
|
584
|
+
seq.present.fail_with(code: :not_found)
|
|
499
585
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
)
|
|
586
|
+
result = seq.(params: { id: 999, user: {} }, current_user: User.new)
|
|
587
|
+
|
|
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
|
|
504
595
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
expect(seq.persist.persisted?).to be false
|
|
596
|
+
test "Does not persist" do
|
|
597
|
+
refute(seq.persist.persisted?)
|
|
508
598
|
end
|
|
509
599
|
end
|
|
510
600
|
```
|
|
511
601
|
|
|
512
602
|
`succeed_with(**ctx_writes)` writes the given keys into `ctx` and returns
|
|
513
|
-
`Result.
|
|
603
|
+
`Result.success(ctx)`, so the outer steps see what the real Present would have
|
|
514
604
|
left behind. `fail_with(**error)` returns a failed `Result` with the given
|
|
515
605
|
error, short-circuiting the outer pipeline. The Update spec doesn't need
|
|
516
606
|
to exercise Find / Build / Policy::Check directly — those live in
|
|
517
|
-
|
|
607
|
+
`Seqs::UpdateUser::Present`'s spec, where they belong.
|
|
518
608
|
|
|
519
609
|
## Observability
|
|
520
610
|
|
|
521
|
-
Every `Result` carries
|
|
522
|
-
successfully, in order. On failure, the failing step is *not* in
|
|
523
|
-
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.
|
|
524
614
|
|
|
525
615
|
```ruby
|
|
526
|
-
result.
|
|
527
|
-
result.
|
|
528
|
-
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
|
|
529
619
|
```
|
|
530
620
|
|
|
531
621
|
When invoked via `run_sequence`, the dispatcher logs a single line per
|
|
532
|
-
invocation summarising the
|
|
622
|
+
invocation summarising the successful steps and (on failure) where it
|
|
623
|
+
stopped:
|
|
533
624
|
|
|
534
625
|
```
|
|
535
626
|
Sequencer Seqs::UpdateUser succeeded: find → build_contract → check_policy → validate → persist
|
|
536
627
|
Sequencer Seqs::UpdateUser failed at :check_policy (forbidden): find → build_contract
|
|
537
628
|
```
|
|
538
629
|
|
|
539
|
-
Nested sequencer
|
|
540
|
-
`: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.
|
|
541
632
|
`error[:step]` carries the inner step name when a nested sequencer fails.
|
|
542
633
|
|
|
543
634
|
## Standard error codes
|
|
@@ -559,4 +650,4 @@ Sequencers can mint their own codes for domain-specific failures
|
|
|
559
650
|
|
|
560
651
|
## License
|
|
561
652
|
|
|
562
|
-
|
|
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)
|
|
@@ -1,84 +1,45 @@
|
|
|
1
1
|
module Hubbado
|
|
2
2
|
module Sequence
|
|
3
|
+
# Railway-style step runner that backs the Sequencer mixin's
|
|
4
|
+
# `pipeline(ctx)` helper. Not part of the public API — sequencers reach
|
|
5
|
+
# it through the helper.
|
|
3
6
|
class Pipeline
|
|
4
|
-
|
|
5
|
-
# runs the block (so steps can be added in statement form), and returns
|
|
6
|
-
# the final Result. The non-block form returns the Pipeline so chained
|
|
7
|
-
# `.step(...)...result` calls still work.
|
|
8
|
-
def self.call(ctx = nil, **kwargs, &block)
|
|
9
|
-
if ctx.nil?
|
|
10
|
-
ctx = Ctx.build(kwargs)
|
|
11
|
-
elsif !kwargs.empty?
|
|
12
|
-
raise ArgumentError, "Pipeline.() takes either a Ctx or keyword arguments, not both"
|
|
13
|
-
elsif !ctx.is_a?(Ctx)
|
|
14
|
-
ctx = Ctx.build(ctx)
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
pipe = new(ctx)
|
|
18
|
-
|
|
19
|
-
if block
|
|
20
|
-
block.call(pipe)
|
|
21
|
-
pipe.result
|
|
22
|
-
else
|
|
23
|
-
pipe
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def initialize(ctx, dispatcher: nil)
|
|
7
|
+
def initialize(ctx, dispatcher:)
|
|
28
8
|
@ctx = ctx
|
|
29
|
-
@
|
|
9
|
+
@successful_steps = []
|
|
30
10
|
@failed_result = nil
|
|
31
11
|
@dispatcher = dispatcher
|
|
32
12
|
end
|
|
33
13
|
|
|
34
|
-
# `step(:name)
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
|
|
40
|
-
# explicitly returns a failed `Result`. Any other return value (nil,
|
|
41
|
-
# false, a model, a hash, `Result.ok(...)`) is taken as success and the
|
|
42
|
-
# pipeline continues with the same `@ctx`. Only `Result.fail(...)` /
|
|
43
|
-
# `failure(ctx, code: ...)` short-circuits the pipeline.
|
|
44
|
-
def step(name, &block)
|
|
14
|
+
# `step(:name)` dispatches to `dispatcher.send(name, ctx)`. The method
|
|
15
|
+
# is treated as successful unless it explicitly returns a failed
|
|
16
|
+
# `Result`; any other return value (nil, false, a model, `Result.success`)
|
|
17
|
+
# continues the pipeline with the same ctx. Only `Result.failure(...)` /
|
|
18
|
+
# `failure(ctx, code: ...)` short-circuits.
|
|
19
|
+
def step(name)
|
|
45
20
|
return self if @failed_result
|
|
46
21
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if return_value.is_a?(Result) && return_value.failure?
|
|
50
|
-
@failed_result = tag_failure(return_value, name)
|
|
51
|
-
else
|
|
52
|
-
@trail << name
|
|
53
|
-
end
|
|
54
|
-
|
|
22
|
+
record(name, invoke_step(name))
|
|
55
23
|
self
|
|
56
24
|
end
|
|
57
25
|
|
|
58
26
|
# `invoke(:name, *args, **kwargs)` calls a declared dependency on the
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
# `step`.
|
|
27
|
+
# dispatcher: gets it via `dispatcher.send(name)` (the reader), then
|
|
28
|
+
# invokes it with `(ctx, *args, **kwargs)`. Same step recording,
|
|
29
|
+
# failure short-circuiting, and lenient return convention as `step`.
|
|
63
30
|
#
|
|
64
|
-
# Use this for any declared dependency — macros
|
|
65
|
-
# and nested sequencers (`Seqs::Present`)
|
|
66
|
-
# instance methods like
|
|
31
|
+
# Use this for any declared dependency — macros
|
|
32
|
+
# (`Macros::Model::Find`) and nested sequencers (`Seqs::Present`)
|
|
33
|
+
# alike. Use `step` for local instance methods like
|
|
34
|
+
# `def deserialize_contract(ctx)`.
|
|
67
35
|
def invoke(name, *args, **kwargs)
|
|
68
36
|
return self if @failed_result
|
|
69
37
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if return_value.is_a?(Result) && return_value.failure?
|
|
73
|
-
@failed_result = tag_failure(return_value, name)
|
|
74
|
-
else
|
|
75
|
-
@trail << name
|
|
76
|
-
end
|
|
77
|
-
|
|
38
|
+
record(name, invoke_dependency(name, args, kwargs))
|
|
78
39
|
self
|
|
79
40
|
end
|
|
80
41
|
|
|
81
|
-
def transaction
|
|
42
|
+
def transaction
|
|
82
43
|
return self if @failed_result
|
|
83
44
|
|
|
84
45
|
if defined?(::ActiveRecord::Base)
|
|
@@ -97,33 +58,22 @@ module Hubbado
|
|
|
97
58
|
if @failed_result
|
|
98
59
|
@failed_result
|
|
99
60
|
else
|
|
100
|
-
Result.
|
|
61
|
+
Result.success(@ctx, successful_steps: @successful_steps.dup)
|
|
101
62
|
end
|
|
102
63
|
end
|
|
103
64
|
|
|
104
65
|
private
|
|
105
66
|
|
|
106
|
-
def invoke_step(name
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
unless @dispatcher.respond_to?(name, true)
|
|
111
|
-
raise NoMethodError,
|
|
112
|
-
"Pipeline step :#{name} expects #{@dispatcher.class.name} to define ##{name}, but it does not"
|
|
113
|
-
end
|
|
114
|
-
@dispatcher.send(name, @ctx)
|
|
115
|
-
else
|
|
116
|
-
raise ArgumentError,
|
|
117
|
-
"Pipeline step :#{name} needs either a block or a dispatcher (use the sequencer's `pipeline(ctx)` helper to enable auto-dispatch)"
|
|
67
|
+
def invoke_step(name)
|
|
68
|
+
unless @dispatcher.respond_to?(name, true)
|
|
69
|
+
raise NoMethodError,
|
|
70
|
+
"Pipeline step :#{name} expects #{@dispatcher.class.name} to define ##{name}, but it does not"
|
|
118
71
|
end
|
|
72
|
+
|
|
73
|
+
@dispatcher.send(name, @ctx)
|
|
119
74
|
end
|
|
120
75
|
|
|
121
76
|
def invoke_dependency(name, args, kwargs)
|
|
122
|
-
unless @dispatcher
|
|
123
|
-
raise ArgumentError,
|
|
124
|
-
"Pipeline#invoke :#{name} requires a dispatcher (use the sequencer's `pipeline(ctx)` helper)"
|
|
125
|
-
end
|
|
126
|
-
|
|
127
77
|
unless @dispatcher.respond_to?(name, true)
|
|
128
78
|
raise NoMethodError,
|
|
129
79
|
"Pipeline#invoke :#{name} expects #{@dispatcher.class.name} to declare a `dependency :#{name}, ...`"
|
|
@@ -132,9 +82,17 @@ module Hubbado
|
|
|
132
82
|
@dispatcher.send(name).(@ctx, *args, **kwargs)
|
|
133
83
|
end
|
|
134
84
|
|
|
85
|
+
def record(name, return_value)
|
|
86
|
+
if return_value.is_a?(Result) && return_value.failure?
|
|
87
|
+
@failed_result = tag_failure(return_value, name)
|
|
88
|
+
else
|
|
89
|
+
@successful_steps << name
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
135
93
|
def tag_failure(result, step_name)
|
|
136
94
|
tagged_error = result.error.merge(step: step_name)
|
|
137
|
-
Result.
|
|
95
|
+
Result.failure(result.ctx, error: tagged_error, successful_steps: @successful_steps.dup, i18n_scope: result.i18n_scope)
|
|
138
96
|
end
|
|
139
97
|
end
|
|
140
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,14 +1,14 @@
|
|
|
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
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-05-
|
|
11
|
+
date: 2026-05-15 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: evt-casing
|
|
@@ -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: []
|