axn 0.1.0.pre.alpha.2.8.1 → 0.1.0.pre.alpha.3

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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/rules/axn-framework-patterns.mdc +43 -0
  3. data/.cursor/rules/general-coding-standards.mdc +27 -0
  4. data/.cursor/rules/spec/testing-patterns.mdc +40 -0
  5. data/CHANGELOG.md +43 -0
  6. data/Rakefile +12 -2
  7. data/docs/.vitepress/config.mjs +8 -3
  8. data/docs/advanced/conventions.md +2 -2
  9. data/docs/advanced/mountable.md +562 -0
  10. data/docs/advanced/profiling.md +355 -0
  11. data/docs/advanced/rough.md +1 -1
  12. data/docs/index.md +5 -3
  13. data/docs/intro/about.md +1 -1
  14. data/docs/intro/overview.md +5 -5
  15. data/docs/recipes/memoization.md +2 -2
  16. data/docs/recipes/rubocop-integration.md +38 -284
  17. data/docs/recipes/testing.md +14 -14
  18. data/docs/recipes/validating-user-input.md +1 -1
  19. data/docs/reference/async.md +160 -0
  20. data/docs/reference/axn-result.md +107 -0
  21. data/docs/reference/class.md +123 -25
  22. data/docs/reference/configuration.md +191 -10
  23. data/docs/reference/instance.md +14 -29
  24. data/docs/strategies/index.md +21 -21
  25. data/docs/strategies/transaction.md +1 -1
  26. data/docs/usage/setup.md +14 -0
  27. data/docs/usage/steps.md +7 -7
  28. data/docs/usage/using.md +23 -12
  29. data/docs/usage/writing.md +92 -11
  30. data/lib/axn/async/adapters/active_job.rb +65 -0
  31. data/lib/axn/async/adapters/disabled.rb +26 -0
  32. data/lib/axn/async/adapters/sidekiq.rb +74 -0
  33. data/lib/axn/async/adapters.rb +26 -0
  34. data/lib/axn/async.rb +61 -0
  35. data/lib/{action → axn}/configuration.rb +21 -3
  36. data/lib/{action → axn}/context.rb +21 -4
  37. data/lib/{action → axn}/core/automatic_logging.rb +6 -6
  38. data/lib/axn/core/context/facade.rb +69 -0
  39. data/lib/{action → axn}/core/context/facade_inspector.rb +31 -4
  40. data/lib/{action → axn}/core/context/internal.rb +5 -5
  41. data/lib/{action → axn}/core/contract.rb +41 -46
  42. data/lib/{action → axn}/core/contract_for_subfields.rb +30 -35
  43. data/lib/{action → axn}/core/contract_validation.rb +16 -6
  44. data/lib/axn/core/contract_validation_for_subfields.rb +158 -0
  45. data/lib/axn/core/field_resolvers/extract.rb +32 -0
  46. data/lib/axn/core/field_resolvers/model.rb +63 -0
  47. data/lib/axn/core/field_resolvers.rb +24 -0
  48. data/lib/{action → axn}/core/flow/callbacks.rb +7 -7
  49. data/lib/{action → axn}/core/flow/exception_execution.rb +4 -13
  50. data/lib/{action → axn}/core/flow/handlers/base_descriptor.rb +3 -2
  51. data/lib/{action → axn}/core/flow/handlers/descriptors/callback_descriptor.rb +2 -2
  52. data/lib/{action → axn}/core/flow/handlers/descriptors/message_descriptor.rb +6 -6
  53. data/lib/{action → axn}/core/flow/handlers/invoker.rb +2 -2
  54. data/lib/{action → axn}/core/flow/handlers/matcher.rb +5 -5
  55. data/lib/{action → axn}/core/flow/handlers/registry.rb +3 -1
  56. data/lib/{action → axn}/core/flow/handlers/resolvers/base_resolver.rb +1 -1
  57. data/lib/{action → axn}/core/flow/handlers/resolvers/callback_resolver.rb +2 -2
  58. data/lib/{action → axn}/core/flow/handlers/resolvers/message_resolver.rb +12 -3
  59. data/lib/axn/core/flow/handlers.rb +20 -0
  60. data/lib/{action → axn}/core/flow/messages.rb +7 -7
  61. data/lib/{action → axn}/core/flow.rb +4 -4
  62. data/lib/{action → axn}/core/hooks.rb +16 -5
  63. data/lib/{action → axn}/core/logging.rb +3 -3
  64. data/lib/{action → axn}/core/nesting_tracking.rb +1 -1
  65. data/lib/axn/core/profiling.rb +124 -0
  66. data/lib/{action → axn}/core/timing.rb +1 -1
  67. data/lib/axn/core/tracing.rb +17 -0
  68. data/lib/axn/core/use_strategy.rb +29 -0
  69. data/lib/{action → axn}/core/validation/fields.rb +26 -2
  70. data/lib/{action → axn}/core/validation/subfields.rb +14 -12
  71. data/lib/axn/core/validation/validators/model_validator.rb +36 -0
  72. data/lib/axn/core/validation/validators/type_validator.rb +80 -0
  73. data/lib/{action → axn}/core/validation/validators/validate_validator.rb +12 -2
  74. data/lib/axn/core.rb +123 -0
  75. data/lib/{action → axn}/exceptions.rb +12 -2
  76. data/lib/axn/factory.rb +102 -34
  77. data/lib/axn/internal/logging.rb +26 -0
  78. data/lib/axn/internal/registry.rb +87 -0
  79. data/lib/axn/mountable/descriptor.rb +76 -0
  80. data/lib/axn/mountable/helpers/class_builder.rb +162 -0
  81. data/lib/axn/mountable/helpers/mounter.rb +33 -0
  82. data/lib/axn/mountable/helpers/namespace_manager.rb +66 -0
  83. data/lib/axn/mountable/helpers/validator.rb +112 -0
  84. data/lib/axn/mountable/inherit_profiles.rb +72 -0
  85. data/lib/axn/mountable/mounting_strategies/_base.rb +83 -0
  86. data/lib/axn/mountable/mounting_strategies/axn.rb +48 -0
  87. data/lib/axn/mountable/mounting_strategies/enqueue_all.rb +55 -0
  88. data/lib/axn/mountable/mounting_strategies/method.rb +95 -0
  89. data/lib/axn/mountable/mounting_strategies/step.rb +69 -0
  90. data/lib/axn/mountable/mounting_strategies.rb +32 -0
  91. data/lib/axn/mountable.rb +85 -0
  92. data/lib/axn/rails/engine.rb +51 -0
  93. data/lib/axn/rails/generators/axn_generator.rb +68 -0
  94. data/lib/axn/rails/generators/templates/action.rb.erb +17 -0
  95. data/lib/axn/rails/generators/templates/action_spec.rb.erb +25 -0
  96. data/lib/{action → axn}/result.rb +30 -11
  97. data/lib/{action → axn}/strategies/transaction.rb +1 -1
  98. data/lib/axn/strategies.rb +20 -0
  99. data/lib/axn/testing/spec_helpers.rb +6 -8
  100. data/lib/axn/util/memoization.rb +20 -0
  101. data/lib/axn/version.rb +1 -1
  102. data/lib/axn.rb +17 -16
  103. data/lib/rubocop/cop/axn/README.md +23 -23
  104. data/lib/rubocop/cop/axn/unchecked_result.rb +138 -17
  105. metadata +88 -64
  106. data/.rspec +0 -3
  107. data/.rubocop.yml +0 -76
  108. data/.tool-versions +0 -1
  109. data/docs/reference/action-result.md +0 -37
  110. data/lib/action/attachable/base.rb +0 -43
  111. data/lib/action/attachable/steps.rb +0 -63
  112. data/lib/action/attachable/subactions.rb +0 -70
  113. data/lib/action/attachable.rb +0 -17
  114. data/lib/action/core/context/facade.rb +0 -48
  115. data/lib/action/core/flow/handlers.rb +0 -20
  116. data/lib/action/core/tracing.rb +0 -17
  117. data/lib/action/core/use_strategy.rb +0 -30
  118. data/lib/action/core/validation/validators/model_validator.rb +0 -34
  119. data/lib/action/core/validation/validators/type_validator.rb +0 -30
  120. data/lib/action/core.rb +0 -108
  121. data/lib/action/enqueueable/via_sidekiq.rb +0 -76
  122. data/lib/action/enqueueable.rb +0 -13
  123. data/lib/action/strategies.rb +0 -48
  124. data/lib/axn/util.rb +0 -24
  125. data/package.json +0 -10
  126. data/yarn.lock +0 -1166
@@ -2,14 +2,24 @@
2
2
 
3
3
  require "active_model"
4
4
 
5
- module Action
5
+ module Axn
6
6
  module Validators
7
7
  class ValidateValidator < ActiveModel::EachValidator
8
+ def self.apply_syntactic_sugar(value, _fields)
9
+ return value if value.is_a?(Hash)
10
+
11
+ { with: value }
12
+ end
13
+
14
+ def check_validity!
15
+ raise ArgumentError, "must supply :with" if options[:with].nil?
16
+ end
17
+
8
18
  def validate_each(record, attribute, value)
9
19
  msg = begin
10
20
  options[:with].call(value)
11
21
  rescue StandardError => e
12
- Axn::Util.piping_error("applying custom validation on field '#{attribute}'", exception: e)
22
+ Axn::Internal::Logging.piping_error("applying custom validation on field '#{attribute}'", exception: e)
13
23
 
14
24
  "failed validation: #{e.message}"
15
25
  end
data/lib/axn/core.rb ADDED
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "axn/internal/logging"
4
+
5
+ require "axn/context"
6
+
7
+ require "axn/strategies"
8
+ require "axn/core/hooks"
9
+ require "axn/core/logging"
10
+ require "axn/core/flow"
11
+ require "axn/core/automatic_logging"
12
+ require "axn/core/use_strategy"
13
+ require "axn/core/timing"
14
+ require "axn/core/tracing"
15
+ require "axn/core/profiling"
16
+ require "axn/core/nesting_tracking"
17
+
18
+ # CONSIDER: make class names match file paths?
19
+ require "axn/core/validation/validators/model_validator"
20
+ require "axn/core/validation/validators/type_validator"
21
+ require "axn/core/validation/validators/validate_validator"
22
+
23
+ require "axn/core/field_resolvers"
24
+ require "axn/core/contract_validation"
25
+ require "axn/core/contract_validation_for_subfields"
26
+ require "axn/core/contract"
27
+ require "axn/core/contract_for_subfields"
28
+
29
+ module Axn
30
+ module Core
31
+ def self.included(base)
32
+ base.class_eval do
33
+ extend ClassMethods
34
+ include Core::Hooks
35
+ include Core::Logging
36
+ include Core::AutomaticLogging
37
+ include Core::Tracing
38
+ include Core::Timing
39
+ include Core::Profiling
40
+
41
+ include Core::Flow
42
+
43
+ include Core::ContractValidation
44
+ include Core::ContractValidationForSubfields
45
+ include Core::Contract
46
+ include Core::ContractForSubfields
47
+ include Core::NestingTracking
48
+
49
+ include Core::UseStrategy
50
+ end
51
+ end
52
+
53
+ module ClassMethods
54
+ def call(**)
55
+ new(**).tap(&:_run).result
56
+ end
57
+
58
+ def call!(**)
59
+ result = call(**)
60
+ return result if result.ok?
61
+
62
+ # When we're nested, we want to raise a failure that includes the source action to support
63
+ # the error message generation's `from` filter
64
+ raise Axn::Failure.new(result.error, source: result.__action__), cause: result.exception if _nested_in_another_axn?
65
+
66
+ raise result.exception
67
+ end
68
+ end
69
+
70
+ def initialize(**)
71
+ @__context = Axn::Context.new(**)
72
+ end
73
+
74
+ # Main entry point for action execution
75
+ def _run
76
+ _tracking_nesting(self) do
77
+ _with_profiling do
78
+ _with_tracing do
79
+ _with_logging do
80
+ _with_timing do
81
+ _with_exception_handling do # Exceptions stop here; outer wrappers access result status (and must not introduce another exception layer)
82
+ _with_contract do # Library internals -- any failures (e.g. contract violations) *should* fail the Action::Result
83
+ _with_hooks do # User hooks -- any failures here *should* fail the Action::Result
84
+ call
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ ensure
94
+ _emit_metrics
95
+ end
96
+
97
+ # User-defined action logic - override this method in your action classes
98
+ def call; end
99
+
100
+ def fail!(message = nil, **exposures)
101
+ expose(**exposures) if exposures.any?
102
+ raise Axn::Failure, message
103
+ end
104
+
105
+ def done!(message = nil, **exposures)
106
+ expose(**exposures) if exposures.any?
107
+ raise Axn::Internal::EarlyCompletion, message
108
+ end
109
+
110
+ private
111
+
112
+ def _emit_metrics
113
+ return unless Axn.config.emit_metrics
114
+
115
+ Axn.config.emit_metrics.call(
116
+ self.class.name || "AnonymousClass",
117
+ result,
118
+ )
119
+ rescue StandardError => e
120
+ Axn::Internal::Logging.piping_error("running metrics hook", action: self, exception: e)
121
+ end
122
+ end
123
+ end
@@ -1,7 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Action
4
- # Raised internally when fail! is called
3
+ module Axn
4
+ module Internal
5
+ # Internal only -- rescued before Axn::Result is returned
6
+ class EarlyCompletion < StandardError; end
7
+ end
8
+
9
+ # Raised when fail! is called
5
10
  class Failure < StandardError
6
11
  DEFAULT_MESSAGE = "Execution was halted"
7
12
 
@@ -22,6 +27,10 @@ module Action
22
27
  def inspect = "#<#{self.class.name} '#{message}'>"
23
28
  end
24
29
 
30
+ module Mountable
31
+ class MountingError < ArgumentError; end
32
+ end
33
+
25
34
  class ContractViolation < StandardError
26
35
  class ReservedAttributeError < ContractViolation
27
36
  def initialize(name)
@@ -34,6 +43,7 @@ module Action
34
43
 
35
44
  class MethodNotAllowed < ContractViolation; end
36
45
  class PreprocessingError < ContractViolation; end
46
+ class DefaultAssignmentError < ContractViolation; end
37
47
 
38
48
  class UnknownExposure < ContractViolation
39
49
  def initialize(key)
data/lib/axn/factory.rb CHANGED
@@ -5,10 +5,15 @@ module Axn
5
5
  class << self
6
6
  # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/ParameterLists
7
7
  def build(
8
+ callable = nil,
8
9
  # Builder-specific options
9
- name: nil,
10
10
  superclass: nil,
11
- expose_return_as: :nil,
11
+ expose_return_as: nil,
12
+
13
+ # Module inclusion options
14
+ include: [],
15
+ extend: [],
16
+ prepend: [],
12
17
 
13
18
  # Expose standard class-level options
14
19
  exposes: [],
@@ -30,19 +35,27 @@ module Axn
30
35
  # Strategies
31
36
  use: [],
32
37
 
38
+ # Async configuration
39
+ async: nil,
40
+
33
41
  &block
34
42
  )
35
- args = block.parameters.each_with_object(_hash_with_default_array) { |(type, field), hash| hash[type] << field }
43
+ raise ArgumentError, "[Axn::Factory] Cannot receive both a callable and a block" if callable.present? && block_given?
44
+
45
+ executable = callable || block
46
+ raise ArgumentError, "[Axn::Factory] Must provide either a callable or a block" unless executable
47
+
48
+ args = executable.parameters.each_with_object(_hash_with_default_array) { |(type, field), hash| hash[type] << field }
36
49
 
37
50
  if args[:opt].present? || args[:req].present? || args[:rest].present?
38
51
  raise ArgumentError,
39
- "[Axn::Factory] Cannot convert block to action: block expects positional arguments"
52
+ "[Axn::Factory] Cannot convert callable to action: callable expects positional arguments"
40
53
  end
41
- raise ArgumentError, "[Axn::Factory] Cannot convert block to action: block expects a splat of keyword arguments" if args[:keyrest].present?
54
+ raise ArgumentError, "[Axn::Factory] Cannot convert callable to action: callable expects a splat of keyword arguments" if args[:keyrest].present?
42
55
 
43
56
  if args[:key].present?
44
57
  raise ArgumentError,
45
- "[Axn::Factory] Cannot convert block to action: block expects keyword arguments with defaults (ruby does not allow introspecting)"
58
+ "[Axn::Factory] Cannot convert callable to action: callable expects keyword arguments with defaults (ruby does not allow introspecting)"
46
59
  end
47
60
 
48
61
  expects = _hydrate_hash(expects)
@@ -53,25 +66,7 @@ module Axn
53
66
  end
54
67
 
55
68
  # NOTE: inheriting from wrapping class, so we can set default values (e.g. for HTTP headers)
56
- Class.new(superclass || Object) do
57
- include Action unless self < Action
58
-
59
- define_singleton_method(:name) do
60
- [
61
- superclass&.name.presence || "AnonymousAction",
62
- name,
63
- ].compact.join("#")
64
- end
65
-
66
- define_method(:call) do
67
- unwrapped_kwargs = Array(args[:keyreq]).each_with_object({}) do |field, hash|
68
- hash[field] = public_send(field)
69
- end
70
-
71
- retval = instance_exec(**unwrapped_kwargs, &block)
72
- expose(expose_return_as => retval) if expose_return_as.present?
73
- end
74
- end.tap do |axn|
69
+ _build_axn_class(superclass:, args:, executable:, expose_return_as:, include:, extend:, prepend:).tap do |axn|
75
70
  expects.each do |field, opts|
76
71
  axn.expects(field, **opts)
77
72
  end
@@ -81,8 +76,8 @@ module Axn
81
76
  end
82
77
 
83
78
  # Apply success and error handlers
84
- _apply_handlers(axn, :success, success, Action::Core::Flow::Handlers::Descriptors::MessageDescriptor)
85
- _apply_handlers(axn, :error, error, Action::Core::Flow::Handlers::Descriptors::MessageDescriptor)
79
+ _apply_handlers(axn, :success, success, Axn::Core::Flow::Handlers::Descriptors::MessageDescriptor)
80
+ _apply_handlers(axn, :error, error, Axn::Core::Flow::Handlers::Descriptors::MessageDescriptor)
86
81
 
87
82
  # Hooks
88
83
  axn.before(before) if before.present?
@@ -90,10 +85,10 @@ module Axn
90
85
  axn.around(around) if around.present?
91
86
 
92
87
  # Callbacks
93
- _apply_handlers(axn, :on_success, on_success, Action::Core::Flow::Handlers::Descriptors::CallbackDescriptor)
94
- _apply_handlers(axn, :on_failure, on_failure, Action::Core::Flow::Handlers::Descriptors::CallbackDescriptor)
95
- _apply_handlers(axn, :on_error, on_error, Action::Core::Flow::Handlers::Descriptors::CallbackDescriptor)
96
- _apply_handlers(axn, :on_exception, on_exception, Action::Core::Flow::Handlers::Descriptors::CallbackDescriptor)
88
+ _apply_handlers(axn, :on_success, on_success, Axn::Core::Flow::Handlers::Descriptors::CallbackDescriptor)
89
+ _apply_handlers(axn, :on_failure, on_failure, Axn::Core::Flow::Handlers::Descriptors::CallbackDescriptor)
90
+ _apply_handlers(axn, :on_error, on_error, Axn::Core::Flow::Handlers::Descriptors::CallbackDescriptor)
91
+ _apply_handlers(axn, :on_exception, on_exception, Axn::Core::Flow::Handlers::Descriptors::CallbackDescriptor)
97
92
 
98
93
  # Strategies
99
94
  Array(use).each do |strategy|
@@ -110,8 +105,19 @@ module Axn
110
105
  end
111
106
  end
112
107
 
108
+ # Async configuration
109
+ unless async.nil?
110
+ async_array = Array(async)
111
+ # Skip async configuration if adapter is nil (but not if array is empty)
112
+ if !async_array.empty? && async_array[0].nil?
113
+ # Do nothing - skip async configuration
114
+ else
115
+ _apply_async_config(axn, async_array)
116
+ end
117
+ end
118
+
113
119
  # Default exposure
114
- axn.exposes(expose_return_as, allow_blank: true) if expose_return_as.present?
120
+ axn.exposes(expose_return_as, optional: true) if expose_return_as.present?
115
121
  end
116
122
  end
117
123
  # rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/ParameterLists
@@ -138,16 +144,78 @@ module Axn
138
144
  return unless value.present?
139
145
 
140
146
  # Check if the value itself is a hash (this catches the case where someone passes a hash literal)
141
- raise Action::UnsupportedArgument, "Cannot pass hash directly to #{method_name} - use descriptor objects for kwargs" if value.is_a?(Hash)
147
+ raise Axn::UnsupportedArgument, "Cannot pass hash directly to #{method_name} - use descriptor objects for kwargs" if value.is_a?(Hash)
142
148
 
143
149
  # Wrap in Array() to handle both single values and arrays
144
150
  Array(value).each do |handler|
145
- raise Action::UnsupportedArgument, "Cannot pass hash directly to #{method_name} - use descriptor objects for kwargs" if handler.is_a?(Hash)
151
+ raise Axn::UnsupportedArgument, "Cannot pass hash directly to #{method_name} - use descriptor objects for kwargs" if handler.is_a?(Hash)
146
152
 
147
153
  # Both descriptor objects and simple cases (string/proc) can be used directly
148
154
  axn.public_send(method_name, handler)
149
155
  end
150
156
  end
157
+
158
+ def _build_axn_class(superclass:, args:, executable:, expose_return_as:, include: nil, extend: nil, prepend: nil)
159
+ Class.new(superclass || Object) do
160
+ include Axn unless self < Axn
161
+
162
+ Array(include).each { |mod| include mod }
163
+ Array(extend).each { |mod| extend mod }
164
+ Array(prepend).each { |mod| prepend mod }
165
+
166
+ # Set a default name for anonymous classes to help with debugging
167
+ define_singleton_method(:name) do
168
+ "AnonymousAxn_#{object_id}"
169
+ end
170
+
171
+ define_method(:call) do
172
+ unwrapped_kwargs = Array(args[:keyreq]).each_with_object({}) do |field, hash|
173
+ hash[field] = public_send(field)
174
+ end
175
+
176
+ retval = instance_exec(**unwrapped_kwargs, &executable)
177
+ expose(expose_return_as => retval) if expose_return_as.present?
178
+ end
179
+ end
180
+ end
181
+
182
+ def _apply_async_config(axn, async)
183
+ raise ArgumentError, "[Axn::Factory] Invalid async configuration" unless _validate_async_config(async)
184
+
185
+ adapter, *config_args = async
186
+
187
+ # Determine hash config and callable config
188
+ config = config_args.find { |arg| arg.is_a?(Hash) }
189
+ block = config_args.find { |arg| arg.respond_to?(:call) }
190
+
191
+ # Call async once with the determined values
192
+ axn.async(adapter, **(config || {}), &block)
193
+ end
194
+
195
+ def _validate_async_config(async_array)
196
+ return false unless async_array.length.between?(1, 3)
197
+
198
+ adapter = async_array[0]
199
+ second_arg = async_array[1]
200
+ third_arg = async_array[2]
201
+
202
+ # First arg must be adapter (symbol/string), false, or nil
203
+ return false unless adapter.is_a?(Symbol) || adapter.is_a?(String) || adapter == false || adapter.nil?
204
+
205
+ case async_array.length
206
+ when 1
207
+ # Pattern A: [:sidekiq], [false], or [nil]
208
+ true
209
+ when 2
210
+ # Pattern B: [:sidekiq, hash_or_callable] or [nil, hash_or_callable]
211
+ second_arg.is_a?(Hash) || second_arg.respond_to?(:call)
212
+ when 3
213
+ # Pattern C: [:sidekiq, hash, callable] or [nil, hash, callable]
214
+ second_arg.is_a?(Hash) && third_arg.respond_to?(:call)
215
+ else
216
+ false
217
+ end
218
+ end
151
219
  end
152
220
  end
153
221
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Internal
5
+ module Logging
6
+ def self.piping_error(desc, exception:, action: nil)
7
+ # Extract just filename/line number from backtrace
8
+ src = exception.backtrace.first.split.first.split("/").last.split(":")[0, 2].join(":")
9
+
10
+ message = if Axn.config.env.production?
11
+ "Ignoring exception raised while #{desc}: #{exception.class.name} - #{exception.message} (from #{src})"
12
+ else
13
+ msg = "!! IGNORING EXCEPTION RAISED WHILE #{desc.upcase} !!\n\n" \
14
+ "\t* Exception: #{exception.class.name}\n" \
15
+ "\t* Message: #{exception.message}\n" \
16
+ "\t* From: #{src}"
17
+ "#{"⌵" * 30}\n\n#{msg}\n\n#{"^" * 30}"
18
+ end
19
+
20
+ (action || Axn.config.logger).send(:warn, message)
21
+
22
+ nil
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+
5
+ module Axn
6
+ module Internal
7
+ class Registry
8
+ class NotFound < StandardError; end
9
+ class DuplicateError < StandardError; end
10
+
11
+ class << self
12
+ def built_in
13
+ @built_in ||= begin
14
+ # Get the directory name from the class name (e.g., "Strategies" -> "strategies")
15
+ dir_name = name.split("::").last.underscore
16
+
17
+ # Load all files from the directory
18
+ files = ::Dir[File.join(registry_directory, dir_name, "*.rb")]
19
+ files.each { |file| require file }
20
+
21
+ # Get all modules defined within this class
22
+ constants = self.constants.map { |const| const_get(const) }
23
+ items = select_constants_to_load(constants)
24
+
25
+ # Convert module names to keys
26
+ items.to_h do |item|
27
+ name = item.name.split("::").last
28
+ key = name.underscore.to_sym
29
+ [key, item]
30
+ end
31
+ end
32
+ end
33
+
34
+ def register(name, item)
35
+ items = all # ensure built_in is initialized
36
+ key = name.to_sym
37
+ raise duplicate_error_class, "#{item_type} #{name} already registered" if items.key?(key)
38
+
39
+ items[key] = item
40
+ items
41
+ end
42
+
43
+ def all
44
+ @items ||= built_in.dup
45
+ end
46
+
47
+ def clear!
48
+ @items = built_in.dup
49
+ end
50
+
51
+ def find(name)
52
+ raise not_found_error_class, "#{item_type} name cannot be nil" if name.nil?
53
+ raise not_found_error_class, "#{item_type} name cannot be empty" if name.to_s.strip.empty?
54
+
55
+ all[name.to_sym] or raise not_found_error_class, "#{item_type} '#{name}' not found"
56
+ end
57
+
58
+ private
59
+
60
+ def item_type
61
+ # Subclasses can override this for better error messages
62
+ "Item"
63
+ end
64
+
65
+ def not_found_error_class
66
+ # Subclasses can override this to return their specific error class
67
+ NotFound
68
+ end
69
+
70
+ def duplicate_error_class
71
+ # Subclasses can override this to return their specific error class
72
+ DuplicateError
73
+ end
74
+
75
+ def registry_directory
76
+ # Subclasses must override this to return their directory
77
+ raise NotImplementedError, "Subclasses must implement registry_directory method"
78
+ end
79
+
80
+ def select_constants_to_load(constants)
81
+ # Subclasses can override this to select which constants to load
82
+ constants.select { |const| const.is_a?(Module) }
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Mountable
5
+ # Descriptor holds the information needed to mount an action
6
+ class Descriptor
7
+ attr_reader :name, :options, :mounted_axn, :mount_strategy, :existing_axn_klass, :block, :raw_kwargs, :kwargs
8
+
9
+ def initialize(name:, as:, axn_klass: nil, block: nil, kwargs: {})
10
+ @mount_strategy = MountingStrategies.find(as)
11
+ @existing_axn_klass = axn_klass
12
+
13
+ @name = name
14
+ @block = block
15
+ @raw_kwargs = kwargs
16
+
17
+ @kwargs = mount_strategy.preprocess_kwargs(**kwargs.except(*mount_strategy.strategy_specific_kwargs), axn_klass:)
18
+ @options = kwargs.slice(*mount_strategy.strategy_specific_kwargs)
19
+
20
+ @validator = Helpers::Validator.new(self)
21
+
22
+ @validator.validate!
23
+ freeze
24
+ end
25
+
26
+ def mount(target:)
27
+ validate_before_mount!(target:)
28
+ mount_strategy.mount(descriptor: self, target:)
29
+ end
30
+
31
+ def mounted_axn_for(target:)
32
+ # Check if the target already has this action class cached
33
+ cache_key = "#{@name}_#{object_id}_#{target.object_id}"
34
+
35
+ # Use a class variable to store the cache on the target
36
+ cache_var = :@_axn_cache
37
+ target.instance_variable_set(cache_var, {}) unless target.instance_variable_defined?(cache_var)
38
+ cache = target.instance_variable_get(cache_var)
39
+
40
+ return cache[cache_key] if cache.key?(cache_key)
41
+
42
+ # Check if constant is already registered
43
+ action_class_builder = Helpers::ClassBuilder.new(self)
44
+ namespace = Helpers::NamespaceManager.get_or_create_namespace(target)
45
+ constant_name = action_class_builder.generate_constant_name(@name.to_s)
46
+ if namespace.const_defined?(constant_name, false)
47
+ mounted_axn = namespace.const_get(constant_name)
48
+ cache[cache_key] = mounted_axn
49
+ return mounted_axn
50
+ end
51
+
52
+ # Build and configure action class
53
+ mounted_axn = action_class_builder.build_and_configure_action_class(target, @name.to_s, namespace)
54
+
55
+ # Cache on the target
56
+ cache[cache_key] = mounted_axn
57
+ mounted_axn
58
+ end
59
+
60
+ def mounted? = @mounted_axn.present?
61
+
62
+ private
63
+
64
+ def method_name = @name.to_s.underscore
65
+
66
+ def validate_before_mount!(target:)
67
+ # Method name collision validation is now handled in mount_axn
68
+ # This method is kept for potential future validation needs
69
+ end
70
+
71
+ def mounting_type_name
72
+ mount_strategy.name.split("::").last.underscore.to_s.humanize
73
+ end
74
+ end
75
+ end
76
+ end