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.
@@ -9,9 +9,9 @@ module Action
9
9
  internal_field_configs.each do |config|
10
10
  next unless config.preprocess
11
11
 
12
- initial_value = @context.public_send(config.field)
12
+ initial_value = @__context.provided_data[config.field]
13
13
  new_value = config.preprocess.call(initial_value)
14
- @context.public_send("#{config.field}=", new_value)
14
+ @__context.provided_data[config.field] = new_value
15
15
  rescue StandardError => e
16
16
  raise Action::ContractViolation::PreprocessingError, "Error preprocessing field '#{config.field}': #{e.message}"
17
17
  end
@@ -33,17 +33,28 @@ module Action
33
33
  def _apply_defaults!(direction)
34
34
  raise ArgumentError, "Invalid direction: #{direction}" unless %i[inbound outbound].include?(direction)
35
35
 
36
+ if direction == :outbound
37
+ # For outbound defaults, first copy values from provided_data for fields that are both expected and exposed
38
+ external_field_configs.each do |config|
39
+ field = config.field
40
+ next if @__context.exposed_data[field].present? # Already has a value
41
+
42
+ @__context.exposed_data[field] = @__context.provided_data[field] if @__context.provided_data[field].present?
43
+ end
44
+ end
45
+
36
46
  configs = direction == :inbound ? internal_field_configs : external_field_configs
37
47
  defaults_mapping = configs.each_with_object({}) do |config, hash|
38
48
  hash[config.field] = config.default
39
49
  end.compact
40
50
 
41
51
  defaults_mapping.each do |field, default_value_getter|
42
- next if @context.public_send(field).present?
52
+ data_hash = direction == :inbound ? @__context.provided_data : @__context.exposed_data
53
+ next if data_hash[field].present?
43
54
 
44
55
  default_value = default_value_getter.respond_to?(:call) ? instance_exec(&default_value_getter) : default_value_getter
45
56
 
46
- @context.public_send("#{field}=", default_value)
57
+ data_hash[field] = default_value
47
58
  end
48
59
  end
49
60
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action/core/event_handlers"
4
+
5
+ module Action
6
+ module Core
7
+ module Flow
8
+ module Callbacks
9
+ def self.included(base)
10
+ base.class_eval do
11
+ class_attribute :_error_handlers, default: []
12
+ class_attribute :_exception_handlers, default: []
13
+ class_attribute :_failure_handlers, default: []
14
+ class_attribute :_success_handlers, default: []
15
+
16
+ extend ClassMethods
17
+ end
18
+ end
19
+
20
+ module ClassMethods
21
+ # ONLY raised exceptions (i.e. NOT fail!). Skipped if exception is rescued via .rescues.
22
+ def on_exception(matcher = -> { true }, &handler)
23
+ raise ArgumentError, "on_exception must be called with a block" unless block_given?
24
+
25
+ self._exception_handlers += [Action::EventHandlers::ConditionalHandler.new(matcher:, handler:)]
26
+ end
27
+
28
+ # ONLY raised on fail! (i.e. NOT unhandled exceptions).
29
+ def on_failure(matcher = -> { true }, &handler)
30
+ raise ArgumentError, "on_failure must be called with a block" unless block_given?
31
+
32
+ self._failure_handlers += [Action::EventHandlers::ConditionalHandler.new(matcher:, handler:)]
33
+ end
34
+
35
+ # Handles both fail! and unhandled exceptions... but is NOT affected by .rescues
36
+ def on_error(matcher = -> { true }, &handler)
37
+ raise ArgumentError, "on_error must be called with a block" unless block_given?
38
+
39
+ self._error_handlers += [Action::EventHandlers::ConditionalHandler.new(matcher:, handler:)]
40
+ end
41
+
42
+ # Executes when the action completes successfully (after all after hooks complete successfully)
43
+ # Runs in child-first order (child handlers before parent handlers)
44
+ def on_success(&handler)
45
+ raise ArgumentError, "on_success must be called with a block" unless block_given?
46
+
47
+ # Prepend like after hooks - child handlers run before parent handlers
48
+ self._success_handlers = [handler] + _success_handlers
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module Core
5
+ module Flow
6
+ module ExceptionExecution
7
+ def self.included(base)
8
+ base.class_eval do
9
+ include InstanceMethods
10
+
11
+ def _trigger_on_exception(exception)
12
+ interceptor = self.class._error_interceptor_for(exception:, action: self)
13
+ return if interceptor&.should_report_error == false
14
+
15
+ # Call any handlers registered on *this specific action* class
16
+ self.class._exception_handlers.each do |handler|
17
+ handler.execute_if_matches(exception:, action: self)
18
+ end
19
+
20
+ # Call any global handlers
21
+ Action.config.on_exception(exception, action: self, context: context_for_logging)
22
+ rescue StandardError => e
23
+ # No action needed -- downstream #on_exception implementation should ideally log any internal failures, but
24
+ # we don't want exception *handling* failures to cascade and overwrite the original exception.
25
+ Axn::Util.piping_error("executing on_exception hooks", action: self, exception: e)
26
+ end
27
+
28
+ def _trigger_on_success
29
+ # Call success handlers in child-first order (like after hooks)
30
+ self.class._success_handlers.each do |handler|
31
+ instance_exec(&handler)
32
+ rescue StandardError => e
33
+ # Log the error but continue with other handlers
34
+ Axn::Util.piping_error("executing on_success hook", action: self, exception: e)
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ module InstanceMethods
41
+ private
42
+
43
+ def _with_exception_handling
44
+ yield
45
+ rescue StandardError => e
46
+ # on_error handlers run for both unhandled exceptions and fail!
47
+ self.class._error_handlers.each do |handler|
48
+ handler.execute_if_matches(exception: e, action: self)
49
+ end
50
+
51
+ # on_failure handlers run ONLY for fail!
52
+ if e.is_a?(Action::Failure)
53
+ self.class._failure_handlers.each do |handler|
54
+ handler.execute_if_matches(exception: e, action: self)
55
+ end
56
+ else
57
+ # on_exception handlers run for ONLY for unhandled exceptions. AND NOTE: may be skipped if the exception is rescued via `rescues`.
58
+ _trigger_on_exception(e)
59
+
60
+ @__context.exception = e
61
+ end
62
+
63
+ # Set failure state using accessor method
64
+ @__context.send(:failure=, true)
65
+ end
66
+
67
+ def try
68
+ yield
69
+ rescue Action::Failure => e
70
+ # NOTE: re-raising so we can still fail! from inside the block
71
+ raise e
72
+ rescue StandardError => e
73
+ _trigger_on_exception(e)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module Core
5
+ module Flow
6
+ module Messages
7
+ def self.included(base)
8
+ base.class_eval do
9
+ class_attribute :_success_msg, :_error_msg
10
+ class_attribute :_custom_error_interceptors, default: []
11
+
12
+ extend ClassMethods
13
+ include InstanceMethods
14
+ end
15
+ end
16
+
17
+ module ClassMethods
18
+ def messages(success: nil, error: nil)
19
+ self._success_msg = success if success.present?
20
+ self._error_msg = error if error.present?
21
+
22
+ true
23
+ end
24
+
25
+ def error_from(matcher = nil, message = nil, **match_and_messages)
26
+ _register_error_interceptor(matcher, message, should_report_error: true, **match_and_messages)
27
+ end
28
+
29
+ def rescues(matcher = nil, message = nil, **match_and_messages)
30
+ _register_error_interceptor(matcher, message, should_report_error: false, **match_and_messages)
31
+ end
32
+
33
+ def default_error = new.internal_context.default_error
34
+
35
+ # Private helpers
36
+
37
+ def _error_interceptor_for(exception:, action:)
38
+ Array(_custom_error_interceptors).detect do |int|
39
+ int.matches?(exception:, action:)
40
+ end
41
+ end
42
+
43
+ def _register_error_interceptor(matcher, message, should_report_error:, **match_and_messages)
44
+ method_name = should_report_error ? "error_from" : "rescues"
45
+ raise ArgumentError, "#{method_name} must be called with a key/value pair, or else keyword args" if [matcher, message].compact.size == 1
46
+
47
+ interceptors = { matcher => message }.compact.merge(match_and_messages).map do |(matcher, message)| # rubocop:disable Lint/ShadowingOuterLocalVariable
48
+ Action::EventHandlers::CustomErrorInterceptor.new(matcher:, message:, should_report_error:)
49
+ end
50
+
51
+ self._custom_error_interceptors += interceptors
52
+ end
53
+ end
54
+
55
+ module InstanceMethods
56
+ delegate :default_error, to: :internal_context
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action/core/flow/messages"
4
+ require "action/core/flow/callbacks"
5
+ require "action/core/flow/exception_execution"
6
+
7
+ module Action
8
+ module Core
9
+ module Flow
10
+ def self.included(base)
11
+ base.class_eval do
12
+ include Messages
13
+ include Callbacks
14
+ include ExceptionExecution
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -46,8 +46,8 @@ module Action
46
46
 
47
47
  # Separate method to allow overriding in subclasses
48
48
  def _handle_hoisted_errors(result, prefix: nil)
49
- @context.exception = result.exception if result.exception.present?
50
- @context.error_prefix = prefix if prefix.present?
49
+ @__context.exception = result.exception if result.exception.present?
50
+ @__context.error_prefix = prefix if prefix.present?
51
51
 
52
52
  error = result.exception.is_a?(Action::Failure) ? result.exception.message : result.error
53
53
  fail! error
@@ -67,44 +67,44 @@ module Action
67
67
 
68
68
  private
69
69
 
70
- def with_hooks
71
- run_around_hooks do
72
- run_before_hooks
70
+ def _with_hooks
71
+ _run_around_hooks do
72
+ _run_before_hooks
73
73
  yield
74
- run_after_hooks
74
+ _run_after_hooks
75
75
  end
76
76
  end
77
77
 
78
78
  # Around hooks are reversed before injection to ensure parent hooks wrap
79
79
  # child hooks (parent outside, child inside).
80
- def run_around_hooks(&block)
80
+ def _run_around_hooks(&block)
81
81
  self.class.around_hooks.reverse.inject(block) do |chain, hook|
82
- proc { run_hook(hook, chain) }
82
+ proc { _run_hook(hook, chain) }
83
83
  end.call
84
84
  end
85
85
 
86
86
  # Before hooks run in the order they were added (parent first, then child).
87
- def run_before_hooks
88
- run_hooks(self.class.before_hooks)
87
+ def _run_before_hooks
88
+ _run_hooks(self.class.before_hooks)
89
89
  end
90
90
 
91
91
  # After hooks are reversed to ensure child hooks run before parent hooks
92
92
  # (specific cleanup first, then general).
93
- def run_after_hooks
94
- run_hooks(self.class.after_hooks.reverse)
93
+ def _run_after_hooks
94
+ _run_hooks(self.class.after_hooks.reverse)
95
95
  end
96
96
 
97
- # Internal: Run a collection of hooks. The "run_hooks" method is the common
97
+ # Internal: Run a collection of hooks. The "_run_hooks" method is the common
98
98
  # interface by which collections of either before or after hooks are run.
99
99
  #
100
100
  # hooks - An Array of Symbol and Procs.
101
101
  #
102
102
  # Returns nothing.
103
- def run_hooks(hooks)
104
- hooks.each { |hook| run_hook(hook) }
103
+ def _run_hooks(hooks)
104
+ hooks.each { |hook| _run_hook(hook) }
105
105
  end
106
106
 
107
- # Internal: Run an individual hook. The "run_hook" method is the common
107
+ # Internal: Run an individual hook. The "_run_hook" method is the common
108
108
  # interface by which an individual hook is run. If the given hook is a
109
109
  # symbol, the method is invoked whether public or private. If the hook is a
110
110
  # proc, the proc is evaluated in the context of the current instance.
@@ -115,7 +115,7 @@ module Action
115
115
  # Symbol method name.
116
116
  #
117
117
  # Returns nothing.
118
- def run_hook(hook, *)
118
+ def _run_hook(hook, *)
119
119
  hook.is_a?(Symbol) ? send(hook, *) : instance_exec(*, &hook)
120
120
  end
121
121
  end
@@ -15,9 +15,9 @@ module Action
15
15
  end
16
16
 
17
17
  module ClassMethods
18
- def default_log_level = Action.config.default_log_level
18
+ def log_level = Action.config.log_level
19
19
 
20
- def log(message, level: default_log_level, before: nil, after: nil)
20
+ def log(message, level: log_level, before: nil, after: nil)
21
21
  msg = [_log_prefix, message].compact_blank.join(" ")
22
22
  msg = [before, msg, after].compact_blank.join if before || after
23
23
 
@@ -3,6 +3,12 @@
3
3
  module Action
4
4
  module Core
5
5
  module Timing
6
+ def self.included(base)
7
+ base.class_eval do
8
+ include InstanceMethods
9
+ end
10
+ end
11
+
6
12
  # Get the current monotonic time
7
13
  def self.now
8
14
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -17,6 +23,18 @@ module Action
17
23
  def self.elapsed_seconds(start_time)
18
24
  (now - start_time).round(6)
19
25
  end
26
+
27
+ module InstanceMethods
28
+ private
29
+
30
+ def _with_timing
31
+ timing_start = Core::Timing.now
32
+ yield
33
+ ensure
34
+ elapsed_mils = Core::Timing.elapsed_ms(timing_start)
35
+ @__context.elapsed_time = elapsed_mils
36
+ end
37
+ end
20
38
  end
21
39
  end
22
40
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module Core
5
+ module Tracing
6
+ private
7
+
8
+ def _with_tracing(&)
9
+ return yield unless Action.config.wrap_with_trace
10
+
11
+ Action.config.wrap_with_trace.call(self.class.name || "AnonymousClass", &)
12
+ rescue StandardError => e
13
+ Axn::Util.piping_error("running trace hook", action: self, exception: e)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -15,6 +15,8 @@ module Action
15
15
  end
16
16
 
17
17
  def read_attribute_for_validation(attr)
18
+ # The context here is actually a facade (InternalContext or Result)
19
+ # which already handles reading from the correct data source
18
20
  @context.public_send(attr)
19
21
  end
20
22
 
data/lib/action/core.rb CHANGED
@@ -6,9 +6,10 @@ require "action/strategies"
6
6
  require "action/core/hooks"
7
7
  require "action/core/logging"
8
8
  require "action/core/hoist_errors"
9
- require "action/core/handle_exceptions"
9
+ require "action/core/flow"
10
10
  require "action/core/automatic_logging"
11
11
  require "action/core/use_strategy"
12
+ require "action/core/tracing"
12
13
 
13
14
  # CONSIDER: make class names match file paths?
14
15
  require "action/core/validation/validators/model_validator"
@@ -28,8 +29,10 @@ module Action
28
29
  include Core::Hooks
29
30
  include Core::Logging
30
31
  include Core::AutomaticLogging
32
+ include Core::Tracing
33
+ include Core::Timing
31
34
 
32
- include Core::HandleExceptions
35
+ include Core::Flow
33
36
 
34
37
  include Core::ContractValidation
35
38
  include Core::Contract
@@ -41,84 +44,32 @@ module Action
41
44
  end
42
45
 
43
46
  module ClassMethods
44
- def call(context = {})
45
- new(context).tap(&:run).result
47
+ def call(**)
48
+ new(**).tap(&:_run).result
46
49
  end
47
50
 
48
- def call!(context = {})
49
- result = call(context)
51
+ def call!(**)
52
+ result = call(**)
50
53
  return result if result.ok?
51
54
 
52
55
  raise result.exception || Action::Failure.new(result.error)
53
56
  end
54
57
  end
55
58
 
56
- def initialize(context = {})
57
- @context = Action::Context.build(context)
59
+ def initialize(**)
60
+ @__context = Action::Context.new(**)
58
61
  end
59
62
 
60
- def with_tracing(&)
61
- return yield unless Action.config.wrap_with_trace
62
-
63
- Action.config.wrap_with_trace.call(self.class.name || "AnonymousClass", &)
64
- rescue StandardError => e
65
- Axn::Util.piping_error("running trace hook", action: self, exception: e)
66
- end
67
-
68
- def with_logging
69
- timing_start = Core::Timing.now
70
- _log_before
71
- yield
72
- ensure
73
- _log_after(timing_start:, outcome: _determine_outcome)
74
- end
75
-
76
- def with_contract
77
- _apply_inbound_preprocessing!
78
- _apply_defaults!(:inbound)
79
- _validate_contract!(:inbound)
80
-
81
- yield
82
-
83
- _apply_defaults!(:outbound)
84
- _validate_contract!(:outbound)
85
-
86
- # TODO: improve location of this triggering
87
- trigger_on_success if respond_to?(:trigger_on_success)
88
- end
89
-
90
- def with_exception_swallowing
91
- yield
92
- rescue StandardError => e
93
- # on_error handlers run for both unhandled exceptions and fail!
94
- self.class._error_handlers.each do |handler|
95
- handler.execute_if_matches(exception: e, action: self)
96
- end
97
-
98
- # on_failure handlers run ONLY for fail!
99
- if e.is_a?(Action::Failure)
100
- @context.instance_variable_set("@error_from_user", e.message) if e.message.present?
101
-
102
- self.class._failure_handlers.each do |handler|
103
- handler.execute_if_matches(exception: e, action: self)
104
- end
105
- else
106
- # on_exception handlers run for ONLY for unhandled exceptions. AND NOTE: may be skipped if the exception is rescued via `rescues`.
107
- trigger_on_exception(e)
108
-
109
- @context.exception = e
110
- end
111
-
112
- @context.instance_variable_set("@failure", true)
113
- end
114
-
115
- def run
116
- with_tracing do
117
- with_logging do
118
- with_exception_swallowing do # Exceptions stop here; outer wrappers access result status (and must not introduce another exception layer)
119
- with_contract do # Library internals -- any failures (e.g. contract violations) *should* fail the Action::Result
120
- with_hooks do # User hooks -- any failures here *should* fail the Action::Result
121
- call
63
+ # Main entry point for action execution
64
+ def _run
65
+ _with_tracing do
66
+ _with_logging do
67
+ _with_timing do
68
+ _with_exception_handling do # Exceptions stop here; outer wrappers access result status (and must not introduce another exception layer)
69
+ _with_contract do # Library internals -- any failures (e.g. contract violations) *should* fail the Action::Result
70
+ _with_hooks do # User hooks -- any failures here *should* fail the Action::Result
71
+ call
72
+ end
122
73
  end
123
74
  end
124
75
  end
@@ -128,8 +79,11 @@ module Action
128
79
  _emit_metrics
129
80
  end
130
81
 
82
+ # User-defined action logic - override this method in your action classes
131
83
  def call; end
132
84
 
85
+ delegate :fail!, to: :@__context
86
+
133
87
  private
134
88
 
135
89
  def _emit_metrics
@@ -137,17 +91,10 @@ module Action
137
91
 
138
92
  Action.config.emit_metrics.call(
139
93
  self.class.name || "AnonymousClass",
140
- _determine_outcome,
94
+ result,
141
95
  )
142
96
  rescue StandardError => e
143
97
  Axn::Util.piping_error("running metrics hook", action: self, exception: e)
144
98
  end
145
-
146
- def _determine_outcome
147
- return "exception" if @context.exception
148
- return "failure" if @context.failure?
149
-
150
- "success"
151
- end
152
99
  end
153
100
  end
@@ -18,9 +18,9 @@ module Action
18
18
  bang = args.size > 1 ? args.last : false
19
19
 
20
20
  if bang
21
- self.class.call!(context)
21
+ self.class.call!(**context)
22
22
  else
23
- self.class.call(context)
23
+ self.class.call(**context)
24
24
  end
25
25
  end
26
26
 
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action/core/context/facade"
4
+ require "action/core/context/facade_inspector"
5
+
6
+ module Action
7
+ # Outbound / External ContextFacade
8
+ class Result < ContextFacade
9
+ # For ease of mocking return results in tests
10
+ class << self
11
+ def ok(msg = nil, **exposures)
12
+ exposes = exposures.keys.to_h { |key| [key, { allow_blank: true }] }
13
+
14
+ Axn::Factory.build(exposes:, messages: { success: msg }) do
15
+ exposures.each do |key, value|
16
+ expose(key, value)
17
+ end
18
+ end.call
19
+ end
20
+
21
+ def error(msg = nil, **exposures, &block)
22
+ exposes = exposures.keys.to_h { |key| [key, { allow_blank: true }] }
23
+ rescues = [-> { true }, msg]
24
+
25
+ Axn::Factory.build(exposes:, rescues:) do
26
+ exposures.each do |key, value|
27
+ expose(key, value)
28
+ end
29
+ block.call if block_given?
30
+ fail!
31
+ end.call
32
+ end
33
+ end
34
+
35
+ # Poke some holes for necessary internal control methods
36
+ delegate :each_pair, to: :context
37
+
38
+ # External interface
39
+ delegate :ok?, :exception, to: :context
40
+
41
+ def error
42
+ return if ok?
43
+
44
+ [@context.error_prefix, determine_error_message].compact.join(" ").squeeze(" ")
45
+ end
46
+
47
+ def success
48
+ return unless ok?
49
+
50
+ stringified(action._success_msg).presence || "Action completed successfully"
51
+ end
52
+
53
+ def ok = success
54
+
55
+ def message = error || success
56
+
57
+ # Outcome constants for action execution results
58
+ OUTCOMES = [
59
+ OUTCOME_SUCCESS = :success,
60
+ OUTCOME_FAILURE = :failure,
61
+ OUTCOME_EXCEPTION = :exception,
62
+ ].freeze
63
+
64
+ def outcome
65
+ return OUTCOME_EXCEPTION if exception
66
+ return OUTCOME_FAILURE if @context.failed?
67
+
68
+ OUTCOME_SUCCESS
69
+ end
70
+
71
+ # Elapsed time in milliseconds
72
+ def elapsed_time
73
+ @context.elapsed_time
74
+ end
75
+
76
+ private
77
+
78
+ def context_data_source = @context.exposed_data
79
+
80
+ def method_missing(method_name, ...) # rubocop:disable Style/MissingRespondToMissing (because we're not actually responding to anything additional)
81
+ if @context.__combined_data.key?(method_name.to_sym)
82
+ msg = <<~MSG
83
+ Method ##{method_name} is not available on Action::Result!
84
+
85
+ #{action_name} may be missing a line like:
86
+ exposes :#{method_name}
87
+ MSG
88
+
89
+ raise Action::ContractViolation::MethodNotAllowed, msg
90
+ end
91
+
92
+ super
93
+ end
94
+ end
95
+ end