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 +7 -0
- data/CHANGELOG.md +36 -0
- data/LICENSE +21 -0
- data/README.md +128 -0
- data/lib/flow_organizer/callable/alias.rb +64 -0
- data/lib/flow_organizer/callable/branch.rb +54 -0
- data/lib/flow_organizer/callable.rb +29 -0
- data/lib/flow_organizer/context.rb +111 -0
- data/lib/flow_organizer/error.rb +4 -0
- data/lib/flow_organizer/version.rb +5 -0
- data/lib/flow_organizer.rb +174 -0
- metadata +86 -0
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,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: []
|