flowy 0.1.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/LICENSE.txt +21 -0
- data/README.md +873 -0
- data/lib/flowy/concern/step_runner.rb +135 -0
- data/lib/flowy/concern.rb +144 -0
- data/lib/flowy/enumerable.rb +29 -0
- data/lib/flowy/error.rb +47 -0
- data/lib/flowy/failure.rb +120 -0
- data/lib/flowy/pipeline.rb +194 -0
- data/lib/flowy/result.rb +63 -0
- data/lib/flowy/success.rb +74 -0
- data/lib/flowy/version.rb +3 -0
- data/lib/flowy.rb +11 -0
- metadata +70 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
module Flowy
|
|
2
|
+
module Concern
|
|
3
|
+
# Internal runtime that wraps a step with hooks, dispatches keyword args
|
|
4
|
+
# from result.data, and converts raised errors into Failures according to
|
|
5
|
+
# the rescue: / on_error: / rescue_errors: contract.
|
|
6
|
+
module StepRunner
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
# Execution order around each step:
|
|
10
|
+
# global before → class before → per-step before
|
|
11
|
+
# global around [ class around [ per-step around [ step ] ] ]
|
|
12
|
+
# per-step after → class after → global after
|
|
13
|
+
def call_step_with_hooks(step_def, previous_result, rescue_errors:)
|
|
14
|
+
is_tap = step_def[:tap]
|
|
15
|
+
step_name = step_def[:name]
|
|
16
|
+
|
|
17
|
+
(Flowy::Concern._flowy_global_before_hooks + self.class._flowy_before_hooks).each do |hook|
|
|
18
|
+
hook.call(step_name, previous_result)
|
|
19
|
+
end
|
|
20
|
+
if (ps_before = step_def[:before_step])
|
|
21
|
+
resolve_hook(ps_before).call(step_name, previous_result)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
innermost = lambda do
|
|
25
|
+
raw = call_step(step_def, previous_result, rescue_errors: rescue_errors)
|
|
26
|
+
is_tap ? previous_result : raw
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
all_around = Flowy::Concern._flowy_global_around_hooks + self.class._flowy_around_hooks
|
|
30
|
+
all_around += [resolve_hook(step_def[:around_step])] if step_def[:around_step]
|
|
31
|
+
|
|
32
|
+
chain = all_around.reverse.reduce(innermost) do |inner, hook|
|
|
33
|
+
lambda { hook.call(step_name, previous_result) { inner.call } }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
result = chain.call
|
|
37
|
+
|
|
38
|
+
unless result.is_a?(Flowy::Result)
|
|
39
|
+
raise TypeError,
|
|
40
|
+
"around_step hook for '#{step_name}' must return a Flowy::Success or Flowy::Failure, got #{result.class}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if (ps_after = step_def[:after_step])
|
|
44
|
+
resolve_hook(ps_after).call(step_name, result)
|
|
45
|
+
end
|
|
46
|
+
(self.class._flowy_after_hooks + Flowy::Concern._flowy_global_after_hooks).each do |hook|
|
|
47
|
+
hook.call(step_name, result)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
result
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def resolve_hook(hook)
|
|
54
|
+
hook.is_a?(Symbol) ? method(hook) : hook
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# `:previous_result` is a reserved keyword: when declared by a step, it
|
|
58
|
+
# always receives the Flowy::Result object, regardless of any value with
|
|
59
|
+
# the same key in previous_result.data.
|
|
60
|
+
def build_step_kwargs(name, previous_result)
|
|
61
|
+
m = method(name)
|
|
62
|
+
params = m.parameters # [[:keyreq, :age], [:key, :name], [:keyrest, :opts], ...]
|
|
63
|
+
|
|
64
|
+
kwargs = {}
|
|
65
|
+
has_keyrest = params.any? { |type, _| type == :keyrest }
|
|
66
|
+
explicit_keys = params
|
|
67
|
+
.select { |type, _| type == :keyreq || type == :key }
|
|
68
|
+
.map { |type, pname| [pname, type == :keyreq] }
|
|
69
|
+
|
|
70
|
+
explicit_keys.each do |pname, required|
|
|
71
|
+
if pname == :previous_result
|
|
72
|
+
kwargs[:previous_result] = previous_result
|
|
73
|
+
elsif required
|
|
74
|
+
unless previous_result.data.key?(pname)
|
|
75
|
+
raise ArgumentError,
|
|
76
|
+
"Step '#{name}' requires key #{pname.inspect} but it is missing from result.data " \
|
|
77
|
+
"(available: #{previous_result.data.keys.inspect})"
|
|
78
|
+
end
|
|
79
|
+
kwargs[pname] = previous_result.data[pname]
|
|
80
|
+
else
|
|
81
|
+
# optional keyword (has a default): pass it only if present in data,
|
|
82
|
+
# otherwise let Ruby apply the declared default.
|
|
83
|
+
kwargs[pname] = previous_result.data[pname] if previous_result.data.key?(pname)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
if has_keyrest
|
|
88
|
+
declared_names = explicit_keys.map(&:first).reject { |p| p == :previous_result }
|
|
89
|
+
data_keys_to_pass = previous_result.data.keys - declared_names
|
|
90
|
+
data_keys_to_pass.each { |k| kwargs[k] = previous_result.data[k] }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
kwargs
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def call_step(step_def, previous_result, rescue_errors:)
|
|
97
|
+
name = step_def[:name]
|
|
98
|
+
is_tap = step_def[:tap]
|
|
99
|
+
rescues = step_def[:rescue]
|
|
100
|
+
on_error = step_def[:on_error]
|
|
101
|
+
|
|
102
|
+
result =
|
|
103
|
+
if name.is_a?(Symbol)
|
|
104
|
+
public_send(name, **build_step_kwargs(name, previous_result))
|
|
105
|
+
elsif name.is_a?(Flowy::Pipeline)
|
|
106
|
+
name.call(starting_data: previous_result.data, rescue_errors: rescue_errors, context: self)
|
|
107
|
+
elsif name.respond_to?(:call)
|
|
108
|
+
name.call(previous_result: previous_result)
|
|
109
|
+
else
|
|
110
|
+
raise ArgumentError, "Step must be a Symbol, Flowy::Pipeline or callable, got #{name.class}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
return previous_result if is_tap
|
|
114
|
+
|
|
115
|
+
unless result.is_a?(Flowy::Success) || result.is_a?(Flowy::Failure)
|
|
116
|
+
raise TypeError, "Step '#{name}' must return a Flowy::Success or Flowy::Failure, got #{result.class}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
result
|
|
120
|
+
rescue StandardError => e
|
|
121
|
+
in_rescues = rescues.any? { |klass| e.is_a?(klass) }
|
|
122
|
+
raise unless in_rescues || rescue_errors
|
|
123
|
+
|
|
124
|
+
if in_rescues && on_error
|
|
125
|
+
public_send(on_error, e, previous_result: previous_result)
|
|
126
|
+
else
|
|
127
|
+
failure(
|
|
128
|
+
error_code: :step_raised_error,
|
|
129
|
+
error_data: { step: name.is_a?(Symbol) ? name : name.class.name, message: e.message }
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
require_relative 'concern/step_runner'
|
|
2
|
+
|
|
3
|
+
module Flowy
|
|
4
|
+
module Concern
|
|
5
|
+
@_flowy_global_around_hooks = []
|
|
6
|
+
@_flowy_global_before_hooks = []
|
|
7
|
+
@_flowy_global_after_hooks = []
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
attr_reader :_flowy_global_around_hooks,
|
|
11
|
+
:_flowy_global_before_hooks,
|
|
12
|
+
:_flowy_global_after_hooks
|
|
13
|
+
|
|
14
|
+
def included(base)
|
|
15
|
+
base.extend(ClassMethods)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Block signature: |step_name, previous_result, &call|
|
|
19
|
+
def around_step(&block)
|
|
20
|
+
@_flowy_global_around_hooks << block
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Block signature: |step_name, previous_result|
|
|
24
|
+
def before_step(&block)
|
|
25
|
+
@_flowy_global_before_hooks << block
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Block signature: |step_name, result|
|
|
29
|
+
def after_step(&block)
|
|
30
|
+
@_flowy_global_after_hooks << block
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def clear_global_hooks!
|
|
34
|
+
@_flowy_global_around_hooks = []
|
|
35
|
+
@_flowy_global_before_hooks = []
|
|
36
|
+
@_flowy_global_after_hooks = []
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
DEFAULT_STEP_DEF = {
|
|
41
|
+
tap: false,
|
|
42
|
+
rescue: [].freeze,
|
|
43
|
+
on_error: nil,
|
|
44
|
+
before_step: nil,
|
|
45
|
+
after_step: nil,
|
|
46
|
+
around_step: nil
|
|
47
|
+
}.freeze
|
|
48
|
+
private_constant :DEFAULT_STEP_DEF
|
|
49
|
+
|
|
50
|
+
def self._build_step_def(name, **overrides)
|
|
51
|
+
DEFAULT_STEP_DEF.merge(name: name, **overrides)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
module ClassMethods
|
|
55
|
+
|
|
56
|
+
def success(**kwargs)
|
|
57
|
+
Flowy::Result.success(**kwargs)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def failure(**kwargs)
|
|
61
|
+
Flowy::Result.failure(**kwargs)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# `rescue:` is captured via **opts because `rescue` is a Ruby reserved
|
|
65
|
+
# word and cannot be referenced as a bare local variable inside a method.
|
|
66
|
+
def step(name, on_error: nil, before_step: nil, after_step: nil, around_step: nil, **opts)
|
|
67
|
+
unknown = opts.keys - [:rescue]
|
|
68
|
+
raise ArgumentError, "unknown keyword: #{unknown.first}" if unknown.any?
|
|
69
|
+
|
|
70
|
+
_flowy_steps << Flowy::Concern._build_step_def(
|
|
71
|
+
name,
|
|
72
|
+
rescue: Array(opts[:rescue]),
|
|
73
|
+
on_error: on_error,
|
|
74
|
+
before_step: before_step,
|
|
75
|
+
after_step: after_step,
|
|
76
|
+
around_step: around_step
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def tap_step(name, before_step: nil, after_step: nil, around_step: nil)
|
|
81
|
+
_flowy_steps << Flowy::Concern._build_step_def(
|
|
82
|
+
name,
|
|
83
|
+
tap: true,
|
|
84
|
+
before_step: before_step,
|
|
85
|
+
after_step: after_step,
|
|
86
|
+
around_step: around_step
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def _flowy_steps
|
|
91
|
+
@_flowy_steps ||= []
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Block signature: |step_name, previous_result, &call|
|
|
95
|
+
def around_step(&block)
|
|
96
|
+
_flowy_around_hooks << block
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Block signature: |step_name, previous_result|
|
|
100
|
+
def before_step(&block)
|
|
101
|
+
_flowy_before_hooks << block
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Block signature: |step_name, result|
|
|
105
|
+
def after_step(&block)
|
|
106
|
+
_flowy_after_hooks << block
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def _flowy_around_hooks
|
|
110
|
+
@_flowy_around_hooks ||= []
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def _flowy_before_hooks
|
|
114
|
+
@_flowy_before_hooks ||= []
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def _flowy_after_hooks
|
|
118
|
+
@_flowy_after_hooks ||= []
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
include StepRunner
|
|
123
|
+
|
|
124
|
+
def success(**kwargs)
|
|
125
|
+
self.class.success(**kwargs)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def failure(**kwargs)
|
|
129
|
+
self.class.failure(**kwargs)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def run_steps(starting_data: {}, steps: nil, rescue_errors: false)
|
|
133
|
+
initial = success(data: starting_data)
|
|
134
|
+
step_list = steps ? steps.map { |s| Flowy::Concern._build_step_def(s) }
|
|
135
|
+
: self.class._flowy_steps
|
|
136
|
+
|
|
137
|
+
step_list.reduce(initial) do |current_result, step_def|
|
|
138
|
+
break current_result if current_result.failure?
|
|
139
|
+
|
|
140
|
+
call_step_with_hooks(step_def, current_result, rescue_errors: rescue_errors)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Enumerable
|
|
2
|
+
# On failure produces `error_code: :partial_failure`.
|
|
3
|
+
def all_success(&block)
|
|
4
|
+
results = Flowy::Result._collect_results(self, &block)
|
|
5
|
+
|
|
6
|
+
if results.all?(&:success?)
|
|
7
|
+
Flowy::Result.success(data: { results: results })
|
|
8
|
+
else
|
|
9
|
+
Flowy::Failure.new(
|
|
10
|
+
error_code: :partial_failure,
|
|
11
|
+
error_data: { results: results }
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# On failure produces `error_code: :all_failed`.
|
|
17
|
+
def any_success(&block)
|
|
18
|
+
results = Flowy::Result._collect_results(self, &block)
|
|
19
|
+
|
|
20
|
+
if results.any?(&:success?)
|
|
21
|
+
Flowy::Result.success(data: { results: results })
|
|
22
|
+
else
|
|
23
|
+
Flowy::Failure.new(
|
|
24
|
+
error_code: :all_failed,
|
|
25
|
+
error_data: { results: results }
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/flowy/error.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module Flowy
|
|
2
|
+
class Error < StandardError
|
|
3
|
+
|
|
4
|
+
attr_reader :code, :title, :detail, :meta
|
|
5
|
+
|
|
6
|
+
def self.initialize_from_failure(failure:)
|
|
7
|
+
unless failure.is_a?(Flowy::Failure)
|
|
8
|
+
raise ArgumentError, "Flowy::Error requires a Flowy::Failure instance, got #{failure.class}"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
new(
|
|
12
|
+
code: failure.error_code,
|
|
13
|
+
title: failure.error_title,
|
|
14
|
+
detail: failure.error_description,
|
|
15
|
+
meta: failure.error_data
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(code:, title: nil, detail: nil, meta: nil)
|
|
20
|
+
@code = code
|
|
21
|
+
@title = title
|
|
22
|
+
@detail = detail
|
|
23
|
+
@meta = meta
|
|
24
|
+
super(build_message(code, title, detail))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_failure
|
|
28
|
+
Flowy::Failure.new(
|
|
29
|
+
error_code: code,
|
|
30
|
+
error_data: meta || {},
|
|
31
|
+
error_title: title,
|
|
32
|
+
error_description: detail
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_hash
|
|
37
|
+
to_failure.to_hash
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def build_message(code, title, detail)
|
|
43
|
+
description = [title, detail].compact.map(&:to_s).reject(&:empty?).join(': ')
|
|
44
|
+
[code.to_s, description].reject(&:empty?).join(' - ')
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
module Flowy
|
|
2
|
+
class Failure
|
|
3
|
+
include Flowy::Result
|
|
4
|
+
|
|
5
|
+
attr_reader :error_code, :error_data, :error_title, :error_description, :parent_failure
|
|
6
|
+
|
|
7
|
+
def initialize(error_code:, error_data: {}, error_title: nil, error_description: nil, parent_failure: nil)
|
|
8
|
+
@error_code = error_code
|
|
9
|
+
@error_data = error_data
|
|
10
|
+
@error_title = error_title
|
|
11
|
+
@error_description = error_description
|
|
12
|
+
@parent_failure = parent_failure
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_hash
|
|
16
|
+
{
|
|
17
|
+
success: false,
|
|
18
|
+
error_code: error_code,
|
|
19
|
+
error_data: error_data,
|
|
20
|
+
error_title: error_title,
|
|
21
|
+
error_description: error_description
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def is?(error_code:)
|
|
26
|
+
self.error_code == error_code
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def raise!
|
|
30
|
+
raise Flowy::Error.initialize_from_failure(failure: self)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def success?
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def failure?
|
|
38
|
+
true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def on_success
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def on_failure
|
|
46
|
+
yield self
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def and_then
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def or_else
|
|
55
|
+
result = yield self
|
|
56
|
+
unless result.is_a?(Flowy::Success) || result.is_a?(Flowy::Failure)
|
|
57
|
+
raise TypeError, "or_else block must return a Flowy::Success or Flowy::Failure, got #{result.class}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
result
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def tap
|
|
64
|
+
yield self
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def failures_chain
|
|
69
|
+
return [self] unless parent_failure
|
|
70
|
+
|
|
71
|
+
parent_failure.failures_chain + [self]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# In block form, when the block-returned Failure omits parent_failure,
|
|
75
|
+
# self is wired in as parent_failure so the chain is never broken.
|
|
76
|
+
def map_failure(error_code: nil, error_data: {}, error_title: nil, error_description: nil)
|
|
77
|
+
if block_given?
|
|
78
|
+
result = yield self
|
|
79
|
+
unless result.is_a?(Flowy::Failure)
|
|
80
|
+
raise TypeError,
|
|
81
|
+
"map_failure block must return a Flowy::Failure, got #{result.class}"
|
|
82
|
+
end
|
|
83
|
+
if result.parent_failure.nil?
|
|
84
|
+
result.class.new(
|
|
85
|
+
error_code: result.error_code,
|
|
86
|
+
error_data: result.error_data,
|
|
87
|
+
error_title: result.error_title,
|
|
88
|
+
error_description: result.error_description,
|
|
89
|
+
parent_failure: self
|
|
90
|
+
)
|
|
91
|
+
else
|
|
92
|
+
result
|
|
93
|
+
end
|
|
94
|
+
else
|
|
95
|
+
raise ArgumentError, 'map_failure requires either a block or error_code:' if error_code.nil?
|
|
96
|
+
|
|
97
|
+
self.class.new(
|
|
98
|
+
error_code: error_code,
|
|
99
|
+
error_data: error_data,
|
|
100
|
+
error_title: error_title,
|
|
101
|
+
error_description: error_description,
|
|
102
|
+
parent_failure: self
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def merge_data(extra = nil)
|
|
108
|
+
extra = block_given? ? yield(error_data) : extra
|
|
109
|
+
raise ArgumentError, 'merge_data requires a Hash' unless extra.is_a?(Hash)
|
|
110
|
+
|
|
111
|
+
self.class.new(
|
|
112
|
+
error_code: error_code,
|
|
113
|
+
error_data: Flowy::Result._deep_merge(error_data, extra),
|
|
114
|
+
error_title: error_title,
|
|
115
|
+
error_description: error_description,
|
|
116
|
+
parent_failure: parent_failure
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
module Flowy
|
|
2
|
+
class Pipeline
|
|
3
|
+
class BranchBuilder
|
|
4
|
+
attr_reader :_branches, :_otherwise
|
|
5
|
+
|
|
6
|
+
def initialize
|
|
7
|
+
@_branches = {}
|
|
8
|
+
@_otherwise = nil
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def when(key, &block)
|
|
12
|
+
@_branches[key] = block
|
|
13
|
+
self
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def otherwise(&block)
|
|
17
|
+
@_otherwise = block
|
|
18
|
+
self
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
private_constant :BranchBuilder
|
|
22
|
+
|
|
23
|
+
def initialize(steps: [])
|
|
24
|
+
@steps = steps.freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Two forms: block form (`step(:name) { |prev| ... }`) and symbolic form
|
|
28
|
+
# (`step(:name)` with no block, resolved against `context:` at call time).
|
|
29
|
+
def step(name, &callable)
|
|
30
|
+
new_step =
|
|
31
|
+
if callable
|
|
32
|
+
{ type: :step, name: name, callable: callable }
|
|
33
|
+
elsif name.is_a?(Symbol)
|
|
34
|
+
{ type: :step, name: name, symbolic: true }
|
|
35
|
+
else
|
|
36
|
+
raise ArgumentError, "step requires a block or a Symbol name"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
self.class.new(steps: @steps + [new_step.freeze])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def branch(on:, &builder_block)
|
|
43
|
+
raise ArgumentError, "branch requires a block" unless builder_block
|
|
44
|
+
|
|
45
|
+
builder = BranchBuilder.new
|
|
46
|
+
builder_block.call(builder)
|
|
47
|
+
|
|
48
|
+
new_step = {
|
|
49
|
+
type: :branch,
|
|
50
|
+
name: :"branch(#{on.is_a?(Symbol) ? on : 'λ'})",
|
|
51
|
+
on: on,
|
|
52
|
+
branches: builder._branches.transform_values(&:call).freeze,
|
|
53
|
+
otherwise: builder._otherwise&.call
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
self.class.new(steps: @steps + [new_step])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def tap_step(name, &callable)
|
|
60
|
+
raise ArgumentError, "tap_step requires a block" unless callable
|
|
61
|
+
|
|
62
|
+
new_step = { type: :tap_step, name: name, callable: callable }.freeze
|
|
63
|
+
self.class.new(steps: @steps + [new_step])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def >>(other)
|
|
67
|
+
raise TypeError, ">> requires a Flowy::Pipeline, got #{other.class}" unless other.is_a?(Flowy::Pipeline)
|
|
68
|
+
|
|
69
|
+
self.class.new(steps: @steps + other._raw_steps)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def call(starting_data: {}, rescue_errors: false, context: nil)
|
|
73
|
+
initial = Flowy::Result.success(data: starting_data)
|
|
74
|
+
|
|
75
|
+
@steps.reduce(initial) do |current, step_def|
|
|
76
|
+
break current if current.failure?
|
|
77
|
+
|
|
78
|
+
execute_step(step_def, current, rescue_errors: rescue_errors, context: context)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def steps
|
|
83
|
+
@steps.map do |s|
|
|
84
|
+
case s[:type]
|
|
85
|
+
when :branch
|
|
86
|
+
{
|
|
87
|
+
type: :branch,
|
|
88
|
+
name: s[:name],
|
|
89
|
+
on: s[:on],
|
|
90
|
+
branches: s[:branches].transform_values { |sub| sub.is_a?(Flowy::Pipeline) ? sub.steps : sub },
|
|
91
|
+
otherwise: s[:otherwise].is_a?(Flowy::Pipeline) ? s[:otherwise].steps : s[:otherwise]
|
|
92
|
+
}
|
|
93
|
+
else
|
|
94
|
+
{ type: s[:type], name: s[:name] }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def size
|
|
100
|
+
@steps.size
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def empty?
|
|
104
|
+
@steps.empty?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Exposed to support composition via #>>.
|
|
108
|
+
def _raw_steps
|
|
109
|
+
@steps
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
protected :_raw_steps
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def execute_step(step_def, previous_result, rescue_errors:, context:)
|
|
117
|
+
case step_def[:type]
|
|
118
|
+
when :step
|
|
119
|
+
invoke_callable(step_def, previous_result, rescue_errors: rescue_errors, context: context)
|
|
120
|
+
when :tap_step
|
|
121
|
+
invoke_callable(step_def, previous_result, rescue_errors: rescue_errors, context: context, must_return_flowy_result: false)
|
|
122
|
+
previous_result
|
|
123
|
+
when :branch
|
|
124
|
+
execute_branch(step_def, previous_result, rescue_errors: rescue_errors, context: context)
|
|
125
|
+
else
|
|
126
|
+
raise ArgumentError, "Unknown step type: #{step_def[:type]}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# must_return_flowy_result: set to false by tap_step, whose return is
|
|
131
|
+
# discarded and therefore should not be type-checked.
|
|
132
|
+
def invoke_callable(step_def, previous_result, rescue_errors:, context:, must_return_flowy_result: true)
|
|
133
|
+
result =
|
|
134
|
+
if step_def[:symbolic]
|
|
135
|
+
unless context
|
|
136
|
+
raise ArgumentError,
|
|
137
|
+
"symbolic step :#{step_def[:name]} requires a `context:` to be passed to #call"
|
|
138
|
+
end
|
|
139
|
+
context.send(step_def[:name], previous_result: previous_result)
|
|
140
|
+
else
|
|
141
|
+
callable = step_def[:callable]
|
|
142
|
+
context ? callable.call(previous_result, context) : callable.call(previous_result)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
if must_return_flowy_result && !result.is_a?(Flowy::Result)
|
|
146
|
+
raise TypeError,
|
|
147
|
+
"Step '#{step_def[:name]}' must return a Flowy::Success or Flowy::Failure, got #{result.class}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
result
|
|
151
|
+
rescue StandardError => e
|
|
152
|
+
raise unless rescue_errors
|
|
153
|
+
|
|
154
|
+
Flowy::Failure.new(
|
|
155
|
+
error_code: :step_raised_error,
|
|
156
|
+
error_data: { step: step_def[:name], message: e.message }
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def execute_branch(step_def, previous_result, rescue_errors:, context:)
|
|
161
|
+
key = resolve_branch_key(step_def[:on], previous_result.data)
|
|
162
|
+
|
|
163
|
+
sub_pipeline = step_def[:branches][key] || step_def[:otherwise]
|
|
164
|
+
|
|
165
|
+
unless sub_pipeline
|
|
166
|
+
return Flowy::Failure.new(
|
|
167
|
+
error_code: :unmatched_branch,
|
|
168
|
+
error_data: { branch: step_def[:name], key: key }
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
unless sub_pipeline.is_a?(Flowy::Pipeline)
|
|
173
|
+
raise TypeError,
|
|
174
|
+
"Branch '#{step_def[:name]}' value for key #{key.inspect} must be a Flowy::Pipeline, got #{sub_pipeline.class}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
sub_pipeline.call(
|
|
178
|
+
starting_data: previous_result.data,
|
|
179
|
+
rescue_errors: rescue_errors,
|
|
180
|
+
context: context
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def resolve_branch_key(on, data)
|
|
185
|
+
if on.is_a?(Symbol)
|
|
186
|
+
data[on]
|
|
187
|
+
elsif on.respond_to?(:call)
|
|
188
|
+
on.call(data)
|
|
189
|
+
else
|
|
190
|
+
raise ArgumentError, "branch `on:` must be a Symbol or callable, got #{on.class}"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|