axn 0.1.0.pre.alpha.2.4 → 0.1.0.pre.alpha.2.4.1

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: e3313bd117a57aae98551088df5895b6db302830101dda0e2fb95f057f97000f
4
- data.tar.gz: 9517e1d297998af62a2fc2107d3a002a71d5b767dda250b6ebec4d29958902a6
3
+ metadata.gz: 19221983bd11ff17620830a4807b4080f8ea35dfb07050e2af2e6c1dac1e1b7d
4
+ data.tar.gz: 7ac5fc8f9ae99bd9a5a189ad53f6113c4c544181ff760bbe923ca1867f011877
5
5
  SHA512:
6
- metadata.gz: e3b80de43c96e2362562dc35d88fd28d7c7b97a0dba2e73168031063fa8bed3d64ae3c46dd4f751af6ea051c17fc4592e6c246f505f9e319e5dd6e87caabfc67
7
- data.tar.gz: c451f8b99639f94bb38d982ab4913b5e126ee099ed97f31cf2ad70903efbc5acb69bc715e634d39e56aad0720d8bd4007a24794255ea9519307f268b4f312061
6
+ metadata.gz: 4115cc144bfac9420ff85de1ff9198efd092f63594811aed1afbf3ac5a07a5b73a9017b9d9665aac78e128c312d79e996d8dc2f8710fb30a8b9825a51caa30e5
7
+ data.tar.gz: 7528abe56dd99066409a59a57f07d04f9b9ea98fd399b62aab7318a762920027eb0870b7895b5f8bff525023a7d754c67f0d8c484661929c8a4dab1a360caf6f
data/.rubocop.yml CHANGED
@@ -36,10 +36,10 @@ Metrics/PerceivedComplexity:
36
36
  Max: 15
37
37
 
38
38
  Metrics/AbcSize:
39
- Max: 51
39
+ Max: 60
40
40
 
41
41
  Metrics/CyclomaticComplexity:
42
- Max: 12
42
+ Max: 14
43
43
 
44
44
  Lint/EmptyBlock:
45
45
  Enabled: false
data/CHANGELOG.md CHANGED
@@ -3,6 +3,9 @@
3
3
  ## UNRELEASED
4
4
  * N/A
5
5
 
6
+ ## 0.1.0-alpha.2.4.1
7
+ * [FEAT] Adds full suite of per-Axn callbacks: `on_exception`, `on_failure`, `on_error`, `on_success`
8
+
6
9
  ## 0.1.0-alpha.2.4
7
10
  * [FEAT] Adds per-Axn `on_exception` handlers
8
11
 
@@ -72,11 +72,38 @@ messages error: "bad"
72
72
  rescues ActiveRecord::InvalidRecord => "Invalid params provided"
73
73
 
74
74
  # These WILL trigger error handler (second demonstrates callable matcher AND message)
75
- error_for ArgumentError, ->(e) { "Argument error: #{e.message}"
75
+ error_for ArgumentError, ->(e) { "Argument error: #{e.message}" }
76
76
  error_for -> { name == "bad" }, -> { "was given bad name: #{name}" }
77
77
  ```
78
78
 
79
- ## `on_exception`
79
+ ## Callbacks
80
+
81
+ 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.
82
+
83
+ ::: danger ALPHA
84
+ * The callbacks themselves are functional. Note the ordering _between_ callbacks is not well defined (currently a side effect of the order they're defined).
85
+ * Ordering may change at any time so while in alpha DO NOT MAKE ASSUMPTIONS ABOUT THE ORDER OF CALLBACK EXECUTION!
86
+ :::
87
+
88
+
89
+ ::: tip Callbacks vs Hooks
90
+ * *Hooks* (`before`/`after`) are executed _as part of the `call`_ -- exceptions or `fail!`s here _will_ change a successful action call to a failure (i.e. `result.ok?` will be false)
91
+ * *Callbacks* (defined below) are executed _after_ the `call` -- exceptions or `fail!`s here will _not_ change `result.ok?`
92
+ :::
93
+
94
+ ### `on_success`
95
+
96
+ This is triggered after the Axn completes, if it was successful. Difference from `after`: if the given block raises an error, this WILL be reported to the global exception handler, but will NOT change `ok?` to false.
97
+
98
+ ### `on_error`
99
+
100
+ Triggered on ANY error (explicit `fail!` or uncaught exception). Optional filter argument works the same as `on_exception` (documented below).
101
+
102
+ ### `on_failure`
103
+
104
+ Triggered ONLY on explicit `fail!` (i.e. _not_ by an uncaught exception). Optional filter argument works the same as `on_exception` (documented below).
105
+
106
+ ### `on_exception`
80
107
 
81
108
  Much like the [globally-configured on_exception hook](/reference/configuration#on-exception), you can also specify exception handlers for a _specific_ Axn class:
82
109
 
@@ -104,23 +104,22 @@ Foo.call(name: "Adams").meaning_of_life # => "Hello Adams, the meaning of life i
104
104
 
105
105
  In addition to `#call`, there are a few additional pieces to be aware of:
106
106
 
107
- ### `#rollback`
107
+ <!-- ### `#rollback`
108
+ *** TODO: rollback actually only applies to rolling back *completed* steps of a multi-step Axn chain. Do not document for now -- need to decide if adding a trigger-when-axn-itself-fails rollback path. ***
108
109
 
109
110
  ::: danger ALPHA
110
111
  * ⚠️ `#rollback` is _expected_ to be added shortly, but is not yet functional!
111
112
  :::
112
113
 
113
- If you define a `#rollback` method, it'll be called (_before_ returning an `Action::Result` to the caller) whenever your action fails.
114
+ If you define a `#rollback` method, it'll be called (_before_ returning an `Action::Result` to the caller) whenever your action fails. -->
114
115
 
115
116
  ### Hooks
116
117
 
117
- `before` and `after` hooks are also supported. They can receive a block directly, or the symbol name of a local method.
118
+ `before` and `after` hooks are supported. They can receive a block directly, or the symbol name of a local method.
118
119
 
119
- Note execution is halted whenever `fail!` is called or an exception is raised (so a `before` block failure won't execute `call` or `after`, while an `after` block failure will make `resuilt.ok?` be false even though `call` completed successfully).
120
+ Note execution is halted whenever `fail!` is called or an exception is raised (so a `before` block failure won't execute `call` or `after`, while an `after` block failure will make `result.ok?` be false even though `call` completed successfully).
120
121
 
121
- ### Concrete example
122
-
123
- Given this series of methods and hooks:
122
+ For instance, given this configuration:
124
123
 
125
124
  ```ruby
126
125
  class Foo
@@ -133,10 +132,6 @@ class Foo
133
132
  log("in call")
134
133
  end
135
134
 
136
- def rollback
137
- log("rolling back")
138
- end
139
-
140
135
  private
141
136
 
142
137
  def log_after
@@ -153,8 +148,11 @@ end
153
148
  before hook
154
149
  in call
155
150
  after hook
156
- rolling back
157
151
  ```
158
152
 
153
+ ### Callbacks
154
+
155
+ 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. See the [Class Interface docs](/reference/class#callbacks) for details.
156
+
159
157
  ## Debugging
160
158
  Remember you can [enable debug logging](/reference/configuration.html#global-debug-logging) to print log lines before and after each action is executed.
@@ -0,0 +1,64 @@
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
+ action.warn("Ignoring #{e.class.name} in when evaluating #{self.class.name} handler: #{e.message}")
31
+ nil
32
+ end
33
+ end
34
+
35
+ class Matcher
36
+ def initialize(matcher)
37
+ @matcher = matcher
38
+ end
39
+
40
+ def matches?(exception:, action:)
41
+ if matcher.respond_to?(:call)
42
+ if matcher.arity == 1
43
+ !!action.instance_exec(exception, &matcher)
44
+ else
45
+ !!action.instance_exec(&matcher)
46
+ end
47
+ elsif matcher.is_a?(String) || matcher.is_a?(Symbol)
48
+ klass = Object.const_get(matcher.to_s)
49
+ klass && exception.is_a?(klass)
50
+ elsif matcher < Exception
51
+ exception.is_a?(matcher)
52
+ else
53
+ action.warn("Ignoring apparently-invalid matcher #{matcher.inspect} -- could not find way to apply it")
54
+ false
55
+ end
56
+ rescue StandardError => e
57
+ action.warn("Ignoring #{e.class.name} raised while determining matcher: #{e.message}")
58
+ false
59
+ end
60
+
61
+ private attr_reader :matcher
62
+ end
63
+ end
64
+ end
@@ -11,7 +11,7 @@ module Action
11
11
  @context = context
12
12
  end
13
13
 
14
- def message = @message.presence || "Execution was halted"
14
+ def message = @message.presence || @context.error_from_user.presence || "Execution was halted"
15
15
 
16
16
  def inspect = "#<#{self.class.name} '#{message}'>"
17
17
  end
@@ -1,42 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "event_handlers"
4
+
3
5
  module Action
4
6
  module SwallowExceptions
5
- CustomErrorInterceptor = Data.define(:matcher, :message, :should_report_error)
6
- CustomErrorHandler = Data.define(:matcher, :block)
7
-
8
- class CustomErrorInterceptor
9
- def self.matches?(matcher:, exception:, action:)
10
- if matcher.respond_to?(:call)
11
- if matcher.arity == 1
12
- !!action.instance_exec(exception, &matcher)
13
- else
14
- !!action.instance_exec(&matcher)
15
- end
16
- elsif matcher.is_a?(String) || matcher.is_a?(Symbol)
17
- klass = Object.const_get(matcher.to_s)
18
- klass && exception.is_a?(klass)
19
- elsif matcher < Exception
20
- exception.is_a?(matcher)
21
- else
22
- action.warn("Ignoring apparently-invalid matcher #{matcher.inspect} -- could not find way to apply it")
23
- false
24
- end
25
- rescue StandardError => e
26
- action.warn("Ignoring #{e.class.name} raised while determining matcher: #{e.message}")
27
- false
28
- end
29
-
30
- def matches?(exception:, action:)
31
- self.class.matches?(matcher:, exception:, action:)
32
- end
33
- end
34
-
35
7
  def self.included(base)
36
8
  base.class_eval do
37
9
  class_attribute :_success_msg, :_error_msg
38
10
  class_attribute :_custom_error_interceptors, default: []
11
+ class_attribute :_error_handlers, default: []
39
12
  class_attribute :_exception_handlers, default: []
13
+ class_attribute :_failure_handlers, default: []
40
14
 
41
15
  include InstanceMethods
42
16
  extend ClassMethods
@@ -44,9 +18,22 @@ module Action
44
18
  def run_with_exception_swallowing!
45
19
  original_run!
46
20
  rescue StandardError => e
47
- raise if e.is_a?(Action::Failure) # TODO: avoid raising if this was passed along from a child action (esp. if wrapped in hoist_errors)
21
+ # on_error handlers run for both unhandled exceptions and fail!
22
+ self.class._error_handlers.each do |handler|
23
+ handler.execute_if_matches(exception: e, action: self)
24
+ end
25
+
26
+ # on_failure handlers run ONLY for fail!
27
+ if e.is_a?(Action::Failure)
28
+ self.class._failure_handlers.each do |handler|
29
+ handler.execute_if_matches(exception: e, action: self)
30
+ end
31
+
32
+ # TODO: avoid raising if this was passed along from a child action (esp. if wrapped in hoist_errors)
33
+ raise e
34
+ end
48
35
 
49
- # Add custom hook for intercepting exceptions (e.g. Teamshares automatically logs to Honeybadger)
36
+ # on_exception handlers run for ONLY for unhandled exceptions. AND NOTE: may be skipped if the exception is rescued via `rescues`.
50
37
  trigger_on_exception(e)
51
38
 
52
39
  @context.exception = e
@@ -65,15 +52,17 @@ module Action
65
52
  raise if @context.object_id != e.context.object_id
66
53
  end
67
54
 
68
- def trigger_on_exception(e)
69
- interceptor = self.class._error_interceptor_for(exception: e, action: self)
55
+ def trigger_on_exception(exception)
56
+ interceptor = self.class._error_interceptor_for(exception:, action: self)
70
57
  return if interceptor&.should_report_error == false
71
58
 
72
59
  # Call any handlers registered on *this specific action* class
73
- _on_exception(e)
60
+ self.class._exception_handlers.each do |handler|
61
+ handler.execute_if_matches(exception:, action: self)
62
+ end
74
63
 
75
64
  # Call any global handlers
76
- Action.config.on_exception(e,
65
+ Action.config.on_exception(exception,
77
66
  action: self,
78
67
  context: respond_to?(:context_for_logging) ? context_for_logging : @context.to_h)
79
68
  rescue StandardError => e
@@ -114,10 +103,34 @@ module Action
114
103
  _register_error_interceptor(matcher, message, should_report_error: false, **match_and_messages)
115
104
  end
116
105
 
117
- def on_exception(matcher = StandardError, &block)
106
+ # ONLY raised exceptions (i.e. NOT fail!). Skipped if exception is rescued via .rescues.
107
+ def on_exception(matcher = -> { true }, &handler)
118
108
  raise ArgumentError, "on_exception must be called with a block" unless block_given?
119
109
 
120
- self._exception_handlers += [CustomErrorHandler.new(matcher:, block:)]
110
+ self._exception_handlers += [Action::EventHandlers::ConditionalHandler.new(matcher:, handler:)]
111
+ end
112
+
113
+ # ONLY raised on fail! (i.e. NOT unhandled exceptions).
114
+ def on_failure(matcher = -> { true }, &handler)
115
+ raise ArgumentError, "on_failure must be called with a block" unless block_given?
116
+
117
+ self._failure_handlers += [Action::EventHandlers::ConditionalHandler.new(matcher:, handler:)]
118
+ end
119
+
120
+ # Handles both fail! and unhandled exceptions... but is NOT affected by .rescues
121
+ def on_error(matcher = -> { true }, &handler)
122
+ raise ArgumentError, "on_error must be called with a block" unless block_given?
123
+
124
+ self._error_handlers += [Action::EventHandlers::ConditionalHandler.new(matcher:, handler:)]
125
+ end
126
+
127
+ # Syntactic sugar for "after { try" (after, but if it fails do NOT fail the action)
128
+ def on_success(&block)
129
+ raise ArgumentError, "on_success must be called with a block" unless block_given?
130
+
131
+ after do
132
+ try { instance_exec(&block) }
133
+ end
121
134
  end
122
135
 
123
136
  def default_error = new.internal_context.default_error
@@ -134,9 +147,11 @@ module Action
134
147
  method_name = should_report_error ? "error_from" : "rescues"
135
148
  raise ArgumentError, "#{method_name} must be called with a key/value pair, or else keyword args" if [matcher, message].compact.size == 1
136
149
 
137
- { matcher => message }.compact.merge(match_and_messages).each do |(matcher, message)| # rubocop:disable Lint/ShadowingOuterLocalVariable
138
- self._custom_error_interceptors += [CustomErrorInterceptor.new(matcher:, message:, should_report_error:)]
150
+ interceptors = { matcher => message }.compact.merge(match_and_messages).map do |(matcher, message)| # rubocop:disable Lint/ShadowingOuterLocalVariable
151
+ Action::EventHandlers::CustomErrorInterceptor.new(matcher:, message:, should_report_error:)
139
152
  end
153
+
154
+ self._custom_error_interceptors += interceptors
140
155
  end
141
156
  end
142
157
 
@@ -148,7 +163,7 @@ module Action
148
163
  @context.error_from_user = message if message.present?
149
164
 
150
165
  # TODO: should we use context_for_logging here? But doublecheck the one place where we're checking object_id on it...
151
- raise Action::Failure.new(@context) # rubocop:disable Style/RaiseArgs
166
+ raise Action::Failure.new(@context, message:)
152
167
  end
153
168
 
154
169
  def try
@@ -161,18 +176,6 @@ module Action
161
176
  end
162
177
 
163
178
  delegate :default_error, to: :internal_context
164
-
165
- def _on_exception(exception)
166
- handlers = self.class._exception_handlers.select do |this|
167
- CustomErrorInterceptor.matches?(matcher: this.matcher, exception:, action: self)
168
- end
169
-
170
- handlers.each do |handler|
171
- instance_exec(exception, &handler.block)
172
- rescue StandardError => e
173
- warn("Ignoring #{e.class.name} in on_exception hook: #{e.message}")
174
- end
175
- end
176
179
  end
177
180
  end
178
181
  end
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.4"
4
+ VERSION = "0.1.0-alpha.2.4.1"
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.4
4
+ version: 0.1.0.pre.alpha.2.4.1
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-05-27 00:00:00.000000000 Z
11
+ date: 2025-06-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activemodel
@@ -93,6 +93,7 @@ files:
93
93
  - lib/action/core/contract.rb
94
94
  - lib/action/core/contract_validator.rb
95
95
  - lib/action/core/enqueueable.rb
96
+ - lib/action/core/event_handlers.rb
96
97
  - lib/action/core/exceptions.rb
97
98
  - lib/action/core/hoist_errors.rb
98
99
  - lib/action/core/logging.rb