hubbado-sequence 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +166 -0
- data/LICENSE +21 -0
- data/README.md +562 -0
- data/config/locales/en.yml +8 -0
- data/hubbado-sequence.gemspec +41 -0
- data/lib/hubbado/sequence/controls/contract.rb +45 -0
- data/lib/hubbado/sequence/controls/model.rb +33 -0
- data/lib/hubbado/sequence/controls/policy.rb +48 -0
- data/lib/hubbado/sequence/controls.rb +3 -0
- data/lib/hubbado/sequence/ctx.rb +19 -0
- data/lib/hubbado/sequence/errors.rb +16 -0
- data/lib/hubbado/sequence/macros/contract/build.rb +48 -0
- data/lib/hubbado/sequence/macros/contract/deserialize.rb +43 -0
- data/lib/hubbado/sequence/macros/contract/persist.rb +50 -0
- data/lib/hubbado/sequence/macros/contract/validate.rb +53 -0
- data/lib/hubbado/sequence/macros/model/build.rb +57 -0
- data/lib/hubbado/sequence/macros/model/find.rb +59 -0
- data/lib/hubbado/sequence/macros/policy/check.rb +65 -0
- data/lib/hubbado/sequence/path.rb +35 -0
- data/lib/hubbado/sequence/pipeline.rb +141 -0
- data/lib/hubbado/sequence/result.rb +83 -0
- data/lib/hubbado/sequence/run_sequence.rb +26 -0
- data/lib/hubbado/sequence/runner.rb +184 -0
- data/lib/hubbado/sequence/sequencer.rb +109 -0
- data/lib/hubbado/sequence.rb +30 -0
- metadata +227 -0
|
@@ -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,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
|