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.
@@ -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
@@ -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