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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/commands/pr.md +36 -0
  3. data/CHANGELOG.md +15 -1
  4. data/Rakefile +102 -2
  5. data/docs/.vitepress/config.mjs +12 -8
  6. data/docs/advanced/conventions.md +1 -1
  7. data/docs/advanced/mountable.md +4 -90
  8. data/docs/advanced/profiling.md +26 -30
  9. data/docs/advanced/rough.md +27 -8
  10. data/docs/intro/overview.md +1 -1
  11. data/docs/recipes/formatting-context-for-error-tracking.md +186 -0
  12. data/docs/recipes/memoization.md +102 -17
  13. data/docs/reference/async.md +269 -0
  14. data/docs/reference/class.md +113 -50
  15. data/docs/reference/configuration.md +226 -75
  16. data/docs/reference/form-object.md +252 -0
  17. data/docs/strategies/client.md +212 -0
  18. data/docs/strategies/form.md +235 -0
  19. data/docs/usage/setup.md +2 -2
  20. data/docs/usage/writing.md +99 -1
  21. data/lib/axn/async/adapters/active_job.rb +19 -10
  22. data/lib/axn/async/adapters/disabled.rb +15 -0
  23. data/lib/axn/async/adapters/sidekiq.rb +25 -32
  24. data/lib/axn/async/batch_enqueue/config.rb +38 -0
  25. data/lib/axn/async/batch_enqueue.rb +99 -0
  26. data/lib/axn/async/enqueue_all_orchestrator.rb +363 -0
  27. data/lib/axn/async.rb +121 -4
  28. data/lib/axn/configuration.rb +53 -13
  29. data/lib/axn/context.rb +1 -0
  30. data/lib/axn/core/automatic_logging.rb +47 -51
  31. data/lib/axn/core/context/facade_inspector.rb +1 -1
  32. data/lib/axn/core/contract.rb +73 -30
  33. data/lib/axn/core/contract_for_subfields.rb +1 -1
  34. data/lib/axn/core/contract_validation.rb +14 -9
  35. data/lib/axn/core/contract_validation_for_subfields.rb +14 -7
  36. data/lib/axn/core/default_call.rb +63 -0
  37. data/lib/axn/core/flow/exception_execution.rb +5 -0
  38. data/lib/axn/core/flow/handlers/descriptors/message_descriptor.rb +19 -7
  39. data/lib/axn/core/flow/handlers/invoker.rb +4 -30
  40. data/lib/axn/core/flow/handlers/matcher.rb +4 -14
  41. data/lib/axn/core/flow/messages.rb +1 -1
  42. data/lib/axn/core/hooks.rb +1 -0
  43. data/lib/axn/core/logging.rb +16 -5
  44. data/lib/axn/core/memoization.rb +53 -0
  45. data/lib/axn/core/tracing.rb +77 -4
  46. data/lib/axn/core/validation/validators/type_validator.rb +1 -1
  47. data/lib/axn/core.rb +31 -46
  48. data/lib/axn/extras/strategies/client.rb +150 -0
  49. data/lib/axn/extras/strategies/vernier.rb +121 -0
  50. data/lib/axn/extras.rb +4 -0
  51. data/lib/axn/factory.rb +22 -2
  52. data/lib/axn/form_object.rb +90 -0
  53. data/lib/axn/internal/logging.rb +5 -1
  54. data/lib/axn/mountable/helpers/class_builder.rb +41 -10
  55. data/lib/axn/mountable/helpers/namespace_manager.rb +6 -34
  56. data/lib/axn/mountable/inherit_profiles.rb +2 -2
  57. data/lib/axn/mountable/mounting_strategies/_base.rb +10 -6
  58. data/lib/axn/mountable/mounting_strategies/method.rb +2 -2
  59. data/lib/axn/mountable.rb +41 -7
  60. data/lib/axn/rails/generators/axn_generator.rb +19 -1
  61. data/lib/axn/rails/generators/templates/action.rb.erb +1 -1
  62. data/lib/axn/result.rb +2 -2
  63. data/lib/axn/strategies/form.rb +98 -0
  64. data/lib/axn/strategies/transaction.rb +7 -0
  65. data/lib/axn/util/callable.rb +120 -0
  66. data/lib/axn/util/contract_error_handling.rb +32 -0
  67. data/lib/axn/util/execution_context.rb +34 -0
  68. data/lib/axn/util/global_id_serialization.rb +52 -0
  69. data/lib/axn/util/logging.rb +87 -0
  70. data/lib/axn/version.rb +1 -1
  71. data/lib/axn.rb +9 -0
  72. metadata +22 -4
  73. data/lib/axn/core/profiling.rb +0 -124
  74. data/lib/axn/mountable/mounting_strategies/enqueue_all.rb +0 -55
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Core
5
+ module Memoization
6
+ def self.included(base)
7
+ base.class_eval do
8
+ extend ClassMethods
9
+ end
10
+ end
11
+
12
+ module ClassMethods
13
+ def memo(method_name)
14
+ if _memo_wise_available?
15
+ _ensure_memo_wise_prepended
16
+ memo_wise(method_name)
17
+ else
18
+ _memo_minimal(method_name)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def _memo_wise_available?
25
+ defined?(MemoWise)
26
+ end
27
+
28
+ def _ensure_memo_wise_prepended
29
+ return if ancestors.include?(MemoWise)
30
+
31
+ prepend MemoWise
32
+ end
33
+
34
+ def _memo_minimal(method_name)
35
+ method = instance_method(method_name)
36
+ params = method.parameters
37
+ has_args = params.any? { |type, _name| %i[req opt rest keyreq key keyrest].include?(type) }
38
+
39
+ if has_args
40
+ raise ArgumentError,
41
+ "Memoization of methods with arguments requires the 'memo_wise' gem. " \
42
+ "Please add 'memo_wise' to your Gemfile or use a method without arguments."
43
+ end
44
+
45
+ # Wrap the method with memoization
46
+ Axn::Util::Memoization.define_memoized_reader_method(self, method_name) do
47
+ method.bind(self).call
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -1,16 +1,89 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module Axn
4
6
  module Core
5
7
  module Tracing
8
+ class << self
9
+ # Cache the tracer instance to avoid repeated lookups
10
+ # The tracer provider may cache internally, but we avoid the method call overhead
11
+ # We check defined?(OpenTelemetry) each time to handle cases where it's loaded lazily
12
+ def tracer
13
+ return nil unless defined?(OpenTelemetry)
14
+
15
+ # Re-fetch if the tracer provider has changed (e.g., in tests with mocks)
16
+ current_provider = OpenTelemetry.tracer_provider
17
+ return @tracer if defined?(@tracer) && defined?(@tracer_provider) && @tracer_provider == current_provider
18
+
19
+ @tracer_provider = current_provider
20
+ @tracer = current_provider.tracer("axn", Axn::VERSION)
21
+ end
22
+ end
23
+
6
24
  private
7
25
 
8
26
  def _with_tracing(&)
9
- return yield unless Axn.config.wrap_with_trace
27
+ resource = self.class.name || "AnonymousClass"
28
+ payload = { resource:, action: self }
29
+
30
+ update_payload = proc do
31
+ result = self.result
32
+ outcome = result.outcome.to_s
33
+ payload[:outcome] = outcome
34
+ payload[:result] = result
35
+ payload[:elapsed_time] = result.elapsed_time
36
+ payload[:exception] = result.exception if result.exception
37
+ rescue StandardError => e
38
+ # Don't raise in ensure block to avoid interfering with existing exceptions
39
+ Axn::Internal::Logging.piping_error("updating notification payload while tracing axn.call", action: self, exception: e)
40
+ end
41
+
42
+ instrument_block = proc do
43
+ ActiveSupport::Notifications.instrument("axn.call", payload, &)
44
+ ensure
45
+ # Update payload BEFORE instrument completes so subscribers see the changes
46
+ update_payload.call
47
+ end
48
+
49
+ # NOTE: despite using block form, ActiveSupport explicitly only emits to subscribers when it's finished,
50
+ # which means it's not suitable for wrapping execution with a span and tracking child spans.
51
+ # We use OpenTelemetry for that, if available.
52
+ if defined?(OpenTelemetry)
53
+ Tracing.tracer.in_span("axn.call", attributes: { "axn.resource" => resource }) do |span|
54
+ instrument_block.call
55
+ ensure
56
+ # Update span with outcome and error status after execution
57
+ # This ensure runs before the span finishes, so we can still update it
58
+ begin
59
+ result = self.result
60
+ outcome = result.outcome.to_s
61
+ span.set_attribute("axn.outcome", outcome)
10
62
 
11
- Axn.config.wrap_with_trace.call(self.class.name || "AnonymousClass", &)
12
- rescue StandardError => e
13
- Axn::Internal::Logging.piping_error("running trace hook", action: self, exception: e)
63
+ if %w[failure exception].include?(outcome) && result.exception
64
+ span.record_exception(result.exception)
65
+ error_message = result.exception.message || result.exception.class.name
66
+ span.status = OpenTelemetry::Trace::Status.error(error_message)
67
+ end
68
+ rescue StandardError => e
69
+ # Don't raise in ensure block to avoid interfering with existing exceptions
70
+ Axn::Internal::Logging.piping_error("updating OTel span while tracing axn.call", action: self, exception: e)
71
+ end
72
+ end
73
+ else
74
+ instrument_block.call
75
+ end
76
+ ensure
77
+ begin
78
+ emit_metrics_proc = Axn.config.emit_metrics
79
+ if emit_metrics_proc
80
+ result = self.result
81
+ Axn::Util::Callable.call_with_desired_shape(emit_metrics_proc, kwargs: { resource:, result: })
82
+ end
83
+ rescue StandardError => e
84
+ # Don't raise in ensure block to avoid interfering with existing exceptions
85
+ Axn::Internal::Logging.piping_error("calling emit_metrics while tracing axn.call", action: self, exception: e)
86
+ end
14
87
  end
15
88
  end
16
89
  end
@@ -39,7 +39,7 @@ module Axn
39
39
  private
40
40
 
41
41
  def types = Array(options[:klass])
42
- def msg = types.size == 1 ? "is not a #{types.first}" : "is not one of #{types.join(", ")}"
42
+ def msg = types.size == 1 ? "is not a #{types.first}" : "is not one of #{types.join(', ')}"
43
43
 
44
44
  def valid_type?(type:, value:, allow_blank:)
45
45
  # NOTE: allow mocks to pass type validation by default (much easier testing ergonomics)
data/lib/axn/core.rb CHANGED
@@ -5,6 +5,7 @@ require "axn/internal/logging"
5
5
  require "axn/context"
6
6
 
7
7
  require "axn/strategies"
8
+ require "axn/extras"
8
9
  require "axn/core/hooks"
9
10
  require "axn/core/logging"
10
11
  require "axn/core/flow"
@@ -12,8 +13,8 @@ require "axn/core/automatic_logging"
12
13
  require "axn/core/use_strategy"
13
14
  require "axn/core/timing"
14
15
  require "axn/core/tracing"
15
- require "axn/core/profiling"
16
16
  require "axn/core/nesting_tracking"
17
+ require "axn/core/memoization"
17
18
 
18
19
  # CONSIDER: make class names match file paths?
19
20
  require "axn/core/validation/validators/model_validator"
@@ -25,9 +26,27 @@ require "axn/core/contract_validation"
25
26
  require "axn/core/contract_validation_for_subfields"
26
27
  require "axn/core/contract"
27
28
  require "axn/core/contract_for_subfields"
29
+ require "axn/core/default_call"
28
30
 
29
31
  module Axn
30
32
  module Core
33
+ module ClassMethods
34
+ def call(**)
35
+ new(**).tap(&:_run).result
36
+ end
37
+
38
+ def call!(**)
39
+ result = call(**)
40
+ return result if result.ok?
41
+
42
+ # When we're nested, we want to raise a failure that includes the source action to support
43
+ # the error message generation's `from` filter
44
+ raise Axn::Failure.new(result.error, source: result.__action__), cause: result.exception if _nested_in_another_axn?
45
+
46
+ raise result.exception
47
+ end
48
+ end
49
+
31
50
  def self.included(base)
32
51
  base.class_eval do
33
52
  extend ClassMethods
@@ -36,7 +55,6 @@ module Axn
36
55
  include Core::AutomaticLogging
37
56
  include Core::Tracing
38
57
  include Core::Timing
39
- include Core::Profiling
40
58
 
41
59
  include Core::Flow
42
60
 
@@ -47,42 +65,21 @@ module Axn
47
65
  include Core::NestingTracking
48
66
 
49
67
  include Core::UseStrategy
68
+ include Core::Memoization
69
+ include Core::DefaultCall
50
70
  end
51
71
  end
52
72
 
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
73
  # Main entry point for action execution
75
74
  def _run
76
75
  _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
76
+ _with_tracing do
77
+ _with_logging do
78
+ _with_timing do
79
+ _with_exception_handling do # Exceptions stop here; outer wrappers access result status (and must not introduce another exception layer)
80
+ _with_contract do # Library internals -- any failures (e.g. contract violations) *should* fail the Action::Result
81
+ _with_hooks do # User hooks -- any failures here *should* fail the Action::Result
82
+ call
86
83
  end
87
84
  end
88
85
  end
@@ -90,13 +87,8 @@ module Axn
90
87
  end
91
88
  end
92
89
  end
93
- ensure
94
- _emit_metrics
95
90
  end
96
91
 
97
- # User-defined action logic - override this method in your action classes
98
- def call; end
99
-
100
92
  def fail!(message = nil, **exposures)
101
93
  expose(**exposures) if exposures.any?
102
94
  raise Axn::Failure, message
@@ -109,15 +101,8 @@ module Axn
109
101
 
110
102
  private
111
103
 
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)
104
+ def initialize(**)
105
+ @__context = Axn::Context.new(**)
121
106
  end
122
107
  end
123
108
  end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockNesting
4
+ module Axn
5
+ module Extras
6
+ module Strategies
7
+ module Client
8
+ def self.configure(name: :client, prepend_config: nil, debug: false, user_agent: nil, error_handler: nil, **options, &block)
9
+ # Aliasing to avoid shadowing/any confusion
10
+ client_name = name
11
+ error_handler_config = error_handler
12
+
13
+ Module.new do
14
+ extend ActiveSupport::Concern
15
+
16
+ included do
17
+ raise ArgumentError, "client strategy: desired client name '#{client_name}' is already taken" if method_defined?(client_name)
18
+
19
+ define_method client_name do
20
+ # Hydrate options that are callable (e.g. procs), so we can set e.g. per-request expiration
21
+ # headers and/or other non-static values.
22
+ hydrated_options = options.transform_values do |value|
23
+ value.respond_to?(:call) ? value.call : value
24
+ end
25
+
26
+ ::Faraday.new(**hydrated_options) do |conn|
27
+ conn.headers["Content-Type"] = "application/json"
28
+ conn.headers["User-Agent"] = user_agent || "#{client_name} / Axn Client Strategy / v#{Axn::VERSION}"
29
+
30
+ # Because middleware is executed in reverse order, downstream user may need flexibility in where to inject configs
31
+ prepend_config&.call(conn)
32
+
33
+ conn.response :raise_error
34
+ conn.request :url_encoded
35
+ conn.request :json
36
+ conn.response :json, content_type: /\bjson$/
37
+
38
+ # Enable for debugging
39
+ conn.response :logger if debug
40
+
41
+ # Inject error handler middleware if configured
42
+ if error_handler_config && defined?(Faraday)
43
+ unless Client.const_defined?(:ErrorHandlerMiddleware, false)
44
+ Client.const_set(:ErrorHandlerMiddleware, Class.new(::Faraday::Middleware) do
45
+ def initialize(app, config)
46
+ super(app)
47
+ @config = config
48
+ end
49
+
50
+ def call(env)
51
+ @app.call(env).on_complete do |response_env|
52
+ body = parse_body(response_env.body)
53
+ condition = @config[:if] || -> { status != 200 }
54
+
55
+ @response_env = response_env
56
+ @body = body
57
+ should_handle = instance_exec(&condition)
58
+
59
+ handle_error(response_env, body) if should_handle
60
+ end
61
+ end
62
+
63
+ def status
64
+ @response_env&.status
65
+ end
66
+
67
+ attr_reader :body, :response_env
68
+
69
+ private
70
+
71
+ def parse_body(body)
72
+ return {} if body.blank?
73
+
74
+ body.is_a?(String) ? JSON.parse(body) : body
75
+ rescue JSON::ParserError
76
+ {}
77
+ end
78
+
79
+ def handle_error(response_env, body)
80
+ error = extract_value(body, @config[:error_key])
81
+ details = extract_value(body, @config[:detail_key]) if @config[:detail_key]
82
+ backtrace = extract_value(body, @config[:backtrace_key]) if @config[:backtrace_key]
83
+
84
+ formatted_message = if @config[:formatter]
85
+ @config[:formatter].call(error, details, response_env)
86
+ else
87
+ format_default_message(error, details)
88
+ end
89
+
90
+ prefix = "Error while #{response_env.method.to_s.upcase}ing #{response_env.url}"
91
+ message = formatted_message.present? ? "#{prefix}: #{formatted_message}" : prefix
92
+
93
+ exception_class = @config[:exception_class] || ::Faraday::BadRequestError
94
+ exception = exception_class.new(message)
95
+ exception.set_backtrace(backtrace) if backtrace.present?
96
+ raise exception
97
+ end
98
+
99
+ def extract_value(data, key)
100
+ return nil if key.blank?
101
+
102
+ keys = key.split(".")
103
+ keys.reduce(data) do |current, k|
104
+ return nil unless current.is_a?(Hash)
105
+
106
+ current[k.to_s] || current[k.to_sym]
107
+ end
108
+ end
109
+
110
+ def format_default_message(error, details)
111
+ parts = []
112
+ parts << error if error
113
+
114
+ if details
115
+ if @config[:extract_detail]
116
+ extracted = if details.is_a?(Hash)
117
+ details.map { |key, value| @config[:extract_detail].call(key, value) }.compact.to_sentence
118
+ else
119
+ Array(details).map { |node| @config[:extract_detail].call(node) }.compact.to_sentence
120
+ end
121
+ parts << extracted if extracted.present?
122
+ elsif details.present?
123
+ raise ArgumentError, "must provide extract_detail when detail_key is set and details is not a string" unless details.is_a?(String)
124
+
125
+ parts << details
126
+ end
127
+ end
128
+
129
+ parts.compact.join(" - ")
130
+ end
131
+ end)
132
+ end
133
+ conn.use Client::ErrorHandlerMiddleware, error_handler_config
134
+ end
135
+
136
+ block&.call(conn)
137
+ end
138
+ end
139
+ memo client_name
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockNesting
148
+
149
+ # Register the strategy only if faraday is available
150
+ Axn::Strategies.register(:client, Axn::Extras::Strategies::Client) if defined?(Faraday)
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "axn/core/flow/handlers/invoker"
5
+
6
+ module Axn
7
+ module Extras
8
+ module Strategies
9
+ module Vernier
10
+ # @param if [Proc, Symbol, #call, nil] Optional condition to determine when to profile
11
+ # @param sample_rate [Float] Sampling rate (0.0 to 1.0, default: 0.1)
12
+ # @param output_dir [String, Pathname] Output directory for profile files (default: Rails.root/tmp/profiles or tmp/profiles)
13
+ # @return [Module] A configured module that adds profiling to the action
14
+ def self.configure(if: nil, sample_rate: 0.1, output_dir: nil)
15
+ condition = binding.local_variable_get(:if)
16
+ sample_rate_value = sample_rate
17
+ output_dir_value = output_dir || _default_output_dir
18
+
19
+ Module.new do
20
+ extend ActiveSupport::Concern
21
+
22
+ included do
23
+ class_attribute :_vernier_condition, default: condition
24
+ class_attribute :_vernier_sample_rate, default: sample_rate_value
25
+ class_attribute :_vernier_output_dir, default: output_dir_value
26
+
27
+ around do |hooked|
28
+ _with_vernier_profiling { hooked.call }
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def _with_vernier_profiling(&)
35
+ return yield unless _should_profile?
36
+
37
+ _profile_with_vernier(&)
38
+ end
39
+
40
+ def _profile_with_vernier(&)
41
+ _ensure_vernier_available!
42
+
43
+ class_name = self.class.name.presence || "AnonymousAction"
44
+ profile_name = "axn_#{class_name}_#{Time.now.to_i}"
45
+
46
+ # Ensure output directory exists (only once per instance)
47
+ _ensure_output_directory_exists
48
+
49
+ # Build output file path
50
+ output_dir = self.class._vernier_output_dir || _default_output_dir
51
+ output_file = File.join(output_dir, "#{profile_name}.json")
52
+
53
+ # Configure Vernier with our settings
54
+ collector_options = {
55
+ out: output_file,
56
+ allocation_sample_rate: (self.class._vernier_sample_rate * 1000).to_i,
57
+ }
58
+
59
+ ::Vernier.profile(**collector_options, &)
60
+ end
61
+
62
+ def _ensure_output_directory_exists
63
+ return if @_vernier_directory_created
64
+
65
+ output_dir = self.class._vernier_output_dir || _default_output_dir
66
+ FileUtils.mkdir_p(output_dir)
67
+ @_vernier_directory_created = true
68
+ end
69
+
70
+ def _should_profile?
71
+ # Fast path: no condition means always profile
72
+ return true unless self.class._vernier_condition
73
+
74
+ # Slow path: evaluate condition (only when needed)
75
+ Axn::Core::Flow::Handlers::Invoker.call(
76
+ action: self,
77
+ handler: self.class._vernier_condition,
78
+ operation: "determining if profiling should run",
79
+ )
80
+ end
81
+
82
+ def _ensure_vernier_available!
83
+ return if defined?(::Vernier) && ::Vernier.is_a?(Module)
84
+
85
+ begin
86
+ require "vernier"
87
+ rescue LoadError
88
+ raise LoadError, <<~ERROR
89
+ Vernier profiler is not available. To use profiling, add 'vernier' to your Gemfile:
90
+
91
+ gem 'vernier', '~> 1.0'
92
+
93
+ Then run: bundle install
94
+ ERROR
95
+ end
96
+ end
97
+
98
+ def _default_output_dir
99
+ if defined?(Rails) && Rails.respond_to?(:root)
100
+ Rails.root.join("tmp", "profiles")
101
+ else
102
+ Pathname.new("tmp/profiles")
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ private_class_method def self._default_output_dir
109
+ if defined?(Rails) && Rails.respond_to?(:root)
110
+ Rails.root.join("tmp", "profiles")
111
+ else
112
+ Pathname.new("tmp/profiles")
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+
120
+ # Register the strategy (it handles missing vernier dependency gracefully)
121
+ Axn::Strategies.register(:vernier, Axn::Extras::Strategies::Vernier)
data/lib/axn/extras.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "axn/extras/strategies/client"
4
+ require "axn/extras/strategies/vernier"
data/lib/axn/factory.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  module Axn
4
4
  class Factory
5
+ NOT_PROVIDED = :__not_provided__
6
+
5
7
  class << self
6
8
  # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/ParameterLists
7
9
  def build(
@@ -38,6 +40,14 @@ module Axn
38
40
  # Async configuration
39
41
  async: nil,
40
42
 
43
+ # Logging configuration
44
+ log_calls: NOT_PROVIDED,
45
+ log_errors: NOT_PROVIDED,
46
+
47
+ # Internal flag to prevent recursion during action class creation
48
+ # Tracks which target class is having an action class created for it
49
+ _creating_action_class_for: nil,
50
+
41
51
  &block
42
52
  )
43
53
  raise ArgumentError, "[Axn::Factory] Cannot receive both a callable and a block" if callable.present? && block_given?
@@ -66,7 +76,7 @@ module Axn
66
76
  end
67
77
 
68
78
  # NOTE: inheriting from wrapping class, so we can set default values (e.g. for HTTP headers)
69
- _build_axn_class(superclass:, args:, executable:, expose_return_as:, include:, extend:, prepend:).tap do |axn|
79
+ _build_axn_class(superclass:, args:, executable:, expose_return_as:, include:, extend:, prepend:, _creating_action_class_for:).tap do |axn|
70
80
  expects.each do |field, opts|
71
81
  axn.expects(field, **opts)
72
82
  end
@@ -75,6 +85,10 @@ module Axn
75
85
  axn.exposes(field, **opts)
76
86
  end
77
87
 
88
+ # Apply logging configuration (always apply if provided to override defaults)
89
+ axn.log_calls(log_calls) unless log_calls == NOT_PROVIDED
90
+ axn.log_errors(log_errors) unless log_errors == NOT_PROVIDED
91
+
78
92
  # Apply success and error handlers
79
93
  _apply_handlers(axn, :success, success, Axn::Core::Flow::Handlers::Descriptors::MessageDescriptor)
80
94
  _apply_handlers(axn, :error, error, Axn::Core::Flow::Handlers::Descriptors::MessageDescriptor)
@@ -155,7 +169,11 @@ module Axn
155
169
  end
156
170
  end
157
171
 
158
- def _build_axn_class(superclass:, args:, executable:, expose_return_as:, include: nil, extend: nil, prepend: nil)
172
+ def _build_axn_class(superclass:, args:, executable:, expose_return_as:, include: nil, extend: nil, prepend: nil, _creating_action_class_for: nil) # rubocop:disable Lint/UnderscorePrefixedVariableName
173
+ # Mark superclass if we're creating an action class (for recursion prevention)
174
+ # Track which target class is having an action created for it
175
+ superclass.instance_variable_set(:@_axn_creating_action_class_for, _creating_action_class_for) if _creating_action_class_for && superclass
176
+
159
177
  Class.new(superclass || Object) do
160
178
  include Axn unless self < Axn
161
179
 
@@ -177,6 +195,8 @@ module Axn
177
195
  expose(expose_return_as => retval) if expose_return_as.present?
178
196
  end
179
197
  end
198
+ ensure
199
+ superclass.instance_variable_set(:@_axn_creating_action_class_for, nil) if _creating_action_class_for && superclass
180
200
  end
181
201
 
182
202
  def _apply_async_config(axn, async)