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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 30c00229f0062bdba5979d0e69de4ffddb6bece3f31413f83b7f7778d3075ef7
4
- data.tar.gz: 92d944caf300ddfc9a134d34ae2cb73e6d235e0af9f7a10a3ee93065ae834690
3
+ metadata.gz: b128603df1eec45048f8656f01508a2e9a706e9acd92ce74813cac0bb038f550
4
+ data.tar.gz: cfc86ffaadab4cc758546eab39992de2d7f3c89547007a2d88643b303d0edd4b
5
5
  SHA512:
6
- metadata.gz: 600b1572f324b8cbbaf2b5dd48108546db86e63e3c7d49a04e1eb9f0f2e83720eafdaabb94ccf0550ff1c0e63b3ec555950443eb5c064698dbc65cf97c2b1733
7
- data.tar.gz: e9229f25cd10dcc54f1f7c5b83ff81b997d96263296818caaf2cfc245fde93ab24d7f0605700ea042c085ab7456f5fc1b20c2cc643324609bc0f4662ecbdcd21
6
+ metadata.gz: 2767343c37a0612a446482b52320d2ab85009af9e4094fffa0be31577bdf4a67e228f08307e31c0d182dbd3b944881f10af3c7e80d19a7d6c6f6f08a33294417
7
+ data.tar.gz: fb5d3942ecbeb2e52f4f4376b5d1b90db696470704d39d1b2db55e6880956073f12f0213a13ad8a564ee2048ad76350bebb811ff90db125bf9724d2d31d3167a
data/CHANGELOG.md CHANGED
@@ -1,7 +1,15 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
4
- * N/A
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`
@@ -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
- ## `.messages`
88
+ ## `.success` and `.error`
89
89
 
90
- The `messages` declaration allows you to customize the `error` and `success` messages on the returned result.
90
+ The `success` and `error` declarations allow you to customize the `error` and `success` messages on the returned result.
91
91
 
92
- Accepts `error` and/or `success` keys. Values can be a string (returned directly) or a callable (evaluated in the action's context, so can access instance methods and variables). If `error` is provided with a callable that expects a positional argument, the exception that was raised will be passed in as that value.
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
- In callables, you can access:
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
- messages success: -> { "Hello #{name}, your greeting: #{result.greeting}" },
101
- error: ->(e) { "Bad news: #{e.message}" }
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
- ## `error_from` and `rescues`
127
+ ## Conditional messages
105
128
 
106
- While `.messages` sets the _default_ error/success messages and is more commonly used, there are times when you want specific error messages for specific failure cases.
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
- `error_from` and `rescues` both register a matcher (exception class, exception class name (string), or callable) and a message to use if the matcher succeeds. They act exactly the same, except if a matcher registered with `rescues` succeeds, the exception _will not_ trigger the configured exception handlers (global or specific to this class).
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
- Callable matchers and messages follow the same data access patterns as other callables: input fields directly, output fields via `result.field`, instance variables, and methods.
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
- messages error: "bad"
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
- # Note this will NOT trigger Action.config.on_exception
116
- rescues ActiveRecord::InvalidRecord => "Invalid params provided"
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
- # These WILL trigger error handler (callable matcher + message with data access)
119
- error_from ArgumentError, ->(e) { "Argument error: #{e.message}" }
120
- error_from -> { name == "bad" }, -> { "Bad input #{name}, result: #{result.status}" }
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
- ::: danger ALPHA
128
- * The callbacks themselves are functional. Note the ordering _between_ callbacks is not well defined (currently a side effect of the order they're defined).
129
- * Ordering may change at any time so while in alpha DO NOT MAKE ASSUMPTIONS ABOUT THE ORDER OF CALLBACK EXECUTION!
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 [`error_from` and `rescues`](#error-for-and-rescues):
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
- on_exception ->(e) { e.is_a?(ZeroDivisionError) } do # [!code focus]
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
- # So can be referenced from within e.g. rescues callables
8
+ # Available for use from within message callables
9
9
  def default_error
10
- [@context.error_prefix, determine_error_message(only_default: true)].compact.join(" ").squeeze(" ")
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/event_handlers"
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 :_error_handlers, default: []
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
- # ONLY raised exceptions (i.e. NOT fail!). Skipped if exception is rescued via .rescues.
22
- def on_exception(matcher = -> { true }, &handler)
23
- raise ArgumentError, "on_exception must be called with a block" unless block_given?
24
-
25
- self._exception_handlers += [Action::EventHandlers::ConditionalHandler.new(matcher:, handler:)]
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 on fail! (i.e. NOT unhandled exceptions).
29
- def on_failure(matcher = -> { true }, &handler)
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
- self._failure_handlers += [Action::EventHandlers::ConditionalHandler.new(matcher:, handler:)]
33
- end
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... but is NOT affected by .rescues
36
- def on_error(matcher = -> { true }, &handler)
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(&handler)
45
- raise ArgumentError, "on_success must be called with a block" unless block_given?
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
- # Prepend like after hooks - child handlers run before parent handlers
48
- self._success_handlers = [handler] + _success_handlers
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._exception_handlers.each do |handler|
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._success_handlers.each do |handler|
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._error_handlers.each do |handler|
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._failure_handlers.each do |handler|
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. AND NOTE: may be skipped if the exception is rescued via `rescues`.
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 :_success_msg, :_error_msg
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
- def messages(success: nil, error: nil)
19
- self._success_msg = success if success.present?
20
- self._error_msg = error if error.present?
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 error_from(matcher = nil, message = nil, **match_and_messages)
26
- _register_error_interceptor(matcher, message, should_report_error: true, **match_and_messages)
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 rescues(matcher = nil, message = nil, **match_and_messages)
30
- _register_error_interceptor(matcher, message, should_report_error: false, **match_and_messages)
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
- # Private helpers
51
+ private
36
52
 
37
- def _error_interceptor_for(exception:, action:)
38
- Array(_custom_error_interceptors).detect do |int|
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
- def _register_error_interceptor(matcher, message, should_report_error:, **match_and_messages)
44
- method_name = should_report_error ? "error_from" : "rescues"
45
- raise ArgumentError, "#{method_name} must be called with a key/value pair, or else keyword args" if [matcher, message].compact.size == 1
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
- interceptors = { matcher => message }.compact.merge(match_and_messages).map do |(matcher, message)| # rubocop:disable Lint/ShadowingOuterLocalVariable
48
- Action::EventHandlers::CustomErrorInterceptor.new(matcher:, message:, should_report_error:)
49
- end
60
+ handler = block_given? ? block : message
50
61
 
51
- self._custom_error_interceptors += interceptors
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:, messages: { success: msg }) do
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:, rescues:) do
24
+ Axn::Factory.build(exposes:, error: msg) do
26
25
  exposures.each do |key, value|
27
26
  expose(key, value)
28
27
  end
29
- block.call if block_given?
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
- stringified(action._success_msg).presence || "Action completed successfully"
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
- messages: {},
17
- error_from: {},
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
- axn.messages(**messages) if messages.present? && messages.values.any?(&:present?)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Axn
4
- VERSION = "0.1.0-alpha.2.6.1"
4
+ VERSION = "0.1.0-alpha.2.7"
5
5
  end
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.6.1
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-12 00:00:00.000000000 Z
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