flow_organizer 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c9e30234c35b2d539513015dc954803f4c3d09ac52c7ddcbf0c38a281f368e9c
4
+ data.tar.gz: 734b2178425821c8979e7b386a35a0e562cf204f1ab00a1fd33b693812683ddf
5
+ SHA512:
6
+ metadata.gz: 1b82678836b3adb059630cb315f7b3af8442e6c5a5386783076aff91b3d1f2cd9b0a758e57ae44f748ff6ca46702b53711972b337df59c9447cf797da892aeb1
7
+ data.tar.gz: b9cd76c5d0c4b7ce35bf3d064a8913dae32d0821c630a12048969cc8332bdefdc5a4adec8c952d22d047cf6a91f3a11d0e75d09279cbce977b6217a951172971
data/CHANGELOG.md ADDED
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
+ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.0] — 2026-06-27
9
+
10
+ Initial release.
11
+
12
+ ### Added
13
+
14
+ - Sequential callable dispatch with a shared context hash threaded through each
15
+ step (`FlowOrganizer.call(list:, ctx:)`).
16
+ - Result-tuple contract: `[:ok]`, `[:halt]`, `[:error]`, each with an optional
17
+ context update hash.
18
+ - Dual-track execution: when a step on `list:` errors, execution switches to
19
+ `list_error:` (Railway-style compensation track).
20
+ - Pluggable callable resolvers:
21
+ - Raw callables (anything responding to `#call`).
22
+ - `FlowOrganizer::Callable::Alias` — register a callable under a symbol and
23
+ reference it as `[:alias, :name]` in a list.
24
+ - `FlowOrganizer::Callable::Branch` — pick a sub-pipeline based on the value
25
+ returned by a branch-selector callable.
26
+ - Parameter introspection cache on `FlowOrganizer::Context` — slice spec is
27
+ computed once per callable, then reused. ~1.8× faster dispatch on warm
28
+ pipelines. Clearable via `clear_parameter_cache` for reloading.
29
+ - Configurable exception reporter: assign a callback to
30
+ `FlowOrganizer.exception_reporter` to forward caught exceptions to
31
+ Sentry/Honeybadger/etc.
32
+ - Forgiving `sanitize_errors` shorthand: callables may return errors as
33
+ `String`, `Hash`, or `Array`, and they are normalized to
34
+ `{ errors: [{ title: ... }] }` shape.
35
+
36
+ [1.0.0]: https://github.com/nathan-appere/ruby-flow-organizer/releases/tag/v1.0.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nathan Appere
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # FlowOrganizer
2
+
3
+ Run a list of callables in sequence, threading a context hash through each step. A small,
4
+ allocation-conscious "functional interactor" / Railway-style pipeline.
5
+
6
+ ```ruby
7
+ FlowOrganizer.call(
8
+ list: [
9
+ ->(email:) { [:ok, normalized_email: email.downcase.strip] },
10
+ ->(normalized_email:) { [:ok, user: User.find_by(email: normalized_email)] },
11
+ ->(user:) { user ? [:ok] : [:error, code: 'user.not_found'] },
12
+ ->(user:) { [:ok, token: Auth.issue_token(user)] },
13
+ ],
14
+ ctx: { email: ' Alice@Example.com ' },
15
+ )
16
+ # => [:ok, { email: ..., normalized_email: ..., user: ..., token: ... }]
17
+ ```
18
+
19
+ ## Why
20
+
21
+ The pattern is known under many names: Pipes and Filters, Pipeline, Railway-Oriented
22
+ Programming, functional interactor.
23
+
24
+ The core idea: a list of small operations passes a shared context forward, any one of them can stop the chain by returning an error, and the caller gets back a single `[status, ctx]` tuple.
25
+
26
+ Comparable Ruby gems include
27
+ [`interactor`](https://github.com/collectiveidea/interactor),
28
+ [`light-service`](https://github.com/adomokos/light-service), and
29
+ [`dry-transaction`](https://dry-rb.org/gems/dry-transaction/).
30
+
31
+ FlowOrganizer trades their OOP approach & custom DSL for a `functional` approach: any callable can be used (lambda, method, or `#call`-responding objects). No need to have every step be a new Class.
32
+
33
+ ## Installation
34
+
35
+ Requires Ruby 3.2+.
36
+
37
+ ```ruby
38
+ # Gemfile
39
+ gem 'flow_organizer'
40
+ ```
41
+
42
+ ```sh
43
+ bundle install
44
+ # or
45
+ gem install flow_organizer
46
+ ```
47
+
48
+ ## The callable contract
49
+
50
+ Every callable must return one of:
51
+
52
+ | Return | Meaning |
53
+ | -------------------------- | ---------------------------------------------------- |
54
+ | `[:ok]` | Success, no context update. |
55
+ | `[:ok, { key: value }]` | Success, merge the hash into the context. |
56
+ | `[:halt]` | Stop the chain. The caller still sees a final `:ok`. |
57
+ | `[:halt, { key: value }]` | Same as above, with a final context update. |
58
+ | `[:error]` | Stop the chain. Caller sees `:error`. |
59
+ | `[:error, errors_payload]` | As above, with a normalized `errors:` array in ctx. |
60
+
61
+ For `:error`, the payload can be a string, a hash with `:title`/`:detail`/`:code`, an array of either, or already a `{ errors: [...] }` hash.
62
+ FlowOrganizer normalizes them all to the canonical `{ errors: [{ title: ... }, ...] }` shape.
63
+
64
+ Callables only receive the context keys their kwargs declare:
65
+
66
+ ```ruby
67
+ ->(value:) { ... } # gets { value: ... }
68
+ ->(value:, other:) { ... } # gets { value: ..., other: ... }
69
+ ->(**) { ... } # gets the full ctx
70
+ ->() { ... } # gets nothing
71
+ ```
72
+
73
+ Positional arguments are not supported: this is what lets it cheaply slice the context per step.
74
+
75
+ ## Dual-track execution
76
+
77
+ Pass `list_error:` to run a compensation track if the success track fails:
78
+
79
+ ```ruby
80
+ FlowOrganizer.call(
81
+ list: [book_flight, charge_card, send_confirmation],
82
+ list_error: [refund_card, send_apology],
83
+ ctx: { user: user, flight: flight },
84
+ )
85
+ ```
86
+
87
+ If `charge_card` returns `[:error]`, execution jumps to `refund_card` with the accumulated context.
88
+
89
+ ## Resolvers
90
+
91
+ Anything responding to `#call` works directly. For more structured lists, two extras ship in the box.
92
+
93
+ ## Exception handling
94
+
95
+ A callable that raises is caught and turned into `[:error, { error: <exception> }]`.
96
+ Two knobs change the default:
97
+
98
+ ```ruby
99
+ # Re-raise instead of catching.
100
+ FlowOrganizer.call(list: [...], ctx: { ... }, raise_exception: true)
101
+
102
+ # Forward caught exceptions to your error reporter.
103
+ FlowOrganizer.exception_reporter = ->(exception:) { Sentry.capture_exception(exception) }
104
+ ```
105
+
106
+ ## Performance
107
+
108
+ The included benchmark compares plain chained lambda calls against `FlowOrganizer.call`
109
+ with a 5-step pipeline, 20 000 iterations:
110
+
111
+ ```
112
+ total per-call ratio vs baseline
113
+ plain chained calls 16.05 ms 0.80 µs 1.00x (baseline)
114
+ flow_organizer (cached) 78.42 ms 3.92 µs 4.87x +388%
115
+ flow_organizer (uncached) 158.92 ms 7.95 µs 9.90x +890%
116
+ ```
117
+
118
+ Run it locally:
119
+
120
+ ```sh
121
+ ruby benchmark/flow_organizer_overhead.rb
122
+ ```
123
+
124
+ The parameter-introspection cache is on by default and warms automatically. Call `FlowOrganizer::Context.clear_parameter_cache` if you reload code in development and want to drop stale `Method` objects.
125
+
126
+ ## License
127
+
128
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+ # Allows registration of a callable in a local store with an alias name.
3
+ # It can be used later by using the alias.
4
+ #
5
+ # ## Registering the alias
6
+ #
7
+ # ```ruby
8
+ # callable = ->(counter:) { [:ok, counter: counter + 1]}
9
+ # FlowOrganizer::Callable::Alias.register(id: :increment_counter, target: callable)
10
+ # ```
11
+ #
12
+ # ## Using the alias
13
+ #
14
+ # ```ruby
15
+ # FlowOrganizer::FlowOrganizer.call(
16
+ # list: [
17
+ # [:alias, :increment_counter],
18
+ # ],
19
+ # ctx: { counter: 1 },
20
+ # )
21
+ # ```
22
+ module FlowOrganizer::Callable::Alias
23
+
24
+ # Resolves `alias_name` to the registered `:callable`
25
+ # @note The expected format is `[:alias, alias_name]`.
26
+ def self.resolve(args:, store: nil)
27
+ _, alias_name = args
28
+ store ||= local_store
29
+
30
+ [:ok, callable: get(id: alias_name, store:)]
31
+ end
32
+
33
+ # Saves a `:target` in the alias store, identified by `:id`
34
+ def self.register(id:, target:, store: nil)
35
+ store ||= local_store
36
+
37
+ store[id.to_sym] = {
38
+ target:,
39
+ }
40
+ end
41
+
42
+ # Get the alias identified with `:id`
43
+ def self.get(id:, store: nil)
44
+ store ||= local_store
45
+ record = store[id.to_sym]
46
+
47
+ if !record
48
+ raise "FlowOrganizer::Callable::Alias | unknown alias `#{ id }`"
49
+ end
50
+
51
+ record[:target]
52
+ end
53
+
54
+ # Create a store to save organizer's aliases
55
+ def self.create_store
56
+ {}
57
+ end
58
+
59
+ # Default store when not specified as a parameter
60
+ def self.local_store
61
+ @local_store ||= create_store
62
+ end
63
+
64
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+ # Call the branch which name is returned by `branch_callable`.
3
+ #
4
+ # ## Example
5
+ #
6
+ # ```ruby
7
+ # FlowOrganizer.call(
8
+ # list: [
9
+ # [:branch, ->(number:) { number.even? ? :even : :odd }, {
10
+ # even: [
11
+ # ->() { some_action },
12
+ # ],
13
+ # odd: [
14
+ # ->() { some_other_action },
15
+ # ],
16
+ # },],
17
+ # ],
18
+ # ctx: { number: rand(1...10) },
19
+ # )
20
+ # ```
21
+ module FlowOrganizer::Callable::Branch
22
+
23
+ # The expected format for `:args` is `[:branch, branch_callable, branch_list]`
24
+ def self.resolve(args:)
25
+ _, callable, branches = args
26
+
27
+ wrapped_callable = ->(**arguments) do
28
+ ctx_in = arguments || {}
29
+
30
+ branch_name, _ctx_out = FlowOrganizer.call(
31
+ list: [callable],
32
+ ctx: ctx_in,
33
+ )
34
+
35
+ status = :ok
36
+ ctx_out = {}
37
+ list = branches[branch_name]
38
+
39
+ if list && !list.empty?
40
+ status, ctx_out = FlowOrganizer.call(
41
+ list: branches[branch_name],
42
+ ctx: ctx_in,
43
+ )
44
+
45
+ ctx_out = (ctx_out) ? FlowOrganizer::Context.update_context(ctx: ctx_in, local_ctx: ctx_out) : ctx_in
46
+ end
47
+
48
+ [status, ctx_out]
49
+ end
50
+
51
+ [:ok, callable: wrapped_callable]
52
+ end
53
+
54
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+ # This module makes FlowOrganizer extensible. You can add your own behaviours to resolve callables.
3
+ module FlowOrganizer::Callable
4
+
5
+ # Local store to add custom behaviours.
6
+ def self.store
7
+ @store ||= {
8
+ alias: FlowOrganizer::Callable::Alias.method(:resolve),
9
+ branch: FlowOrganizer::Callable::Branch.method(:resolve),
10
+ }
11
+ end
12
+
13
+ # Transform each target to a callable. This is delegated to submodules registered in the local `store`.
14
+ # @param target A callable or some data that can be transformed to a callable
15
+ # @return A callable
16
+ def self.resolve(target:)
17
+ if target.respond_to?(:call)
18
+ [:ok, callable: target]
19
+ else
20
+ type = target[0]
21
+ if store[type]
22
+ store[type].call(args: target)
23
+ else
24
+ [:error, "FlowOrganizer could not resolve callable type `#{ type }`"]
25
+ end
26
+ end
27
+ end
28
+
29
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+ # Handle context operations.
3
+ module FlowOrganizer::Context
4
+
5
+ # Performs a 1 level deep merge on the organizer context.
6
+ # @api private
7
+ # @note The content of the `:errors` key is merged manually.
8
+ def self.update_context(ctx:, local_ctx:)
9
+ local_ctx ||= {}
10
+ ctx_errors = ctx[:errors]
11
+
12
+ # NOTE: 1 level deep only (not a deep merge)
13
+ ctx = ctx.merge(local_ctx)
14
+
15
+ if ctx_errors
16
+ ctx[:errors] = ctx_errors + (local_ctx[:errors] || [])
17
+ end
18
+
19
+ ctx
20
+ end
21
+
22
+ # In-place variant of `update_context`. The caller must already own `ctx`
23
+ # (e.g. it was `dup`ed at the top of `FlowOrganizer.call`). Used on the dispatch
24
+ # hot path to avoid allocating a fresh hash per callable.
25
+ # @api private
26
+ def self.update_context!(ctx:, local_ctx:)
27
+ return ctx if local_ctx.nil? || local_ctx.empty?
28
+
29
+ ctx_errors = ctx[:errors]
30
+ local_errors = local_ctx[:errors]
31
+
32
+ ctx.merge!(local_ctx)
33
+
34
+ if ctx_errors
35
+ ctx[:errors] = ctx_errors + (local_errors || [])
36
+ end
37
+
38
+ ctx
39
+ end
40
+
41
+ # Extract needed key from the organizer context to send them to the callable.
42
+ # @api private
43
+ # @note This id done by using introspection on the callable.
44
+ # @example
45
+ # generate_callable_ctx(callable: ->(a:), { a: 1, b: 2, c: 3 }) => { a: 1 }
46
+ # generate_callable_ctx(callable: ->(a:, **), { a: 1, b: 2, c: 3 }) => { a: 1, b: 2, c: 3 }
47
+ def self.generate_callable_ctx(callable:, ctx:)
48
+ spec = parameter_cache[callable] ||= compute_parameter_spec(callable:)
49
+
50
+ case spec
51
+ when :keyrest
52
+ ctx.dup
53
+ when :none
54
+ nil
55
+ else
56
+ ctx.slice(*spec)
57
+ end
58
+ end
59
+
60
+ # Given a `callable`, get its `parameters` data
61
+ def self.get_parameters(callable:)
62
+ if callable.is_a?(Proc) || callable.is_a?(Method)
63
+ callable.parameters
64
+ elsif callable.respond_to?(:call)
65
+ callable.method(:call).parameters
66
+ else
67
+ # NOTE: this should not be callable :)
68
+ raise FlowOrganizer::Error.new("Unsupported callable #{ callable.class } `#{ callable }`")
69
+ end
70
+ end
71
+
72
+ # Compute the slice spec for a callable from its keyword parameter list.
73
+ #
74
+ # Returns one of:
75
+ # - `:keyrest` the callable accepts `**`, pass the full ctx
76
+ # - `:none` the callable accepts nothing, pass nil
77
+ # - `Array<Symbol>` the kw arg names to slice from the ctx
78
+ #
79
+ # @api private
80
+ def self.compute_parameter_spec(callable:)
81
+ parameters = get_parameters(callable:)
82
+ by_type = parameters.group_by { |el| el[0] }
83
+
84
+ if (by_type.keys - [:keyreq, :key, :keyrest]).size > 0
85
+ raise FlowOrganizer::Error.new('FlowOrganizer only supports methods with keyword arguments')
86
+ end
87
+
88
+ if by_type[:keyrest]
89
+ :keyrest
90
+ elsif by_type[:keyreq] || by_type[:key]
91
+ ((by_type[:keyreq] || []).map { |el| el[1] }) + ((by_type[:key] || []).map { |el| el[1] })
92
+ else
93
+ :none
94
+ end
95
+ end
96
+
97
+ # Memoized `callable => slice_spec` map. Method/Proc objects with identical
98
+ # signatures hash and compare equal, so they collapse to one cache entry.
99
+ #
100
+ # @api private
101
+ def self.parameter_cache
102
+ @parameter_cache ||= {}
103
+ end
104
+
105
+ # Drops every cached parameter spec. Useful for tests, benchmarks, and code
106
+ # reloading scenarios where stale Method objects should not be retained.
107
+ def self.clear_parameter_cache
108
+ @parameter_cache = {}
109
+ end
110
+
111
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ class FlowOrganizer::Error < StandardError
4
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlowOrganizer
4
+ VERSION = '1.0.0'
5
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ # FlowOrganizer passes the result of a callable to another callable (as long as the result is successfull).
4
+ #
5
+ # It is mostly useful when you need to execute a series of operations ressembling a pipeline.
6
+ #
7
+ # You might alredy be familiar with some solutions that deal with this (Promises, Railway Programming, Pipe operators):
8
+ # `FlowOrganizer` is a flavor of functional interactor.
9
+ #
10
+ # ### Introduction
11
+ # Describing a list of operations often leads to code that is difficult to follow or nested requires a lot of nesting. For instance:
12
+ # ```ruby
13
+ # fire_user_created_event(persist_user(validate_password(validate_email({ email: email, password: password }))))
14
+ #
15
+ # # or
16
+ #
17
+ # valid_email? = validate_email(email)
18
+ # valid_password? = validate_password(password)
19
+ # if valid_email? && valid_password?
20
+ # user = persist_user(email: email, password: password)
21
+ # if user
22
+ # fire_user_created_event(user: user)
23
+ # end
24
+ # end
25
+ # ```
26
+ #
27
+ # With `FlowOrganizer`, this is expressed as:
28
+ # ```ruby
29
+ # FlowOrganizer.call(
30
+ # list: [
31
+ # [:alias, :validate_email],
32
+ # [:alias, :validate_password],
33
+ # [:alias, :persist_user],
34
+ # [:alias, :fire_user_created_event],
35
+ # ],
36
+ # ctx: {
37
+ # email: '',
38
+ # password: '',
39
+ # },
40
+ # )
41
+ # ```
42
+ #
43
+ # #### Context
44
+ # An organizer uses a context. The context contains everything the set of operations need to work.
45
+ # When an operation is called, it can affect the context.
46
+ #
47
+ # #### Callable
48
+ # A callable is expected to return a result tupple of the following format:
49
+ # ```ruby
50
+ # [:ok] || [:ok, context_update] || [:halt] || [:halt, context_update] || [:error] || [:error, context_update]
51
+ # ```
52
+ module FlowOrganizer
53
+
54
+ class << self
55
+ # Optional callback invoked when a callable raises. Signature: `->(exception:) { ... }`.
56
+ # Wire this to Sentry/Honeybadger/etc. The default is no-op.
57
+ attr_accessor :exception_reporter
58
+ end
59
+
60
+ # Run a `list` of `operations` (callables) in order.
61
+ #
62
+ # Each results update the initial `ctx` which is then sent to the next operation.
63
+ #
64
+ # An `operation` needs to be a callable, but it can be resolved from other format (see `#to_callable`)
65
+ #
66
+ # NOTE: Every operation is expected to return a tupple of the format `[:ok]` or `[:error]` with an optional context update (`[:ok, { new_ctx_key: 'value' }]`, `[:errors, { errors: [{ detail: 'Error explaination' }], }]`). If an `:error` tupple is returned, the next operations are canceled and `call` will return.
67
+ #
68
+ # @param list An array of operations (callables) that will be called in order
69
+ # @param ctx A hash containing values to send to the operations (callables). It will be updated after every operation.
70
+ # @return The updated context.
71
+ def self.call(list:, list_error: nil, ctx: nil, raise_exception: false)
72
+ ctx = (ctx || {}).dup
73
+ list_error = [] unless list_error.is_a?(Array)
74
+
75
+ status, ctx = call_list(list, :ok, ctx, raise_exception)
76
+
77
+ # If there is an error on the "success" track (list), switch to the "error" track, (list_error)
78
+ if status == :error
79
+ status, ctx = call_list(list_error, status, ctx, raise_exception)
80
+ end
81
+
82
+ status = :ok if status == :halt
83
+
84
+ [status, ctx]
85
+ end
86
+
87
+ def self.call_list(list, status, ctx, raise_exception)
88
+ list.each do |el|
89
+ # Inline fast path for raw callables — skip the resolver allocation.
90
+ if el.respond_to?(:call)
91
+ callable = el
92
+ else
93
+ _, resolved = FlowOrganizer::Callable.resolve(target: el)
94
+ callable = resolved[:callable]
95
+ end
96
+
97
+ # Generate arguments compatible with what the callable expects
98
+ local_ctx = FlowOrganizer::Context.generate_callable_ctx(callable: callable, ctx: ctx)
99
+
100
+ # Skip the empty `**` splat when the callable takes no kwargs.
101
+ result = local_ctx.nil? ? callable.call : callable.call(**local_ctx)
102
+ status, local_ctx = result
103
+
104
+ # `sanitize_errors` is a no-op on `:ok` — only call when it might do work.
105
+ if status == :error
106
+ result = sanitize_errors(result)
107
+ _, local_ctx = result
108
+ end
109
+
110
+ # Mutate ctx in place (we own it — `call` dup'd it) and skip when nothing to merge.
111
+ if local_ctx && !local_ctx.empty?
112
+ FlowOrganizer::Context.update_context!(ctx: ctx, local_ctx: local_ctx)
113
+ end
114
+
115
+ # Stop execution status is not `:ok`
116
+ break if status == :error || status == :halt
117
+ rescue StandardError => e
118
+ status = :error
119
+ ctx[:error] = e
120
+
121
+ if raise_exception
122
+ raise e
123
+ else
124
+ exception_reporter&.call(exception: e)
125
+ end
126
+
127
+ break
128
+ end
129
+
130
+ [status, ctx]
131
+ end
132
+
133
+ # Sanitizes returned errors, if any.
134
+ #
135
+ # @api private
136
+ # @note Enable simpler error return format from an organized callable.
137
+ #
138
+ # @example Returning an error as a string
139
+ # sanitize_result([:error, 'Error details']) => [:error, { errors: [{ detail: 'Error details' }] }]
140
+ # @example Returning an error as hash with detail
141
+ # sanitize_result([:error, { detail: 'Error details' }]) => [:error, { errors: [{ detail: 'Error details' }] }]
142
+ # @example Returning an array of errors in the two former formats
143
+ # sanitize_result([:error, ['Error1 detail', { detail: 'Error2 details' }]) => [:error, { errors: [{ detail: 'Error1 details' }, { detail: 'Error2 details' }] }]
144
+ def self.sanitize_errors(result)
145
+ status, ctx = result
146
+
147
+ return result if status != :error
148
+
149
+ case ctx
150
+ when String
151
+ ctx = { errors: [{ title: ctx }] }
152
+ when Hash
153
+ if !ctx[:errors]
154
+ if ctx[:title] || ctx[:detail] || ctx[:code]
155
+ ctx = { errors: [ctx] }
156
+ elsif ctx[:error]
157
+ ctx = { errors: [ctx[:error]] }
158
+ end
159
+ end
160
+ when Array
161
+ ctx = { errors: ctx.map { |el| (el.is_a?(String)) ? { title: el } : el } }
162
+ end
163
+
164
+ [status, ctx]
165
+ end
166
+
167
+ end
168
+
169
+ require_relative 'flow_organizer/version'
170
+ require_relative 'flow_organizer/error'
171
+ require_relative 'flow_organizer/context'
172
+ require_relative 'flow_organizer/callable'
173
+ require_relative 'flow_organizer/callable/alias'
174
+ require_relative 'flow_organizer/callable/branch'
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: flow_organizer
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Nathan Appere
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rake
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '13.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '13.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.13'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.13'
40
+ description: |
41
+ FlowOrganizer runs a list of callables (lambdas, methods, anything responding to #call)
42
+ in sequence, threading a context hash through each step. Inspired by Railway
43
+ Programming / Promise chains / functional interactors.
44
+ email:
45
+ - nathan.appere@gmail.com
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - CHANGELOG.md
51
+ - LICENSE
52
+ - README.md
53
+ - lib/flow_organizer.rb
54
+ - lib/flow_organizer/callable.rb
55
+ - lib/flow_organizer/callable/alias.rb
56
+ - lib/flow_organizer/callable/branch.rb
57
+ - lib/flow_organizer/context.rb
58
+ - lib/flow_organizer/error.rb
59
+ - lib/flow_organizer/version.rb
60
+ homepage: https://github.com/nathan-appere/ruby-flow-organizer
61
+ licenses:
62
+ - MIT
63
+ metadata:
64
+ source_code_uri: https://github.com/nathan-appere/ruby-flow-organizer
65
+ bug_tracker_uri: https://github.com/nathan-appere/ruby-flow-organizer/issues
66
+ changelog_uri: https://github.com/nathan-appere/ruby-flow-organizer/blob/main/CHANGELOG.md
67
+ rubygems_mfa_required: 'true'
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '3.2'
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 4.0.15
83
+ specification_version: 4
84
+ summary: 'Functional interactor: run a list of callables in a pipeline with shared
85
+ context.'
86
+ test_files: []