axn 0.1.0.pre.alpha.2.6 → 0.1.0.pre.alpha.2.6.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.
data/lib/axn/factory.rb CHANGED
@@ -22,6 +22,15 @@ module Axn
22
22
  after: nil,
23
23
  around: nil,
24
24
 
25
+ # Callbacks
26
+ on_success: nil,
27
+ on_failure: nil,
28
+ on_error: nil,
29
+ on_exception: nil,
30
+
31
+ # Strategies
32
+ use: [],
33
+
25
34
  &block
26
35
  )
27
36
  args = block.parameters.each_with_object(_hash_with_default_array) { |(type, field), hash| hash[type] << field }
@@ -82,6 +91,27 @@ module Axn
82
91
  axn.after(after) if after.present?
83
92
  axn.around(around) if around.present?
84
93
 
94
+ # Callbacks
95
+ axn.on_success(&on_success) if on_success.present?
96
+ axn.on_failure(&on_failure) if on_failure.present?
97
+ axn.on_error(&on_error) if on_error.present?
98
+ axn.on_exception(&on_exception) if on_exception.present?
99
+
100
+ # Strategies
101
+ Array(use).each do |strategy|
102
+ if strategy.is_a?(Array)
103
+ strategy_name, *config_args = strategy
104
+ if config_args.last.is_a?(Hash)
105
+ *other_args, config = config_args
106
+ axn.use(strategy_name, *other_args, **config)
107
+ else
108
+ axn.use(strategy_name, *config_args)
109
+ end
110
+ else
111
+ axn.use(strategy)
112
+ end
113
+ end
114
+
85
115
  # Default exposure
86
116
  axn.exposes(expose_return_as, allow_blank: true) if expose_return_as.present?
87
117
  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.6"
4
+ VERSION = "0.1.0-alpha.2.6.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.6
4
+ version: 0.1.0.pre.alpha.2.6.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-08-07 00:00:00.000000000 Z
11
+ date: 2025-08-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activemodel
@@ -80,16 +80,22 @@ files:
80
80
  - lib/action/context.rb
81
81
  - lib/action/core.rb
82
82
  - lib/action/core/automatic_logging.rb
83
- - lib/action/core/context_facade.rb
83
+ - lib/action/core/context/facade.rb
84
+ - lib/action/core/context/facade_inspector.rb
85
+ - lib/action/core/context/internal.rb
84
86
  - lib/action/core/contract.rb
85
87
  - lib/action/core/contract_for_subfields.rb
86
88
  - lib/action/core/contract_validation.rb
87
89
  - lib/action/core/event_handlers.rb
88
- - lib/action/core/handle_exceptions.rb
90
+ - lib/action/core/flow.rb
91
+ - lib/action/core/flow/callbacks.rb
92
+ - lib/action/core/flow/exception_execution.rb
93
+ - lib/action/core/flow/messages.rb
89
94
  - lib/action/core/hoist_errors.rb
90
95
  - lib/action/core/hooks.rb
91
96
  - lib/action/core/logging.rb
92
97
  - lib/action/core/timing.rb
98
+ - lib/action/core/tracing.rb
93
99
  - lib/action/core/use_strategy.rb
94
100
  - lib/action/core/validation/fields.rb
95
101
  - lib/action/core/validation/subfields.rb
@@ -99,6 +105,7 @@ files:
99
105
  - lib/action/enqueueable.rb
100
106
  - lib/action/enqueueable/via_sidekiq.rb
101
107
  - lib/action/exceptions.rb
108
+ - lib/action/result.rb
102
109
  - lib/action/strategies.rb
103
110
  - lib/action/strategies/transaction.rb
104
111
  - lib/axn.rb
@@ -1,209 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_support/parameter_filter"
4
-
5
- module Action
6
- class ContextFacade
7
- def initialize(action:, context:, declared_fields:, implicitly_allowed_fields: nil)
8
- if self.class.name == "Action::ContextFacade" # rubocop:disable Style/ClassEqualityComparison
9
- raise "Action::ContextFacade is an abstract class and should not be instantiated directly"
10
- end
11
-
12
- @context = context
13
- @action = action
14
- @declared_fields = declared_fields
15
-
16
- (@declared_fields + Array(implicitly_allowed_fields)).each do |field|
17
- singleton_class.define_method(field) { @context.public_send(field) }
18
- end
19
- end
20
-
21
- attr_reader :declared_fields
22
-
23
- def inspect = Inspector.new(facade: self, action:, context:).call
24
-
25
- def fail!(...)
26
- raise Action::ContractViolation::MethodNotAllowed, "Call fail! directly rather than on the context"
27
- end
28
-
29
- private
30
-
31
- attr_reader :action, :context
32
-
33
- def exposure_method_name = raise NotImplementedError
34
-
35
- # Add nice error message for missing methods
36
- def method_missing(method_name, ...) # rubocop:disable Style/MissingRespondToMissing (because we're not actually responding to anything additional)
37
- if context.respond_to?(method_name)
38
- msg = <<~MSG
39
- Method ##{method_name} is not available on #{self.class.name}!
40
-
41
- #{@action.class.name || "The action"} may be missing a line like:
42
- #{exposure_method_name} :#{method_name}
43
- MSG
44
-
45
- raise Action::ContractViolation::MethodNotAllowed, msg
46
- end
47
-
48
- super
49
- end
50
-
51
- def determine_error_message(only_default: false)
52
- return @context.error_from_user if @context.error_from_user.present?
53
-
54
- # We need an exception for interceptors, and also in case the messages.error callable expects an argument
55
- exception = @context.exception || Action::Failure.new
56
-
57
- msg = action._error_msg
58
-
59
- unless only_default
60
- interceptor = action.class._error_interceptor_for(exception:, action:)
61
- msg = interceptor.message if interceptor
62
- end
63
-
64
- stringified(msg, exception:).presence || "Something went wrong"
65
- end
66
-
67
- # Allow for callable OR string messages
68
- def stringified(msg, exception: nil)
69
- return msg.presence unless msg.respond_to?(:call)
70
-
71
- # The error message callable can take the exception as an argument
72
- if exception && msg.arity == 1
73
- action.instance_exec(exception, &msg)
74
- else
75
- action.instance_exec(&msg)
76
- end
77
- rescue StandardError => e
78
- Axn::Util.piping_error("determining message callable", action:, exception: e)
79
- end
80
- end
81
-
82
- # Inbound / Internal ContextFacade
83
- class InternalContext < ContextFacade
84
- # So can be referenced from within e.g. rescues callables
85
- def default_error
86
- [@context.error_prefix, determine_error_message(only_default: true)].compact.join(" ").squeeze(" ")
87
- end
88
-
89
- private
90
-
91
- def exposure_method_name = :expects
92
- end
93
-
94
- # Outbound / External ContextFacade
95
- class Result < ContextFacade
96
- # For ease of mocking return results in tests
97
- class << self
98
- def ok(msg = nil, **exposures)
99
- exposes = exposures.keys.to_h { |key| [key, { allow_blank: true }] }
100
-
101
- Axn::Factory.build(exposes:, messages: { success: msg }) do
102
- exposures.each do |key, value|
103
- expose(key, value)
104
- end
105
- end.call
106
- end
107
-
108
- def error(msg = nil, **exposures, &block)
109
- exposes = exposures.keys.to_h { |key| [key, { allow_blank: true }] }
110
- rescues = [-> { true }, msg]
111
-
112
- Axn::Factory.build(exposes:, rescues:) do
113
- exposures.each do |key, value|
114
- expose(key, value)
115
- end
116
- block.call if block_given?
117
- fail!
118
- end.call
119
- end
120
- end
121
-
122
- # Poke some holes for necessary internal control methods
123
- delegate :each_pair, to: :context
124
-
125
- # External interface
126
- delegate :success?, :exception, to: :context
127
- def ok? = success?
128
-
129
- def error
130
- return if ok?
131
-
132
- [@context.error_prefix, determine_error_message].compact.join(" ").squeeze(" ")
133
- end
134
-
135
- def success
136
- return unless ok?
137
-
138
- stringified(action._success_msg).presence || "Action completed successfully"
139
- end
140
-
141
- def ok = success
142
-
143
- def message = error || success
144
-
145
- private
146
-
147
- def exposure_method_name = :exposes
148
- end
149
-
150
- class Inspector
151
- def initialize(action:, facade:, context:)
152
- @action = action
153
- @facade = facade
154
- @context = context
155
- end
156
-
157
- def call
158
- str = [status, visible_fields].compact_blank.join(" ")
159
-
160
- "#<#{class_name} #{str}>"
161
- end
162
-
163
- private
164
-
165
- attr_reader :action, :facade, :context
166
-
167
- def status
168
- return unless facade.is_a?(Action::Result)
169
-
170
- return "[OK]" if context.success?
171
- unless context.exception
172
- return context.error_from_user.present? ? "[failed with '#{context.error_from_user}']" : "[failed]"
173
- end
174
-
175
- %([failed with #{context.exception.class.name}: '#{context.exception.message}'])
176
- end
177
-
178
- def visible_fields
179
- declared_fields.map do |field|
180
- value = facade.public_send(field)
181
-
182
- "#{field}: #{format_for_inspect(field, value)}"
183
- end.join(", ")
184
- end
185
-
186
- def class_name = facade.class.name
187
- def declared_fields = facade.send(:declared_fields)
188
-
189
- def format_for_inspect(field, value)
190
- return value.inspect if value.nil?
191
-
192
- # Initially based on https://github.com/rails/rails/blob/800976975253be2912d09a80757ee70a2bb1e984/activerecord/lib/active_record/attribute_methods.rb#L527
193
- inspected_value = if value.is_a?(String) && value.length > 50
194
- "#{value[0, 50]}...".inspect
195
- elsif value.is_a?(Date) || value.is_a?(Time)
196
- %("#{value.to_fs(:inspect)}")
197
- elsif defined?(::ActiveRecord::Relation) && value.instance_of?(::ActiveRecord::Relation)
198
- # Avoid hydrating full AR relation (i.e. avoid loading records just to report an error)
199
- "#{value.name}::ActiveRecord_Relation"
200
- else
201
- value.inspect
202
- end
203
-
204
- inspection_filter.filter_param(field, inspected_value)
205
- end
206
-
207
- def inspection_filter = action.send(:inspection_filter)
208
- end
209
- end
@@ -1,143 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # TODO: maybe namespace those under core?
4
- require "action/core/event_handlers"
5
-
6
- module Action
7
- module Core
8
- module HandleExceptions
9
- def self.included(base)
10
- base.class_eval do
11
- class_attribute :_success_msg, :_error_msg
12
- class_attribute :_custom_error_interceptors, default: []
13
- class_attribute :_error_handlers, default: []
14
- class_attribute :_exception_handlers, default: []
15
- class_attribute :_failure_handlers, default: []
16
- class_attribute :_success_handlers, default: []
17
-
18
- include InstanceMethods
19
- extend ClassMethods
20
-
21
- def trigger_on_exception(exception)
22
- interceptor = self.class._error_interceptor_for(exception:, action: self)
23
- return if interceptor&.should_report_error == false
24
-
25
- # Call any handlers registered on *this specific action* class
26
- self.class._exception_handlers.each do |handler|
27
- handler.execute_if_matches(exception:, action: self)
28
- end
29
-
30
- # Call any global handlers
31
- Action.config.on_exception(exception,
32
- action: self,
33
- context: respond_to?(:context_for_logging) ? context_for_logging : @context.to_h)
34
- rescue StandardError => e
35
- # No action needed -- downstream #on_exception implementation should ideally log any internal failures, but
36
- # we don't want exception *handling* failures to cascade and overwrite the original exception.
37
- Axn::Util.piping_error("executing on_exception hooks", action: self, exception: e)
38
- end
39
-
40
- def trigger_on_success
41
- # Call success handlers in child-first order (like after hooks)
42
- self.class._success_handlers.each do |handler|
43
- instance_exec(&handler)
44
- rescue StandardError => e
45
- # Log the error but continue with other handlers
46
- Axn::Util.piping_error("executing on_success hook", action: self, exception: e)
47
- end
48
- end
49
- end
50
- end
51
-
52
- module ClassMethods
53
- def messages(success: nil, error: nil)
54
- self._success_msg = success if success.present?
55
- self._error_msg = error if error.present?
56
-
57
- true
58
- end
59
-
60
- def error_from(matcher = nil, message = nil, **match_and_messages)
61
- _register_error_interceptor(matcher, message, should_report_error: true, **match_and_messages)
62
- end
63
-
64
- def rescues(matcher = nil, message = nil, **match_and_messages)
65
- _register_error_interceptor(matcher, message, should_report_error: false, **match_and_messages)
66
- end
67
-
68
- # ONLY raised exceptions (i.e. NOT fail!). Skipped if exception is rescued via .rescues.
69
- def on_exception(matcher = -> { true }, &handler)
70
- raise ArgumentError, "on_exception must be called with a block" unless block_given?
71
-
72
- self._exception_handlers += [Action::EventHandlers::ConditionalHandler.new(matcher:, handler:)]
73
- end
74
-
75
- # ONLY raised on fail! (i.e. NOT unhandled exceptions).
76
- def on_failure(matcher = -> { true }, &handler)
77
- raise ArgumentError, "on_failure must be called with a block" unless block_given?
78
-
79
- self._failure_handlers += [Action::EventHandlers::ConditionalHandler.new(matcher:, handler:)]
80
- end
81
-
82
- # Handles both fail! and unhandled exceptions... but is NOT affected by .rescues
83
- def on_error(matcher = -> { true }, &handler)
84
- raise ArgumentError, "on_error must be called with a block" unless block_given?
85
-
86
- self._error_handlers += [Action::EventHandlers::ConditionalHandler.new(matcher:, handler:)]
87
- end
88
-
89
- # Executes when the action completes successfully (after all after hooks complete successfully)
90
- # Runs in child-first order (child handlers before parent handlers)
91
- def on_success(&handler)
92
- raise ArgumentError, "on_success must be called with a block" unless block_given?
93
-
94
- # Prepend like after hooks - child handlers run before parent handlers
95
- self._success_handlers = [handler] + _success_handlers
96
- end
97
-
98
- def default_error = new.internal_context.default_error
99
-
100
- # Private helpers
101
-
102
- def _error_interceptor_for(exception:, action:)
103
- Array(_custom_error_interceptors).detect do |int|
104
- int.matches?(exception:, action:)
105
- end
106
- end
107
-
108
- def _register_error_interceptor(matcher, message, should_report_error:, **match_and_messages)
109
- method_name = should_report_error ? "error_from" : "rescues"
110
- raise ArgumentError, "#{method_name} must be called with a key/value pair, or else keyword args" if [matcher, message].compact.size == 1
111
-
112
- interceptors = { matcher => message }.compact.merge(match_and_messages).map do |(matcher, message)| # rubocop:disable Lint/ShadowingOuterLocalVariable
113
- Action::EventHandlers::CustomErrorInterceptor.new(matcher:, message:, should_report_error:)
114
- end
115
-
116
- self._custom_error_interceptors += interceptors
117
- end
118
- end
119
-
120
- module InstanceMethods
121
- private
122
-
123
- def fail!(message = nil)
124
- @context.instance_variable_set("@failure", true)
125
- @context.error_from_user = message if message.present?
126
-
127
- raise Action::Failure, message
128
- end
129
-
130
- def try
131
- yield
132
- rescue Action::Failure => e
133
- # NOTE: re-raising so we can still fail! from inside the block
134
- raise e
135
- rescue StandardError => e
136
- trigger_on_exception(e)
137
- end
138
-
139
- delegate :default_error, to: :internal_context
140
- end
141
- end
142
- end
143
- end