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.
- checksums.yaml +4 -4
- data/.rubocop.yml +4 -1
- data/CHANGELOG.md +13 -0
- data/docs/reference/action-result.md +2 -0
- data/docs/reference/class.md +12 -4
- data/docs/reference/configuration.md +42 -20
- data/docs/usage/writing.md +1 -1
- data/lib/action/attachable/steps.rb +16 -1
- data/lib/action/configuration.rb +2 -3
- data/lib/action/context.rb +28 -18
- data/lib/action/core/automatic_logging.rb +24 -8
- data/lib/action/core/context/facade.rb +69 -0
- data/lib/action/core/context/facade_inspector.rb +63 -0
- data/lib/action/core/context/internal.rb +32 -0
- data/lib/action/core/contract.rb +25 -8
- data/lib/action/core/contract_for_subfields.rb +1 -1
- data/lib/action/core/contract_validation.rb +15 -4
- data/lib/action/core/flow/callbacks.rb +54 -0
- data/lib/action/core/flow/exception_execution.rb +79 -0
- data/lib/action/core/flow/messages.rb +61 -0
- data/lib/action/core/flow.rb +19 -0
- data/lib/action/core/hoist_errors.rb +2 -2
- data/lib/action/core/hooks.rb +15 -15
- data/lib/action/core/logging.rb +2 -2
- data/lib/action/core/timing.rb +18 -0
- data/lib/action/core/tracing.rb +17 -0
- data/lib/action/core/validation/fields.rb +2 -0
- data/lib/action/core.rb +25 -78
- data/lib/action/enqueueable/via_sidekiq.rb +2 -2
- data/lib/action/result.rb +95 -0
- data/lib/axn/factory.rb +30 -0
- data/lib/axn/version.rb +1 -1
- metadata +11 -4
- data/lib/action/core/context_facade.rb +0 -209
- data/lib/action/core/handle_exceptions.rb +0 -143
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
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-
|
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/
|
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/
|
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
|