hubbado-sequence 0.6.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 +88 -0
- data/README.md +106 -8
- data/hubbado-sequence.gemspec +1 -1
- data/lib/hubbado/sequence/macros/contract/build.rb +4 -4
- data/lib/hubbado/sequence/macros/policy/check.rb +19 -9
- data/lib/hubbado/sequence/sequencer.rb +2 -2
- 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,94 @@ 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
|
+
|
|
7
95
|
## [0.6.0] - Result.failure flat kwargs; Dispatch delegates reads and exposes raise helpers
|
|
8
96
|
|
|
9
97
|
### 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,20 +288,50 @@ 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
296
|
instance must respond to the action method and return a
|
|
296
297
|
`Hubbado::Policy::Result`-shaped object (`permitted?`, `denied?`,
|
|
297
|
-
`reason`, `message`).
|
|
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
|
+
```
|
|
298
312
|
|
|
299
313
|
| | |
|
|
300
314
|
|---|---|
|
|
301
|
-
| **Reads** | `ctx[:current_user]`, `ctx[record_key]` |
|
|
302
|
-
| **Writes** |
|
|
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`) |
|
|
303
317
|
| **Fails** | `:forbidden` when `permitted?` is false; `result.data` carries `{ policy:, policy_result: }` |
|
|
304
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
|
+
|
|
305
335
|
A controller can branch on the denial reason via `data`:
|
|
306
336
|
|
|
307
337
|
```ruby
|
|
@@ -328,7 +358,7 @@ def call(ctx)
|
|
|
328
358
|
pipeline(ctx) do |p|
|
|
329
359
|
p.invoke(:find, User, as: :user)
|
|
330
360
|
p.invoke(:build_contract, Contracts::UpdateUser, :user)
|
|
331
|
-
p.invoke(:check_policy, Policies::User, :
|
|
361
|
+
p.invoke(:check_policy, Policies::User, :update, :user)
|
|
332
362
|
|
|
333
363
|
p.transaction do |t|
|
|
334
364
|
t.invoke(:validate, from: %i[params user])
|
|
@@ -383,7 +413,7 @@ class Seqs::UpdateUser
|
|
|
383
413
|
pipeline(ctx) do |p|
|
|
384
414
|
p.invoke(:find, User, as: :user)
|
|
385
415
|
p.invoke(:build_contract, Contracts::UpdateUser, :user)
|
|
386
|
-
p.invoke(:check_policy, Policies::User, :
|
|
416
|
+
p.invoke(:check_policy, Policies::User, :update, :user)
|
|
387
417
|
end
|
|
388
418
|
end
|
|
389
419
|
end
|
|
@@ -474,6 +504,74 @@ with the sequencer's auto-derived i18n scope already applied. It takes
|
|
|
474
504
|
the same kwargs as `Result.failure` (`code:`, `data:`, `step:`,
|
|
475
505
|
`i18n_scope:`, `i18n_key:`, `i18n_args:`).
|
|
476
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
|
+
|
|
477
575
|
## Outcome blocks and safety nets
|
|
478
576
|
|
|
479
577
|
`run_sequence` enforces that serious failures are addressed. Forgetting to
|
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,7 +30,7 @@ module Hubbado
|
|
|
30
30
|
self
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
record def call(ctx, contract_class,
|
|
33
|
+
record def call(ctx, contract_class, model = nil)
|
|
34
34
|
return Result.failure(ctx, **@configured_error) if @configured_error
|
|
35
35
|
|
|
36
36
|
ctx[:contract] = @return_value if @configured_success
|
|
@@ -10,29 +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
|
-
code: :forbidden,
|
|
26
|
-
data: { policy: policy_instance, policy_result: policy_result }
|
|
27
|
-
)
|
|
33
|
+
self.class.failure(ctx, policy_instance, policy_result)
|
|
28
34
|
end
|
|
29
35
|
end
|
|
30
36
|
|
|
31
37
|
module Substitute
|
|
32
38
|
include ::RecordInvocation
|
|
33
39
|
|
|
34
|
-
def succeed_with
|
|
40
|
+
def succeed_with(policy_instance = nil)
|
|
35
41
|
@configured_success = true
|
|
42
|
+
@return_policy = policy_instance
|
|
36
43
|
self
|
|
37
44
|
end
|
|
38
45
|
|
|
@@ -41,7 +48,9 @@ module Hubbado
|
|
|
41
48
|
self
|
|
42
49
|
end
|
|
43
50
|
|
|
44
|
-
record def call(ctx, policy, record_key,
|
|
51
|
+
record def call(ctx, policy, action, record_key = nil, as: nil)
|
|
52
|
+
as ||= :policy
|
|
53
|
+
|
|
45
54
|
unless policy.method_defined?(action)
|
|
46
55
|
raise ArgumentError,
|
|
47
56
|
"Macros::Policy::Check substitute: #{policy} does not declare action :#{action}"
|
|
@@ -49,6 +58,7 @@ module Hubbado
|
|
|
49
58
|
|
|
50
59
|
return Result.failure(ctx, **@configured_error) if @configured_error
|
|
51
60
|
|
|
61
|
+
ctx[as] = @return_policy if @return_policy
|
|
52
62
|
Result.success(ctx)
|
|
53
63
|
end
|
|
54
64
|
|
|
@@ -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
|
|
@@ -100,7 +100,7 @@ module Hubbado
|
|
|
100
100
|
|
|
101
101
|
if block
|
|
102
102
|
block.call(pipe)
|
|
103
|
-
pipe.result
|
|
103
|
+
pipe.result.with_i18n_scope(i18n_scope)
|
|
104
104
|
else
|
|
105
105
|
pipe
|
|
106
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
|