axn 0.1.0.pre.alpha.3 → 0.1.0.pre.alpha.4
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/.cursor/commands/pr.md +36 -0
- data/CHANGELOG.md +15 -1
- data/Rakefile +102 -2
- data/docs/.vitepress/config.mjs +12 -8
- data/docs/advanced/conventions.md +1 -1
- data/docs/advanced/mountable.md +4 -90
- data/docs/advanced/profiling.md +26 -30
- data/docs/advanced/rough.md +27 -8
- data/docs/intro/overview.md +1 -1
- data/docs/recipes/formatting-context-for-error-tracking.md +186 -0
- data/docs/recipes/memoization.md +102 -17
- data/docs/reference/async.md +269 -0
- data/docs/reference/class.md +113 -50
- data/docs/reference/configuration.md +226 -75
- data/docs/reference/form-object.md +252 -0
- data/docs/strategies/client.md +212 -0
- data/docs/strategies/form.md +235 -0
- data/docs/usage/setup.md +2 -2
- data/docs/usage/writing.md +99 -1
- data/lib/axn/async/adapters/active_job.rb +19 -10
- data/lib/axn/async/adapters/disabled.rb +15 -0
- data/lib/axn/async/adapters/sidekiq.rb +25 -32
- data/lib/axn/async/batch_enqueue/config.rb +38 -0
- data/lib/axn/async/batch_enqueue.rb +99 -0
- data/lib/axn/async/enqueue_all_orchestrator.rb +363 -0
- data/lib/axn/async.rb +121 -4
- data/lib/axn/configuration.rb +53 -13
- data/lib/axn/context.rb +1 -0
- data/lib/axn/core/automatic_logging.rb +47 -51
- data/lib/axn/core/context/facade_inspector.rb +1 -1
- data/lib/axn/core/contract.rb +73 -30
- data/lib/axn/core/contract_for_subfields.rb +1 -1
- data/lib/axn/core/contract_validation.rb +14 -9
- data/lib/axn/core/contract_validation_for_subfields.rb +14 -7
- data/lib/axn/core/default_call.rb +63 -0
- data/lib/axn/core/flow/exception_execution.rb +5 -0
- data/lib/axn/core/flow/handlers/descriptors/message_descriptor.rb +19 -7
- data/lib/axn/core/flow/handlers/invoker.rb +4 -30
- data/lib/axn/core/flow/handlers/matcher.rb +4 -14
- data/lib/axn/core/flow/messages.rb +1 -1
- data/lib/axn/core/hooks.rb +1 -0
- data/lib/axn/core/logging.rb +16 -5
- data/lib/axn/core/memoization.rb +53 -0
- data/lib/axn/core/tracing.rb +77 -4
- data/lib/axn/core/validation/validators/type_validator.rb +1 -1
- data/lib/axn/core.rb +31 -46
- data/lib/axn/extras/strategies/client.rb +150 -0
- data/lib/axn/extras/strategies/vernier.rb +121 -0
- data/lib/axn/extras.rb +4 -0
- data/lib/axn/factory.rb +22 -2
- data/lib/axn/form_object.rb +90 -0
- data/lib/axn/internal/logging.rb +5 -1
- data/lib/axn/mountable/helpers/class_builder.rb +41 -10
- data/lib/axn/mountable/helpers/namespace_manager.rb +6 -34
- data/lib/axn/mountable/inherit_profiles.rb +2 -2
- data/lib/axn/mountable/mounting_strategies/_base.rb +10 -6
- data/lib/axn/mountable/mounting_strategies/method.rb +2 -2
- data/lib/axn/mountable.rb +41 -7
- data/lib/axn/rails/generators/axn_generator.rb +19 -1
- data/lib/axn/rails/generators/templates/action.rb.erb +1 -1
- data/lib/axn/result.rb +2 -2
- data/lib/axn/strategies/form.rb +98 -0
- data/lib/axn/strategies/transaction.rb +7 -0
- data/lib/axn/util/callable.rb +120 -0
- data/lib/axn/util/contract_error_handling.rb +32 -0
- data/lib/axn/util/execution_context.rb +34 -0
- data/lib/axn/util/global_id_serialization.rb +52 -0
- data/lib/axn/util/logging.rb +87 -0
- data/lib/axn/version.rb +1 -1
- data/lib/axn.rb +9 -0
- metadata +22 -4
- data/lib/axn/core/profiling.rb +0 -124
- data/lib/axn/mountable/mounting_strategies/enqueue_all.rb +0 -55
|
@@ -9,13 +9,18 @@ module Axn
|
|
|
9
9
|
include InstanceMethods
|
|
10
10
|
|
|
11
11
|
# Single class_attribute - nil means disabled, any level means enabled
|
|
12
|
-
class_attribute :
|
|
12
|
+
class_attribute :log_calls_level, default: Axn.config.log_level
|
|
13
|
+
class_attribute :log_errors_level, default: nil
|
|
13
14
|
end
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
module ClassMethods
|
|
17
|
-
def
|
|
18
|
-
self.
|
|
18
|
+
def log_calls(level)
|
|
19
|
+
self.log_calls_level = level.presence
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def log_errors(level)
|
|
23
|
+
self.log_errors_level = level.presence
|
|
19
24
|
end
|
|
20
25
|
end
|
|
21
26
|
|
|
@@ -23,69 +28,60 @@ module Axn
|
|
|
23
28
|
private
|
|
24
29
|
|
|
25
30
|
def _with_logging
|
|
26
|
-
_log_before if self.class.
|
|
31
|
+
_log_before if self.class.log_calls_level
|
|
27
32
|
yield
|
|
28
33
|
ensure
|
|
29
|
-
_log_after if self.class.
|
|
34
|
+
_log_after if self.class.log_calls_level || self.class.log_errors_level
|
|
30
35
|
end
|
|
31
36
|
|
|
32
37
|
def _log_before
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
before: Axn.config.env.production? ? nil : "\n------\n",
|
|
38
|
+
Axn::Util::Logging.log_at_level(
|
|
39
|
+
self.class,
|
|
40
|
+
level: self.class.log_calls_level,
|
|
41
|
+
message_parts: ["About to execute"],
|
|
42
|
+
join_string: " with: ",
|
|
43
|
+
before: _top_level_separator,
|
|
44
|
+
error_context: "logging before hook",
|
|
45
|
+
context_direction: :inbound,
|
|
46
|
+
context_instance: self,
|
|
43
47
|
)
|
|
44
|
-
rescue StandardError => e
|
|
45
|
-
Axn::Internal::Logging.piping_error("logging before hook", action: self, exception: e)
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
def _log_after
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
[
|
|
55
|
-
"Execution completed (with outcome: #{result.outcome}) in #{result.elapsed_time} milliseconds",
|
|
56
|
-
_log_context(:outbound),
|
|
57
|
-
].compact.join(". Set: "),
|
|
58
|
-
after: Axn.config.env.production? ? nil : "\n------\n",
|
|
59
|
-
)
|
|
60
|
-
rescue StandardError => e
|
|
61
|
-
Axn::Internal::Logging.piping_error("logging after hook", action: self, exception: e)
|
|
62
|
-
end
|
|
51
|
+
# Check log_calls_level first (logs all outcomes)
|
|
52
|
+
if self.class.log_calls_level
|
|
53
|
+
_log_after_at_level(self.class.log_calls_level)
|
|
54
|
+
return
|
|
55
|
+
end
|
|
63
56
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return unless data.present?
|
|
57
|
+
# Check log_errors_level (only logs when result.ok? is false)
|
|
58
|
+
return unless self.class.log_errors_level && !result.ok?
|
|
67
59
|
|
|
68
|
-
|
|
69
|
-
|
|
60
|
+
_log_after_at_level(self.class.log_errors_level)
|
|
61
|
+
end
|
|
70
62
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
63
|
+
def _log_after_at_level(level)
|
|
64
|
+
Axn::Util::Logging.log_at_level(
|
|
65
|
+
self.class,
|
|
66
|
+
level:,
|
|
67
|
+
message_parts: [
|
|
68
|
+
"Execution completed (with outcome: #{result.outcome}) in #{result.elapsed_time} milliseconds",
|
|
69
|
+
],
|
|
70
|
+
join_string: ". Set: ",
|
|
71
|
+
after: _top_level_separator,
|
|
72
|
+
error_context: "logging after hook",
|
|
73
|
+
context_direction: :outbound,
|
|
74
|
+
context_instance: self,
|
|
75
|
+
)
|
|
74
76
|
end
|
|
75
77
|
|
|
76
|
-
def
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
when Array
|
|
82
|
-
data.map { |v| _log_object(v) }
|
|
83
|
-
else
|
|
84
|
-
return data.to_unsafe_h if defined?(ActionController::Parameters) && data.is_a?(ActionController::Parameters)
|
|
85
|
-
return "<#{data.class.name}##{data.to_param.presence || "unpersisted"}>" if defined?(ActiveRecord::Base) && data.is_a?(ActiveRecord::Base)
|
|
78
|
+
def _top_level_separator
|
|
79
|
+
return if Axn.config.env.production?
|
|
80
|
+
return if Axn::Util::ExecutionContext.background?
|
|
81
|
+
return if Axn::Util::ExecutionContext.console?
|
|
82
|
+
return if NestingTracking._current_axn_stack.size > 1
|
|
86
83
|
|
|
87
|
-
|
|
88
|
-
end
|
|
84
|
+
"\n------\n"
|
|
89
85
|
end
|
|
90
86
|
end
|
|
91
87
|
end
|
|
@@ -65,7 +65,7 @@ module Axn
|
|
|
65
65
|
inspection_filter.filter_param(field, inspected_value)
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
-
def inspection_filter = action.
|
|
68
|
+
def inspection_filter = action.class.inspection_filter
|
|
69
69
|
|
|
70
70
|
def sensitive_subfields?(field)
|
|
71
71
|
action.subfield_configs.any? { |config| config.on == field && config.sensitive }
|
data/lib/axn/core/contract.rb
CHANGED
|
@@ -41,7 +41,7 @@ module Axn
|
|
|
41
41
|
|
|
42
42
|
_parse_field_configs(*fields, allow_blank:, allow_nil:, optional:, default:, preprocess:, sensitive:, **validations).tap do |configs|
|
|
43
43
|
duplicated = internal_field_configs.map(&:field) & configs.map(&:field)
|
|
44
|
-
raise Axn::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(
|
|
44
|
+
raise Axn::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(', ')}" if duplicated.any?
|
|
45
45
|
|
|
46
46
|
# NOTE: avoid <<, which would update value for parents and children
|
|
47
47
|
self.internal_field_configs += configs
|
|
@@ -63,13 +63,37 @@ module Axn
|
|
|
63
63
|
|
|
64
64
|
_parse_field_configs(*fields, allow_blank:, allow_nil:, optional:, default:, preprocess: nil, sensitive:, **validations).tap do |configs|
|
|
65
65
|
duplicated = external_field_configs.map(&:field) & configs.map(&:field)
|
|
66
|
-
raise Axn::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(
|
|
66
|
+
raise Axn::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(', ')}" if duplicated.any?
|
|
67
67
|
|
|
68
68
|
# NOTE: avoid <<, which would update value for parents and children
|
|
69
69
|
self.external_field_configs += configs
|
|
70
70
|
end
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
+
def inspection_filter
|
|
74
|
+
@inspection_filter ||= ActiveSupport::ParameterFilter.new(sensitive_fields)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def sensitive_fields
|
|
78
|
+
(internal_field_configs + external_field_configs + subfield_configs).select(&:sensitive).map(&:field)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def _declared_fields(direction)
|
|
82
|
+
raise ArgumentError, "Invalid direction: #{direction}" unless direction.nil? || %i[inbound outbound].include?(direction)
|
|
83
|
+
|
|
84
|
+
configs = case direction
|
|
85
|
+
when :inbound then internal_field_configs
|
|
86
|
+
when :outbound then external_field_configs
|
|
87
|
+
else (internal_field_configs + external_field_configs)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
configs.map(&:field)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def context_for_logging(data:, direction: nil)
|
|
94
|
+
inspection_filter.filter(data.slice(*_declared_fields(direction)))
|
|
95
|
+
end
|
|
96
|
+
|
|
73
97
|
private
|
|
74
98
|
|
|
75
99
|
RESERVED_FIELD_NAMES_FOR_EXPECTATIONS = %w[
|
|
@@ -169,54 +193,73 @@ module Axn
|
|
|
169
193
|
end
|
|
170
194
|
end
|
|
171
195
|
|
|
196
|
+
# Set additional context to be included in exception logging
|
|
197
|
+
# This context is only used when exceptions occur, not in normal pre/post logging
|
|
198
|
+
def set_logging_context(**kwargs)
|
|
199
|
+
@__additional_logging_context ||= {}
|
|
200
|
+
@__additional_logging_context.merge!(kwargs)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Clear any previously set additional logging context
|
|
204
|
+
def clear_logging_context
|
|
205
|
+
@__additional_logging_context = nil
|
|
206
|
+
end
|
|
207
|
+
|
|
172
208
|
def context_for_logging(direction = nil)
|
|
173
|
-
|
|
209
|
+
base_context = self.class.context_for_logging(
|
|
210
|
+
data: @__context.__combined_data,
|
|
211
|
+
direction:,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Only merge additional context for exception logging (direction is nil)
|
|
215
|
+
# Pre/post logging don't need additional context since they only log inputs/outputs
|
|
216
|
+
return base_context if direction
|
|
217
|
+
|
|
218
|
+
# Merge both explicit setter context and hook method context (if both exist)
|
|
219
|
+
explicit_context = @__additional_logging_context || {}
|
|
220
|
+
hook_context = respond_to?(:additional_logging_context, true) ? additional_logging_context : {}
|
|
221
|
+
base_context.merge(explicit_context).merge(hook_context)
|
|
174
222
|
end
|
|
175
223
|
|
|
176
224
|
private
|
|
177
225
|
|
|
178
|
-
def
|
|
179
|
-
|
|
180
|
-
|
|
226
|
+
def _handle_early_completion_if_raised
|
|
227
|
+
yield
|
|
228
|
+
nil
|
|
229
|
+
rescue Axn::Internal::EarlyCompletion => e
|
|
230
|
+
@__context.__record_early_completion(e.message)
|
|
231
|
+
_trigger_on_success
|
|
232
|
+
true
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def _with_contract(&)
|
|
236
|
+
return if _handle_early_completion_if_raised { _apply_inbound_preprocessing! }
|
|
237
|
+
return if _handle_early_completion_if_raised { _apply_defaults!(:inbound) }
|
|
238
|
+
|
|
181
239
|
_validate_contract!(:inbound)
|
|
182
240
|
|
|
183
|
-
|
|
241
|
+
if _handle_early_completion_if_raised(&)
|
|
242
|
+
# Even with early completion, we need to validate outbound and apply defaults
|
|
243
|
+
_apply_defaults!(:outbound)
|
|
244
|
+
_validate_contract!(:outbound)
|
|
245
|
+
return
|
|
246
|
+
end
|
|
184
247
|
|
|
185
248
|
_apply_defaults!(:outbound)
|
|
186
249
|
_validate_contract!(:outbound)
|
|
187
250
|
|
|
188
251
|
# TODO: improve location of this triggering
|
|
189
252
|
@__context.__finalize! # Mark result as finalized
|
|
190
|
-
_trigger_on_success
|
|
253
|
+
_trigger_on_success
|
|
191
254
|
end
|
|
192
255
|
|
|
193
256
|
def _build_context_facade(direction)
|
|
194
257
|
raise ArgumentError, "Invalid direction: #{direction}" unless %i[inbound outbound].include?(direction)
|
|
195
258
|
|
|
196
259
|
klass = direction == :inbound ? Axn::InternalContext : Axn::Result
|
|
197
|
-
implicitly_allowed_fields = direction == :inbound ? _declared_fields(:outbound) : []
|
|
198
|
-
|
|
199
|
-
klass.new(action: self, context: @__context, declared_fields: _declared_fields(direction), implicitly_allowed_fields:)
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
def inspection_filter
|
|
203
|
-
@inspection_filter ||= ActiveSupport::ParameterFilter.new(sensitive_fields)
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
def sensitive_fields
|
|
207
|
-
(internal_field_configs + external_field_configs + subfield_configs).select(&:sensitive).map(&:field)
|
|
208
|
-
end
|
|
260
|
+
implicitly_allowed_fields = direction == :inbound ? self.class._declared_fields(:outbound) : []
|
|
209
261
|
|
|
210
|
-
|
|
211
|
-
raise ArgumentError, "Invalid direction: #{direction}" unless direction.nil? || %i[inbound outbound].include?(direction)
|
|
212
|
-
|
|
213
|
-
configs = case direction
|
|
214
|
-
when :inbound then internal_field_configs
|
|
215
|
-
when :outbound then external_field_configs
|
|
216
|
-
else (internal_field_configs + external_field_configs)
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
configs.map(&:field)
|
|
262
|
+
klass.new(action: self, context: @__context, declared_fields: self.class._declared_fields(direction), implicitly_allowed_fields:)
|
|
220
263
|
end
|
|
221
264
|
end
|
|
222
265
|
end
|
|
@@ -39,7 +39,7 @@ module Axn
|
|
|
39
39
|
_parse_subfield_configs(*fields, on:, readers:, allow_blank:, allow_nil:, optional:, preprocess:, sensitive:, default:,
|
|
40
40
|
**validations).tap do |configs|
|
|
41
41
|
duplicated = subfield_configs.map(&:field) & configs.map(&:field)
|
|
42
|
-
raise Axn::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(
|
|
42
|
+
raise Axn::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(', ')}" if duplicated.any?
|
|
43
43
|
|
|
44
44
|
# NOTE: avoid <<, which would update value for parents and children
|
|
45
45
|
self.subfield_configs += configs
|
|
@@ -10,10 +10,13 @@ module Axn
|
|
|
10
10
|
next unless config.preprocess
|
|
11
11
|
|
|
12
12
|
initial_value = @__context.provided_data[config.field]
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
@__context.provided_data[config.field] = Axn::Util::ContractErrorHandling.with_contract_error_handling(
|
|
14
|
+
exception_class: Axn::ContractViolation::PreprocessingError,
|
|
15
|
+
message: ->(field, error) { "Error preprocessing field '#{field}': #{error.message}" },
|
|
16
|
+
field_identifier: config.field,
|
|
17
|
+
) do
|
|
18
|
+
instance_exec(initial_value, &config.preprocess)
|
|
19
|
+
end
|
|
17
20
|
end
|
|
18
21
|
|
|
19
22
|
_apply_inbound_preprocessing_for_subfields!
|
|
@@ -57,11 +60,13 @@ module Axn
|
|
|
57
60
|
data_hash = direction == :inbound ? @__context.provided_data : @__context.exposed_data
|
|
58
61
|
next if data_hash.key?(field) && !data_hash[field].nil?
|
|
59
62
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
data_hash[field] = Axn::Util::ContractErrorHandling.with_contract_error_handling(
|
|
64
|
+
exception_class: Axn::ContractViolation::DefaultAssignmentError,
|
|
65
|
+
message: ->(field_name, error) { "Error applying default for field '#{field_name}': #{error.message}" },
|
|
66
|
+
field_identifier: field,
|
|
67
|
+
) do
|
|
68
|
+
default_value_getter.respond_to?(:call) ? instance_exec(&default_value_getter) : default_value_getter
|
|
69
|
+
end
|
|
65
70
|
end
|
|
66
71
|
|
|
67
72
|
# Apply subfield defaults for inbound direction
|
|
@@ -9,10 +9,14 @@ module Axn
|
|
|
9
9
|
def _apply_inbound_preprocessing_for_subfields!
|
|
10
10
|
_for_each_relevant_subfield_config(:preprocess) do |config, parent_field, subfield, parent_value|
|
|
11
11
|
current_subfield_value = Axn::Core::FieldResolvers.resolve(type: :extract, field: subfield, provided_data: parent_value)
|
|
12
|
-
preprocessed_value =
|
|
12
|
+
preprocessed_value = Axn::Util::ContractErrorHandling.with_contract_error_handling(
|
|
13
|
+
exception_class: Axn::ContractViolation::PreprocessingError,
|
|
14
|
+
message: ->(_field, error) { "Error preprocessing subfield '#{config.field}' on '#{config.on}': #{error.message}" },
|
|
15
|
+
field_identifier: "#{config.field} on #{config.on}",
|
|
16
|
+
) do
|
|
17
|
+
instance_exec(current_subfield_value, &config.preprocess)
|
|
18
|
+
end
|
|
13
19
|
_update_subfield_value(parent_field, subfield, preprocessed_value)
|
|
14
|
-
rescue StandardError => e
|
|
15
|
-
raise Axn::ContractViolation::PreprocessingError, "Error preprocessing subfield '#{config.field}' on '#{config.on}': #{e.message}", cause: e
|
|
16
20
|
end
|
|
17
21
|
end
|
|
18
22
|
|
|
@@ -23,11 +27,14 @@ module Axn
|
|
|
23
27
|
|
|
24
28
|
@__context.provided_data[parent_field] = {} if parent_value.nil?
|
|
25
29
|
|
|
26
|
-
default_value =
|
|
30
|
+
default_value = Axn::Util::ContractErrorHandling.with_contract_error_handling(
|
|
31
|
+
exception_class: Axn::ContractViolation::DefaultAssignmentError,
|
|
32
|
+
message: ->(_field, error) { "Error applying default for subfield '#{config.field}' on '#{config.on}': #{error.message}" },
|
|
33
|
+
field_identifier: "#{config.field} on #{config.on}",
|
|
34
|
+
) do
|
|
35
|
+
config.default.respond_to?(:call) ? instance_exec(&config.default) : config.default
|
|
36
|
+
end
|
|
27
37
|
_update_subfield_value(parent_field, subfield, default_value)
|
|
28
|
-
rescue StandardError => e
|
|
29
|
-
raise Axn::ContractViolation::DefaultAssignmentError, "Error applying default for subfield '#{config.field}' on '#{config.on}': #{e.message}",
|
|
30
|
-
cause: e
|
|
31
38
|
end
|
|
32
39
|
end
|
|
33
40
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Core
|
|
5
|
+
# Default implementation of the call method that automatically exposes
|
|
6
|
+
# all declared exposures by calling methods with matching names.
|
|
7
|
+
module DefaultCall
|
|
8
|
+
# User-defined action logic - override this method in your action classes
|
|
9
|
+
# Default implementation automatically exposes all declared exposures by calling
|
|
10
|
+
# methods with matching names. Raises if a method is missing and no default is provided.
|
|
11
|
+
def call
|
|
12
|
+
return if self.class.external_field_configs.empty?
|
|
13
|
+
|
|
14
|
+
exposures = {}
|
|
15
|
+
|
|
16
|
+
self.class.external_field_configs.each do |config|
|
|
17
|
+
field = config.field
|
|
18
|
+
# Check if field is optional (allow_blank or no presence validation)
|
|
19
|
+
is_optional = _field_is_optional?(config)
|
|
20
|
+
|
|
21
|
+
# If method exists, call it (user-defined methods override auto-generated ones)
|
|
22
|
+
# The auto-generated method for exposed-only fields returns nil (field not in provided_data)
|
|
23
|
+
next unless respond_to?(field, true)
|
|
24
|
+
|
|
25
|
+
value = send(field)
|
|
26
|
+
# If it returns nil and it's an exposed-only field with no default,
|
|
27
|
+
# it's likely the auto-generated method (user methods can also return nil, but
|
|
28
|
+
# we'll assume it's auto-generated in this case)
|
|
29
|
+
is_exposed_only = !self.class.internal_field_configs.map(&:field).include?(field)
|
|
30
|
+
is_not_in_provided = !@__context.provided_data.key?(field)
|
|
31
|
+
|
|
32
|
+
# Only expose if we have a value, or if it's nil but there's a default
|
|
33
|
+
# If it's nil and optional, don't expose - let validation handle it
|
|
34
|
+
if value.nil? && is_exposed_only && is_not_in_provided && config.default.nil? && !is_optional
|
|
35
|
+
# This is the auto-generated method returning nil for a required field
|
|
36
|
+
# Don't expose it - let outbound validation catch the missing exposure
|
|
37
|
+
else
|
|
38
|
+
exposures[field] = value unless value.nil? && config.default.nil?
|
|
39
|
+
end
|
|
40
|
+
# If method doesn't exist:
|
|
41
|
+
# - If optional, skip it - validation will handle it
|
|
42
|
+
# - If not optional and no default, skip it - let outbound validation catch it
|
|
43
|
+
# - If there's a default, skip it - the default will be applied later
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
expose(**exposures) if exposures.any?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def _field_is_optional?(config)
|
|
52
|
+
validations = config.validations
|
|
53
|
+
# Field is optional if:
|
|
54
|
+
# 1. It doesn't have presence: true validation (presence is the default for non-optional fields)
|
|
55
|
+
# 2. Any validator has allow_blank: true
|
|
56
|
+
return true unless validations.key?(:presence) && validations[:presence] == true
|
|
57
|
+
|
|
58
|
+
# Check if any validator has allow_blank: true
|
|
59
|
+
validations.values.any? { |v| v.is_a?(Hash) && v[:allow_blank] == true }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -10,6 +10,7 @@ module Axn
|
|
|
10
10
|
|
|
11
11
|
def _trigger_on_exception(exception)
|
|
12
12
|
# Call any handlers registered on *this specific action* class
|
|
13
|
+
# (handlers can call context_for_logging themselves if needed)
|
|
13
14
|
self.class._dispatch_callbacks(:exception, action: self, exception:)
|
|
14
15
|
|
|
15
16
|
# Call any global handlers
|
|
@@ -32,6 +33,10 @@ module Axn
|
|
|
32
33
|
|
|
33
34
|
def _with_exception_handling
|
|
34
35
|
yield
|
|
36
|
+
rescue Axn::Internal::EarlyCompletion
|
|
37
|
+
# Early completion is not an error - it's a control flow mechanism
|
|
38
|
+
# It should propagate through to be handled by the result builder
|
|
39
|
+
raise
|
|
35
40
|
rescue StandardError => e
|
|
36
41
|
@__context.__record_exception(e)
|
|
37
42
|
|
|
@@ -37,13 +37,25 @@ module Axn
|
|
|
37
37
|
def self._build_rule_for_from_condition(from_class)
|
|
38
38
|
return nil unless from_class
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
40
|
+
# Special case: `from: true` means "from any child action"
|
|
41
|
+
return ->(exception:, **) { exception.is_a?(Axn::Failure) && exception.source } if from_class == true
|
|
42
|
+
|
|
43
|
+
from_classes = Array(from_class)
|
|
44
|
+
lambda { |exception:, **|
|
|
45
|
+
return false unless exception.is_a?(Axn::Failure) && exception.source
|
|
46
|
+
|
|
47
|
+
source = exception.source
|
|
48
|
+
from_classes.any? do |cls|
|
|
49
|
+
if cls.is_a?(String)
|
|
50
|
+
# rubocop:disable Style/ClassEqualityComparison
|
|
51
|
+
# We're comparing class name strings, not classes themselves
|
|
52
|
+
source.class.name == cls
|
|
53
|
+
# rubocop:enable Style/ClassEqualityComparison
|
|
54
|
+
else
|
|
55
|
+
source.is_a?(cls)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
}
|
|
47
59
|
end
|
|
48
60
|
end
|
|
49
61
|
end
|
|
@@ -17,22 +17,6 @@ module Axn
|
|
|
17
17
|
Axn::Internal::Logging.piping_error(operation, action:, exception: e)
|
|
18
18
|
end
|
|
19
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
20
|
private
|
|
37
21
|
|
|
38
22
|
def symbol?(value) = value.is_a?(Symbol)
|
|
@@ -46,23 +30,13 @@ module Axn
|
|
|
46
30
|
end
|
|
47
31
|
|
|
48
32
|
method = action.method(symbol)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
elsif exception && accepts_positional_exception?(method)
|
|
52
|
-
action.send(symbol, exception)
|
|
53
|
-
else
|
|
54
|
-
action.send(symbol)
|
|
55
|
-
end
|
|
33
|
+
filtered_args, filtered_kwargs = Axn::Util::Callable.only_requested_params_for_exception(method, exception)
|
|
34
|
+
action.send(symbol, *filtered_args, **filtered_kwargs)
|
|
56
35
|
end
|
|
57
36
|
|
|
58
37
|
def call_callable_handler(action:, callable:, exception: nil)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
elsif exception && accepts_positional_exception?(callable)
|
|
62
|
-
action.instance_exec(exception, &callable)
|
|
63
|
-
else
|
|
64
|
-
action.instance_exec(&callable)
|
|
65
|
-
end
|
|
38
|
+
filtered_args, filtered_kwargs = Axn::Util::Callable.only_requested_params_for_exception(callable, exception)
|
|
39
|
+
action.instance_exec(*filtered_args, **filtered_kwargs, &callable)
|
|
66
40
|
end
|
|
67
41
|
|
|
68
42
|
def literal_value(value) = value
|
|
@@ -36,25 +36,15 @@ module Axn
|
|
|
36
36
|
def exception_class? = @rule.is_a?(Class) && @rule <= Exception
|
|
37
37
|
|
|
38
38
|
def apply_callable(action:, exception:)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
elsif exception && Invoker.accepts_positional_exception?(@rule)
|
|
42
|
-
!!action.instance_exec(exception, &@rule)
|
|
43
|
-
else
|
|
44
|
-
!!action.instance_exec(&@rule)
|
|
45
|
-
end
|
|
39
|
+
filtered_args, filtered_kwargs = Axn::Util::Callable.only_requested_params_for_exception(@rule, exception)
|
|
40
|
+
!!action.instance_exec(*filtered_args, **filtered_kwargs, &@rule)
|
|
46
41
|
end
|
|
47
42
|
|
|
48
43
|
def apply_symbol(action:, exception:)
|
|
49
44
|
if action.respond_to?(@rule)
|
|
50
45
|
method = action.method(@rule)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
elsif exception && Invoker.accepts_positional_exception?(method)
|
|
54
|
-
!!action.public_send(@rule, exception)
|
|
55
|
-
else
|
|
56
|
-
!!action.public_send(@rule)
|
|
57
|
-
end
|
|
46
|
+
filtered_args, filtered_kwargs = Axn::Util::Callable.only_requested_params_for_exception(method, exception)
|
|
47
|
+
!!action.public_send(@rule, *filtered_args, **filtered_kwargs)
|
|
58
48
|
else
|
|
59
49
|
begin
|
|
60
50
|
klass = Object.const_get(@rule.to_s)
|
|
@@ -24,7 +24,7 @@ module Axn
|
|
|
24
24
|
raise Axn::UnsupportedArgument, "calling #{kind} with both :if and :unless" if kwargs.key?(:if) && kwargs.key?(:unless)
|
|
25
25
|
raise Axn::UnsupportedArgument, "Combining from: with if: or unless:" if kwargs.key?(:from) && (kwargs.key?(:if) || kwargs.key?(:unless))
|
|
26
26
|
raise ArgumentError, "Provide either a message or a block, not both" if message && block_given?
|
|
27
|
-
raise ArgumentError, "Provide a message, block, or prefix" unless message || block_given? || kwargs[:prefix]
|
|
27
|
+
raise ArgumentError, "Provide a message, block, or prefix" unless message || block_given? || kwargs[:prefix] || kwargs[:from]
|
|
28
28
|
raise ArgumentError, "from: only applies to error messages" if kwargs.key?(:from) && kind != :error
|
|
29
29
|
|
|
30
30
|
# If message is already a descriptor, use it directly
|
data/lib/axn/core/hooks.rb
CHANGED
data/lib/axn/core/logging.rb
CHANGED
|
@@ -17,20 +17,31 @@ module Axn
|
|
|
17
17
|
module ClassMethods
|
|
18
18
|
def log_level = Axn.config.log_level
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
# @param message [String] The message to log
|
|
21
|
+
# @param level [Symbol] The log level (default: log_level)
|
|
22
|
+
# @param before [String, nil] Text to prepend to the message
|
|
23
|
+
# @param after [String, nil] Text to append to the message
|
|
24
|
+
# @param prefix [String, nil] Override the default prefix (useful for class-level logging)
|
|
25
|
+
def log(message, level: log_level, before: nil, after: nil, prefix: nil)
|
|
26
|
+
resolved_prefix = prefix.nil? ? _log_prefix : prefix
|
|
27
|
+
msg = [resolved_prefix, message].compact_blank.join(" ")
|
|
22
28
|
msg = [before, msg, after].compact_blank.join if before || after
|
|
23
29
|
|
|
24
30
|
Axn.config.logger.send(level, msg)
|
|
25
31
|
end
|
|
26
32
|
|
|
27
33
|
LEVELS.each do |level|
|
|
28
|
-
define_method(level) do |message, before: nil, after: nil|
|
|
29
|
-
log(message, level:, before:, after:)
|
|
34
|
+
define_method(level) do |message, before: nil, after: nil, prefix: nil|
|
|
35
|
+
log(message, level:, before:, after:, prefix:)
|
|
30
36
|
end
|
|
31
37
|
end
|
|
32
38
|
|
|
33
|
-
def _log_prefix
|
|
39
|
+
def _log_prefix
|
|
40
|
+
names = NestingTracking._current_axn_stack.map do |axn|
|
|
41
|
+
axn.class.name.presence || "Anonymous Class"
|
|
42
|
+
end
|
|
43
|
+
"[#{names.join(' > ')}]"
|
|
44
|
+
end
|
|
34
45
|
end
|
|
35
46
|
end
|
|
36
47
|
end
|