axn 0.1.0.pre.alpha.2.6.1 → 0.1.0.pre.alpha.2.7
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 +4 -4
- data/CHANGELOG.md +10 -2
- data/docs/reference/class.md +135 -22
- data/lib/action/core/context/facade.rb +0 -30
- data/lib/action/core/context/internal.rb +8 -2
- data/lib/action/core/flow/callbacks.rb +25 -25
- data/lib/action/core/flow/exception_execution.rb +5 -19
- data/lib/action/core/flow/handlers/base_handler.rb +32 -0
- data/lib/action/core/flow/handlers/callback_handler.rb +21 -0
- data/lib/action/core/flow/handlers/invoker.rb +73 -0
- data/lib/action/core/flow/handlers/matcher.rb +85 -0
- data/lib/action/core/flow/handlers/message_handler.rb +27 -0
- data/lib/action/core/flow/handlers/registry.rb +40 -0
- data/lib/action/core/flow/handlers.rb +17 -0
- data/lib/action/core/flow/messages.rb +39 -25
- data/lib/action/result.rb +24 -5
- data/lib/axn/factory.rb +15 -7
- data/lib/axn/version.rb +1 -1
- metadata +9 -3
- data/lib/action/core/event_handlers.rb +0 -62
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b128603df1eec45048f8656f01508a2e9a706e9acd92ce74813cac0bb038f550
|
4
|
+
data.tar.gz: cfc86ffaadab4cc758546eab39992de2d7f3c89547007a2d88643b303d0edd4b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2767343c37a0612a446482b52320d2ab85009af9e4094fffa0be31577bdf4a67e228f08307e31c0d182dbd3b944881f10af3c7e80d19a7d6c6f6f08a33294417
|
7
|
+
data.tar.gz: fb5d3942ecbeb2e52f4f4376b5d1b90db696470704d39d1b2db55e6880956073f12f0213a13ad8a564ee2048ad76350bebb811ff90db125bf9724d2d31d3167a
|
data/CHANGELOG.md
CHANGED
@@ -1,7 +1,15 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
-
##
|
4
|
-
*
|
3
|
+
## 0.1.0-alpha.2.7
|
4
|
+
* [BREAKING] Replaced `messages` declaration with separate `success` and `error` calls
|
5
|
+
* [BREAKING] Removed `rescues` method (use `error_from` for custom error messages; all exceptions now report to `on_exception` handlers)
|
6
|
+
* [BREAKING] Replaced `error_from` with an optional `if:` argument on `error`
|
7
|
+
* [FEAT] Implemented conditional success message filtering as well
|
8
|
+
* [FEAT] Added block support for `error` and `success`
|
9
|
+
* [FEAT] `if:` now supports symbol predicates referencing instance methods (arity 0, 1, or keyword `exception:`). If the method accepts `exception:` it is passed as a keyword; else if it accepts one positional arg, it is passed positionally; otherwise it is called with no args. If the method is missing, the symbol falls back to constant lookup (e.g., `:ArgumentError`).
|
10
|
+
* [FEAT] `success`/`error` and callbacks now accept symbol method names (e.g., `success :local_method`). Handlers can receive the exception via keyword (`exception:`) or single positional argument; otherwise they are called with no args.
|
11
|
+
* [BREAKING] Updated callback methods (`on_success`, `on_error`, `on_failure`, `on_exception`) to use consistent `if:` interface (matching messages)
|
12
|
+
* [FEAT] Added `unless:` support to both `success`/`error` messages and callbacks (`on_success`, `on_error`, `on_failure`, `on_exception`)
|
5
13
|
|
6
14
|
## 0.1.0-alpha.2.6.1
|
7
15
|
* [FEAT] Added `elapsed_time` and `outcome` methods to `Action::Result`
|
data/docs/reference/class.md
CHANGED
@@ -85,48 +85,148 @@ expects :date, type: Date, preprocess: ->(d) { d.is_a?(Date) ? d : Date.parse(d)
|
|
85
85
|
|
86
86
|
will succeed if given _either_ an actual Date object _or_ a string that Date.parse can convert into one. If the preprocess callable raises an exception, that'll be swallowed and the action failed.
|
87
87
|
|
88
|
-
## `.
|
88
|
+
## `.success` and `.error`
|
89
89
|
|
90
|
-
The `
|
90
|
+
The `success` and `error` declarations allow you to customize the `error` and `success` messages on the returned result.
|
91
91
|
|
92
|
-
|
92
|
+
Both methods accept a string (returned directly), a symbol (resolved as a local instance method on the action), or a block (evaluated in the action's context, so can access instance methods and variables).
|
93
93
|
|
94
|
-
|
94
|
+
When an exception is available (e.g., during `error`), handlers can receive it in either of two equivalent ways:
|
95
|
+
- Keyword form: accept `exception:` and it will be passed as a keyword
|
96
|
+
- Positional form: if the handler accepts a single positional argument, it will be passed positionally
|
97
|
+
|
98
|
+
This applies to both blocks and symbol-backed instance methods. Choose the style that best fits your codebase (clarity vs concision).
|
99
|
+
|
100
|
+
In callables and symbol-backed methods, you can access:
|
95
101
|
- **Input data**: Use field names directly (e.g., `name`)
|
96
102
|
- **Output data**: Use `result.field` pattern (e.g., `result.greeting`)
|
97
103
|
- **Instance methods and variables**: Direct access
|
98
104
|
|
99
105
|
```ruby
|
100
|
-
|
101
|
-
|
106
|
+
success { "Hello #{name}, your greeting: #{result.greeting}" }
|
107
|
+
error { |e| "Bad news: #{e.message}" }
|
108
|
+
error { |exception:| "Bad news: #{exception.message}" }
|
109
|
+
|
110
|
+
# Using symbol method names
|
111
|
+
success :build_success_message
|
112
|
+
error :build_error_message
|
113
|
+
|
114
|
+
def build_success_message
|
115
|
+
"Hello #{name}, your greeting: #{result.greeting}"
|
116
|
+
end
|
117
|
+
|
118
|
+
def build_error_message(e)
|
119
|
+
"Bad news: #{e.message}"
|
120
|
+
end
|
121
|
+
|
122
|
+
def build_error_message(exception:)
|
123
|
+
"Bad news: #{exception.message}"
|
124
|
+
end
|
102
125
|
```
|
103
126
|
|
104
|
-
##
|
127
|
+
## Conditional messages
|
105
128
|
|
106
|
-
While `.
|
129
|
+
While `.error` and `.success` set the default messages, you can register conditional messages using an optional `if:` or `unless:` matcher. The matcher can be:
|
107
130
|
|
108
|
-
|
131
|
+
- an exception class (e.g., `ArgumentError`)
|
132
|
+
- a class name string (e.g., `"Action::InboundValidationError"`)
|
133
|
+
- a symbol referencing a local instance method predicate (arity 0 or 1, or keyword `exception:`), e.g. `:bad_input?`
|
134
|
+
- a callable (arity 0 or 1, or keyword `exception:`)
|
109
135
|
|
110
|
-
|
136
|
+
Symbols are resolved as methods on the action instance. If the method accepts `exception:` it will be passed as a keyword; otherwise, if it accepts one positional argument, the raised exception is passed positionally; otherwise it is called with no arguments. If the action does not respond to the symbol, we fall back to constant lookup (e.g., `if: :ArgumentError` behaves like `if: ArgumentError`). Symbols are also supported for the message itself (e.g., `success :method_name`), resolved via the same rules.
|
111
137
|
|
112
138
|
```ruby
|
113
|
-
|
139
|
+
error "bad"
|
140
|
+
|
141
|
+
# Custom message with exception class matcher
|
142
|
+
error "Invalid params provided", if: ActiveRecord::InvalidRecord
|
143
|
+
|
144
|
+
# Custom message with callable matcher and message
|
145
|
+
error(if: ArgumentError) { |e| "Argument error: #{e.message}" }
|
146
|
+
error(if: -> { name == "bad" }) { "Bad input #{name}, result: #{result.status}" }
|
147
|
+
|
148
|
+
# Custom message with symbol predicate (arity 0)
|
149
|
+
error "Transient error, please retry", if: :transient_error?
|
150
|
+
|
151
|
+
def transient_error?
|
152
|
+
# local decision based on inputs/outputs
|
153
|
+
name == "temporary"
|
154
|
+
end
|
155
|
+
|
156
|
+
# Symbol predicate (arity 1), receives the exception
|
157
|
+
error(if: :argument_error?) { |e| "Bad argument: #{e.message}" }
|
158
|
+
|
159
|
+
def argument_error?(e)
|
160
|
+
e.is_a?(ArgumentError)
|
161
|
+
end
|
162
|
+
|
163
|
+
# Symbol predicate (keyword), receives the exception via keyword
|
164
|
+
error(if: :argument_error_kw?) { |exception:| "Bad argument: #{exception.message}" }
|
165
|
+
|
166
|
+
def argument_error_kw?(exception:)
|
167
|
+
exception.is_a?(ArgumentError)
|
168
|
+
end
|
114
169
|
|
115
|
-
#
|
116
|
-
|
170
|
+
# Lambda predicate with keyword
|
171
|
+
error "AE", if: ->(exception:) { exception.is_a?(ArgumentError) }
|
172
|
+
|
173
|
+
# Using unless: for inverse logic
|
174
|
+
error "Custom error", unless: :should_skip?
|
175
|
+
|
176
|
+
def should_skip?
|
177
|
+
# local decision based on inputs/outputs
|
178
|
+
name == "temporary"
|
179
|
+
end
|
180
|
+
|
181
|
+
::: warning
|
182
|
+
You cannot use both `if:` and `unless:` for the same message - this will raise an `ArgumentError`.
|
183
|
+
:::
|
117
184
|
|
118
|
-
|
119
|
-
|
120
|
-
|
185
|
+
### Message ordering and inheritance
|
186
|
+
|
187
|
+
Messages are evaluated in **last-defined-first** order, meaning the most recently defined message that matches its conditions will be used. This applies to both success and error messages:
|
188
|
+
|
189
|
+
```ruby
|
190
|
+
class ParentAction
|
191
|
+
include Action
|
192
|
+
|
193
|
+
success "Parent success message"
|
194
|
+
error "Parent error message"
|
195
|
+
end
|
196
|
+
|
197
|
+
class ChildAction < ParentAction
|
198
|
+
success "Child success message" # This will be used when action succeeds
|
199
|
+
error "Child error message" # This will be used when action fails
|
200
|
+
end
|
201
|
+
```
|
202
|
+
|
203
|
+
Within a single class, later definitions override earlier ones:
|
204
|
+
|
205
|
+
```ruby
|
206
|
+
class MyAction
|
207
|
+
include Action
|
208
|
+
|
209
|
+
success "First success message" # Ignored
|
210
|
+
success "Second success message" # Ignored
|
211
|
+
success "Final success message" # This will be used
|
212
|
+
|
213
|
+
error "First error message" # Ignored
|
214
|
+
error "Second error message" # Ignored
|
215
|
+
error "Final error message" # This will be used
|
216
|
+
end
|
217
|
+
```
|
218
|
+
|
219
|
+
When using conditional messages, the system evaluates handlers in the order defined above until it finds one that matches and doesn't raise an exception. If a handler raises an exception, it falls back to the next matching handler, then to static messages, and finally to the default message.
|
121
220
|
```
|
122
221
|
|
123
222
|
## Callbacks
|
124
223
|
|
125
224
|
In addition to the [global exception handler](/reference/configuration#on-exception), a number of custom callback are available for you as well, if you want to take specific actions when a given Axn succeeds or fails.
|
126
225
|
|
127
|
-
:::
|
128
|
-
*
|
129
|
-
|
226
|
+
::: tip Callback Ordering
|
227
|
+
* Callbacks are executed in **last-defined-first** order, similar to messages
|
228
|
+
* Child class callbacks execute before parent class callbacks
|
229
|
+
* Multiple matching callbacks of the same type will *all* execute
|
130
230
|
:::
|
131
231
|
|
132
232
|
|
@@ -161,17 +261,30 @@ class Foo
|
|
161
261
|
end
|
162
262
|
```
|
163
263
|
|
164
|
-
Note that by default the `on_exception` block will be applied to _any_ `StandardError` that is raised, but you can specify a matcher using the same logic as for
|
264
|
+
Note that by default the `on_exception` block will be applied to _any_ `StandardError` that is raised, but you can specify a matcher using the same logic as for conditional messages (`if:` or `unless:`):
|
165
265
|
|
166
266
|
```ruby
|
167
267
|
class Foo
|
168
268
|
include Action
|
169
269
|
|
170
|
-
on_exception NoMethodError do |exception| # [!code focus]
|
270
|
+
on_exception(if: NoMethodError) do |exception| # [!code focus]
|
171
271
|
# e.g. trigger a slack error
|
172
272
|
end
|
173
273
|
|
174
|
-
|
274
|
+
on_exception(unless: :transient_error?) do |exception| # [!code focus]
|
275
|
+
# e.g. trigger a slack error for non-transient errors
|
276
|
+
end
|
277
|
+
|
278
|
+
def transient_error?
|
279
|
+
# local decision based on inputs/outputs
|
280
|
+
name == "temporary"
|
281
|
+
end
|
282
|
+
|
283
|
+
::: warning
|
284
|
+
You cannot use both `if:` and `unless:` for the same callback - this will raise an `ArgumentError`.
|
285
|
+
:::
|
286
|
+
|
287
|
+
on_exception(if: ->(e) { e.is_a?(ZeroDivisionError) }) do # [!code focus]
|
175
288
|
# e.g. trigger a slack error
|
176
289
|
end
|
177
290
|
end
|
@@ -35,35 +35,5 @@ module Action
|
|
35
35
|
def action_name = @action.class.name.presence || "The action"
|
36
36
|
|
37
37
|
def context_data_source = raise NotImplementedError
|
38
|
-
|
39
|
-
def determine_error_message(only_default: false)
|
40
|
-
return @context.error_from_user if @context.error_from_user.present?
|
41
|
-
|
42
|
-
# We need an exception for interceptors, and also in case the messages.error callable expects an argument
|
43
|
-
exception = @context.exception || Action::Failure.new
|
44
|
-
|
45
|
-
msg = action._error_msg
|
46
|
-
|
47
|
-
unless only_default
|
48
|
-
interceptor = action.class._error_interceptor_for(exception:, action:)
|
49
|
-
msg = interceptor.message if interceptor
|
50
|
-
end
|
51
|
-
|
52
|
-
stringified(msg, exception:).presence || "Something went wrong"
|
53
|
-
end
|
54
|
-
|
55
|
-
# Allow for callable OR string messages
|
56
|
-
def stringified(msg, exception: nil)
|
57
|
-
return msg.presence unless msg.respond_to?(:call)
|
58
|
-
|
59
|
-
# The error message callable can take the exception as an argument
|
60
|
-
if exception && msg.arity == 1
|
61
|
-
action.instance_exec(exception, &msg)
|
62
|
-
else
|
63
|
-
action.instance_exec(&msg)
|
64
|
-
end
|
65
|
-
rescue StandardError => e
|
66
|
-
Axn::Util.piping_error("determining message callable", action:, exception: e)
|
67
|
-
end
|
68
38
|
end
|
69
39
|
end
|
@@ -5,9 +5,15 @@ require "action/core/context/facade"
|
|
5
5
|
module Action
|
6
6
|
# Inbound / Internal ContextFacade
|
7
7
|
class InternalContext < ContextFacade
|
8
|
-
#
|
8
|
+
# Available for use from within message callables
|
9
9
|
def default_error
|
10
|
-
|
10
|
+
msg = action.class._static_message_for(:error, action:, exception: @context.exception || Action::Failure.new)
|
11
|
+
[@context.error_prefix, msg.presence || "Something went wrong"].compact.join(" ").squeeze(" ")
|
12
|
+
end
|
13
|
+
|
14
|
+
def default_success
|
15
|
+
msg = action.class._static_message_for(:success, action:, exception: nil)
|
16
|
+
msg.presence || "Action completed successfully"
|
11
17
|
end
|
12
18
|
|
13
19
|
private
|
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "action/core/
|
3
|
+
require "action/core/flow/handlers"
|
4
4
|
|
5
5
|
module Action
|
6
6
|
module Core
|
@@ -8,44 +8,44 @@ module Action
|
|
8
8
|
module Callbacks
|
9
9
|
def self.included(base)
|
10
10
|
base.class_eval do
|
11
|
-
class_attribute :
|
12
|
-
class_attribute :_exception_handlers, default: []
|
13
|
-
class_attribute :_failure_handlers, default: []
|
14
|
-
class_attribute :_success_handlers, default: []
|
11
|
+
class_attribute :_callbacks_registry, default: Action::Core::Flow::Handlers::Registry.empty
|
15
12
|
|
16
13
|
extend ClassMethods
|
17
14
|
end
|
18
15
|
end
|
19
16
|
|
20
17
|
module ClassMethods
|
21
|
-
#
|
22
|
-
def
|
23
|
-
|
24
|
-
|
25
|
-
|
18
|
+
# Internal dispatcher
|
19
|
+
def _dispatch_callbacks(event_type, action:, exception: nil)
|
20
|
+
_callbacks_registry.for(event_type).each do |handler|
|
21
|
+
handler.apply(action:, exception:)
|
22
|
+
end
|
26
23
|
end
|
27
24
|
|
28
|
-
# ONLY raised
|
29
|
-
def
|
30
|
-
raise ArgumentError, "on_failure must be called with a block" unless block_given?
|
25
|
+
# ONLY raised exceptions (i.e. NOT fail!).
|
26
|
+
def on_exception(**, &block) = _add_callback(:exception, **, block:)
|
31
27
|
|
32
|
-
|
33
|
-
|
28
|
+
# ONLY raised on fail! (i.e. NOT unhandled exceptions).
|
29
|
+
def on_failure(**, &block) = _add_callback(:failure, **, block:)
|
34
30
|
|
35
|
-
# Handles both fail! and unhandled exceptions
|
36
|
-
def on_error(
|
37
|
-
raise ArgumentError, "on_error must be called with a block" unless block_given?
|
38
|
-
|
39
|
-
self._error_handlers += [Action::EventHandlers::ConditionalHandler.new(matcher:, handler:)]
|
40
|
-
end
|
31
|
+
# Handles both fail! and unhandled exceptions
|
32
|
+
def on_error(**, &block) = _add_callback(:error, **, block:)
|
41
33
|
|
42
34
|
# Executes when the action completes successfully (after all after hooks complete successfully)
|
43
35
|
# Runs in child-first order (child handlers before parent handlers)
|
44
|
-
def on_success(&
|
45
|
-
|
36
|
+
def on_success(**, &block) = _add_callback(:success, **, block:)
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def _add_callback(event_type, block:, **kwargs)
|
41
|
+
raise ArgumentError, "on_#{event_type} cannot be called with both :if and :unless" if kwargs.key?(:if) && kwargs.key?(:unless)
|
42
|
+
|
43
|
+
condition = kwargs.key?(:if) ? kwargs[:if] : kwargs[:unless]
|
44
|
+
raise ArgumentError, "on_#{event_type} must be called with a block" unless block
|
46
45
|
|
47
|
-
|
48
|
-
|
46
|
+
matcher = condition.nil? ? nil : Action::Core::Flow::Handlers::Matcher.new(condition, invert: kwargs.key?(:unless))
|
47
|
+
entry = Action::Core::Flow::Handlers::CallbackHandler.new(matcher:, handler: block)
|
48
|
+
self._callbacks_registry = _callbacks_registry.register(event_type:, entry:)
|
49
49
|
end
|
50
50
|
end
|
51
51
|
end
|
@@ -9,13 +9,8 @@ module Action
|
|
9
9
|
include InstanceMethods
|
10
10
|
|
11
11
|
def _trigger_on_exception(exception)
|
12
|
-
interceptor = self.class._error_interceptor_for(exception:, action: self)
|
13
|
-
return if interceptor&.should_report_error == false
|
14
|
-
|
15
12
|
# Call any handlers registered on *this specific action* class
|
16
|
-
self.class.
|
17
|
-
handler.execute_if_matches(exception:, action: self)
|
18
|
-
end
|
13
|
+
self.class._dispatch_callbacks(:exception, action: self, exception:)
|
19
14
|
|
20
15
|
# Call any global handlers
|
21
16
|
Action.config.on_exception(exception, action: self, context: context_for_logging)
|
@@ -27,12 +22,7 @@ module Action
|
|
27
22
|
|
28
23
|
def _trigger_on_success
|
29
24
|
# Call success handlers in child-first order (like after hooks)
|
30
|
-
self.class.
|
31
|
-
instance_exec(&handler)
|
32
|
-
rescue StandardError => e
|
33
|
-
# Log the error but continue with other handlers
|
34
|
-
Axn::Util.piping_error("executing on_success hook", action: self, exception: e)
|
35
|
-
end
|
25
|
+
self.class._dispatch_callbacks(:success, action: self, exception: nil)
|
36
26
|
end
|
37
27
|
end
|
38
28
|
end
|
@@ -44,17 +34,13 @@ module Action
|
|
44
34
|
yield
|
45
35
|
rescue StandardError => e
|
46
36
|
# on_error handlers run for both unhandled exceptions and fail!
|
47
|
-
self.class.
|
48
|
-
handler.execute_if_matches(exception: e, action: self)
|
49
|
-
end
|
37
|
+
self.class._dispatch_callbacks(:error, action: self, exception: e)
|
50
38
|
|
51
39
|
# on_failure handlers run ONLY for fail!
|
52
40
|
if e.is_a?(Action::Failure)
|
53
|
-
self.class.
|
54
|
-
handler.execute_if_matches(exception: e, action: self)
|
55
|
-
end
|
41
|
+
self.class._dispatch_callbacks(:failure, action: self, exception: e)
|
56
42
|
else
|
57
|
-
# on_exception handlers run for ONLY for unhandled exceptions.
|
43
|
+
# on_exception handlers run for ONLY for unhandled exceptions.
|
58
44
|
_trigger_on_exception(e)
|
59
45
|
|
60
46
|
@__context.exception = e
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "action/core/flow/handlers/matcher"
|
4
|
+
|
5
|
+
module Action
|
6
|
+
module Core
|
7
|
+
module Flow
|
8
|
+
# "Handlers" doesn't feel like *quite* the right name for this, but basically things in this namespace
|
9
|
+
# relate to conditionally-invoked code blocks (e.g. callbacks, messages, etc.)
|
10
|
+
module Handlers
|
11
|
+
class BaseHandler
|
12
|
+
def initialize(matcher: nil, handler: nil)
|
13
|
+
@matcher = matcher
|
14
|
+
@handler = handler
|
15
|
+
end
|
16
|
+
|
17
|
+
attr_reader :handler
|
18
|
+
|
19
|
+
def static? = @matcher.nil?
|
20
|
+
|
21
|
+
def matches?(action:, exception:)
|
22
|
+
return true if static?
|
23
|
+
|
24
|
+
@matcher.call(exception:, action:)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Subclasses should implement `apply(action:, exception:)`
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "action/core/flow/handlers/base_handler"
|
4
|
+
require "action/core/flow/handlers/invoker"
|
5
|
+
|
6
|
+
module Action
|
7
|
+
module Core
|
8
|
+
module Flow
|
9
|
+
module Handlers
|
10
|
+
class CallbackHandler < BaseHandler
|
11
|
+
def apply(action:, exception:)
|
12
|
+
return false unless matches?(action:, exception:)
|
13
|
+
|
14
|
+
Invoker.call(action:, handler:, exception:, operation: "executing handler")
|
15
|
+
true
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Action
|
4
|
+
module Core
|
5
|
+
module Flow
|
6
|
+
module Handlers
|
7
|
+
# Shared block evaluation with consistent arity handling and error piping
|
8
|
+
module Invoker
|
9
|
+
extend self
|
10
|
+
|
11
|
+
def call(action:, handler:, exception: nil, operation: "executing handler")
|
12
|
+
return call_symbol_handler(action:, symbol: handler, exception:) if symbol?(handler)
|
13
|
+
return call_callable_handler(action:, callable: handler, exception:) if callable?(handler)
|
14
|
+
|
15
|
+
literal_value(handler)
|
16
|
+
rescue StandardError => e
|
17
|
+
Axn::Util.piping_error(operation, action:, exception: e)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Shared introspection helpers
|
21
|
+
def accepts_exception_keyword?(callable_or_method)
|
22
|
+
return false unless callable_or_method.respond_to?(:parameters)
|
23
|
+
|
24
|
+
params = callable_or_method.parameters
|
25
|
+
params.any? { |type, name| %i[keyreq key].include?(type) && name == :exception } ||
|
26
|
+
params.any? { |type, _| type == :keyrest }
|
27
|
+
end
|
28
|
+
|
29
|
+
def accepts_positional_exception?(callable_or_method)
|
30
|
+
return false unless callable_or_method.respond_to?(:arity)
|
31
|
+
|
32
|
+
arity = callable_or_method.arity
|
33
|
+
arity == 1 || arity.negative?
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def symbol?(value) = value.is_a?(Symbol)
|
39
|
+
|
40
|
+
def callable?(value) = value.respond_to?(:arity)
|
41
|
+
|
42
|
+
def call_symbol_handler(action:, symbol:, exception: nil)
|
43
|
+
unless action.respond_to?(symbol)
|
44
|
+
action.warn("Ignoring apparently-invalid symbol #{symbol.inspect} -- action does not respond to method")
|
45
|
+
return nil
|
46
|
+
end
|
47
|
+
|
48
|
+
method = action.method(symbol)
|
49
|
+
if exception && accepts_exception_keyword?(method)
|
50
|
+
action.public_send(symbol, exception:)
|
51
|
+
elsif exception && accepts_positional_exception?(method)
|
52
|
+
action.public_send(symbol, exception)
|
53
|
+
else
|
54
|
+
action.public_send(symbol)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def call_callable_handler(action:, callable:, exception: nil)
|
59
|
+
if exception && accepts_exception_keyword?(callable)
|
60
|
+
action.instance_exec(exception:, &callable)
|
61
|
+
elsif exception && accepts_positional_exception?(callable)
|
62
|
+
action.instance_exec(exception, &callable)
|
63
|
+
else
|
64
|
+
action.instance_exec(&callable)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def literal_value(value) = value
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "action/core/flow/handlers/invoker"
|
4
|
+
|
5
|
+
module Action
|
6
|
+
module Core
|
7
|
+
module Flow
|
8
|
+
module Handlers
|
9
|
+
class Matcher
|
10
|
+
def initialize(rule, invert: false)
|
11
|
+
@rule = rule
|
12
|
+
@invert = invert
|
13
|
+
end
|
14
|
+
|
15
|
+
def call(exception:, action:)
|
16
|
+
@invert ? !matches?(exception:, action:) : matches?(exception:, action:)
|
17
|
+
rescue StandardError => e
|
18
|
+
Axn::Util.piping_error("determining if handler applies to exception", action:, exception: e)
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def matches?(exception:, action:)
|
24
|
+
return apply_callable(action:, exception:) if callable?
|
25
|
+
return apply_symbol(action:, exception:) if symbol?
|
26
|
+
return apply_string(exception:) if string?
|
27
|
+
return apply_exception_class(exception:) if exception_class?
|
28
|
+
|
29
|
+
handle_invalid(action:)
|
30
|
+
end
|
31
|
+
|
32
|
+
def callable? = @rule.respond_to?(:call)
|
33
|
+
def symbol? = @rule.is_a?(Symbol)
|
34
|
+
def string? = @rule.is_a?(String)
|
35
|
+
def exception_class? = @rule.is_a?(Class) && @rule <= Exception
|
36
|
+
|
37
|
+
def apply_callable(action:, exception:)
|
38
|
+
if exception && Invoker.accepts_exception_keyword?(@rule)
|
39
|
+
!!action.instance_exec(exception:, &@rule)
|
40
|
+
elsif exception && Invoker.accepts_positional_exception?(@rule)
|
41
|
+
!!action.instance_exec(exception, &@rule)
|
42
|
+
else
|
43
|
+
!!action.instance_exec(&@rule)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def apply_symbol(action:, exception:)
|
48
|
+
if action.respond_to?(@rule)
|
49
|
+
method = action.method(@rule)
|
50
|
+
if exception && Invoker.accepts_exception_keyword?(method)
|
51
|
+
!!action.public_send(@rule, exception:)
|
52
|
+
elsif exception && Invoker.accepts_positional_exception?(method)
|
53
|
+
!!action.public_send(@rule, exception)
|
54
|
+
else
|
55
|
+
!!action.public_send(@rule)
|
56
|
+
end
|
57
|
+
else
|
58
|
+
begin
|
59
|
+
klass = Object.const_get(@rule.to_s)
|
60
|
+
klass && exception.is_a?(klass)
|
61
|
+
rescue NameError
|
62
|
+
action.warn("Ignoring apparently-invalid matcher #{@rule.inspect} -- neither action method nor constant found")
|
63
|
+
false
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def apply_string(exception:)
|
69
|
+
klass = Object.const_get(@rule.to_s)
|
70
|
+
klass && exception.is_a?(klass)
|
71
|
+
end
|
72
|
+
|
73
|
+
def apply_exception_class(exception:)
|
74
|
+
exception.is_a?(@rule)
|
75
|
+
end
|
76
|
+
|
77
|
+
def handle_invalid(action:)
|
78
|
+
action.warn("Ignoring apparently-invalid matcher #{@rule.inspect} -- could not find way to apply it")
|
79
|
+
false
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "action/core/flow/handlers/base_handler"
|
4
|
+
require "action/core/flow/handlers/invoker"
|
5
|
+
|
6
|
+
module Action
|
7
|
+
module Core
|
8
|
+
module Flow
|
9
|
+
module Handlers
|
10
|
+
class MessageHandler < BaseHandler
|
11
|
+
# Returns a string (truthy) when it applies and yields a non-blank message; otherwise nil
|
12
|
+
def apply(action:, exception:)
|
13
|
+
return nil unless matches?(action:, exception:)
|
14
|
+
|
15
|
+
value =
|
16
|
+
if handler.is_a?(Symbol) || handler.respond_to?(:call)
|
17
|
+
Invoker.call(action:, handler:, exception:, operation: "determining message callable")
|
18
|
+
else
|
19
|
+
handler
|
20
|
+
end
|
21
|
+
value.respond_to?(:presence) ? value.presence : value
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Action
|
4
|
+
module Core
|
5
|
+
module Flow
|
6
|
+
module Handlers
|
7
|
+
# Small, immutable, copy-on-write registry keyed by event_type.
|
8
|
+
# Stores arrays of entries (handlers/interceptors) in insertion order.
|
9
|
+
class Registry
|
10
|
+
def self.empty = new({})
|
11
|
+
|
12
|
+
def initialize(index)
|
13
|
+
# Freeze arrays and the index for immutability
|
14
|
+
@index = index.transform_values { |arr| Array(arr).freeze }.freeze
|
15
|
+
end
|
16
|
+
|
17
|
+
# Always register most-recent-first (last-defined wins). Simpler mental model.
|
18
|
+
def register(event_type:, entry:)
|
19
|
+
key = event_type.to_sym
|
20
|
+
existing = Array(@index[key])
|
21
|
+
updated = [entry] + existing
|
22
|
+
self.class.new(@index.merge(key => updated.freeze))
|
23
|
+
end
|
24
|
+
|
25
|
+
def for(event_type)
|
26
|
+
Array(@index[event_type.to_sym])
|
27
|
+
end
|
28
|
+
|
29
|
+
def empty?
|
30
|
+
@index.empty?
|
31
|
+
end
|
32
|
+
|
33
|
+
protected
|
34
|
+
|
35
|
+
attr_reader :index
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Action
|
4
|
+
module Core
|
5
|
+
module Flow
|
6
|
+
module Handlers
|
7
|
+
end
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
require "action/core/flow/handlers/invoker"
|
13
|
+
require "action/core/flow/handlers/registry"
|
14
|
+
require "action/core/flow/handlers/matcher"
|
15
|
+
require "action/core/flow/handlers/base_handler"
|
16
|
+
require "action/core/flow/handlers/callback_handler"
|
17
|
+
require "action/core/flow/handlers/message_handler"
|
@@ -1,13 +1,14 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "action/core/flow/handlers"
|
4
|
+
|
3
5
|
module Action
|
4
6
|
module Core
|
5
7
|
module Flow
|
6
8
|
module Messages
|
7
9
|
def self.included(base)
|
8
10
|
base.class_eval do
|
9
|
-
class_attribute :
|
10
|
-
class_attribute :_custom_error_interceptors, default: []
|
11
|
+
class_attribute :_messages_registry, default: Action::Core::Flow::Handlers::Registry.empty
|
11
12
|
|
12
13
|
extend ClassMethods
|
13
14
|
include InstanceMethods
|
@@ -15,45 +16,58 @@ module Action
|
|
15
16
|
end
|
16
17
|
|
17
18
|
module ClassMethods
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
true
|
19
|
+
# Internal: resolve a message for the given event (conditional first, then static)
|
20
|
+
def _message_for(event_type, action:, exception: nil)
|
21
|
+
_conditional_message_for(event_type, action:, exception:) ||
|
22
|
+
_static_message_for(event_type, action:, exception:)
|
23
23
|
end
|
24
24
|
|
25
|
-
def
|
26
|
-
|
25
|
+
def _conditional_message_for(event_type, action:, exception: nil)
|
26
|
+
_messages_registry.for(event_type).each do |handler|
|
27
|
+
next if handler.respond_to?(:static?) && handler.static?
|
28
|
+
|
29
|
+
msg = handler.apply(action:, exception:)
|
30
|
+
return msg if msg.present?
|
31
|
+
end
|
32
|
+
nil
|
27
33
|
end
|
28
34
|
|
29
|
-
def
|
30
|
-
|
35
|
+
def _static_message_for(event_type, action:, exception: nil)
|
36
|
+
_messages_registry.for(event_type).each do |handler|
|
37
|
+
next unless handler.respond_to?(:static?) && handler.static?
|
38
|
+
|
39
|
+
msg = handler.apply(action:, exception:)
|
40
|
+
return msg if msg.present?
|
41
|
+
end
|
42
|
+
nil
|
31
43
|
end
|
32
44
|
|
45
|
+
def success(message = nil, **, &) = _add_message(:success, message:, **, &)
|
46
|
+
def error(message = nil, **, &) = _add_message(:error, message:, **, &)
|
47
|
+
|
33
48
|
def default_error = new.internal_context.default_error
|
49
|
+
def default_success = new.internal_context.default_success
|
34
50
|
|
35
|
-
|
51
|
+
private
|
36
52
|
|
37
|
-
def
|
38
|
-
|
39
|
-
int.matches?(exception:, action:)
|
40
|
-
end
|
41
|
-
end
|
53
|
+
def _add_message(kind, message:, **kwargs, &block)
|
54
|
+
raise ArgumentError, "#{kind} cannot be called with both :if and :unless" if kwargs.key?(:if) && kwargs.key?(:unless)
|
42
55
|
|
43
|
-
|
44
|
-
|
45
|
-
raise ArgumentError, "
|
56
|
+
condition = kwargs.key?(:if) ? kwargs[:if] : kwargs[:unless]
|
57
|
+
raise ArgumentError, "Provide either a message or a block, not both" if message && block_given?
|
58
|
+
raise ArgumentError, "Provide a message or a block" unless message || block_given?
|
46
59
|
|
47
|
-
|
48
|
-
Action::EventHandlers::CustomErrorInterceptor.new(matcher:, message:, should_report_error:)
|
49
|
-
end
|
60
|
+
handler = block_given? ? block : message
|
50
61
|
|
51
|
-
|
62
|
+
matcher = condition.nil? ? nil : Action::Core::Flow::Handlers::Matcher.new(condition, invert: kwargs.key?(:unless))
|
63
|
+
entry = Action::Core::Flow::Handlers::MessageHandler.new(matcher:, handler:)
|
64
|
+
self._messages_registry = _messages_registry.register(event_type: kind, entry:)
|
65
|
+
true
|
52
66
|
end
|
53
67
|
end
|
54
68
|
|
55
69
|
module InstanceMethods
|
56
|
-
delegate :default_error, to: :internal_context
|
70
|
+
delegate :default_error, :default_success, to: :internal_context
|
57
71
|
end
|
58
72
|
end
|
59
73
|
end
|
data/lib/action/result.rb
CHANGED
@@ -11,7 +11,7 @@ module Action
|
|
11
11
|
def ok(msg = nil, **exposures)
|
12
12
|
exposes = exposures.keys.to_h { |key| [key, { allow_blank: true }] }
|
13
13
|
|
14
|
-
Axn::Factory.build(exposes:,
|
14
|
+
Axn::Factory.build(exposes:, success: msg) do
|
15
15
|
exposures.each do |key, value|
|
16
16
|
expose(key, value)
|
17
17
|
end
|
@@ -20,13 +20,19 @@ module Action
|
|
20
20
|
|
21
21
|
def error(msg = nil, **exposures, &block)
|
22
22
|
exposes = exposures.keys.to_h { |key| [key, { allow_blank: true }] }
|
23
|
-
rescues = [-> { true }, msg]
|
24
23
|
|
25
|
-
Axn::Factory.build(exposes:,
|
24
|
+
Axn::Factory.build(exposes:, error: msg) do
|
26
25
|
exposures.each do |key, value|
|
27
26
|
expose(key, value)
|
28
27
|
end
|
29
|
-
|
28
|
+
if block_given?
|
29
|
+
begin
|
30
|
+
block.call
|
31
|
+
rescue StandardError => e
|
32
|
+
# Set the exception directly without triggering on_exception handlers
|
33
|
+
@__context.exception = e
|
34
|
+
end
|
35
|
+
end
|
30
36
|
fail!
|
31
37
|
end.call
|
32
38
|
end
|
@@ -47,7 +53,7 @@ module Action
|
|
47
53
|
def success
|
48
54
|
return unless ok?
|
49
55
|
|
50
|
-
|
56
|
+
determine_success_message
|
51
57
|
end
|
52
58
|
|
53
59
|
def ok = success
|
@@ -77,6 +83,19 @@ module Action
|
|
77
83
|
|
78
84
|
def context_data_source = @context.exposed_data
|
79
85
|
|
86
|
+
def determine_error_message
|
87
|
+
return @context.error_from_user if @context.error_from_user.present?
|
88
|
+
|
89
|
+
exception = @context.exception || Action::Failure.new
|
90
|
+
msg = action.class._message_for(:error, action:, exception:)
|
91
|
+
msg.presence || "Something went wrong"
|
92
|
+
end
|
93
|
+
|
94
|
+
def determine_success_message
|
95
|
+
msg = action.class._message_for(:success, action:, exception: nil)
|
96
|
+
msg.presence || "Action completed successfully"
|
97
|
+
end
|
98
|
+
|
80
99
|
def method_missing(method_name, ...) # rubocop:disable Style/MissingRespondToMissing (because we're not actually responding to anything additional)
|
81
100
|
if @context.__combined_data.key?(method_name.to_sym)
|
82
101
|
msg = <<~MSG
|
data/lib/axn/factory.rb
CHANGED
@@ -13,9 +13,8 @@ module Axn
|
|
13
13
|
# Expose standard class-level options
|
14
14
|
exposes: [],
|
15
15
|
expects: [],
|
16
|
-
|
17
|
-
|
18
|
-
rescues: {},
|
16
|
+
success: nil,
|
17
|
+
error: nil,
|
19
18
|
|
20
19
|
# Hooks
|
21
20
|
before: nil,
|
@@ -73,6 +72,17 @@ module Axn
|
|
73
72
|
expose(expose_return_as => retval) if expose_return_as.present?
|
74
73
|
end
|
75
74
|
end.tap do |axn|
|
75
|
+
apply_message = lambda do |kind, value|
|
76
|
+
return unless value.present?
|
77
|
+
|
78
|
+
if value.is_a?(Array) && value.size == 2
|
79
|
+
matcher, msg = value
|
80
|
+
axn.public_send(kind, msg, if: matcher)
|
81
|
+
else
|
82
|
+
axn.public_send(kind, value)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
76
86
|
expects.each do |field, opts|
|
77
87
|
axn.expects(field, **opts)
|
78
88
|
end
|
@@ -81,10 +91,8 @@ module Axn
|
|
81
91
|
axn.exposes(field, **opts)
|
82
92
|
end
|
83
93
|
|
84
|
-
|
85
|
-
|
86
|
-
axn.error_from(**_array_to_hash(error_from)) if error_from.present?
|
87
|
-
axn.rescues(**_array_to_hash(rescues)) if rescues.present?
|
94
|
+
apply_message.call(:success, success)
|
95
|
+
apply_message.call(:error, error)
|
88
96
|
|
89
97
|
# Hooks
|
90
98
|
axn.before(before) if before.present?
|
data/lib/axn/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: axn
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.0.pre.alpha.2.
|
4
|
+
version: 0.1.0.pre.alpha.2.7
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Kali Donovan
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-08-
|
11
|
+
date: 2025-08-15 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activemodel
|
@@ -86,10 +86,16 @@ files:
|
|
86
86
|
- lib/action/core/contract.rb
|
87
87
|
- lib/action/core/contract_for_subfields.rb
|
88
88
|
- lib/action/core/contract_validation.rb
|
89
|
-
- lib/action/core/event_handlers.rb
|
90
89
|
- lib/action/core/flow.rb
|
91
90
|
- lib/action/core/flow/callbacks.rb
|
92
91
|
- lib/action/core/flow/exception_execution.rb
|
92
|
+
- lib/action/core/flow/handlers.rb
|
93
|
+
- lib/action/core/flow/handlers/base_handler.rb
|
94
|
+
- lib/action/core/flow/handlers/callback_handler.rb
|
95
|
+
- lib/action/core/flow/handlers/invoker.rb
|
96
|
+
- lib/action/core/flow/handlers/matcher.rb
|
97
|
+
- lib/action/core/flow/handlers/message_handler.rb
|
98
|
+
- lib/action/core/flow/handlers/registry.rb
|
93
99
|
- lib/action/core/flow/messages.rb
|
94
100
|
- lib/action/core/hoist_errors.rb
|
95
101
|
- lib/action/core/hooks.rb
|
@@ -1,62 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Action
|
4
|
-
module EventHandlers
|
5
|
-
class CustomErrorInterceptor
|
6
|
-
def initialize(matcher:, message:, should_report_error:)
|
7
|
-
@matcher = Matcher.new(matcher)
|
8
|
-
@message = message
|
9
|
-
@should_report_error = should_report_error
|
10
|
-
end
|
11
|
-
|
12
|
-
delegate :matches?, to: :@matcher
|
13
|
-
attr_reader :message, :should_report_error
|
14
|
-
end
|
15
|
-
|
16
|
-
class ConditionalHandler
|
17
|
-
def initialize(matcher:, handler:)
|
18
|
-
@matcher = Matcher.new(matcher)
|
19
|
-
@handler = handler
|
20
|
-
end
|
21
|
-
|
22
|
-
delegate :matches?, to: :@matcher
|
23
|
-
|
24
|
-
def execute_if_matches(action:, exception:)
|
25
|
-
return false unless matches?(exception:, action:)
|
26
|
-
|
27
|
-
action.instance_exec(exception, &@handler)
|
28
|
-
true
|
29
|
-
rescue StandardError => e
|
30
|
-
Axn::Util.piping_error("executing handler", action:, exception: e)
|
31
|
-
end
|
32
|
-
end
|
33
|
-
|
34
|
-
class Matcher
|
35
|
-
def initialize(matcher)
|
36
|
-
@matcher = matcher
|
37
|
-
end
|
38
|
-
|
39
|
-
def matches?(exception:, action:)
|
40
|
-
if matcher.respond_to?(:call)
|
41
|
-
if matcher.arity == 1
|
42
|
-
!!action.instance_exec(exception, &matcher)
|
43
|
-
else
|
44
|
-
!!action.instance_exec(&matcher)
|
45
|
-
end
|
46
|
-
elsif matcher.is_a?(String) || matcher.is_a?(Symbol)
|
47
|
-
klass = Object.const_get(matcher.to_s)
|
48
|
-
klass && exception.is_a?(klass)
|
49
|
-
elsif matcher < Exception
|
50
|
-
exception.is_a?(matcher)
|
51
|
-
else
|
52
|
-
action.warn("Ignoring apparently-invalid matcher #{matcher.inspect} -- could not find way to apply it")
|
53
|
-
false
|
54
|
-
end
|
55
|
-
rescue StandardError => e
|
56
|
-
Axn::Util.piping_error("determining if handler applies to exception", action:, exception: e)
|
57
|
-
end
|
58
|
-
|
59
|
-
private attr_reader :matcher
|
60
|
-
end
|
61
|
-
end
|
62
|
-
end
|