axn 0.1.0.pre.alpha.2.3 → 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: ac3f240b093cdf14fef16b5eb6993dbb8f6d49ba58c837245128a0efb3558f11
4
- data.tar.gz: 40b631f49349b51811ebb0bbc60cbc7962a3f7d633bab1134b7b234b4f681123
3
+ metadata.gz: 19221983bd11ff17620830a4807b4080f8ea35dfb07050e2af2e6c1dac1e1b7d
4
+ data.tar.gz: 7ac5fc8f9ae99bd9a5a189ad53f6113c4c544181ff760bbe923ca1867f011877
5
5
  SHA512:
6
- metadata.gz: c8ac8be69e70d72d04b0c5be89e4ec198223c4a1771a15d254b82b0a204543c627a80321b3de95d69fa97a244fc56c9c39ca59262600c6b991c363603e0a7beb
7
- data.tar.gz: 4eba5152626d0417ff3be725da9512034276d4b034898f8ad331890499196c1851a1d3dcd1cde880955c42f425090ab6c5fe30cac1496835013c7bd6cda52cdb
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,12 @@
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
+
9
+ ## 0.1.0-alpha.2.4
10
+ * [FEAT] Adds per-Axn `on_exception` handlers
11
+
6
12
  ## 0.1.0-alpha.2.3
7
13
  * `expects` / `exposes`: Add `type: :uuid` special case validation
8
14
  * [BUGFIX] Allow `hoist_errors` to pass the result through on success (allow access to subactions' exposures)
@@ -63,7 +63,7 @@ messages success: "All good!", error: ->(e) { "Bad news: #{e.message}" }
63
63
 
64
64
  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.
65
65
 
66
- `error_for` 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 global error handler.
66
+ `error_for` 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).
67
67
 
68
68
  ```ruby
69
69
  messages error: "bad"
@@ -72,6 +72,67 @@ 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
+
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`
107
+
108
+ Much like the [globally-configured on_exception hook](/reference/configuration#on-exception), you can also specify exception handlers for a _specific_ Axn class:
109
+
110
+ ```ruby
111
+ class Foo
112
+ include Action
113
+
114
+ on_exception do |exception| # [!code focus:3]
115
+ # e.g. trigger a slack error
116
+ end
117
+ end
118
+ ```
119
+
120
+ 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_for` and `rescues`](#error-for-and-rescues):
121
+
122
+ ```ruby
123
+ class Foo
124
+ include Action
125
+
126
+ on_exception NoMethodError do |exception| # [!code focus]
127
+ # e.g. trigger a slack error
128
+ end
129
+
130
+ on_exception ->(e) { e.is_a?(ZeroDivisionError) } do # [!code focus]
131
+ # e.g. trigger a slack error
132
+ end
133
+ end
134
+ ```
135
+
136
+ If multiple `on_exception` handlers are provided, ALL that match the raised exception will be triggered in the order provided.
137
+
138
+ The _global_ handler will be triggered _after_ all class-specific handlers.
@@ -39,6 +39,7 @@ A couple notes:
39
39
 
40
40
  * `context` will contain the arguments passed to the `action`, _but_ any marked as sensitive (e.g. `expects :foo, sensitive: true`) will be filtered out in the logs.
41
41
  * If your handler raises, the failure will _also_ be swallowed and logged
42
+ * This handler is global across _all_ Axns. You can also specify per-Action handlers via [the class-level declaration](/reference/class#on-exception).
42
43
 
43
44
 
44
45
  ## `top_level_around_hook`
@@ -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,35 +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
- class CustomErrorInterceptor
7
- def matches?(exception:, action:)
8
- if matcher.respond_to?(:call)
9
- if matcher.arity == 1
10
- !!action.instance_exec(exception, &matcher)
11
- else
12
- !!action.instance_exec(&matcher)
13
- end
14
- elsif matcher.is_a?(String) || matcher.is_a?(Symbol)
15
- klass = Object.const_get(matcher.to_s)
16
- klass && exception.is_a?(klass)
17
- elsif matcher < Exception
18
- exception.is_a?(matcher)
19
- else
20
- action.warn("Ignoring apparently-invalid matcher #{matcher.inspect} -- could not find way to apply it")
21
- false
22
- end
23
- rescue StandardError => e
24
- action.warn("Ignoring #{e.class.name} raised while determining matcher: #{e.message}")
25
- false
26
- end
27
- end
28
-
29
7
  def self.included(base)
30
8
  base.class_eval do
31
9
  class_attribute :_success_msg, :_error_msg
32
10
  class_attribute :_custom_error_interceptors, default: []
11
+ class_attribute :_error_handlers, default: []
12
+ class_attribute :_exception_handlers, default: []
13
+ class_attribute :_failure_handlers, default: []
33
14
 
34
15
  include InstanceMethods
35
16
  extend ClassMethods
@@ -37,9 +18,22 @@ module Action
37
18
  def run_with_exception_swallowing!
38
19
  original_run!
39
20
  rescue StandardError => e
40
- 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
41
35
 
42
- # 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`.
43
37
  trigger_on_exception(e)
44
38
 
45
39
  @context.exception = e
@@ -58,11 +52,17 @@ module Action
58
52
  raise if @context.object_id != e.context.object_id
59
53
  end
60
54
 
61
- def trigger_on_exception(e)
62
- 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)
63
57
  return if interceptor&.should_report_error == false
64
58
 
65
- Action.config.on_exception(e,
59
+ # Call any handlers registered on *this specific action* class
60
+ self.class._exception_handlers.each do |handler|
61
+ handler.execute_if_matches(exception:, action: self)
62
+ end
63
+
64
+ # Call any global handlers
65
+ Action.config.on_exception(exception,
66
66
  action: self,
67
67
  context: respond_to?(:context_for_logging) ? context_for_logging : @context.to_h)
68
68
  rescue StandardError => e
@@ -103,6 +103,36 @@ module Action
103
103
  _register_error_interceptor(matcher, message, should_report_error: false, **match_and_messages)
104
104
  end
105
105
 
106
+ # ONLY raised exceptions (i.e. NOT fail!). Skipped if exception is rescued via .rescues.
107
+ def on_exception(matcher = -> { true }, &handler)
108
+ raise ArgumentError, "on_exception must be called with a block" unless block_given?
109
+
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
134
+ end
135
+
106
136
  def default_error = new.internal_context.default_error
107
137
 
108
138
  # Private helpers
@@ -117,9 +147,11 @@ module Action
117
147
  method_name = should_report_error ? "error_from" : "rescues"
118
148
  raise ArgumentError, "#{method_name} must be called with a key/value pair, or else keyword args" if [matcher, message].compact.size == 1
119
149
 
120
- { matcher => message }.compact.merge(match_and_messages).each do |(matcher, message)| # rubocop:disable Lint/ShadowingOuterLocalVariable
121
- 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:)
122
152
  end
153
+
154
+ self._custom_error_interceptors += interceptors
123
155
  end
124
156
  end
125
157
 
@@ -131,7 +163,7 @@ module Action
131
163
  @context.error_from_user = message if message.present?
132
164
 
133
165
  # TODO: should we use context_for_logging here? But doublecheck the one place where we're checking object_id on it...
134
- raise Action::Failure.new(@context) # rubocop:disable Style/RaiseArgs
166
+ raise Action::Failure.new(@context, message:)
135
167
  end
136
168
 
137
169
  def try
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.3"
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.3
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-22 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