axn 0.1.0.pre.alpha.2.8.1 → 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 (148) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/commands/pr.md +36 -0
  3. data/.cursor/rules/axn-framework-patterns.mdc +43 -0
  4. data/.cursor/rules/general-coding-standards.mdc +27 -0
  5. data/.cursor/rules/spec/testing-patterns.mdc +40 -0
  6. data/CHANGELOG.md +57 -0
  7. data/Rakefile +114 -4
  8. data/docs/.vitepress/config.mjs +19 -10
  9. data/docs/advanced/conventions.md +3 -3
  10. data/docs/advanced/mountable.md +476 -0
  11. data/docs/advanced/profiling.md +351 -0
  12. data/docs/advanced/rough.md +27 -8
  13. data/docs/index.md +5 -3
  14. data/docs/intro/about.md +1 -1
  15. data/docs/intro/overview.md +6 -6
  16. data/docs/recipes/formatting-context-for-error-tracking.md +186 -0
  17. data/docs/recipes/memoization.md +103 -18
  18. data/docs/recipes/rubocop-integration.md +38 -284
  19. data/docs/recipes/testing.md +14 -14
  20. data/docs/recipes/validating-user-input.md +1 -1
  21. data/docs/reference/async.md +429 -0
  22. data/docs/reference/axn-result.md +107 -0
  23. data/docs/reference/class.md +225 -64
  24. data/docs/reference/configuration.md +366 -34
  25. data/docs/reference/form-object.md +252 -0
  26. data/docs/reference/instance.md +14 -29
  27. data/docs/strategies/client.md +212 -0
  28. data/docs/strategies/form.md +235 -0
  29. data/docs/strategies/index.md +21 -21
  30. data/docs/strategies/transaction.md +1 -1
  31. data/docs/usage/setup.md +16 -2
  32. data/docs/usage/steps.md +7 -7
  33. data/docs/usage/using.md +23 -12
  34. data/docs/usage/writing.md +191 -12
  35. data/lib/axn/async/adapters/active_job.rb +74 -0
  36. data/lib/axn/async/adapters/disabled.rb +41 -0
  37. data/lib/axn/async/adapters/sidekiq.rb +67 -0
  38. data/lib/axn/async/adapters.rb +26 -0
  39. data/lib/axn/async/batch_enqueue/config.rb +38 -0
  40. data/lib/axn/async/batch_enqueue.rb +99 -0
  41. data/lib/axn/async/enqueue_all_orchestrator.rb +363 -0
  42. data/lib/axn/async.rb +178 -0
  43. data/lib/axn/configuration.rb +113 -0
  44. data/lib/{action → axn}/context.rb +22 -4
  45. data/lib/axn/core/automatic_logging.rb +89 -0
  46. data/lib/axn/core/context/facade.rb +69 -0
  47. data/lib/{action → axn}/core/context/facade_inspector.rb +32 -5
  48. data/lib/{action → axn}/core/context/internal.rb +5 -5
  49. data/lib/{action → axn}/core/contract.rb +111 -73
  50. data/lib/{action → axn}/core/contract_for_subfields.rb +30 -35
  51. data/lib/{action → axn}/core/contract_validation.rb +27 -12
  52. data/lib/axn/core/contract_validation_for_subfields.rb +165 -0
  53. data/lib/axn/core/default_call.rb +63 -0
  54. data/lib/axn/core/field_resolvers/extract.rb +32 -0
  55. data/lib/axn/core/field_resolvers/model.rb +63 -0
  56. data/lib/axn/core/field_resolvers.rb +24 -0
  57. data/lib/{action → axn}/core/flow/callbacks.rb +7 -7
  58. data/lib/{action → axn}/core/flow/exception_execution.rb +9 -13
  59. data/lib/{action → axn}/core/flow/handlers/base_descriptor.rb +3 -2
  60. data/lib/{action → axn}/core/flow/handlers/descriptors/callback_descriptor.rb +2 -2
  61. data/lib/{action → axn}/core/flow/handlers/descriptors/message_descriptor.rb +23 -11
  62. data/lib/axn/core/flow/handlers/invoker.rb +47 -0
  63. data/lib/{action → axn}/core/flow/handlers/matcher.rb +9 -19
  64. data/lib/{action → axn}/core/flow/handlers/registry.rb +3 -1
  65. data/lib/{action → axn}/core/flow/handlers/resolvers/base_resolver.rb +1 -1
  66. data/lib/{action → axn}/core/flow/handlers/resolvers/callback_resolver.rb +2 -2
  67. data/lib/{action → axn}/core/flow/handlers/resolvers/message_resolver.rb +12 -3
  68. data/lib/axn/core/flow/handlers.rb +20 -0
  69. data/lib/{action → axn}/core/flow/messages.rb +8 -8
  70. data/lib/{action → axn}/core/flow.rb +4 -4
  71. data/lib/{action → axn}/core/hooks.rb +17 -5
  72. data/lib/axn/core/logging.rb +48 -0
  73. data/lib/axn/core/memoization.rb +53 -0
  74. data/lib/{action → axn}/core/nesting_tracking.rb +1 -1
  75. data/lib/{action → axn}/core/timing.rb +1 -1
  76. data/lib/axn/core/tracing.rb +90 -0
  77. data/lib/axn/core/use_strategy.rb +29 -0
  78. data/lib/{action → axn}/core/validation/fields.rb +26 -2
  79. data/lib/{action → axn}/core/validation/subfields.rb +14 -12
  80. data/lib/axn/core/validation/validators/model_validator.rb +36 -0
  81. data/lib/axn/core/validation/validators/type_validator.rb +80 -0
  82. data/lib/{action → axn}/core/validation/validators/validate_validator.rb +12 -2
  83. data/lib/{action → axn}/core.rb +55 -55
  84. data/lib/{action → axn}/exceptions.rb +12 -2
  85. data/lib/axn/extras/strategies/client.rb +150 -0
  86. data/lib/axn/extras/strategies/vernier.rb +121 -0
  87. data/lib/axn/extras.rb +4 -0
  88. data/lib/axn/factory.rb +122 -34
  89. data/lib/axn/form_object.rb +90 -0
  90. data/lib/axn/internal/logging.rb +30 -0
  91. data/lib/axn/internal/registry.rb +87 -0
  92. data/lib/axn/mountable/descriptor.rb +76 -0
  93. data/lib/axn/mountable/helpers/class_builder.rb +193 -0
  94. data/lib/axn/mountable/helpers/mounter.rb +33 -0
  95. data/lib/axn/mountable/helpers/namespace_manager.rb +38 -0
  96. data/lib/axn/mountable/helpers/validator.rb +112 -0
  97. data/lib/axn/mountable/inherit_profiles.rb +72 -0
  98. data/lib/axn/mountable/mounting_strategies/_base.rb +87 -0
  99. data/lib/axn/mountable/mounting_strategies/axn.rb +48 -0
  100. data/lib/axn/mountable/mounting_strategies/method.rb +95 -0
  101. data/lib/axn/mountable/mounting_strategies/step.rb +69 -0
  102. data/lib/axn/mountable/mounting_strategies.rb +32 -0
  103. data/lib/axn/mountable.rb +119 -0
  104. data/lib/axn/rails/engine.rb +51 -0
  105. data/lib/axn/rails/generators/axn_generator.rb +86 -0
  106. data/lib/axn/rails/generators/templates/action.rb.erb +17 -0
  107. data/lib/axn/rails/generators/templates/action_spec.rb.erb +25 -0
  108. data/lib/{action → axn}/result.rb +32 -13
  109. data/lib/axn/strategies/form.rb +98 -0
  110. data/lib/axn/strategies/transaction.rb +26 -0
  111. data/lib/axn/strategies.rb +20 -0
  112. data/lib/axn/testing/spec_helpers.rb +6 -8
  113. data/lib/axn/util/callable.rb +120 -0
  114. data/lib/axn/util/contract_error_handling.rb +32 -0
  115. data/lib/axn/util/execution_context.rb +34 -0
  116. data/lib/axn/util/global_id_serialization.rb +52 -0
  117. data/lib/axn/util/logging.rb +87 -0
  118. data/lib/axn/util/memoization.rb +20 -0
  119. data/lib/axn/version.rb +1 -1
  120. data/lib/axn.rb +26 -16
  121. data/lib/rubocop/cop/axn/README.md +23 -23
  122. data/lib/rubocop/cop/axn/unchecked_result.rb +138 -17
  123. metadata +106 -64
  124. data/.rspec +0 -3
  125. data/.rubocop.yml +0 -76
  126. data/.tool-versions +0 -1
  127. data/docs/reference/action-result.md +0 -37
  128. data/lib/action/attachable/base.rb +0 -43
  129. data/lib/action/attachable/steps.rb +0 -63
  130. data/lib/action/attachable/subactions.rb +0 -70
  131. data/lib/action/attachable.rb +0 -17
  132. data/lib/action/configuration.rb +0 -55
  133. data/lib/action/core/automatic_logging.rb +0 -93
  134. data/lib/action/core/context/facade.rb +0 -48
  135. data/lib/action/core/flow/handlers/invoker.rb +0 -73
  136. data/lib/action/core/flow/handlers.rb +0 -20
  137. data/lib/action/core/logging.rb +0 -37
  138. data/lib/action/core/tracing.rb +0 -17
  139. data/lib/action/core/use_strategy.rb +0 -30
  140. data/lib/action/core/validation/validators/model_validator.rb +0 -34
  141. data/lib/action/core/validation/validators/type_validator.rb +0 -30
  142. data/lib/action/enqueueable/via_sidekiq.rb +0 -76
  143. data/lib/action/enqueueable.rb +0 -13
  144. data/lib/action/strategies/transaction.rb +0 -19
  145. data/lib/action/strategies.rb +0 -48
  146. data/lib/axn/util.rb +0 -24
  147. data/package.json +0 -10
  148. data/yarn.lock +0 -1166
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "active_support/core_ext/hash/indifferent_access"
4
4
 
5
- module Action
5
+ module Axn
6
6
  module Validation
7
7
  class Subfields
8
8
  include ActiveModel::Validations
@@ -17,24 +17,26 @@ module Action
17
17
  end
18
18
 
19
19
  def read_attribute_for_validation(attr)
20
- self.class.extract(attr, @source)
20
+ # Only use action's reader methods for model fields that need special resolution
21
+ # For all other fields, use the unified FieldResolvers system
22
+ if @action && @validations&.key?(:model) && @action.respond_to?(attr)
23
+ @action.public_send(attr)
24
+ else
25
+ Axn::Core::FieldResolvers.resolve(type: :extract, field: attr, provided_data: @source)
26
+ end
21
27
  end
22
28
 
23
- def self.extract(attr, source)
24
- return source.public_send(attr) if source.respond_to?(attr)
25
- raise "Unclear how to extract #{attr} from #{source.inspect}" unless source.respond_to?(:dig)
26
-
27
- base = source.respond_to?(:with_indifferent_access) ? source.with_indifferent_access : source
28
- base.dig(*attr.to_s.split("."))
29
- end
30
-
31
- def self.validate!(field:, validations:, source:, exception_klass:)
29
+ def self.validate!(field:, validations:, source:, exception_klass:, action: nil)
32
30
  validator = Class.new(self) do
33
- def self.name = "Action::Validation::Subfields::OneOff"
31
+ def self.name = "Axn::Validation::Subfields::OneOff"
34
32
 
35
33
  validates field, **validations
36
34
  end.new(source)
37
35
 
36
+ # Set the action context for model field resolution
37
+ validator.instance_variable_set(:@action, action)
38
+ validator.instance_variable_set(:@validations, validations)
39
+
38
40
  return if validator.valid?
39
41
 
40
42
  raise exception_klass, validator.errors
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module Axn
6
+ module Validators
7
+ class ModelValidator < ActiveModel::EachValidator
8
+ # Syntactic sugar: model: User -> model: { klass: User }
9
+ def self.apply_syntactic_sugar(value, fields)
10
+ (value.is_a?(Hash) ? value.dup : { klass: value }).tap do |options|
11
+ # Set default klass based on field name if not provided
12
+ options[:klass] = nil if options[:klass] == true
13
+ options[:klass] ||= fields.first.to_s.classify
14
+
15
+ # Constantize string klass names
16
+ options[:klass] = options[:klass].constantize if options[:klass].is_a?(String)
17
+
18
+ # Set default finder if not provided
19
+ options[:finder] ||= :find
20
+ end
21
+ end
22
+
23
+ def check_validity!
24
+ return unless options[:klass].nil?
25
+
26
+ raise ArgumentError, "must supply :klass"
27
+ end
28
+
29
+ def validate_each(record, attribute, value)
30
+ # The value is already resolved by the facade, just validate the type
31
+ type_validator = TypeValidator.new(attributes: [attribute], **options)
32
+ type_validator.validate_each(record, attribute, value)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module Axn
6
+ module Validators
7
+ class TypeValidator < ActiveModel::EachValidator
8
+ def self.apply_syntactic_sugar(value, _fields)
9
+ return value if value.is_a?(Hash)
10
+
11
+ { klass: value }
12
+ end
13
+
14
+ def check_validity!
15
+ raise ArgumentError, "must supply :klass" if options[:klass].nil?
16
+ end
17
+
18
+ # NOTE: we override the default validate method to allow for custom allow_blank logic
19
+ # (e.g. type: Hash should fail if given false or "", but by default EachValidator would skip)
20
+ def validate(record)
21
+ attributes.each do |attribute|
22
+ value = record.read_attribute_for_validation(attribute)
23
+ validate_each(record, attribute, value)
24
+ end
25
+ end
26
+
27
+ def validate_each(record, attribute, value)
28
+ # Custom allow_blank logic: only skip validation for nil, not other blank values
29
+ return if value.nil? && (options[:allow_nil] || options[:allow_blank])
30
+
31
+ # Check if any of the types are valid
32
+ valid = types.any? do |type|
33
+ valid_type?(type:, value:, allow_blank: options[:allow_blank])
34
+ end
35
+
36
+ record.errors.add attribute, (options[:message] || msg) unless valid
37
+ end
38
+
39
+ private
40
+
41
+ def types = Array(options[:klass])
42
+ def msg = types.size == 1 ? "is not a #{types.first}" : "is not one of #{types.join(', ')}"
43
+
44
+ def valid_type?(type:, value:, allow_blank:)
45
+ # NOTE: allow mocks to pass type validation by default (much easier testing ergonomics)
46
+ return true if Axn.config.env.test? && value.class.name&.start_with?("RSpec::Mocks::")
47
+
48
+ case type
49
+ when :boolean
50
+ boolean_type?(value)
51
+ when :uuid
52
+ uuid_type?(value, allow_blank:)
53
+ when :params
54
+ params_type?(value)
55
+ else
56
+ class_type?(type, value)
57
+ end
58
+ end
59
+
60
+ def boolean_type?(value)
61
+ [true, false].include?(value)
62
+ end
63
+
64
+ def uuid_type?(value, allow_blank: false)
65
+ return false unless value.is_a?(String)
66
+ return true if value.blank? && allow_blank
67
+
68
+ value.match?(/\A[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}\z/i)
69
+ end
70
+
71
+ def params_type?(value)
72
+ value.is_a?(Hash) || (defined?(ActionController::Parameters) && value.is_a?(ActionController::Parameters))
73
+ end
74
+
75
+ def class_type?(type, value)
76
+ value.is_a?(type)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -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
@@ -1,28 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "action/context"
4
-
5
- require "action/strategies"
6
- require "action/core/hooks"
7
- require "action/core/logging"
8
- require "action/core/flow"
9
- require "action/core/automatic_logging"
10
- require "action/core/use_strategy"
11
- require "action/core/timing"
12
- require "action/core/tracing"
13
- require "action/core/nesting_tracking"
3
+ require "axn/internal/logging"
4
+
5
+ require "axn/context"
6
+
7
+ require "axn/strategies"
8
+ require "axn/extras"
9
+ require "axn/core/hooks"
10
+ require "axn/core/logging"
11
+ require "axn/core/flow"
12
+ require "axn/core/automatic_logging"
13
+ require "axn/core/use_strategy"
14
+ require "axn/core/timing"
15
+ require "axn/core/tracing"
16
+ require "axn/core/nesting_tracking"
17
+ require "axn/core/memoization"
14
18
 
15
19
  # CONSIDER: make class names match file paths?
16
- require "action/core/validation/validators/model_validator"
17
- require "action/core/validation/validators/type_validator"
18
- require "action/core/validation/validators/validate_validator"
20
+ require "axn/core/validation/validators/model_validator"
21
+ require "axn/core/validation/validators/type_validator"
22
+ require "axn/core/validation/validators/validate_validator"
23
+
24
+ require "axn/core/field_resolvers"
25
+ require "axn/core/contract_validation"
26
+ require "axn/core/contract_validation_for_subfields"
27
+ require "axn/core/contract"
28
+ require "axn/core/contract_for_subfields"
29
+ require "axn/core/default_call"
30
+
31
+ module Axn
32
+ module Core
33
+ module ClassMethods
34
+ def call(**)
35
+ new(**).tap(&:_run).result
36
+ end
19
37
 
20
- require "action/core/contract_validation"
21
- require "action/core/contract"
22
- require "action/core/contract_for_subfields"
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
23
49
 
24
- module Action
25
- module Core
26
50
  def self.included(base)
27
51
  base.class_eval do
28
52
  extend ClassMethods
@@ -35,35 +59,17 @@ module Action
35
59
  include Core::Flow
36
60
 
37
61
  include Core::ContractValidation
62
+ include Core::ContractValidationForSubfields
38
63
  include Core::Contract
39
64
  include Core::ContractForSubfields
40
65
  include Core::NestingTracking
41
66
 
42
67
  include Core::UseStrategy
68
+ include Core::Memoization
69
+ include Core::DefaultCall
43
70
  end
44
71
  end
45
72
 
46
- module ClassMethods
47
- def call(**)
48
- new(**).tap(&:_run).result
49
- end
50
-
51
- def call!(**)
52
- result = call(**)
53
- return result if result.ok?
54
-
55
- # When we're nested, we want to raise a failure that includes the source action to support
56
- # the error message generation's `from` filter
57
- raise Action::Failure.new(result.error, source: result.__action__), cause: result.exception if _nested_in_another_axn?
58
-
59
- raise result.exception
60
- end
61
- end
62
-
63
- def initialize(**)
64
- @__context = Action::Context.new(**)
65
- end
66
-
67
73
  # Main entry point for action execution
68
74
  def _run
69
75
  _tracking_nesting(self) do
@@ -81,28 +87,22 @@ module Action
81
87
  end
82
88
  end
83
89
  end
84
- ensure
85
- _emit_metrics
86
90
  end
87
91
 
88
- # User-defined action logic - override this method in your action classes
89
- def call; end
92
+ def fail!(message = nil, **exposures)
93
+ expose(**exposures) if exposures.any?
94
+ raise Axn::Failure, message
95
+ end
90
96
 
91
- def fail!(message = nil)
92
- raise Action::Failure, message
97
+ def done!(message = nil, **exposures)
98
+ expose(**exposures) if exposures.any?
99
+ raise Axn::Internal::EarlyCompletion, message
93
100
  end
94
101
 
95
102
  private
96
103
 
97
- def _emit_metrics
98
- return unless Action.config.emit_metrics
99
-
100
- Action.config.emit_metrics.call(
101
- self.class.name || "AnonymousClass",
102
- result,
103
- )
104
- rescue StandardError => e
105
- Axn::Util.piping_error("running metrics hook", action: self, exception: e)
104
+ def initialize(**)
105
+ @__context = Axn::Context.new(**)
106
106
  end
107
107
  end
108
108
  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)
@@ -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"