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.
@@ -0,0 +1,48 @@
1
+ module Hubbado
2
+ module Sequence
3
+ module Controls
4
+ module Policy
5
+ # Minimal stand-in for a hubbado-policy::Result so we don't take a hard
6
+ # dependency on the policy gem from these controls.
7
+ class PolicyResult
8
+ attr_reader :reason
9
+
10
+ def initialize(permitted, reason)
11
+ @permitted = permitted
12
+ @reason = reason
13
+ end
14
+
15
+ def permitted?; @permitted; end
16
+ def denied?; !@permitted; end
17
+ end
18
+
19
+ def self.example_class(decision: :permit, action: :update)
20
+ Class.new do
21
+ attr_reader :user, :record
22
+
23
+ define_singleton_method(:default_decision) { decision }
24
+ define_singleton_method(:default_action) { action }
25
+
26
+ def self.build(user, record)
27
+ new(user, record)
28
+ end
29
+
30
+ def initialize(user, record)
31
+ @user = user
32
+ @record = record
33
+ end
34
+
35
+ define_method(action) do
36
+ decision = self.class.default_decision
37
+ if decision == :permit
38
+ PolicyResult.new(true, :permitted)
39
+ else
40
+ PolicyResult.new(false, :not_owner)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,3 @@
1
+ require "hubbado/sequence/controls/model"
2
+ require "hubbado/sequence/controls/contract"
3
+ require "hubbado/sequence/controls/policy"
@@ -0,0 +1,19 @@
1
+ module Hubbado
2
+ module Sequence
3
+ class Ctx < Hash
4
+ def self.build(initial = {})
5
+ ctx = new
6
+ ctx.merge!(initial)
7
+ ctx
8
+ end
9
+
10
+ def [](key)
11
+ unless key?(key)
12
+ raise KeyError, "key not found: #{key.inspect}"
13
+ end
14
+
15
+ super
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,16 @@
1
+ module Hubbado
2
+ module Sequence
3
+ module Errors
4
+ Failed = Class.new(StandardError)
5
+ NotFound = Class.new(StandardError)
6
+ Unauthorized = Class.new(StandardError) do
7
+ attr_reader :result
8
+
9
+ def initialize(message = nil, result = nil)
10
+ super(message)
11
+ @result = result
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,48 @@
1
+ # Works with Reform contracts. Wraps a model in a contract class and writes the instance to ctx[:contract].
2
+ module Hubbado
3
+ module Sequence
4
+ module Macros
5
+ module Contract
6
+ class Build
7
+ configure :build_contract
8
+
9
+ def self.build
10
+ new
11
+ end
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)
16
+ Result.ok(ctx)
17
+ end
18
+
19
+ module Substitute
20
+ include ::RecordInvocation
21
+
22
+ def succeed_with(contract)
23
+ @return_value = contract
24
+ @configured_success = true
25
+ self
26
+ end
27
+
28
+ def fail_with(**error_attrs)
29
+ @configured_error = error_attrs
30
+ self
31
+ end
32
+
33
+ record def call(ctx, contract_class, attr_name = nil)
34
+ return Result.fail(ctx, error: @configured_error) if @configured_error
35
+
36
+ ctx[:contract] = @return_value if @configured_success
37
+ Result.ok(ctx)
38
+ end
39
+
40
+ def built?(**kwargs)
41
+ invoked?(:call, **kwargs)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,43 @@
1
+ # Works with Reform contracts. Calls contract.deserialize with params sourced from ctx; no-op when the path is absent.
2
+ module Hubbado
3
+ module Sequence
4
+ module Macros
5
+ module Contract
6
+ class Deserialize
7
+ configure :deserialize_to_contract
8
+
9
+ def self.build
10
+ new
11
+ end
12
+
13
+ def call(ctx, from:)
14
+ params = Path.resolve(ctx, from, missing: :nil)
15
+
16
+ ctx[:contract].deserialize(params) if params
17
+
18
+ Result.ok(ctx)
19
+ end
20
+
21
+ module Substitute
22
+ include ::RecordInvocation
23
+
24
+ def fail_with(**error_attrs)
25
+ @configured_error = error_attrs
26
+ self
27
+ end
28
+
29
+ record def call(ctx, from:)
30
+ return Result.fail(ctx, error: @configured_error) if @configured_error
31
+
32
+ Result.ok(ctx)
33
+ end
34
+
35
+ def deserialized?(**kwargs)
36
+ invoked?(:call, **kwargs)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,50 @@
1
+ # Works with Reform contracts. Calls contract.save; fails with :persist_failed when save returns false.
2
+ module Hubbado
3
+ module Sequence
4
+ module Macros
5
+ module Contract
6
+ class Persist
7
+ configure :persist
8
+
9
+ def self.build
10
+ new
11
+ end
12
+
13
+ def call(ctx)
14
+ contract = ctx[:contract]
15
+
16
+ if contract.save
17
+ Result.ok(ctx)
18
+ else
19
+ Result.fail(ctx, error: { code: :persist_failed })
20
+ end
21
+ end
22
+
23
+ module Substitute
24
+ include ::RecordInvocation
25
+
26
+ def succeed_with
27
+ @configured_success = true
28
+ self
29
+ end
30
+
31
+ def fail_with(**error_attrs)
32
+ @configured_error = error_attrs
33
+ self
34
+ end
35
+
36
+ record def call(ctx)
37
+ return Result.fail(ctx, error: @configured_error) if @configured_error
38
+
39
+ Result.ok(ctx)
40
+ end
41
+
42
+ def persisted?(**kwargs)
43
+ invoked?(:call, **kwargs)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,53 @@
1
+ # Works with Reform contracts. Validates the contract and checks errors; fails with :validation_failed when invalid.
2
+ module Hubbado
3
+ module Sequence
4
+ module Macros
5
+ module Contract
6
+ class Validate
7
+ configure :validate
8
+
9
+ def self.build
10
+ new
11
+ end
12
+
13
+ def call(ctx, from: nil)
14
+ contract = ctx[:contract]
15
+ params = from ? Path.resolve(ctx, from) : {}
16
+
17
+ contract.validate(params)
18
+
19
+ if contract.errors.empty?
20
+ Result.ok(ctx)
21
+ else
22
+ Result.fail(ctx, error: { code: :validation_failed })
23
+ end
24
+ end
25
+
26
+ module Substitute
27
+ include ::RecordInvocation
28
+
29
+ def succeed_with
30
+ @configured_success = true
31
+ self
32
+ end
33
+
34
+ def fail_with(**error_attrs)
35
+ @configured_error = error_attrs
36
+ self
37
+ end
38
+
39
+ record def call(ctx, from: nil)
40
+ return Result.fail(ctx, error: @configured_error) if @configured_error
41
+
42
+ Result.ok(ctx)
43
+ end
44
+
45
+ def validated?(**kwargs)
46
+ invoked?(:call, **kwargs)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,57 @@
1
+ # Works with ActiveRecord models. Instantiates a new model record (with optional attributes) and writes it to ctx.
2
+ module Hubbado
3
+ module Sequence
4
+ module Macros
5
+ module Model
6
+ class Build
7
+ configure :build_record
8
+
9
+ def self.build
10
+ new
11
+ end
12
+
13
+ def call(ctx, model, as:, attributes: nil)
14
+ ctx[as] =
15
+ if attributes.nil?
16
+ model.new
17
+ else
18
+ model.new(attributes)
19
+ end
20
+ Result.ok(ctx)
21
+ end
22
+
23
+ module Substitute
24
+ include ::RecordInvocation
25
+
26
+ def succeed_with(value)
27
+ @return_value = value
28
+ @configured_success = true
29
+ self
30
+ end
31
+
32
+ def fail_with(**error_attrs)
33
+ @configured_error = error_attrs
34
+ self
35
+ end
36
+
37
+ record def call(ctx, model, as:, attributes: nil)
38
+ unless model.respond_to?(:new)
39
+ raise ArgumentError,
40
+ "Macros::Model::Build substitute: #{model} does not respond to :new"
41
+ end
42
+
43
+ return Result.fail(ctx, error: @configured_error) if @configured_error
44
+
45
+ ctx[as] = @return_value if @configured_success
46
+ Result.ok(ctx)
47
+ end
48
+
49
+ def built?(**kwargs)
50
+ invoked?(:call, **kwargs)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,59 @@
1
+ # Works with ActiveRecord models. Fetches a record by id from ctx and writes it to ctx; fails with :not_found on miss.
2
+ module Hubbado
3
+ module Sequence
4
+ module Macros
5
+ module Model
6
+ class Find
7
+ configure :find
8
+
9
+ def self.build
10
+ new
11
+ end
12
+
13
+ def call(ctx, model, as:, id_key: %i[params id])
14
+ id = Path.resolve(ctx, id_key)
15
+ record = model.find_by(id: id)
16
+
17
+ if record
18
+ ctx[as] = record
19
+ Result.ok(ctx)
20
+ else
21
+ Result.fail(ctx, error: { code: :not_found })
22
+ end
23
+ end
24
+
25
+ module Substitute
26
+ include ::RecordInvocation
27
+
28
+ def succeed_with(value)
29
+ @return_value = value
30
+ @configured_success = true
31
+ self
32
+ end
33
+
34
+ def fail_with(**error_attrs)
35
+ @configured_error = error_attrs
36
+ self
37
+ end
38
+
39
+ record def call(ctx, model, as:, id_key: %i[params id])
40
+ unless model.respond_to?(:find_by)
41
+ raise ArgumentError,
42
+ "Macros::Model::Find substitute: #{model} does not respond to :find_by"
43
+ end
44
+
45
+ return Result.fail(ctx, error: @configured_error) if @configured_error
46
+
47
+ ctx[as] = @return_value if @configured_success
48
+ Result.ok(ctx)
49
+ end
50
+
51
+ def fetched?(**kwargs)
52
+ invoked?(:call, **kwargs)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,65 @@
1
+ # Works with hubbado-policy. Builds a policy instance and calls the action; fails with :forbidden when denied.
2
+ module Hubbado
3
+ module Sequence
4
+ module Macros
5
+ module Policy
6
+ class Check
7
+ configure :check_policy
8
+
9
+ def self.build
10
+ new
11
+ end
12
+
13
+ def call(ctx, policy, record_key, action)
14
+ current_user = ctx[:current_user]
15
+ record = ctx[record_key]
16
+
17
+ policy_instance = policy.build(current_user, record)
18
+ policy_result = policy_instance.public_send(action)
19
+
20
+ if policy_result.permitted?
21
+ Result.ok(ctx)
22
+ else
23
+ Result.fail(
24
+ ctx,
25
+ error: {
26
+ code: :forbidden,
27
+ data: { policy: policy_instance, policy_result: policy_result }
28
+ }
29
+ )
30
+ end
31
+ end
32
+
33
+ module Substitute
34
+ include ::RecordInvocation
35
+
36
+ def succeed_with
37
+ @configured_success = true
38
+ self
39
+ end
40
+
41
+ def fail_with(**error_attrs)
42
+ @configured_error = error_attrs
43
+ self
44
+ end
45
+
46
+ record def call(ctx, policy, record_key, action)
47
+ unless policy.method_defined?(action)
48
+ raise ArgumentError,
49
+ "Macros::Policy::Check substitute: #{policy} does not declare action :#{action}"
50
+ end
51
+
52
+ return Result.fail(ctx, error: @configured_error) if @configured_error
53
+
54
+ Result.ok(ctx)
55
+ end
56
+
57
+ def checked?(**kwargs)
58
+ invoked?(:call, **kwargs)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,35 @@
1
+ module Hubbado
2
+ module Sequence
3
+ # Resolves a ctx path expressed as a single Symbol (one-key shorthand) or
4
+ # an Array of Symbols (nested fetch). Used by macros that need to read a
5
+ # value out of ctx at a configurable location.
6
+ #
7
+ # `missing:` selects how an absent key is reported:
8
+ # :raise (default) — propagate KeyError. Right for Find/Validate/Build,
9
+ # where a missing path is a wiring bug or a not-found.
10
+ # :nil — return nil. Right for Deserialize, which runs ahead
11
+ # of validation and may legitimately encounter absent
12
+ # params (e.g. a fresh GET before the form is posted).
13
+ module Path
14
+ def self.resolve(ctx, path, missing: nil)
15
+ missing ||= :raise
16
+
17
+ unless %i[raise nil].include?(missing)
18
+ raise ArgumentError, "unknown missing policy: #{missing.inspect}"
19
+ end
20
+
21
+ if path.is_a?(Array) && path.empty?
22
+ raise ArgumentError, "path cannot be empty"
23
+ end
24
+
25
+ Array(path).reduce(ctx) do |acc, key|
26
+ if missing == :nil
27
+ acc.fetch(key) { return nil }
28
+ else
29
+ acc.fetch(key)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,141 @@
1
+ module Hubbado
2
+ module Sequence
3
+ class Pipeline
4
+ # `Pipeline.(ctx) { |p| ... }` is the block form: yields the pipeline,
5
+ # runs the block (so steps can be added in statement form), and returns
6
+ # the final Result. The non-block form returns the Pipeline so chained
7
+ # `.step(...)...result` calls still work.
8
+ def self.call(ctx = nil, **kwargs, &block)
9
+ if ctx.nil?
10
+ ctx = Ctx.build(kwargs)
11
+ elsif !kwargs.empty?
12
+ raise ArgumentError, "Pipeline.() takes either a Ctx or keyword arguments, not both"
13
+ elsif !ctx.is_a?(Ctx)
14
+ ctx = Ctx.build(ctx)
15
+ end
16
+
17
+ pipe = new(ctx)
18
+
19
+ if block
20
+ block.call(pipe)
21
+ pipe.result
22
+ else
23
+ pipe
24
+ end
25
+ end
26
+
27
+ def initialize(ctx, dispatcher: nil)
28
+ @ctx = ctx
29
+ @trail = []
30
+ @failed_result = nil
31
+ @dispatcher = dispatcher
32
+ end
33
+
34
+ # `step(:name) { |ctx| ... }` runs the block. `step(:name)` with no
35
+ # block dispatches to `dispatcher.send(name, ctx)` on the sequencer
36
+ # that built this pipeline (via the mixin's `pipeline(ctx)` helper).
37
+ # Block beats dispatch when both are available; raises if neither.
38
+ #
39
+ # Lenient return convention: a step is treated as successful unless it
40
+ # explicitly returns a failed `Result`. Any other return value (nil,
41
+ # false, a model, a hash, `Result.ok(...)`) is taken as success and the
42
+ # pipeline continues with the same `@ctx`. Only `Result.fail(...)` /
43
+ # `failure(ctx, code: ...)` short-circuits the pipeline.
44
+ def step(name, &block)
45
+ return self if @failed_result
46
+
47
+ return_value = invoke_step(name, block)
48
+
49
+ if return_value.is_a?(Result) && return_value.failure?
50
+ @failed_result = tag_failure(return_value, name)
51
+ else
52
+ @trail << name
53
+ end
54
+
55
+ self
56
+ end
57
+
58
+ # `invoke(:name, *args, **kwargs)` calls a declared dependency on the
59
+ # sequencer: gets the dependency via `dispatcher.send(name)` (the
60
+ # reader), then invokes it with `(ctx, *args, **kwargs)`. Same trail
61
+ # recording, failure short-circuiting, and lenient return convention as
62
+ # `step`.
63
+ #
64
+ # Use this for any declared dependency — macros (`Macros::Model::Find`)
65
+ # and nested sequencers (`Seqs::Present`) alike. Use `step` for local
66
+ # instance methods like `def deserialize_contract(ctx)`.
67
+ def invoke(name, *args, **kwargs)
68
+ return self if @failed_result
69
+
70
+ return_value = invoke_dependency(name, args, kwargs)
71
+
72
+ if return_value.is_a?(Result) && return_value.failure?
73
+ @failed_result = tag_failure(return_value, name)
74
+ else
75
+ @trail << name
76
+ end
77
+
78
+ self
79
+ end
80
+
81
+ def transaction(&block)
82
+ return self if @failed_result
83
+
84
+ if defined?(::ActiveRecord::Base)
85
+ ::ActiveRecord::Base.transaction do
86
+ yield(self)
87
+ raise ::ActiveRecord::Rollback if @failed_result
88
+ end
89
+ else
90
+ yield(self)
91
+ end
92
+
93
+ self
94
+ end
95
+
96
+ def result
97
+ if @failed_result
98
+ @failed_result
99
+ else
100
+ Result.ok(@ctx, trail: @trail.dup)
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def invoke_step(name, block)
107
+ if block
108
+ block.call(@ctx)
109
+ elsif @dispatcher
110
+ unless @dispatcher.respond_to?(name, true)
111
+ raise NoMethodError,
112
+ "Pipeline step :#{name} expects #{@dispatcher.class.name} to define ##{name}, but it does not"
113
+ end
114
+ @dispatcher.send(name, @ctx)
115
+ else
116
+ raise ArgumentError,
117
+ "Pipeline step :#{name} needs either a block or a dispatcher (use the sequencer's `pipeline(ctx)` helper to enable auto-dispatch)"
118
+ end
119
+ end
120
+
121
+ def invoke_dependency(name, args, kwargs)
122
+ unless @dispatcher
123
+ raise ArgumentError,
124
+ "Pipeline#invoke :#{name} requires a dispatcher (use the sequencer's `pipeline(ctx)` helper)"
125
+ end
126
+
127
+ unless @dispatcher.respond_to?(name, true)
128
+ raise NoMethodError,
129
+ "Pipeline#invoke :#{name} expects #{@dispatcher.class.name} to declare a `dependency :#{name}, ...`"
130
+ end
131
+
132
+ @dispatcher.send(name).(@ctx, *args, **kwargs)
133
+ end
134
+
135
+ def tag_failure(result, step_name)
136
+ tagged_error = result.error.merge(step: step_name)
137
+ Result.fail(result.ctx, error: tagged_error, trail: @trail.dup, i18n_scope: result.i18n_scope)
138
+ end
139
+ end
140
+ end
141
+ end