hubbado-sequence 0.5.0 → 0.7.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 +179 -0
- data/README.md +182 -18
- data/hubbado-sequence.gemspec +1 -1
- data/lib/hubbado/sequence/macros/contract/build.rb +5 -5
- data/lib/hubbado/sequence/macros/contract/deserialize.rb +1 -1
- data/lib/hubbado/sequence/macros/contract/persist.rb +2 -2
- data/lib/hubbado/sequence/macros/contract/validate.rb +2 -2
- data/lib/hubbado/sequence/macros/model/build.rb +1 -1
- data/lib/hubbado/sequence/macros/model/find.rb +2 -2
- data/lib/hubbado/sequence/macros/policy/check.rb +20 -12
- data/lib/hubbado/sequence/pipeline.rb +3 -6
- data/lib/hubbado/sequence/result.rb +59 -20
- data/lib/hubbado/sequence/runner.rb +37 -18
- data/lib/hubbado/sequence/sequencer.rb +5 -4
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4c072f343cb764529d76c7babb7824217546cadeaf4cab59c305b40b0b1f7647
|
|
4
|
+
data.tar.gz: 15c860841338562d2ba8b39c8d49d1a95959345eec316489878676a0b9f4a3e4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 414d6011c8b468686ba3fe6f93adfa119d16196b25cc3313073463a56673aaf5a51100cb2b21ecd96e471511b4954cff6bbfd7e14806fc2c9370ac7c40a40020
|
|
7
|
+
data.tar.gz: df9d8e8d1c8ce2715f7b8582de9ed6a51106f5ff0f6c99caf596d9fb0f8b4ec5dc0818fecf01cb92149aa13f9813c40b7221b4cdcb1604d9a8ef3b0f43e037db
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,185 @@ 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.7.0] - Macros::Policy::Check record-less policies; Sequencer i18n_scope applied at boundary
|
|
8
|
+
|
|
9
|
+
### Changed (breaking)
|
|
10
|
+
|
|
11
|
+
- **`Macros::Policy::Check#call` signature changed** from `(ctx, policy,
|
|
12
|
+
record_key, action)` to `(ctx, policy, action, record_key = nil)`.
|
|
13
|
+
`record_key` is now a trailing optional positional; omitting it
|
|
14
|
+
builds the policy with `nil` as the record, the shape required by
|
|
15
|
+
plural / collection policies (e.g. `Policies::Jobs`) that authorise
|
|
16
|
+
on a non-record subject rather than gating on a specific record:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
# before
|
|
20
|
+
p.invoke(:check_policy, Policies::User, :user, :update)
|
|
21
|
+
|
|
22
|
+
# after
|
|
23
|
+
p.invoke(:check_policy, Policies::User, :update, :user) # singular
|
|
24
|
+
p.invoke(:check_policy, Policies::Jobs, :list) # record-less
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Migration: at every `p.invoke(:check_policy, ...)` call site, swap
|
|
28
|
+
the third and fourth positional arguments. Substitutes and the
|
|
29
|
+
underlying `policy.method_defined?(action)` typo-catch are
|
|
30
|
+
unchanged in behaviour; the parameter order on the substitute's
|
|
31
|
+
`call` is migrated to match.
|
|
32
|
+
|
|
33
|
+
See `docs/design.md` "Resolved Through Iteration" for the rationale
|
|
34
|
+
and the alternatives considered.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- **`Macros::Policy::Check.failure(ctx, policy, policy_result)`** class
|
|
39
|
+
helper. Returns `Result.failure(ctx, code: :forbidden, data: { policy:,
|
|
40
|
+
policy_result: })` — the same failure shape the macro produces. Lets
|
|
41
|
+
hand-rolled policy-check steps (for policy actions that take arguments,
|
|
42
|
+
or for compound logic the macro doesn't cover) produce the standard
|
|
43
|
+
failure shape without duplicating framework knowledge.
|
|
44
|
+
|
|
45
|
+
- **`Macros::Policy::Check` now stores the built policy on `ctx[:policy]`.**
|
|
46
|
+
After building the policy instance and before invoking the action, the
|
|
47
|
+
macro writes it to ctx under `:policy` by default. Downstream steps (e.g.
|
|
48
|
+
contract construction that needs the policy injected) can read it
|
|
49
|
+
directly without re-building. Pass `as:` to store under a different key
|
|
50
|
+
when a sequencer runs multiple policy checks:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
p.invoke(:check_policy, Policies::Document, :update, :document)
|
|
54
|
+
# ctx[:policy] is now the built Policies::Document instance
|
|
55
|
+
|
|
56
|
+
p.invoke(:check_policy, Policies::User, :show, :user, as: :user_policy)
|
|
57
|
+
# ctx[:user_policy] is the built Policies::User instance
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The substitute's `succeed_with` now accepts an optional policy instance;
|
|
61
|
+
passing one mirrors the production write to `ctx[as]` so substituted
|
|
62
|
+
specs can drive the same downstream paths.
|
|
63
|
+
|
|
64
|
+
### Changed
|
|
65
|
+
|
|
66
|
+
- **`Macros::Contract::Build`'s second parameter renamed** from
|
|
67
|
+
`attr_name` to `model`. The positional shape is unchanged — this is
|
|
68
|
+
an internal rename only — and the name now describes what the
|
|
69
|
+
parameter is (the ctx key/path for the model the contract wraps)
|
|
70
|
+
rather than what it isn't (an "attribute name" on anything). Callers
|
|
71
|
+
passing the value positionally (the only in-tree shape) are
|
|
72
|
+
unaffected.
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
|
|
76
|
+
- **`Sequencer#pipeline` and `Sequencer.()` now apply the sequencer's
|
|
77
|
+
auto-derived `i18n_scope` to the returned Result.** Closes a
|
|
78
|
+
documented-but-unimplemented step in the `Result#message`
|
|
79
|
+
translation fallback chain. Previously only `Sequencer#failure` (the
|
|
80
|
+
explicit helper) tagged a result with the sequencer's scope; macros
|
|
81
|
+
call `Result.failure` directly with no scope, so a sequencer body
|
|
82
|
+
that returned a macro's failure unchanged produced an unscoped
|
|
83
|
+
Result and `Result#message` fell through to the framework default
|
|
84
|
+
(`sequence.errors.<code>`) instead of the per-sequencer scoped
|
|
85
|
+
translation. Pure-macro sequencers (e.g. a body that's just
|
|
86
|
+
`pipeline(ctx) { |p| p.invoke(:check_policy, ...) }`) could never
|
|
87
|
+
produce a message translated under their own namespace. Tagging at
|
|
88
|
+
the boundary (`pipeline.result` and `Sequencer.()`) via
|
|
89
|
+
`Result#with_i18n_scope` preserves nested-sequencer "innermost scope
|
|
90
|
+
wins" semantics — `with_i18n_scope` is a no-op when the scope is
|
|
91
|
+
already set, so an inner sequencer's scope survives the outer
|
|
92
|
+
wrapper. See `docs/design.md` "Resolved Through Iteration" for the
|
|
93
|
+
rationale.
|
|
94
|
+
|
|
95
|
+
## [0.6.0] - Result.failure flat kwargs; Dispatch delegates reads and exposes raise helpers
|
|
96
|
+
|
|
97
|
+
### Changed (breaking)
|
|
98
|
+
|
|
99
|
+
- **`Result.failure` takes flat kwargs; the `error:` hash wrapper is
|
|
100
|
+
gone.** The previous shape — `Result.failure(ctx, error: { code:,
|
|
101
|
+
data:, ... })` — wrapped its keys in an `error:` hash for no reason
|
|
102
|
+
beyond convention. The fields are now first-class kwargs on
|
|
103
|
+
`Result.failure` and first-class attrs on `Result`:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
# before
|
|
107
|
+
Result.failure(ctx, error: { code: :forbidden, data: { policy_result: pr } })
|
|
108
|
+
result.error[:code] # => :forbidden
|
|
109
|
+
result.error[:data][:policy_result]
|
|
110
|
+
|
|
111
|
+
# after
|
|
112
|
+
Result.failure(ctx, code: :forbidden, data: { policy_result: pr })
|
|
113
|
+
result.code # => :forbidden
|
|
114
|
+
result.data[:policy_result]
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`Result` exposes `code`, `data`, `step`, `message_override`,
|
|
118
|
+
`i18n_scope`, `i18n_key`, `i18n_args` as readers. `Result#error`
|
|
119
|
+
is removed.
|
|
120
|
+
|
|
121
|
+
Migration: in callers, replace `Result.failure(ctx, error: { code:
|
|
122
|
+
:X })` with `Result.failure(ctx, code: :X)`. Replace `result.error[:X]`
|
|
123
|
+
reads with `result.X`. The `Sequencer#failure(ctx, **error_attrs)`
|
|
124
|
+
helper is unchanged at the call site (it always took flat kwargs).
|
|
125
|
+
Macro substitutes' `fail_with(**error_attrs)` is unchanged at the call
|
|
126
|
+
site; arbitrary extra attrs that used to live in the error hash should
|
|
127
|
+
move into `data:` (`fail_with(code: :forbidden, data: { reason:
|
|
128
|
+
:not_owner })`).
|
|
129
|
+
|
|
130
|
+
- **The per-error `i18n_scope` override path is removed.** Previously a
|
|
131
|
+
caller could put `i18n_scope:` inside the `error:` hash *and* pass a
|
|
132
|
+
separate `i18n_scope:` to the surrounding wrapper, with the per-error
|
|
133
|
+
one winning. With flat kwargs there is one `i18n_scope` slot. The
|
|
134
|
+
`Sequencer#failure` helper still applies the sequencer's auto-derived
|
|
135
|
+
scope when the caller doesn't pass one (`error_attrs[:i18n_scope] ||=
|
|
136
|
+
i18n_scope`), preserving the "caller wins" semantics where it matters
|
|
137
|
+
in practice. `Result#with_i18n_scope` (used to apply a scope to an
|
|
138
|
+
already-built Result) is unchanged.
|
|
139
|
+
|
|
140
|
+
- **`Runner::Dispatch#result` is removed.** Master exposed the wrapped
|
|
141
|
+
Result via `attr_reader :result`, which was the source of the
|
|
142
|
+
`result.result.error.dig(...)` four-hop pattern. With the new
|
|
143
|
+
read-through delegations (`code`, `data`, `step`, `message`,
|
|
144
|
+
`successful_steps`, `ctx`) there's no reason for outcome blocks to
|
|
145
|
+
reach into the inner Result. Any caller still doing
|
|
146
|
+
`result.result.X` from inside a `run_sequence` block will now raise
|
|
147
|
+
`NoMethodError`; replace with the matching delegation on the
|
|
148
|
+
dispatch object itself.
|
|
149
|
+
|
|
150
|
+
- **The `message:` kwarg on `Result.failure` is removed.** It set a
|
|
151
|
+
literal-string fallback returned by `Result#message` when no
|
|
152
|
+
translation matched. No in-tree caller used it (the
|
|
153
|
+
i18n-translation chain plus `humanize_code` fallback covered every
|
|
154
|
+
real case), and the path was test-only. If a caller needs a custom
|
|
155
|
+
message they can supply `i18n_key:` and a matching translation, or
|
|
156
|
+
pass a humanizable `code:` symbol.
|
|
157
|
+
|
|
158
|
+
### Added
|
|
159
|
+
|
|
160
|
+
- **`Runner::Dispatch` delegates reads to its wrapped `Result`.** Outcome
|
|
161
|
+
blocks can call `result.code`, `result.data`, `result.message`,
|
|
162
|
+
`result.step`, `result.successful_steps`, `result.ctx` on the
|
|
163
|
+
`Dispatch` object (the block argument) without hopping through an
|
|
164
|
+
inner `.result.` reference. The previous `result.result.error.dig(...)`
|
|
165
|
+
pattern collapses to one read.
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
result.policy_failed do |ctx|
|
|
169
|
+
if result.data[:policy_result].reason == :not_open
|
|
170
|
+
redirect_to public_path(ctx[:job])
|
|
171
|
+
else
|
|
172
|
+
result.raise_policy_failed
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
- **Public raise helpers on `Runner::Dispatch`:** `raise_policy_failed`,
|
|
178
|
+
`raise_not_found`, and `raise_failed`. They produce the same exceptions
|
|
179
|
+
the safety net would raise, but can be called explicitly from inside an
|
|
180
|
+
outcome block — useful when a caller handles some failure cases inline
|
|
181
|
+
and wants the framework's standard escalation for the rest.
|
|
182
|
+
`enforce_safety_nets!` now delegates to the same helpers, so the
|
|
183
|
+
exception shapes stay aligned whether the caller invokes them directly
|
|
184
|
+
or the runner does it automatically.
|
|
185
|
+
|
|
7
186
|
## [0.5.0] - Result vocabulary renamed: success/failure and successful_steps
|
|
8
187
|
|
|
9
188
|
### Changed (breaking)
|
data/README.md
CHANGED
|
@@ -126,7 +126,7 @@ class Seqs::UpdateUser
|
|
|
126
126
|
pipeline(ctx) do |p|
|
|
127
127
|
p.invoke(:find, User, as: :user)
|
|
128
128
|
p.invoke(:build_contract, Contracts::UpdateUser, :user)
|
|
129
|
-
p.invoke(:check_policy, Policies::User, :
|
|
129
|
+
p.invoke(:check_policy, Policies::User, :update, :user)
|
|
130
130
|
|
|
131
131
|
p.transaction do |t|
|
|
132
132
|
t.invoke(:validate, from: %i[params user])
|
|
@@ -230,7 +230,7 @@ p.invoke(:build_contract, Contracts::CreateUser) # no model
|
|
|
230
230
|
|
|
231
231
|
| | |
|
|
232
232
|
|---|---|
|
|
233
|
-
| **Reads** | `ctx
|
|
233
|
+
| **Reads** | `ctx` at `model` for the model (optional) |
|
|
234
234
|
| **Writes** | `ctx[:contract]` |
|
|
235
235
|
| **Fails** | never |
|
|
236
236
|
|
|
@@ -288,18 +288,64 @@ Designed to work with the
|
|
|
288
288
|
Builds a policy and calls the named action to authorise the operation.
|
|
289
289
|
|
|
290
290
|
```ruby
|
|
291
|
-
p.invoke(:check_policy, Policies::User, :
|
|
291
|
+
p.invoke(:check_policy, Policies::User, :update, :user) # policy on a record
|
|
292
|
+
p.invoke(:check_policy, Policies::Jobs, :list) # plural / record-less
|
|
292
293
|
```
|
|
293
294
|
|
|
294
295
|
The policy class must respond to `.build(current_user, record)`; the
|
|
295
|
-
instance must respond to the action method and return
|
|
296
|
-
`permitted
|
|
296
|
+
instance must respond to the action method and return a
|
|
297
|
+
`Hubbado::Policy::Result`-shaped object (`permitted?`, `denied?`,
|
|
298
|
+
`reason`, `message`). When `record_key` is omitted the policy is built
|
|
299
|
+
with `nil` as the record — the shape for plural / collection policies
|
|
300
|
+
that authorise against a non-record subject (e.g. a company id read
|
|
301
|
+
from `current_user`).
|
|
302
|
+
|
|
303
|
+
The built policy instance is written to `ctx[:policy]` so downstream
|
|
304
|
+
steps (e.g. contract construction that needs the policy injected) can
|
|
305
|
+
read it directly. Pass `as:` to store under a different key when a
|
|
306
|
+
sequencer runs more than one policy check:
|
|
307
|
+
|
|
308
|
+
```ruby
|
|
309
|
+
p.invoke(:check_policy, Policies::User, :show, :user, as: :user_policy)
|
|
310
|
+
# ctx[:user_policy] — the built Policies::User instance
|
|
311
|
+
```
|
|
297
312
|
|
|
298
313
|
| | |
|
|
299
314
|
|---|---|
|
|
300
|
-
| **Reads** | `ctx[:current_user]`, `ctx[record_key]` |
|
|
301
|
-
| **Writes** |
|
|
302
|
-
| **Fails** | `:forbidden` when `permitted?` is false; `
|
|
315
|
+
| **Reads** | `ctx[:current_user]`, `ctx[record_key]` when `record_key` is supplied |
|
|
316
|
+
| **Writes** | `ctx[as]` — the built policy instance (`as:` defaults to `:policy`) |
|
|
317
|
+
| **Fails** | `:forbidden` when `permitted?` is false; `result.data` carries `{ policy:, policy_result: }` |
|
|
318
|
+
|
|
319
|
+
The macro only covers zero-arg policy actions. For actions that take
|
|
320
|
+
arguments (e.g. `Policies::Jobs#create(company_id)`), hand-roll a step
|
|
321
|
+
and use `Macros::Policy::Check.failure(ctx, policy, policy_result)` to
|
|
322
|
+
produce the standard failure shape:
|
|
323
|
+
|
|
324
|
+
```ruby
|
|
325
|
+
def check_create_policy(ctx)
|
|
326
|
+
policy = Policies::Jobs.build(ctx[:current_user], nil)
|
|
327
|
+
result = policy.create(ctx[:company_id])
|
|
328
|
+
|
|
329
|
+
return Macros::Policy::Check.failure(ctx, policy, result) unless result.permitted?
|
|
330
|
+
|
|
331
|
+
Result.success(ctx)
|
|
332
|
+
end
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
A controller can branch on the denial reason via `data`:
|
|
336
|
+
|
|
337
|
+
```ruby
|
|
338
|
+
result.policy_failed do |ctx|
|
|
339
|
+
if result.data[:policy_result].reason == :not_open
|
|
340
|
+
redirect_to public_path(ctx[:job])
|
|
341
|
+
else
|
|
342
|
+
result.raise_policy_failed
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
See [Handling specific failure reasons inside an outcome block](#handling-specific-failure-reasons-inside-an-outcome-block)
|
|
348
|
+
for when `raise_policy_failed` and its siblings come in handy.
|
|
303
349
|
|
|
304
350
|
## Transactions
|
|
305
351
|
|
|
@@ -312,7 +358,7 @@ def call(ctx)
|
|
|
312
358
|
pipeline(ctx) do |p|
|
|
313
359
|
p.invoke(:find, User, as: :user)
|
|
314
360
|
p.invoke(:build_contract, Contracts::UpdateUser, :user)
|
|
315
|
-
p.invoke(:check_policy, Policies::User, :
|
|
361
|
+
p.invoke(:check_policy, Policies::User, :update, :user)
|
|
316
362
|
|
|
317
363
|
p.transaction do |t|
|
|
318
364
|
t.invoke(:validate, from: %i[params user])
|
|
@@ -367,7 +413,7 @@ class Seqs::UpdateUser
|
|
|
367
413
|
pipeline(ctx) do |p|
|
|
368
414
|
p.invoke(:find, User, as: :user)
|
|
369
415
|
p.invoke(:build_contract, Contracts::UpdateUser, :user)
|
|
370
|
-
p.invoke(:check_policy, Policies::User, :
|
|
416
|
+
p.invoke(:check_policy, Policies::User, :update, :user)
|
|
371
417
|
end
|
|
372
418
|
end
|
|
373
419
|
end
|
|
@@ -454,9 +500,118 @@ end
|
|
|
454
500
|
```
|
|
455
501
|
|
|
456
502
|
`failure(ctx, ...)` is a sequencer helper that builds a failed `Result`
|
|
457
|
-
with the sequencer's auto-derived i18n scope already applied. It takes
|
|
458
|
-
same
|
|
459
|
-
`
|
|
503
|
+
with the sequencer's auto-derived i18n scope already applied. It takes
|
|
504
|
+
the same kwargs as `Result.failure` (`code:`, `data:`, `step:`,
|
|
505
|
+
`i18n_scope:`, `i18n_key:`, `i18n_args:`).
|
|
506
|
+
|
|
507
|
+
## Translations
|
|
508
|
+
|
|
509
|
+
`Result#message` translates the failure `code` through a fallback chain:
|
|
510
|
+
|
|
511
|
+
1. **Per-error scope** — whatever the failure set as `i18n_scope:` (or
|
|
512
|
+
`i18n_key:` for an explicit key override).
|
|
513
|
+
2. **Sequencer's auto-derived scope** — the class name underscored, with
|
|
514
|
+
`/` → `.`. `Seqs::UpdateUser` becomes `seqs.update_user`;
|
|
515
|
+
`Jobadder::Seqs::AuthorizationCallback` becomes
|
|
516
|
+
`jobadder.seqs.authorization_callback`.
|
|
517
|
+
3. **Framework default** — `sequence.errors.<code>` (the gem ships
|
|
518
|
+
translations for the standard codes; see "Standard error codes" below).
|
|
519
|
+
4. **Humanized code** — `:not_found` → `"Not found"`.
|
|
520
|
+
|
|
521
|
+
The sequencer's scope is applied automatically. Both the `failure(ctx, ...)`
|
|
522
|
+
helper *and* the boundary itself (`Sequencer#pipeline` and `Sequencer.()`)
|
|
523
|
+
tag the returned `Result` with `i18n_scope` via `Result#with_i18n_scope`.
|
|
524
|
+
That means an unscoped failure produced inside a macro, a hand-rolled
|
|
525
|
+
step, or anywhere else in the sequencer body picks up the sequencer's
|
|
526
|
+
scope when the Result bubbles out — no `failure` call required.
|
|
527
|
+
|
|
528
|
+
### Defining translations for a sequencer
|
|
529
|
+
|
|
530
|
+
Drop translations under the sequencer's auto-derived scope in your locale
|
|
531
|
+
file:
|
|
532
|
+
|
|
533
|
+
```yaml
|
|
534
|
+
en:
|
|
535
|
+
jobadder:
|
|
536
|
+
seqs:
|
|
537
|
+
authorization_callback:
|
|
538
|
+
forbidden: "You are not allowed to connect this company to JobAdder"
|
|
539
|
+
authorization_failed: "Could not authorize with JobAdder"
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
Now `result.message` returns the scoped string when the sequencer fails
|
|
543
|
+
with `code: :forbidden` or `code: :authorization_failed`. Missing
|
|
544
|
+
translations fall through to the framework default, then to the humanized
|
|
545
|
+
code — so a fresh app gets sensible behaviour with zero config.
|
|
546
|
+
|
|
547
|
+
### Per-error overrides
|
|
548
|
+
|
|
549
|
+
A specific failure can override the scope or key. The error's own scope
|
|
550
|
+
beats the sequencer's:
|
|
551
|
+
|
|
552
|
+
```ruby
|
|
553
|
+
failure(
|
|
554
|
+
ctx,
|
|
555
|
+
code: :not_shippable,
|
|
556
|
+
i18n_scope: "checkout.errors", # used instead of seqs.place_order
|
|
557
|
+
i18n_key: :address_invalid, # used instead of :not_shippable
|
|
558
|
+
i18n_args: { region: ctx[:country] }
|
|
559
|
+
)
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
Resolves `checkout.errors.address_invalid` with the `%{region}`
|
|
563
|
+
interpolation supplied.
|
|
564
|
+
|
|
565
|
+
### Nested sequencers: innermost scope wins
|
|
566
|
+
|
|
567
|
+
`Result#with_i18n_scope` is a no-op when the scope is already set, so a
|
|
568
|
+
nested sequencer's scope sticks. If `UpdateUser` calls `Present` and
|
|
569
|
+
Present's `Model::Find` macro fails, the failure is tagged with
|
|
570
|
+
`seqs.present` first (Present's boundary); `UpdateUser`'s boundary tries
|
|
571
|
+
to retag with `seqs.update_user` but the no-op preserves the inner scope.
|
|
572
|
+
Messages resolve under the namespace of the sequencer that actually
|
|
573
|
+
produced the failure, not the outermost wrapper.
|
|
574
|
+
|
|
575
|
+
## Outcome blocks and safety nets
|
|
576
|
+
|
|
577
|
+
`run_sequence` enforces that serious failures are addressed. Forgetting to
|
|
578
|
+
handle them raises rather than silently swallowing:
|
|
579
|
+
|
|
580
|
+
- An unhandled `:forbidden` raises `Hubbado::Sequence::Errors::Unauthorized`.
|
|
581
|
+
- An unhandled `:not_found` raises `Hubbado::Sequence::Errors::NotFound`.
|
|
582
|
+
- An unhandled non-policy / non-not-found failure (and an `otherwise` block
|
|
583
|
+
isn't given) raises `Hubbado::Sequence::Errors::Failed`.
|
|
584
|
+
|
|
585
|
+
`otherwise` deliberately does *not* catch `:forbidden` or `:not_found` —
|
|
586
|
+
that's what prevents a generic `otherwise` accidentally rendering a form
|
|
587
|
+
when the policy denied access.
|
|
588
|
+
|
|
589
|
+
### Handling specific failure reasons inside an outcome block
|
|
590
|
+
|
|
591
|
+
The dispatch object exposes the standard escalation paths as public
|
|
592
|
+
methods, so an outcome block can handle some cases inline and fall back to
|
|
593
|
+
the framework's exception for the rest:
|
|
594
|
+
|
|
595
|
+
```ruby
|
|
596
|
+
result.policy_failed do |ctx|
|
|
597
|
+
if result.data[:policy_result].reason == :not_open
|
|
598
|
+
redirect_to public_path(ctx[:job])
|
|
599
|
+
else
|
|
600
|
+
result.raise_policy_failed
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
The available helpers mirror the safety nets:
|
|
606
|
+
|
|
607
|
+
- `result.raise_policy_failed` — raises `Errors::Unauthorized` with the
|
|
608
|
+
standard message.
|
|
609
|
+
- `result.raise_not_found` — raises `Errors::NotFound`.
|
|
610
|
+
- `result.raise_failed` — raises `Errors::Failed`.
|
|
611
|
+
|
|
612
|
+
Use them when you genuinely want the framework's default escalation;
|
|
613
|
+
prefer plain Ruby control flow (`return`, `if`/`else`) for ordinary
|
|
614
|
+
branching.
|
|
460
615
|
|
|
461
616
|
## Testing
|
|
462
617
|
|
|
@@ -510,7 +665,7 @@ context "Seqs::UpdateUser::Present when the user is not found" do
|
|
|
510
665
|
result = seq.(params: { id: 999 }, current_user: User.new)
|
|
511
666
|
|
|
512
667
|
test "Fails with :not_found" do
|
|
513
|
-
assert(result.
|
|
668
|
+
assert(result.code == :not_found)
|
|
514
669
|
end
|
|
515
670
|
|
|
516
671
|
test "Does not build the contract" do
|
|
@@ -523,6 +678,15 @@ context "Seqs::UpdateUser::Present when the user is not found" do
|
|
|
523
678
|
end
|
|
524
679
|
```
|
|
525
680
|
|
|
681
|
+
The `Policy::Check` substitute's `fail_with(**error)` accepts the same
|
|
682
|
+
attributes `Result.failure` does, so a test that needs an outcome block
|
|
683
|
+
to branch on `result.data[:policy_result].reason` can configure the data
|
|
684
|
+
payload directly:
|
|
685
|
+
|
|
686
|
+
```ruby
|
|
687
|
+
seq.check_policy.fail_with(code: :forbidden, data: { policy_result: DeniedResult.new(:not_open) })
|
|
688
|
+
```
|
|
689
|
+
|
|
526
690
|
### Substituting a nested sequencer
|
|
527
691
|
|
|
528
692
|
Every sequencer ships a default `Substitute` module (installed by
|
|
@@ -567,7 +731,7 @@ context "Seqs::UpdateUser when Present denies access" do
|
|
|
567
731
|
end
|
|
568
732
|
|
|
569
733
|
test "Fails with :forbidden" do
|
|
570
|
-
assert(result.
|
|
734
|
+
assert(result.code == :forbidden)
|
|
571
735
|
end
|
|
572
736
|
|
|
573
737
|
test "Does not validate" do
|
|
@@ -586,7 +750,7 @@ context "Seqs::UpdateUser when Present cannot find the record" do
|
|
|
586
750
|
result = seq.(params: { id: 999, user: {} }, current_user: User.new)
|
|
587
751
|
|
|
588
752
|
test "Fails with :not_found" do
|
|
589
|
-
assert(result.
|
|
753
|
+
assert(result.code == :not_found)
|
|
590
754
|
end
|
|
591
755
|
|
|
592
756
|
test "Does not validate" do
|
|
@@ -610,12 +774,12 @@ to exercise Find / Build / Policy::Check directly — those live in
|
|
|
610
774
|
|
|
611
775
|
Every `Result` carries **successful_steps** — the list of step names that
|
|
612
776
|
completed successfully, in order. On failure, the failing step is *not* in
|
|
613
|
-
`successful_steps`; it's tagged on `
|
|
777
|
+
`successful_steps`; it's tagged on `step` instead.
|
|
614
778
|
|
|
615
779
|
```ruby
|
|
616
780
|
result.successful_steps # => [:find, :build_contract, :check_policy, :validate, :persist] # success
|
|
617
781
|
result.successful_steps # => [:find, :build_contract] # failed at :check_policy
|
|
618
|
-
result.
|
|
782
|
+
result.step # => :check_policy
|
|
619
783
|
```
|
|
620
784
|
|
|
621
785
|
When invoked via `run_sequence`, the dispatcher logs a single line per
|
data/hubbado-sequence.gemspec
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# -*- encoding: utf-8 -*-
|
|
2
2
|
Gem::Specification.new do |s|
|
|
3
3
|
s.name = "hubbado-sequence"
|
|
4
|
-
s.version = "0.
|
|
4
|
+
s.version = "0.7.0"
|
|
5
5
|
s.summary = "A small framework for the short sequences of common steps that controller actions usually boil down to"
|
|
6
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
|
|
|
@@ -10,9 +10,9 @@ module Hubbado
|
|
|
10
10
|
new
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
def call(ctx, contract_class,
|
|
14
|
-
|
|
15
|
-
ctx[:contract] = contract_class.new(
|
|
13
|
+
def call(ctx, contract_class, model = nil)
|
|
14
|
+
resolved_model = model && Path.resolve(ctx, model)
|
|
15
|
+
ctx[:contract] = contract_class.new(resolved_model)
|
|
16
16
|
Result.success(ctx)
|
|
17
17
|
end
|
|
18
18
|
|
|
@@ -30,8 +30,8 @@ module Hubbado
|
|
|
30
30
|
self
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
record def call(ctx, contract_class,
|
|
34
|
-
return Result.failure(ctx,
|
|
33
|
+
record def call(ctx, contract_class, model = nil)
|
|
34
|
+
return Result.failure(ctx, **@configured_error) if @configured_error
|
|
35
35
|
|
|
36
36
|
ctx[:contract] = @return_value if @configured_success
|
|
37
37
|
Result.success(ctx)
|
|
@@ -16,7 +16,7 @@ module Hubbado
|
|
|
16
16
|
if contract.save
|
|
17
17
|
Result.success(ctx)
|
|
18
18
|
else
|
|
19
|
-
Result.failure(ctx,
|
|
19
|
+
Result.failure(ctx, code: :persist_failed)
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
@@ -34,7 +34,7 @@ module Hubbado
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
record def call(ctx)
|
|
37
|
-
return Result.failure(ctx,
|
|
37
|
+
return Result.failure(ctx, **@configured_error) if @configured_error
|
|
38
38
|
|
|
39
39
|
Result.success(ctx)
|
|
40
40
|
end
|
|
@@ -19,7 +19,7 @@ module Hubbado
|
|
|
19
19
|
if contract.errors.empty?
|
|
20
20
|
Result.success(ctx)
|
|
21
21
|
else
|
|
22
|
-
Result.failure(ctx,
|
|
22
|
+
Result.failure(ctx, code: :validation_failed)
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
@@ -37,7 +37,7 @@ module Hubbado
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
record def call(ctx, from: nil)
|
|
40
|
-
return Result.failure(ctx,
|
|
40
|
+
return Result.failure(ctx, **@configured_error) if @configured_error
|
|
41
41
|
|
|
42
42
|
Result.success(ctx)
|
|
43
43
|
end
|
|
@@ -40,7 +40,7 @@ module Hubbado
|
|
|
40
40
|
"Macros::Model::Build substitute: #{model} does not respond to :new"
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
return Result.failure(ctx,
|
|
43
|
+
return Result.failure(ctx, **@configured_error) if @configured_error
|
|
44
44
|
|
|
45
45
|
ctx[as] = @return_value if @configured_success
|
|
46
46
|
Result.success(ctx)
|
|
@@ -18,7 +18,7 @@ module Hubbado
|
|
|
18
18
|
ctx[as] = record
|
|
19
19
|
Result.success(ctx)
|
|
20
20
|
else
|
|
21
|
-
Result.failure(ctx,
|
|
21
|
+
Result.failure(ctx, code: :not_found)
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
24
|
|
|
@@ -42,7 +42,7 @@ module Hubbado
|
|
|
42
42
|
"Macros::Model::Find substitute: #{model} does not respond to :find_by"
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
return Result.failure(ctx,
|
|
45
|
+
return Result.failure(ctx, **@configured_error) if @configured_error
|
|
46
46
|
|
|
47
47
|
ctx[as] = @return_value if @configured_success
|
|
48
48
|
Result.success(ctx)
|
|
@@ -10,31 +10,36 @@ module Hubbado
|
|
|
10
10
|
new
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
def
|
|
13
|
+
def self.failure(ctx, policy, policy_result)
|
|
14
|
+
Result.failure(
|
|
15
|
+
ctx,
|
|
16
|
+
code: :forbidden,
|
|
17
|
+
data: { policy: policy, policy_result: policy_result }
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(ctx, policy, action, record_key = nil, as: nil)
|
|
22
|
+
as ||= :policy
|
|
14
23
|
current_user = ctx[:current_user]
|
|
15
|
-
record = ctx[record_key]
|
|
24
|
+
record = record_key && ctx[record_key]
|
|
16
25
|
|
|
17
26
|
policy_instance = policy.build(current_user, record)
|
|
27
|
+
ctx[as] = policy_instance
|
|
18
28
|
policy_result = policy_instance.public_send(action)
|
|
19
29
|
|
|
20
30
|
if policy_result.permitted?
|
|
21
31
|
Result.success(ctx)
|
|
22
32
|
else
|
|
23
|
-
|
|
24
|
-
ctx,
|
|
25
|
-
error: {
|
|
26
|
-
code: :forbidden,
|
|
27
|
-
data: { policy: policy_instance, policy_result: policy_result }
|
|
28
|
-
}
|
|
29
|
-
)
|
|
33
|
+
self.class.failure(ctx, policy_instance, policy_result)
|
|
30
34
|
end
|
|
31
35
|
end
|
|
32
36
|
|
|
33
37
|
module Substitute
|
|
34
38
|
include ::RecordInvocation
|
|
35
39
|
|
|
36
|
-
def succeed_with
|
|
40
|
+
def succeed_with(policy_instance = nil)
|
|
37
41
|
@configured_success = true
|
|
42
|
+
@return_policy = policy_instance
|
|
38
43
|
self
|
|
39
44
|
end
|
|
40
45
|
|
|
@@ -43,14 +48,17 @@ module Hubbado
|
|
|
43
48
|
self
|
|
44
49
|
end
|
|
45
50
|
|
|
46
|
-
record def call(ctx, policy, record_key,
|
|
51
|
+
record def call(ctx, policy, action, record_key = nil, as: nil)
|
|
52
|
+
as ||= :policy
|
|
53
|
+
|
|
47
54
|
unless policy.method_defined?(action)
|
|
48
55
|
raise ArgumentError,
|
|
49
56
|
"Macros::Policy::Check substitute: #{policy} does not declare action :#{action}"
|
|
50
57
|
end
|
|
51
58
|
|
|
52
|
-
return Result.failure(ctx,
|
|
59
|
+
return Result.failure(ctx, **@configured_error) if @configured_error
|
|
53
60
|
|
|
61
|
+
ctx[as] = @return_policy if @return_policy
|
|
54
62
|
Result.success(ctx)
|
|
55
63
|
end
|
|
56
64
|
|
|
@@ -84,16 +84,13 @@ module Hubbado
|
|
|
84
84
|
|
|
85
85
|
def record(name, return_value)
|
|
86
86
|
if return_value.is_a?(Result) && return_value.failure?
|
|
87
|
-
@failed_result =
|
|
87
|
+
@failed_result = return_value
|
|
88
|
+
.with_step(name)
|
|
89
|
+
.with_successful_steps(@successful_steps.dup)
|
|
88
90
|
else
|
|
89
91
|
@successful_steps << name
|
|
90
92
|
end
|
|
91
93
|
end
|
|
92
|
-
|
|
93
|
-
def tag_failure(result, step_name)
|
|
94
|
-
tagged_error = result.error.merge(step: step_name)
|
|
95
|
-
Result.failure(result.ctx, error: tagged_error, successful_steps: @successful_steps.dup, i18n_scope: result.i18n_scope)
|
|
96
|
-
end
|
|
97
94
|
end
|
|
98
95
|
end
|
|
99
96
|
end
|
|
@@ -4,28 +4,52 @@ module Hubbado
|
|
|
4
4
|
FRAMEWORK_I18N_SCOPE = "sequence.errors".freeze
|
|
5
5
|
|
|
6
6
|
attr_reader :ctx
|
|
7
|
-
attr_reader :
|
|
7
|
+
attr_reader :code
|
|
8
|
+
attr_reader :data
|
|
9
|
+
attr_reader :step
|
|
8
10
|
attr_reader :successful_steps
|
|
9
11
|
attr_reader :i18n_scope
|
|
12
|
+
attr_reader :i18n_key
|
|
13
|
+
attr_reader :i18n_args
|
|
10
14
|
|
|
11
15
|
def self.success(ctx, successful_steps: [], i18n_scope: nil)
|
|
12
|
-
new(
|
|
16
|
+
new(
|
|
17
|
+
:success,
|
|
18
|
+
ctx: ctx,
|
|
19
|
+
successful_steps: successful_steps,
|
|
20
|
+
i18n_scope: i18n_scope
|
|
21
|
+
)
|
|
13
22
|
end
|
|
14
23
|
|
|
15
|
-
def self.failure(ctx,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
def self.failure(ctx, code:, data: nil, step: nil,
|
|
25
|
+
i18n_scope: nil, i18n_key: nil, i18n_args: nil, successful_steps: [])
|
|
26
|
+
raise ArgumentError, "Result.failure requires code:" unless code
|
|
27
|
+
|
|
28
|
+
new(
|
|
29
|
+
:failure,
|
|
30
|
+
ctx: ctx,
|
|
31
|
+
code: code,
|
|
32
|
+
data: data,
|
|
33
|
+
step: step,
|
|
34
|
+
successful_steps: successful_steps,
|
|
35
|
+
i18n_scope: i18n_scope,
|
|
36
|
+
i18n_key: i18n_key,
|
|
37
|
+
i18n_args: i18n_args
|
|
38
|
+
)
|
|
21
39
|
end
|
|
22
40
|
|
|
23
|
-
def initialize(status, ctx
|
|
41
|
+
def initialize(status, ctx:, successful_steps:, i18n_scope:,
|
|
42
|
+
code: nil, data: nil, step: nil,
|
|
43
|
+
i18n_key: nil, i18n_args: nil)
|
|
24
44
|
@status = status
|
|
25
45
|
@ctx = ctx
|
|
26
|
-
@
|
|
46
|
+
@code = code
|
|
47
|
+
@data = data
|
|
48
|
+
@step = step
|
|
27
49
|
@successful_steps = successful_steps
|
|
28
50
|
@i18n_scope = i18n_scope
|
|
51
|
+
@i18n_key = i18n_key
|
|
52
|
+
@i18n_args = i18n_args
|
|
29
53
|
end
|
|
30
54
|
|
|
31
55
|
def success?
|
|
@@ -37,35 +61,50 @@ module Hubbado
|
|
|
37
61
|
end
|
|
38
62
|
|
|
39
63
|
def with_successful_steps(successful_steps)
|
|
40
|
-
|
|
64
|
+
copy(successful_steps: successful_steps)
|
|
41
65
|
end
|
|
42
66
|
|
|
43
67
|
def with_i18n_scope(scope)
|
|
44
68
|
return self unless @i18n_scope.nil?
|
|
45
69
|
|
|
46
|
-
|
|
70
|
+
copy(i18n_scope: scope)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def with_step(step)
|
|
74
|
+
copy(step: step)
|
|
47
75
|
end
|
|
48
76
|
|
|
49
77
|
def message
|
|
50
78
|
return nil if success?
|
|
51
79
|
|
|
52
|
-
|
|
53
|
-
return translation if translation
|
|
54
|
-
|
|
55
|
-
@error[:message] || humanize_code
|
|
80
|
+
translate_with_chain || humanize_code
|
|
56
81
|
end
|
|
57
82
|
|
|
58
83
|
private
|
|
59
84
|
|
|
85
|
+
def copy(**overrides)
|
|
86
|
+
self.class.new(
|
|
87
|
+
@status,
|
|
88
|
+
ctx: @ctx,
|
|
89
|
+
code: @code,
|
|
90
|
+
data: @data,
|
|
91
|
+
step: @step,
|
|
92
|
+
successful_steps: @successful_steps,
|
|
93
|
+
i18n_scope: @i18n_scope,
|
|
94
|
+
i18n_key: @i18n_key,
|
|
95
|
+
i18n_args: @i18n_args,
|
|
96
|
+
**overrides
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
60
100
|
def translate_with_chain
|
|
61
101
|
scopes = []
|
|
62
|
-
scopes << @error[:i18n_scope] if @error[:i18n_scope]
|
|
63
102
|
scopes << @i18n_scope if @i18n_scope
|
|
64
103
|
scopes << FRAMEWORK_I18N_SCOPE
|
|
65
104
|
scopes.uniq!
|
|
66
105
|
|
|
67
|
-
key = @
|
|
68
|
-
args = @
|
|
106
|
+
key = @i18n_key || @code
|
|
107
|
+
args = @i18n_args || {}
|
|
69
108
|
|
|
70
109
|
scopes.each do |scope|
|
|
71
110
|
translated = ::I18n.t("#{scope}.#{key}", default: nil, **args)
|
|
@@ -76,7 +115,7 @@ module Hubbado
|
|
|
76
115
|
end
|
|
77
116
|
|
|
78
117
|
def humanize_code
|
|
79
|
-
@
|
|
118
|
+
@code.to_s.tr("_", " ").capitalize
|
|
80
119
|
end
|
|
81
120
|
end
|
|
82
121
|
end
|
|
@@ -25,7 +25,7 @@ module Hubbado
|
|
|
25
25
|
class Dispatch
|
|
26
26
|
include Hubbado::Log::Dependency
|
|
27
27
|
|
|
28
|
-
attr_reader :returned, :
|
|
28
|
+
attr_reader :returned, :sequencer_class
|
|
29
29
|
|
|
30
30
|
def initialize(sequencer_class, result)
|
|
31
31
|
@sequencer_class = sequencer_class
|
|
@@ -33,6 +33,16 @@ module Hubbado
|
|
|
33
33
|
@handled = false
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# Read-throughs to the wrapped Result. Outcome blocks read these on
|
|
37
|
+
# the Dispatch object (the block argument) without hopping through
|
|
38
|
+
# an inner Result reference.
|
|
39
|
+
def code = @result.code
|
|
40
|
+
def data = @result.data
|
|
41
|
+
def step = @result.step
|
|
42
|
+
def message = @result.message
|
|
43
|
+
def successful_steps = @result.successful_steps
|
|
44
|
+
def ctx = @result.ctx
|
|
45
|
+
|
|
36
46
|
def success
|
|
37
47
|
return unless @result.success?
|
|
38
48
|
execute { yield(@result.ctx) }
|
|
@@ -69,29 +79,38 @@ module Hubbado
|
|
|
69
79
|
logger.info("Sequencer #{@sequencer_class.name} failed at #{step_label} (#{code}): #{steps_summary}")
|
|
70
80
|
end
|
|
71
81
|
|
|
72
|
-
def code
|
|
73
|
-
@result.error&.[](:code)
|
|
74
|
-
end
|
|
75
|
-
|
|
76
82
|
def handled?
|
|
77
83
|
@result.success? || @handled
|
|
78
84
|
end
|
|
79
85
|
|
|
86
|
+
# Raise the standard policy-denial exception. Available inside an
|
|
87
|
+
# outcome block (e.g. for callers that handle some policy reasons
|
|
88
|
+
# inline and want the framework's standard escalation for the rest)
|
|
89
|
+
# and used internally by enforce_safety_nets! when no handler ran.
|
|
90
|
+
def raise_policy_failed
|
|
91
|
+
raise Errors::Unauthorized.new(
|
|
92
|
+
"#{@sequencer_class.name} denied: #{@result.message}",
|
|
93
|
+
@result
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def raise_not_found
|
|
98
|
+
raise Errors::NotFound, "#{@sequencer_class.name} reported not_found"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def raise_failed
|
|
102
|
+
raise Errors::Failed, "#{@sequencer_class.name} failed (#{code}): #{@result.message}"
|
|
103
|
+
end
|
|
104
|
+
|
|
80
105
|
def enforce_safety_nets!
|
|
81
106
|
return if handled?
|
|
82
107
|
|
|
83
108
|
log_unhandled
|
|
84
109
|
|
|
85
110
|
case code
|
|
86
|
-
when :forbidden
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
@result
|
|
90
|
-
)
|
|
91
|
-
when :not_found
|
|
92
|
-
raise Errors::NotFound, "#{@sequencer_class.name} reported not_found"
|
|
93
|
-
else
|
|
94
|
-
raise Errors::Failed, "#{@sequencer_class.name} failed (#{code}): #{@result.message}"
|
|
111
|
+
when :forbidden then raise_policy_failed
|
|
112
|
+
when :not_found then raise_not_found
|
|
113
|
+
else raise_failed
|
|
95
114
|
end
|
|
96
115
|
end
|
|
97
116
|
|
|
@@ -107,11 +126,10 @@ module Hubbado
|
|
|
107
126
|
end
|
|
108
127
|
|
|
109
128
|
def steps_summary
|
|
110
|
-
|
|
129
|
+
successful_steps.empty? ? "(no steps)" : successful_steps.map(&:to_s).join(" → ")
|
|
111
130
|
end
|
|
112
131
|
|
|
113
132
|
def step_label
|
|
114
|
-
step = @result.error && @result.error[:step]
|
|
115
133
|
step ? step.inspect : "(unknown step)"
|
|
116
134
|
end
|
|
117
135
|
end
|
|
@@ -162,7 +180,8 @@ module Hubbado
|
|
|
162
180
|
def configure_failure(code, error_attrs)
|
|
163
181
|
@configured_outcome = {
|
|
164
182
|
kind: :failure,
|
|
165
|
-
|
|
183
|
+
code: code,
|
|
184
|
+
error_attrs: error_attrs
|
|
166
185
|
}
|
|
167
186
|
self
|
|
168
187
|
end
|
|
@@ -175,7 +194,7 @@ module Hubbado
|
|
|
175
194
|
outcome[:ctx_writes].each { |key, value| ctx[key] = value }
|
|
176
195
|
Hubbado::Sequence::Result.success(ctx)
|
|
177
196
|
else
|
|
178
|
-
Hubbado::Sequence::Result.failure(ctx,
|
|
197
|
+
Hubbado::Sequence::Result.failure(ctx, code: outcome[:code], **outcome[:error_attrs])
|
|
179
198
|
end
|
|
180
199
|
end
|
|
181
200
|
end
|
|
@@ -34,7 +34,7 @@ module Hubbado
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
record def call(ctx)
|
|
37
|
-
return ::Hubbado::Sequence::Result.failure(ctx,
|
|
37
|
+
return ::Hubbado::Sequence::Result.failure(ctx, **@configured_error) if @configured_error
|
|
38
38
|
|
|
39
39
|
if @configured_writes
|
|
40
40
|
@configured_writes.each { |k, v| ctx[k] = v }
|
|
@@ -62,7 +62,7 @@ module Hubbado
|
|
|
62
62
|
ctx = Ctx.build(ctx)
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
-
build.call(ctx)
|
|
65
|
+
build.call(ctx).with_i18n_scope(i18n_scope)
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
# Default factory: a sequencer with no configurable dependencies needs
|
|
@@ -83,7 +83,8 @@ module Hubbado
|
|
|
83
83
|
end
|
|
84
84
|
|
|
85
85
|
def failure(ctx, **error_attrs)
|
|
86
|
-
|
|
86
|
+
error_attrs[:i18n_scope] ||= i18n_scope
|
|
87
|
+
Result.failure(ctx, **error_attrs)
|
|
87
88
|
end
|
|
88
89
|
|
|
89
90
|
# Builds a Pipeline that auto-dispatches blockless `step(:foo)` calls to
|
|
@@ -99,7 +100,7 @@ module Hubbado
|
|
|
99
100
|
|
|
100
101
|
if block
|
|
101
102
|
block.call(pipe)
|
|
102
|
-
pipe.result
|
|
103
|
+
pipe.result.with_i18n_scope(i18n_scope)
|
|
103
104
|
else
|
|
104
105
|
pipe
|
|
105
106
|
end
|
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.7.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-18 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: evt-casing
|