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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cc373389ca48751e5d60ebc84c00dfd8740b437ec89a53b8d9a1b986ea5c2a48
4
- data.tar.gz: 7053947aa597354f5b973832083ebc8cc9e60e4de3a1cee23524045f803a63cd
3
+ metadata.gz: 4c072f343cb764529d76c7babb7824217546cadeaf4cab59c305b40b0b1f7647
4
+ data.tar.gz: 15c860841338562d2ba8b39c8d49d1a95959345eec316489878676a0b9f4a3e4
5
5
  SHA512:
6
- metadata.gz: 240dbbf380bea6a9007872456c97d052a6cedf0f5b716fbffe63876362f7c66f89f18accbf51d1cd99f2ee0f3f377376c7161770e46d85f6314548bc07d6549b
7
- data.tar.gz: ef08b3226afc5465ecac7030ca8e36ad9c3a2ecb967530144bb96aad79a670e2085eda9e74d80b4cc05e4617deab78ac0903150ff5ed6b78f4cf1b58a912d778
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, :user, :update)
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[attr_name]` for the model (optional) |
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, :user, :update)
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** | nothing |
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, :user, :update)
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, :user, :update)
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
@@ -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.6.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, attr_name = nil)
14
- model = attr_name && Path.resolve(ctx, attr_name)
15
- ctx[:contract] = contract_class.new(model)
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, attr_name = nil)
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 call(ctx, policy, record_key, action)
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
- Result.failure(
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, action)
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.6.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-16 00:00:00.000000000 Z
11
+ date: 2026-05-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: evt-casing