hubbado-sequence 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +166 -0
- data/LICENSE +21 -0
- data/README.md +562 -0
- data/config/locales/en.yml +8 -0
- data/hubbado-sequence.gemspec +41 -0
- data/lib/hubbado/sequence/controls/contract.rb +45 -0
- data/lib/hubbado/sequence/controls/model.rb +33 -0
- data/lib/hubbado/sequence/controls/policy.rb +48 -0
- data/lib/hubbado/sequence/controls.rb +3 -0
- data/lib/hubbado/sequence/ctx.rb +19 -0
- data/lib/hubbado/sequence/errors.rb +16 -0
- data/lib/hubbado/sequence/macros/contract/build.rb +48 -0
- data/lib/hubbado/sequence/macros/contract/deserialize.rb +43 -0
- data/lib/hubbado/sequence/macros/contract/persist.rb +50 -0
- data/lib/hubbado/sequence/macros/contract/validate.rb +53 -0
- data/lib/hubbado/sequence/macros/model/build.rb +57 -0
- data/lib/hubbado/sequence/macros/model/find.rb +59 -0
- data/lib/hubbado/sequence/macros/policy/check.rb +65 -0
- data/lib/hubbado/sequence/path.rb +35 -0
- data/lib/hubbado/sequence/pipeline.rb +141 -0
- data/lib/hubbado/sequence/result.rb +83 -0
- data/lib/hubbado/sequence/run_sequence.rb +26 -0
- data/lib/hubbado/sequence/runner.rb +184 -0
- data/lib/hubbado/sequence/sequencer.rb +109 -0
- data/lib/hubbado/sequence.rb +30 -0
- metadata +227 -0
data/README.md
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
# hubbado-sequence
|
|
2
|
+
|
|
3
|
+
A small framework for orchestrating units of business behaviour. The eventual
|
|
4
|
+
replacement for Trailblazer operations at Hubbado, designed to coexist with
|
|
5
|
+
them during migration.
|
|
6
|
+
|
|
7
|
+
A sequencer takes input, runs a sequence of steps, and returns a `Result`
|
|
8
|
+
indicating success or failure plus the working context that was built up
|
|
9
|
+
during execution.
|
|
10
|
+
|
|
11
|
+
The full design rationale lives in [`docs/design.md`](docs/design.md). This
|
|
12
|
+
README is a quick tour.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
Add to your Gemfile:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem "hubbado-sequence"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Then run `bundle install`.
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
- Ruby >= 3.3
|
|
27
|
+
- [evt-dependency](https://github.com/eventide-project/dependency) — powers
|
|
28
|
+
the injectable macro / nested-sequencer pattern (declared as a runtime
|
|
29
|
+
dependency of the gem).
|
|
30
|
+
|
|
31
|
+
Optional, depending on which macros you use:
|
|
32
|
+
|
|
33
|
+
- [ActiveRecord](https://github.com/rails/rails) for `Model::Find`,
|
|
34
|
+
`Model::Build`, and `Pipeline#transaction`.
|
|
35
|
+
- [Reform](https://github.com/trailblazer/reform) for `Contract::Build`,
|
|
36
|
+
`Contract::Deserialize`, `Contract::Validate`, and `Contract::Persist`.
|
|
37
|
+
- [hubbado-policy](https://github.com/hubbado/hubbado-policy) for
|
|
38
|
+
`Policy::Check`.
|
|
39
|
+
|
|
40
|
+
## Philosophy
|
|
41
|
+
|
|
42
|
+
Sequencers sit at the controller boundary. They receive input from a Rails
|
|
43
|
+
action, orchestrate the work, and hand a `Result` back. The sequencer's job
|
|
44
|
+
is **orchestration only** — it should not contain business logic itself. Real
|
|
45
|
+
behaviour lives in the models, contracts, policies, and domain objects it
|
|
46
|
+
calls.
|
|
47
|
+
|
|
48
|
+
Nesting is intentionally shallow. The only nesting we use in practice is a
|
|
49
|
+
`Present` sequencer inside an `Update` sequencer: Present loads the record,
|
|
50
|
+
builds the contract, and checks the policy; Update calls Present and then
|
|
51
|
+
validates and persists. Chains longer than one level are rare enough to be a
|
|
52
|
+
signal that something should be a plain Ruby object instead.
|
|
53
|
+
|
|
54
|
+
The framework uses [evt-dependency](https://github.com/eventide-project/dependency),
|
|
55
|
+
which means every macro and every nested sequencer is an injectable
|
|
56
|
+
dependency. Calling `.new` on a sequencer installs substitutes for all of
|
|
57
|
+
them, so unit tests exercise the sequencer's orchestration logic — what runs,
|
|
58
|
+
in what order, what short-circuits — without hitting the database, the policy
|
|
59
|
+
gem, or Reform. The substitutes default to pass-through `ok`, so a test only
|
|
60
|
+
configures the outcomes that matter for the scenario it's verifying.
|
|
61
|
+
|
|
62
|
+
Integration coverage (using `.build` to wire real collaborators) is reserved
|
|
63
|
+
for the controller boundary — one happy-path integration test per sequencer
|
|
64
|
+
is usually enough to confirm the wiring is correct.
|
|
65
|
+
|
|
66
|
+
## Quick start
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
class Seqs::UpdateUser
|
|
70
|
+
include Hubbado::Sequence::Sequencer
|
|
71
|
+
|
|
72
|
+
dependency :find, Macros::Model::Find
|
|
73
|
+
dependency :build_contract, Macros::Contract::Build
|
|
74
|
+
dependency :check_policy, Macros::Policy::Check
|
|
75
|
+
dependency :validate, Macros::Contract::Validate
|
|
76
|
+
dependency :persist, Macros::Contract::Persist
|
|
77
|
+
|
|
78
|
+
def self.build
|
|
79
|
+
new.tap do |instance|
|
|
80
|
+
Macros::Model::Find.configure(instance)
|
|
81
|
+
Macros::Contract::Build.configure(instance)
|
|
82
|
+
Macros::Policy::Check.configure(instance)
|
|
83
|
+
Macros::Contract::Validate.configure(instance)
|
|
84
|
+
Macros::Contract::Persist.configure(instance)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def call(ctx)
|
|
89
|
+
pipeline(ctx) do |p|
|
|
90
|
+
p.invoke(:find, User, as: :user)
|
|
91
|
+
p.invoke(:build_contract, Contracts::UpdateUser, :user)
|
|
92
|
+
p.invoke(:check_policy, Policies::User, :user, :update)
|
|
93
|
+
|
|
94
|
+
p.transaction do |t|
|
|
95
|
+
t.invoke(:validate, from: %i[params user])
|
|
96
|
+
t.invoke(:persist)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# In a controller:
|
|
103
|
+
class UsersController < ApplicationController
|
|
104
|
+
include Hubbado::Sequence::RunSequence
|
|
105
|
+
|
|
106
|
+
def update
|
|
107
|
+
run_sequence Seqs::UpdateUser, params: params, current_user: current_user do |result|
|
|
108
|
+
result.success { |ctx| redirect_to ctx[:user] }
|
|
109
|
+
result.policy_failed { |ctx| redirect_to root_path, alert: result.message }
|
|
110
|
+
result.not_found { |ctx| render_404 }
|
|
111
|
+
result.validation_failed { |ctx| render :edit, locals: { contract: ctx[:contract] } }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## The three step shapes
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
pipeline(ctx) do |p|
|
|
121
|
+
p.invoke(:find, :user) # declared dependency (macro or sequencer)
|
|
122
|
+
p.step(:scrub_params) # local method `def scrub_params(ctx)`
|
|
123
|
+
p.step(:audit) { |c| AuditLog.append(c[:user]) } # inline block
|
|
124
|
+
end
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
- `p.invoke(:foo, *args, **kwargs)` — a `dependency :foo, …` declared on the
|
|
128
|
+
sequencer (a macro or a nested sequencer). Calls
|
|
129
|
+
`dispatcher.foo.(ctx, *args, **kwargs)`.
|
|
130
|
+
- `p.step(:foo)` — a local instance method. Auto-dispatches to
|
|
131
|
+
`self.foo(ctx)`.
|
|
132
|
+
- `p.step(:foo) { |ctx| … }` — explicit inline block.
|
|
133
|
+
|
|
134
|
+
The `pipeline(ctx)` helper (lowercase `p`) is what enables blockless
|
|
135
|
+
`p.step(:foo)` auto-dispatch — it builds a Pipeline that knows which
|
|
136
|
+
sequencer to dispatch back to. `Pipeline.(ctx)` (capital `P`) is the bare
|
|
137
|
+
constructor with no dispatcher and requires every `step` to have a block.
|
|
138
|
+
Use `pipeline(ctx)` inside a sequencer; `Pipeline.(ctx)` is mainly useful
|
|
139
|
+
for framework tests.
|
|
140
|
+
|
|
141
|
+
## Built-in macros
|
|
142
|
+
|
|
143
|
+
Each macro is a dependency declared on a sequencer with `dependency :name, Macros::...`
|
|
144
|
+
and wired via `.configure(instance)` in `.build`.
|
|
145
|
+
|
|
146
|
+
The model macros are designed to work with ActiveRecord models.
|
|
147
|
+
|
|
148
|
+
### Model::Find
|
|
149
|
+
|
|
150
|
+
Fetches a record using `model.find_by(id:)` and writes it to `ctx[as]`.
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
p.invoke(:find, User, as: :user)
|
|
154
|
+
p.invoke(:find, User, as: :user, id_key: :user_id) # single key
|
|
155
|
+
p.invoke(:find, User, as: :user, id_key: %i[params id]) # nested path (default)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
| | |
|
|
159
|
+
|---|---|
|
|
160
|
+
| **Reads** | `ctx` at `id_key` (default: `%i[params id]`) |
|
|
161
|
+
| **Writes** | `ctx[as]` — the found record |
|
|
162
|
+
| **Fails** | `:not_found` when `find_by` returns nil |
|
|
163
|
+
|
|
164
|
+
### Model::Build
|
|
165
|
+
|
|
166
|
+
Instantiates a new record and writes it to `ctx[as]`.
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
p.invoke(:build_record, User, as: :user)
|
|
170
|
+
p.invoke(:build_record, User, as: :user, attributes: { role: :admin })
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
| | |
|
|
174
|
+
|---|---|
|
|
175
|
+
| **Reads** | nothing |
|
|
176
|
+
| **Writes** | `ctx[as]` — the new instance |
|
|
177
|
+
| **Fails** | never |
|
|
178
|
+
|
|
179
|
+
The contract macros are designed to work with [Reform](https://github.com/trailblazer/reform) form objects.
|
|
180
|
+
|
|
181
|
+
### Contract::Build
|
|
182
|
+
|
|
183
|
+
Wraps a model in a contract and writes it to `ctx[:contract]`.
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
p.invoke(:build_contract, Contracts::UpdateUser, :user) # model from ctx[:user]
|
|
187
|
+
p.invoke(:build_contract, Contracts::CreateUser) # no model
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
| | |
|
|
191
|
+
|---|---|
|
|
192
|
+
| **Reads** | `ctx[attr_name]` for the model (optional) |
|
|
193
|
+
| **Writes** | `ctx[:contract]` |
|
|
194
|
+
| **Fails** | never |
|
|
195
|
+
|
|
196
|
+
### Contract::Deserialize
|
|
197
|
+
|
|
198
|
+
Deserializes params into the contract via `contract.deserialize(params)`.
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
p.invoke(:deserialize_to_contract, from: %i[params user])
|
|
202
|
+
p.invoke(:deserialize_to_contract, from: :raw_params)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
| | |
|
|
206
|
+
|---|---|
|
|
207
|
+
| **Reads** | `ctx[:contract]`, `ctx` at `from:` |
|
|
208
|
+
| **Writes** | nothing (mutates the contract in place) |
|
|
209
|
+
| **Fails** | never (no-op when the `from:` path is absent) |
|
|
210
|
+
|
|
211
|
+
### Contract::Validate
|
|
212
|
+
|
|
213
|
+
Validates the contract via `contract.validate(params)` and checks `errors`.
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
p.invoke(:validate, from: %i[params user])
|
|
217
|
+
p.invoke(:validate) # contract already deserialized; passes empty params
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
| | |
|
|
221
|
+
|---|---|
|
|
222
|
+
| **Reads** | `ctx[:contract]`, `ctx` at `from:` (when given) |
|
|
223
|
+
| **Writes** | nothing (populates `contract.errors` on invalid) |
|
|
224
|
+
| **Fails** | `:validation_failed` when `contract.errors` is non-empty |
|
|
225
|
+
|
|
226
|
+
### Contract::Persist
|
|
227
|
+
|
|
228
|
+
Saves the contract via `contract.save`.
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
p.invoke(:persist)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
| | |
|
|
235
|
+
|---|---|
|
|
236
|
+
| **Reads** | `ctx[:contract]` |
|
|
237
|
+
| **Writes** | nothing |
|
|
238
|
+
| **Fails** | `:persist_failed` when `save` returns false |
|
|
239
|
+
|
|
240
|
+
### Policy::Check
|
|
241
|
+
|
|
242
|
+
Builds a policy and calls the named action to authorise the operation.
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
p.invoke(:check_policy, Policies::User, :user, :update)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Designed to work with the [hubbado-policy](https://github.com/hubbado/hubbado-policy) gem.
|
|
249
|
+
The policy class must respond to `.build(current_user, record)`; the instance must
|
|
250
|
+
respond to the action method and return an object with `permitted?`.
|
|
251
|
+
|
|
252
|
+
| | |
|
|
253
|
+
|---|---|
|
|
254
|
+
| **Reads** | `ctx[:current_user]`, `ctx[record_key]` |
|
|
255
|
+
| **Writes** | nothing |
|
|
256
|
+
| **Fails** | `:forbidden` when `permitted?` is false; `error[:data]` carries `{ policy:, policy_result: }` |
|
|
257
|
+
|
|
258
|
+
## Transactions
|
|
259
|
+
|
|
260
|
+
`Pipeline#transaction` wraps inner steps in `ActiveRecord::Base.transaction`.
|
|
261
|
+
A failed inner step raises `ActiveRecord::Rollback` and the failed `Result`
|
|
262
|
+
still propagates outward.
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
def call(ctx)
|
|
266
|
+
pipeline(ctx) do |p|
|
|
267
|
+
p.invoke(:find, User, as: :user)
|
|
268
|
+
p.invoke(:build_contract, Contracts::UpdateUser, :user)
|
|
269
|
+
p.invoke(:check_policy, Policies::User, :user, :update)
|
|
270
|
+
|
|
271
|
+
p.transaction do |t|
|
|
272
|
+
t.invoke(:validate, from: %i[params user])
|
|
273
|
+
t.invoke(:persist)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
p.step(:notify) { |c| UserMailer.updated(c[:user]).deliver_later }
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Steps before the transaction run outside it (read-only lookups, policy
|
|
282
|
+
checks). Steps after run after commit (notifications, emails — things that
|
|
283
|
+
shouldn't run if the DB write didn't stick).
|
|
284
|
+
|
|
285
|
+
When ActiveRecord isn't loaded, `transaction` runs the inner block inline
|
|
286
|
+
as part of the same pipeline.
|
|
287
|
+
|
|
288
|
+
## Nested sequencers (Present + Update)
|
|
289
|
+
|
|
290
|
+
The "find the record, build the contract, check the policy" shape is shared
|
|
291
|
+
between an edit form and an update action — both need exactly that, and the
|
|
292
|
+
update then validates and persists. Extract the shared part as a Present
|
|
293
|
+
sequencer and nest it as a dependency:
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
class Seqs::PresentUser
|
|
297
|
+
include Hubbado::Sequence::Sequencer
|
|
298
|
+
|
|
299
|
+
configure :present # so a parent can use `Seqs::PresentUser.configure(instance)`
|
|
300
|
+
|
|
301
|
+
dependency :find, Macros::Model::Find
|
|
302
|
+
dependency :build_contract, Macros::Contract::Build
|
|
303
|
+
dependency :check_policy, Macros::Policy::Check
|
|
304
|
+
|
|
305
|
+
def self.build
|
|
306
|
+
new.tap do |instance|
|
|
307
|
+
Macros::Model::Find.configure(instance)
|
|
308
|
+
Macros::Contract::Build.configure(instance)
|
|
309
|
+
Macros::Policy::Check.configure(instance)
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def call(ctx)
|
|
314
|
+
pipeline(ctx) do |p|
|
|
315
|
+
p.invoke(:find, User, as: :user)
|
|
316
|
+
p.invoke(:build_contract, Contracts::UpdateUser, :user)
|
|
317
|
+
p.invoke(:check_policy, Policies::User, :user, :update)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
class Seqs::UpdateUser
|
|
323
|
+
include Hubbado::Sequence::Sequencer
|
|
324
|
+
|
|
325
|
+
dependency :present, Seqs::PresentUser
|
|
326
|
+
dependency :validate, Macros::Contract::Validate
|
|
327
|
+
dependency :persist, Macros::Contract::Persist
|
|
328
|
+
|
|
329
|
+
def self.build
|
|
330
|
+
new.tap do |instance|
|
|
331
|
+
Seqs::PresentUser.configure(instance)
|
|
332
|
+
Macros::Contract::Validate.configure(instance)
|
|
333
|
+
Macros::Contract::Persist.configure(instance)
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def call(ctx)
|
|
338
|
+
pipeline(ctx) do |p|
|
|
339
|
+
p.invoke(:present)
|
|
340
|
+
|
|
341
|
+
p.transaction do |t|
|
|
342
|
+
t.invoke(:validate, from: %i[params user])
|
|
343
|
+
t.invoke(:persist)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
The edit action runs Present and renders the form; the update action runs
|
|
351
|
+
Update and either redirects or re-renders:
|
|
352
|
+
|
|
353
|
+
```ruby
|
|
354
|
+
class UsersController < ApplicationController
|
|
355
|
+
include Hubbado::Sequence::RunSequence
|
|
356
|
+
|
|
357
|
+
def edit
|
|
358
|
+
run_sequence Seqs::PresentUser, params: params, current_user: current_user do |result|
|
|
359
|
+
result.success { |ctx| render :edit, locals: { contract: ctx[:contract] } }
|
|
360
|
+
result.policy_failed { |_| redirect_to root_path, alert: result.message }
|
|
361
|
+
result.not_found { |_| render_404 }
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def update
|
|
366
|
+
run_sequence Seqs::UpdateUser, params: params, current_user: current_user do |result|
|
|
367
|
+
result.success { |ctx| redirect_to ctx[:user] }
|
|
368
|
+
result.policy_failed { |_| redirect_to root_path, alert: result.message }
|
|
369
|
+
result.not_found { |_| render_404 }
|
|
370
|
+
result.validation_failed { |ctx| render :edit, locals: { contract: ctx[:contract] } }
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Inner writes (`ctx[:user]`, `ctx[:contract]`) are visible to outer steps —
|
|
377
|
+
Present and Update share the same `Ctx`, so `:validate` and `:persist` see
|
|
378
|
+
exactly what Present built. The outer trail records `:present` as a single
|
|
379
|
+
step; Present's inner steps stay opaque to the parent.
|
|
380
|
+
|
|
381
|
+
## Result, success, failure
|
|
382
|
+
|
|
383
|
+
A step is **successful unless it explicitly returns a failed `Result`**. Any
|
|
384
|
+
other return value (`nil`, `false`, a model, `Result.ok(...)`) is taken as
|
|
385
|
+
success and the pipeline continues with the same `ctx`. Only
|
|
386
|
+
`Result.fail(...)` or the `failure(ctx, code: ...)` helper short-circuits.
|
|
387
|
+
|
|
388
|
+
```ruby
|
|
389
|
+
def call(ctx)
|
|
390
|
+
pipeline(ctx) do |p|
|
|
391
|
+
p.step(:must_be_premium)
|
|
392
|
+
p.invoke(:persist)
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
private
|
|
397
|
+
|
|
398
|
+
def must_be_premium(ctx)
|
|
399
|
+
return failure(ctx, code: :forbidden) unless ctx[:user].premium?
|
|
400
|
+
# implicit ok if we get here
|
|
401
|
+
end
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
`failure(ctx, ...)` is a sequencer helper that builds a failed `Result`
|
|
405
|
+
with the sequencer's auto-derived i18n scope already applied. It takes the
|
|
406
|
+
same error attrs as the underlying error hash (`code:`, `i18n_key:`,
|
|
407
|
+
`i18n_args:`, `data:`, `message:`).
|
|
408
|
+
|
|
409
|
+
## Testing
|
|
410
|
+
|
|
411
|
+
`described_class.new` returns a sequencer with all dependencies installed as
|
|
412
|
+
substitutes. Tests configure the substitutes for the scenario at hand.
|
|
413
|
+
`described_class.build` runs the production wiring (the real macros).
|
|
414
|
+
Substitutes default to pass-through `Result.ok(ctx)` so a test only
|
|
415
|
+
configures the ones whose return matters.
|
|
416
|
+
|
|
417
|
+
### Substituting macros directly
|
|
418
|
+
|
|
419
|
+
```ruby
|
|
420
|
+
RSpec.describe Seqs::PresentUser do
|
|
421
|
+
it "loads the user, builds the contract, and passes the policy" do
|
|
422
|
+
seq = described_class.new
|
|
423
|
+
user = User.new(id: 1, email: "old@example.com")
|
|
424
|
+
contract = Contracts::UpdateUser.new(user)
|
|
425
|
+
seq.find.succeed_with(user)
|
|
426
|
+
seq.build_contract.succeed_with(contract)
|
|
427
|
+
|
|
428
|
+
result = seq.(Hubbado::Sequence::Ctx.build(
|
|
429
|
+
params: { id: 1 },
|
|
430
|
+
current_user: User.new
|
|
431
|
+
))
|
|
432
|
+
|
|
433
|
+
expect(result).to be_ok
|
|
434
|
+
expect(seq.find.fetched?(as: :user)).to be true
|
|
435
|
+
expect(seq.build_contract.built?).to be true
|
|
436
|
+
expect(seq.check_policy.checked?).to be true
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
it "fails with :not_found when the user doesn't exist" do
|
|
440
|
+
seq = described_class.new
|
|
441
|
+
seq.find.fail_with(code: :not_found)
|
|
442
|
+
|
|
443
|
+
result = seq.(Hubbado::Sequence::Ctx.build(
|
|
444
|
+
params: { id: 999 },
|
|
445
|
+
current_user: User.new
|
|
446
|
+
))
|
|
447
|
+
|
|
448
|
+
expect(result.error[:code]).to eq(:not_found)
|
|
449
|
+
expect(seq.build_contract.built?).to be false
|
|
450
|
+
expect(seq.check_policy.checked?).to be false
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
### Substituting a nested sequencer
|
|
456
|
+
|
|
457
|
+
Every sequencer ships a default `Substitute` module (installed by
|
|
458
|
+
`include Hubbado::Sequence::Sequencer`) with `succeed_with(**ctx_writes)` /
|
|
459
|
+
`fail_with(**error)` / `called?(**partial_kwargs)`. The parent's tests can
|
|
460
|
+
short-circuit a nested sequencer without reaching into its inner pieces:
|
|
461
|
+
|
|
462
|
+
```ruby
|
|
463
|
+
RSpec.describe Seqs::UpdateUser do
|
|
464
|
+
it "updates the user when present succeeds" do
|
|
465
|
+
seq = described_class.new
|
|
466
|
+
|
|
467
|
+
user = User.new(id: 1, email: "old@example.com")
|
|
468
|
+
contract = Contracts::UpdateUser.new(user)
|
|
469
|
+
seq.present.succeed_with(user: user, contract: contract)
|
|
470
|
+
|
|
471
|
+
result = seq.(Hubbado::Sequence::Ctx.build(
|
|
472
|
+
params: { user: { email: "new@example.com" } },
|
|
473
|
+
current_user: User.new
|
|
474
|
+
))
|
|
475
|
+
|
|
476
|
+
expect(result).to be_ok
|
|
477
|
+
expect(seq.present.called?).to be true
|
|
478
|
+
expect(seq.persist.persisted?).to be true
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
it "stops when present denies access" do
|
|
482
|
+
seq = described_class.new
|
|
483
|
+
seq.present.fail_with(code: :forbidden)
|
|
484
|
+
|
|
485
|
+
result = seq.(Hubbado::Sequence::Ctx.build(
|
|
486
|
+
params: { user: {} },
|
|
487
|
+
current_user: User.new
|
|
488
|
+
))
|
|
489
|
+
|
|
490
|
+
expect(result.failure?).to be true
|
|
491
|
+
expect(result.error[:code]).to eq(:forbidden)
|
|
492
|
+
expect(seq.validate.validated?).to be false
|
|
493
|
+
expect(seq.persist.persisted?).to be false
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
it "stops when present cannot find the record" do
|
|
497
|
+
seq = described_class.new
|
|
498
|
+
seq.present.fail_with(code: :not_found)
|
|
499
|
+
|
|
500
|
+
result = seq.(Hubbado::Sequence::Ctx.build(
|
|
501
|
+
params: { id: 999, user: {} },
|
|
502
|
+
current_user: User.new
|
|
503
|
+
))
|
|
504
|
+
|
|
505
|
+
expect(result.error[:code]).to eq(:not_found)
|
|
506
|
+
expect(seq.validate.validated?).to be false
|
|
507
|
+
expect(seq.persist.persisted?).to be false
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
`succeed_with(**ctx_writes)` writes the given keys into `ctx` and returns
|
|
513
|
+
`Result.ok(ctx)`, so the outer steps see what the real Present would have
|
|
514
|
+
left behind. `fail_with(**error)` returns a failed `Result` with the given
|
|
515
|
+
error, short-circuiting the outer pipeline. The Update spec doesn't need
|
|
516
|
+
to exercise Find / Build / Policy::Check directly — those live in
|
|
517
|
+
PresentUser's spec, where they belong.
|
|
518
|
+
|
|
519
|
+
## Observability
|
|
520
|
+
|
|
521
|
+
Every `Result` carries a **trail** — the list of step names that completed
|
|
522
|
+
successfully, in order. On failure, the failing step is *not* in the trail;
|
|
523
|
+
it's tagged on `error[:step]` instead.
|
|
524
|
+
|
|
525
|
+
```ruby
|
|
526
|
+
result.trail # => [:find, :build_contract, :check_policy, :validate, :persist] # success
|
|
527
|
+
result.trail # => [:find, :build_contract] # failed at :check_policy
|
|
528
|
+
result.error[:step] # => :check_policy
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
When invoked via `run_sequence`, the dispatcher logs a single line per
|
|
532
|
+
invocation summarising the trail and (on failure) where it stopped:
|
|
533
|
+
|
|
534
|
+
```
|
|
535
|
+
Sequencer Seqs::UpdateUser succeeded: find → build_contract → check_policy → validate → persist
|
|
536
|
+
Sequencer Seqs::UpdateUser failed at :check_policy (forbidden): find → build_contract
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
Nested sequencer trails are opaque to the parent: a parent's trail shows
|
|
540
|
+
`:present` as a single step, not the sub-steps inside Present.
|
|
541
|
+
`error[:step]` carries the inner step name when a nested sequencer fails.
|
|
542
|
+
|
|
543
|
+
## Standard error codes
|
|
544
|
+
|
|
545
|
+
- `:not_found` — `Model::Find` couldn't find the record.
|
|
546
|
+
- `:forbidden` — policy denied.
|
|
547
|
+
- `:validation_failed` — contract invalid; see `ctx[:contract].errors`.
|
|
548
|
+
- `:persist_failed` — save failed for non-validation reasons.
|
|
549
|
+
- `:conflict` — uniqueness or optimistic locking.
|
|
550
|
+
|
|
551
|
+
Sequencers can mint their own codes for domain-specific failures
|
|
552
|
+
(`:not_shippable`, `:already_cancelled`).
|
|
553
|
+
|
|
554
|
+
## Documentation
|
|
555
|
+
|
|
556
|
+
- [`docs/design.md`](docs/design.md) — full design and rationale (decisions
|
|
557
|
+
considered and rejected, "Resolved Through Iteration" log of reversals,
|
|
558
|
+
open questions).
|
|
559
|
+
|
|
560
|
+
## License
|
|
561
|
+
|
|
562
|
+
Internal Hubbado gem.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
|
2
|
+
Gem::Specification.new do |s|
|
|
3
|
+
s.name = "hubbado-sequence"
|
|
4
|
+
s.version = "0.3.0"
|
|
5
|
+
s.summary = "A small framework for orchestrating units of business behaviour"
|
|
6
|
+
s.description = "Sequencer takes input, runs a sequence of steps, and returns a Result indicating success or failure plus the working context that was built up during execution."
|
|
7
|
+
|
|
8
|
+
s.authors = ["Hubbado Devs"]
|
|
9
|
+
s.email = ["devs@hubbado.com"]
|
|
10
|
+
s.homepage = "https://github.com/hubbado/hubbado-sequence"
|
|
11
|
+
s.license = "MIT"
|
|
12
|
+
|
|
13
|
+
s.metadata["homepage_uri"] = s.homepage
|
|
14
|
+
s.metadata["source_code_uri"] = s.homepage
|
|
15
|
+
s.metadata["changelog_uri"] = "#{s.homepage}/blob/master/CHANGELOG.md"
|
|
16
|
+
|
|
17
|
+
s.require_paths = ["lib"]
|
|
18
|
+
s.files = Dir.glob(%w[
|
|
19
|
+
lib/**/*.rb
|
|
20
|
+
config/**/*.yml
|
|
21
|
+
*.gemspec
|
|
22
|
+
LICENSE*
|
|
23
|
+
README*
|
|
24
|
+
CHANGELOG*
|
|
25
|
+
])
|
|
26
|
+
s.platform = Gem::Platform::RUBY
|
|
27
|
+
s.required_ruby_version = ">= 3.3"
|
|
28
|
+
|
|
29
|
+
s.add_runtime_dependency "evt-casing"
|
|
30
|
+
s.add_runtime_dependency "evt-configure"
|
|
31
|
+
s.add_runtime_dependency "evt-dependency"
|
|
32
|
+
s.add_runtime_dependency "evt-record_invocation"
|
|
33
|
+
s.add_runtime_dependency "evt-template_method"
|
|
34
|
+
s.add_runtime_dependency "hubbado-log"
|
|
35
|
+
s.add_runtime_dependency "i18n"
|
|
36
|
+
|
|
37
|
+
s.add_development_dependency "debug"
|
|
38
|
+
s.add_development_dependency "hubbado-style"
|
|
39
|
+
s.add_development_dependency "rake"
|
|
40
|
+
s.add_development_dependency "test_bench"
|
|
41
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Hubbado
|
|
2
|
+
module Sequence
|
|
3
|
+
module Controls
|
|
4
|
+
module Contract
|
|
5
|
+
def self.example(model: nil, valid: true, save_result: true)
|
|
6
|
+
example_class(valid: valid, save_result: save_result).new(model)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Returns a contract class that can be passed to Contract::Build as
|
|
10
|
+
# `contract_class:`. The class wraps whatever model is passed to .new.
|
|
11
|
+
def self.example_class(valid: true, save_result: true)
|
|
12
|
+
Class.new do
|
|
13
|
+
attr_reader :model, :validated_with, :deserialized_with, :saved
|
|
14
|
+
attr_accessor :errors
|
|
15
|
+
|
|
16
|
+
define_singleton_method(:default_valid) { valid }
|
|
17
|
+
define_singleton_method(:default_save_result) { save_result }
|
|
18
|
+
|
|
19
|
+
def initialize(model = nil)
|
|
20
|
+
@model = model
|
|
21
|
+
@valid = self.class.default_valid
|
|
22
|
+
@save_result = self.class.default_save_result
|
|
23
|
+
@errors = @valid ? [] : [:something_invalid]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def validate(params)
|
|
27
|
+
@validated_with = params
|
|
28
|
+
@valid
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def deserialize(params)
|
|
32
|
+
@deserialized_with = params
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def save
|
|
37
|
+
@saved = true
|
|
38
|
+
@save_result
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Hubbado
|
|
2
|
+
module Sequence
|
|
3
|
+
module Controls
|
|
4
|
+
module Model
|
|
5
|
+
def self.example_class
|
|
6
|
+
Class.new do
|
|
7
|
+
def self.records
|
|
8
|
+
@records ||= {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.put(id, value)
|
|
12
|
+
records[id] = value
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.find_by(id:)
|
|
16
|
+
records[id]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.reset
|
|
20
|
+
@records = {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
attr_reader :init_attributes
|
|
24
|
+
|
|
25
|
+
def initialize(attributes = {})
|
|
26
|
+
@init_attributes = attributes
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|