axn 0.1.0.pre.alpha.2.6 → 0.1.0.pre.alpha.2.7

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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -1
  3. data/CHANGELOG.md +23 -2
  4. data/docs/reference/action-result.md +2 -0
  5. data/docs/reference/class.md +140 -19
  6. data/docs/reference/configuration.md +42 -20
  7. data/docs/usage/writing.md +1 -1
  8. data/lib/action/attachable/steps.rb +16 -1
  9. data/lib/action/configuration.rb +2 -3
  10. data/lib/action/context.rb +28 -18
  11. data/lib/action/core/automatic_logging.rb +24 -8
  12. data/lib/action/core/context/facade.rb +39 -0
  13. data/lib/action/core/context/facade_inspector.rb +63 -0
  14. data/lib/action/core/context/internal.rb +38 -0
  15. data/lib/action/core/contract.rb +25 -8
  16. data/lib/action/core/contract_for_subfields.rb +1 -1
  17. data/lib/action/core/contract_validation.rb +15 -4
  18. data/lib/action/core/flow/callbacks.rb +54 -0
  19. data/lib/action/core/flow/exception_execution.rb +65 -0
  20. data/lib/action/core/flow/handlers/base_handler.rb +32 -0
  21. data/lib/action/core/flow/handlers/callback_handler.rb +21 -0
  22. data/lib/action/core/flow/handlers/invoker.rb +73 -0
  23. data/lib/action/core/flow/handlers/matcher.rb +85 -0
  24. data/lib/action/core/flow/handlers/message_handler.rb +27 -0
  25. data/lib/action/core/flow/handlers/registry.rb +40 -0
  26. data/lib/action/core/flow/handlers.rb +17 -0
  27. data/lib/action/core/flow/messages.rb +75 -0
  28. data/lib/action/core/flow.rb +19 -0
  29. data/lib/action/core/hoist_errors.rb +2 -2
  30. data/lib/action/core/hooks.rb +15 -15
  31. data/lib/action/core/logging.rb +2 -2
  32. data/lib/action/core/timing.rb +18 -0
  33. data/lib/action/core/tracing.rb +17 -0
  34. data/lib/action/core/validation/fields.rb +2 -0
  35. data/lib/action/core.rb +25 -78
  36. data/lib/action/enqueueable/via_sidekiq.rb +2 -2
  37. data/lib/action/result.rb +114 -0
  38. data/lib/axn/factory.rb +45 -7
  39. data/lib/axn/version.rb +1 -1
  40. metadata +18 -5
  41. data/lib/action/core/context_facade.rb +0 -209
  42. data/lib/action/core/event_handlers.rb +0 -62
  43. data/lib/action/core/handle_exceptions.rb +0 -143
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action/core/context/facade"
4
+
5
+ module Action
6
+ # Inbound / Internal ContextFacade
7
+ class InternalContext < ContextFacade
8
+ # Available for use from within message callables
9
+ def default_error
10
+ msg = action.class._static_message_for(:error, action:, exception: @context.exception || Action::Failure.new)
11
+ [@context.error_prefix, msg.presence || "Something went wrong"].compact.join(" ").squeeze(" ")
12
+ end
13
+
14
+ def default_success
15
+ msg = action.class._static_message_for(:success, action:, exception: nil)
16
+ msg.presence || "Action completed successfully"
17
+ end
18
+
19
+ private
20
+
21
+ def context_data_source = @context.provided_data
22
+
23
+ def method_missing(method_name, ...) # rubocop:disable Style/MissingRespondToMissing (because we're not actually responding to anything additional)
24
+ if @context.__combined_data.key?(method_name.to_sym)
25
+ msg = <<~MSG
26
+ Method ##{method_name} is not available on Action::InternalContext!
27
+
28
+ #{action_name} may be missing a line like:
29
+ expects :#{method_name}
30
+ MSG
31
+
32
+ raise Action::ContractViolation::MethodNotAllowed, msg
33
+ end
34
+
35
+ super
36
+ end
37
+ end
38
+ end
@@ -4,7 +4,8 @@ require "active_support/core_ext/enumerable"
4
4
  require "active_support/core_ext/module/delegation"
5
5
 
6
6
  require "action/core/validation/fields"
7
- require "action/core/context_facade"
7
+ require "action/result"
8
+ require "action/core/context/internal"
8
9
 
9
10
  module Action
10
11
  module Core
@@ -70,13 +71,13 @@ module Action
70
71
  private
71
72
 
72
73
  RESERVED_FIELD_NAMES_FOR_EXPECTATIONS = %w[
73
- fail! success? ok?
74
+ fail! ok?
74
75
  inspect default_error
75
76
  each_pair
76
77
  ].freeze
77
78
 
78
79
  RESERVED_FIELD_NAMES_FOR_EXPOSURES = %w[
79
- fail! success? ok?
80
+ fail! ok?
80
81
  inspect each_pair default_error
81
82
  ok error success message
82
83
  ].freeze
@@ -114,13 +115,15 @@ module Action
114
115
  define_method(field) { internal_context.public_send(field) }
115
116
  end
116
117
 
117
- def _define_model_reader(field, klass)
118
+ def _define_model_reader(field, klass, &id_extractor)
118
119
  name = field.to_s.delete_suffix("_id")
119
120
  raise ArgumentError, "Model validation expects to be given a field ending in _id (given: #{field})" unless field.to_s.end_with?("_id")
120
121
  raise ArgumentError, "Failed to define model reader - #{name} is already defined" if method_defined?(name)
121
122
 
123
+ id_extractor ||= -> { public_send(field) }
124
+
122
125
  define_memoized_reader_method(name) do
123
- Validators::ModelValidator.instance_for(field:, klass:, id: public_send(field))
126
+ Validators::ModelValidator.instance_for(field:, klass:, id: instance_exec(&id_extractor))
124
127
  end
125
128
  end
126
129
 
@@ -166,23 +169,37 @@ module Action
166
169
  kwargs.each do |key, value|
167
170
  raise Action::ContractViolation::UnknownExposure, key unless result.respond_to?(key)
168
171
 
169
- @context.public_send("#{key}=", value)
172
+ @__context.exposed_data[key] = value
170
173
  end
171
174
  end
172
175
 
173
176
  def context_for_logging(direction = nil)
174
- inspection_filter.filter(@context.to_h.slice(*_declared_fields(direction)))
177
+ inspection_filter.filter(@__context.__combined_data.slice(*_declared_fields(direction)))
175
178
  end
176
179
 
177
180
  private
178
181
 
182
+ def _with_contract
183
+ _apply_inbound_preprocessing!
184
+ _apply_defaults!(:inbound)
185
+ _validate_contract!(:inbound)
186
+
187
+ yield
188
+
189
+ _apply_defaults!(:outbound)
190
+ _validate_contract!(:outbound)
191
+
192
+ # TODO: improve location of this triggering
193
+ _trigger_on_success if respond_to?(:_trigger_on_success)
194
+ end
195
+
179
196
  def _build_context_facade(direction)
180
197
  raise ArgumentError, "Invalid direction: #{direction}" unless %i[inbound outbound].include?(direction)
181
198
 
182
199
  klass = direction == :inbound ? Action::InternalContext : Action::Result
183
200
  implicitly_allowed_fields = direction == :inbound ? _declared_fields(:outbound) : []
184
201
 
185
- klass.new(action: self, context: @context, declared_fields: _declared_fields(direction), implicitly_allowed_fields:)
202
+ klass.new(action: self, context: @__context, declared_fields: _declared_fields(direction), implicitly_allowed_fields:)
186
203
  end
187
204
 
188
205
  def inspection_filter
@@ -86,7 +86,7 @@ module Action
86
86
  Action::Validation::Subfields.extract(field, public_send(on))
87
87
  end
88
88
 
89
- _define_model_reader(field, validations[:model]) if validations.key?(:model)
89
+ _define_model_reader(field, validations[:model]) { Action::Validation::Subfields.extract(field, public_send(on)) } if validations.key?(:model)
90
90
  end
91
91
  end
92
92
 
@@ -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/flow/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 :_callbacks_registry, default: Action::Core::Flow::Handlers::Registry.empty
12
+
13
+ extend ClassMethods
14
+ end
15
+ end
16
+
17
+ module ClassMethods
18
+ # Internal dispatcher
19
+ def _dispatch_callbacks(event_type, action:, exception: nil)
20
+ _callbacks_registry.for(event_type).each do |handler|
21
+ handler.apply(action:, exception:)
22
+ end
23
+ end
24
+
25
+ # ONLY raised exceptions (i.e. NOT fail!).
26
+ def on_exception(**, &block) = _add_callback(:exception, **, block:)
27
+
28
+ # ONLY raised on fail! (i.e. NOT unhandled exceptions).
29
+ def on_failure(**, &block) = _add_callback(:failure, **, block:)
30
+
31
+ # Handles both fail! and unhandled exceptions
32
+ def on_error(**, &block) = _add_callback(:error, **, block:)
33
+
34
+ # Executes when the action completes successfully (after all after hooks complete successfully)
35
+ # Runs in child-first order (child handlers before parent handlers)
36
+ def on_success(**, &block) = _add_callback(:success, **, block:)
37
+
38
+ private
39
+
40
+ def _add_callback(event_type, block:, **kwargs)
41
+ raise ArgumentError, "on_#{event_type} cannot be called with both :if and :unless" if kwargs.key?(:if) && kwargs.key?(:unless)
42
+
43
+ condition = kwargs.key?(:if) ? kwargs[:if] : kwargs[:unless]
44
+ raise ArgumentError, "on_#{event_type} must be called with a block" unless block
45
+
46
+ matcher = condition.nil? ? nil : Action::Core::Flow::Handlers::Matcher.new(condition, invert: kwargs.key?(:unless))
47
+ entry = Action::Core::Flow::Handlers::CallbackHandler.new(matcher:, handler: block)
48
+ self._callbacks_registry = _callbacks_registry.register(event_type:, entry:)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,65 @@
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
+ # Call any handlers registered on *this specific action* class
13
+ self.class._dispatch_callbacks(:exception, action: self, exception:)
14
+
15
+ # Call any global handlers
16
+ Action.config.on_exception(exception, action: self, context: context_for_logging)
17
+ rescue StandardError => e
18
+ # No action needed -- downstream #on_exception implementation should ideally log any internal failures, but
19
+ # we don't want exception *handling* failures to cascade and overwrite the original exception.
20
+ Axn::Util.piping_error("executing on_exception hooks", action: self, exception: e)
21
+ end
22
+
23
+ def _trigger_on_success
24
+ # Call success handlers in child-first order (like after hooks)
25
+ self.class._dispatch_callbacks(:success, action: self, exception: nil)
26
+ end
27
+ end
28
+ end
29
+
30
+ module InstanceMethods
31
+ private
32
+
33
+ def _with_exception_handling
34
+ yield
35
+ rescue StandardError => e
36
+ # on_error handlers run for both unhandled exceptions and fail!
37
+ self.class._dispatch_callbacks(:error, action: self, exception: e)
38
+
39
+ # on_failure handlers run ONLY for fail!
40
+ if e.is_a?(Action::Failure)
41
+ self.class._dispatch_callbacks(:failure, action: self, exception: e)
42
+ else
43
+ # on_exception handlers run for ONLY for unhandled exceptions.
44
+ _trigger_on_exception(e)
45
+
46
+ @__context.exception = e
47
+ end
48
+
49
+ # Set failure state using accessor method
50
+ @__context.send(:failure=, true)
51
+ end
52
+
53
+ def try
54
+ yield
55
+ rescue Action::Failure => e
56
+ # NOTE: re-raising so we can still fail! from inside the block
57
+ raise e
58
+ rescue StandardError => e
59
+ _trigger_on_exception(e)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action/core/flow/handlers/matcher"
4
+
5
+ module Action
6
+ module Core
7
+ module Flow
8
+ # "Handlers" doesn't feel like *quite* the right name for this, but basically things in this namespace
9
+ # relate to conditionally-invoked code blocks (e.g. callbacks, messages, etc.)
10
+ module Handlers
11
+ class BaseHandler
12
+ def initialize(matcher: nil, handler: nil)
13
+ @matcher = matcher
14
+ @handler = handler
15
+ end
16
+
17
+ attr_reader :handler
18
+
19
+ def static? = @matcher.nil?
20
+
21
+ def matches?(action:, exception:)
22
+ return true if static?
23
+
24
+ @matcher.call(exception:, action:)
25
+ end
26
+
27
+ # Subclasses should implement `apply(action:, exception:)`
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action/core/flow/handlers/base_handler"
4
+ require "action/core/flow/handlers/invoker"
5
+
6
+ module Action
7
+ module Core
8
+ module Flow
9
+ module Handlers
10
+ class CallbackHandler < BaseHandler
11
+ def apply(action:, exception:)
12
+ return false unless matches?(action:, exception:)
13
+
14
+ Invoker.call(action:, handler:, exception:, operation: "executing handler")
15
+ true
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module Core
5
+ module Flow
6
+ module Handlers
7
+ # Shared block evaluation with consistent arity handling and error piping
8
+ module Invoker
9
+ extend self
10
+
11
+ def call(action:, handler:, exception: nil, operation: "executing handler")
12
+ return call_symbol_handler(action:, symbol: handler, exception:) if symbol?(handler)
13
+ return call_callable_handler(action:, callable: handler, exception:) if callable?(handler)
14
+
15
+ literal_value(handler)
16
+ rescue StandardError => e
17
+ Axn::Util.piping_error(operation, action:, exception: e)
18
+ end
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
+ private
37
+
38
+ def symbol?(value) = value.is_a?(Symbol)
39
+
40
+ def callable?(value) = value.respond_to?(:arity)
41
+
42
+ def call_symbol_handler(action:, symbol:, exception: nil)
43
+ unless action.respond_to?(symbol)
44
+ action.warn("Ignoring apparently-invalid symbol #{symbol.inspect} -- action does not respond to method")
45
+ return nil
46
+ end
47
+
48
+ method = action.method(symbol)
49
+ if exception && accepts_exception_keyword?(method)
50
+ action.public_send(symbol, exception:)
51
+ elsif exception && accepts_positional_exception?(method)
52
+ action.public_send(symbol, exception)
53
+ else
54
+ action.public_send(symbol)
55
+ end
56
+ end
57
+
58
+ def call_callable_handler(action:, callable:, exception: nil)
59
+ if exception && accepts_exception_keyword?(callable)
60
+ action.instance_exec(exception:, &callable)
61
+ elsif exception && accepts_positional_exception?(callable)
62
+ action.instance_exec(exception, &callable)
63
+ else
64
+ action.instance_exec(&callable)
65
+ end
66
+ end
67
+
68
+ def literal_value(value) = value
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action/core/flow/handlers/invoker"
4
+
5
+ module Action
6
+ module Core
7
+ module Flow
8
+ module Handlers
9
+ class Matcher
10
+ def initialize(rule, invert: false)
11
+ @rule = rule
12
+ @invert = invert
13
+ end
14
+
15
+ def call(exception:, action:)
16
+ @invert ? !matches?(exception:, action:) : matches?(exception:, action:)
17
+ rescue StandardError => e
18
+ Axn::Util.piping_error("determining if handler applies to exception", action:, exception: e)
19
+ end
20
+
21
+ private
22
+
23
+ def matches?(exception:, action:)
24
+ return apply_callable(action:, exception:) if callable?
25
+ return apply_symbol(action:, exception:) if symbol?
26
+ return apply_string(exception:) if string?
27
+ return apply_exception_class(exception:) if exception_class?
28
+
29
+ handle_invalid(action:)
30
+ end
31
+
32
+ def callable? = @rule.respond_to?(:call)
33
+ def symbol? = @rule.is_a?(Symbol)
34
+ def string? = @rule.is_a?(String)
35
+ def exception_class? = @rule.is_a?(Class) && @rule <= Exception
36
+
37
+ def apply_callable(action:, exception:)
38
+ if exception && Invoker.accepts_exception_keyword?(@rule)
39
+ !!action.instance_exec(exception:, &@rule)
40
+ elsif exception && Invoker.accepts_positional_exception?(@rule)
41
+ !!action.instance_exec(exception, &@rule)
42
+ else
43
+ !!action.instance_exec(&@rule)
44
+ end
45
+ end
46
+
47
+ def apply_symbol(action:, exception:)
48
+ if action.respond_to?(@rule)
49
+ method = action.method(@rule)
50
+ if exception && Invoker.accepts_exception_keyword?(method)
51
+ !!action.public_send(@rule, exception:)
52
+ elsif exception && Invoker.accepts_positional_exception?(method)
53
+ !!action.public_send(@rule, exception)
54
+ else
55
+ !!action.public_send(@rule)
56
+ end
57
+ else
58
+ begin
59
+ klass = Object.const_get(@rule.to_s)
60
+ klass && exception.is_a?(klass)
61
+ rescue NameError
62
+ action.warn("Ignoring apparently-invalid matcher #{@rule.inspect} -- neither action method nor constant found")
63
+ false
64
+ end
65
+ end
66
+ end
67
+
68
+ def apply_string(exception:)
69
+ klass = Object.const_get(@rule.to_s)
70
+ klass && exception.is_a?(klass)
71
+ end
72
+
73
+ def apply_exception_class(exception:)
74
+ exception.is_a?(@rule)
75
+ end
76
+
77
+ def handle_invalid(action:)
78
+ action.warn("Ignoring apparently-invalid matcher #{@rule.inspect} -- could not find way to apply it")
79
+ false
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action/core/flow/handlers/base_handler"
4
+ require "action/core/flow/handlers/invoker"
5
+
6
+ module Action
7
+ module Core
8
+ module Flow
9
+ module Handlers
10
+ class MessageHandler < BaseHandler
11
+ # Returns a string (truthy) when it applies and yields a non-blank message; otherwise nil
12
+ def apply(action:, exception:)
13
+ return nil unless matches?(action:, exception:)
14
+
15
+ value =
16
+ if handler.is_a?(Symbol) || handler.respond_to?(:call)
17
+ Invoker.call(action:, handler:, exception:, operation: "determining message callable")
18
+ else
19
+ handler
20
+ end
21
+ value.respond_to?(:presence) ? value.presence : value
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module Core
5
+ module Flow
6
+ module Handlers
7
+ # Small, immutable, copy-on-write registry keyed by event_type.
8
+ # Stores arrays of entries (handlers/interceptors) in insertion order.
9
+ class Registry
10
+ def self.empty = new({})
11
+
12
+ def initialize(index)
13
+ # Freeze arrays and the index for immutability
14
+ @index = index.transform_values { |arr| Array(arr).freeze }.freeze
15
+ end
16
+
17
+ # Always register most-recent-first (last-defined wins). Simpler mental model.
18
+ def register(event_type:, entry:)
19
+ key = event_type.to_sym
20
+ existing = Array(@index[key])
21
+ updated = [entry] + existing
22
+ self.class.new(@index.merge(key => updated.freeze))
23
+ end
24
+
25
+ def for(event_type)
26
+ Array(@index[event_type.to_sym])
27
+ end
28
+
29
+ def empty?
30
+ @index.empty?
31
+ end
32
+
33
+ protected
34
+
35
+ attr_reader :index
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module Core
5
+ module Flow
6
+ module Handlers
7
+ end
8
+ end
9
+ end
10
+ end
11
+
12
+ require "action/core/flow/handlers/invoker"
13
+ require "action/core/flow/handlers/registry"
14
+ require "action/core/flow/handlers/matcher"
15
+ require "action/core/flow/handlers/base_handler"
16
+ require "action/core/flow/handlers/callback_handler"
17
+ require "action/core/flow/handlers/message_handler"
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action/core/flow/handlers"
4
+
5
+ module Action
6
+ module Core
7
+ module Flow
8
+ module Messages
9
+ def self.included(base)
10
+ base.class_eval do
11
+ class_attribute :_messages_registry, default: Action::Core::Flow::Handlers::Registry.empty
12
+
13
+ extend ClassMethods
14
+ include InstanceMethods
15
+ end
16
+ end
17
+
18
+ module ClassMethods
19
+ # Internal: resolve a message for the given event (conditional first, then static)
20
+ def _message_for(event_type, action:, exception: nil)
21
+ _conditional_message_for(event_type, action:, exception:) ||
22
+ _static_message_for(event_type, action:, exception:)
23
+ end
24
+
25
+ def _conditional_message_for(event_type, action:, exception: nil)
26
+ _messages_registry.for(event_type).each do |handler|
27
+ next if handler.respond_to?(:static?) && handler.static?
28
+
29
+ msg = handler.apply(action:, exception:)
30
+ return msg if msg.present?
31
+ end
32
+ nil
33
+ end
34
+
35
+ def _static_message_for(event_type, action:, exception: nil)
36
+ _messages_registry.for(event_type).each do |handler|
37
+ next unless handler.respond_to?(:static?) && handler.static?
38
+
39
+ msg = handler.apply(action:, exception:)
40
+ return msg if msg.present?
41
+ end
42
+ nil
43
+ end
44
+
45
+ def success(message = nil, **, &) = _add_message(:success, message:, **, &)
46
+ def error(message = nil, **, &) = _add_message(:error, message:, **, &)
47
+
48
+ def default_error = new.internal_context.default_error
49
+ def default_success = new.internal_context.default_success
50
+
51
+ private
52
+
53
+ def _add_message(kind, message:, **kwargs, &block)
54
+ raise ArgumentError, "#{kind} cannot be called with both :if and :unless" if kwargs.key?(:if) && kwargs.key?(:unless)
55
+
56
+ condition = kwargs.key?(:if) ? kwargs[:if] : kwargs[:unless]
57
+ raise ArgumentError, "Provide either a message or a block, not both" if message && block_given?
58
+ raise ArgumentError, "Provide a message or a block" unless message || block_given?
59
+
60
+ handler = block_given? ? block : message
61
+
62
+ matcher = condition.nil? ? nil : Action::Core::Flow::Handlers::Matcher.new(condition, invert: kwargs.key?(:unless))
63
+ entry = Action::Core::Flow::Handlers::MessageHandler.new(matcher:, handler:)
64
+ self._messages_registry = _messages_registry.register(event_type: kind, entry:)
65
+ true
66
+ end
67
+ end
68
+
69
+ module InstanceMethods
70
+ delegate :default_error, :default_success, to: :internal_context
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end