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
@@ -1,9 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Action
3
+ module Axn
4
4
  class Context
5
- attr_accessor :provided_data, :exposed_data
6
-
7
5
  def initialize(**provided_data)
8
6
  @provided_data = provided_data
9
7
  @exposed_data = {}
@@ -17,18 +15,38 @@ module Action
17
15
  # Framework state methods
18
16
  def ok? = !@failure
19
17
  def failed? = @failure || false
18
+ def finalized? = @finalized || false
20
19
 
21
20
  # Framework field accessors
22
- attr_accessor :elapsed_time
21
+ attr_accessor :provided_data, :exposed_data, :elapsed_time
23
22
  attr_reader :exception
24
23
  private :elapsed_time=
25
24
 
25
+ #
26
+ # Here down intended for internal use only
27
+ #
28
+
26
29
  # INTERNAL: base for further filtering (for logging) or providing user with usage hints
27
30
  def __combined_data = @provided_data.merge(@exposed_data)
28
31
 
32
+ def __early_completion? = @early_completion || false
33
+
29
34
  def __record_exception(e)
30
35
  @exception = e
31
36
  @failure = true
37
+ @finalized = true
38
+ end
39
+
40
+ def __record_early_completion(message)
41
+ @early_completion_message = message unless message == Axn::Internal::EarlyCompletion.new.message
42
+ @early_completion = true
43
+ @finalized = true
44
+ end
45
+
46
+ def __early_completion_message = @early_completion_message.presence
47
+
48
+ def __finalize!
49
+ @finalized = true
32
50
  end
33
51
  end
34
52
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Core
5
+ module AutomaticLogging
6
+ def self.included(base)
7
+ base.class_eval do
8
+ extend ClassMethods
9
+ include InstanceMethods
10
+
11
+ # Single class_attribute - nil means disabled, any level means enabled
12
+ class_attribute :log_calls_level, default: Axn.config.log_level
13
+ class_attribute :log_errors_level, default: nil
14
+ end
15
+ end
16
+
17
+ module ClassMethods
18
+ def log_calls(level)
19
+ self.log_calls_level = level.presence
20
+ end
21
+
22
+ def log_errors(level)
23
+ self.log_errors_level = level.presence
24
+ end
25
+ end
26
+
27
+ module InstanceMethods
28
+ private
29
+
30
+ def _with_logging
31
+ _log_before if self.class.log_calls_level
32
+ yield
33
+ ensure
34
+ _log_after if self.class.log_calls_level || self.class.log_errors_level
35
+ end
36
+
37
+ def _log_before
38
+ Axn::Util::Logging.log_at_level(
39
+ self.class,
40
+ level: self.class.log_calls_level,
41
+ message_parts: ["About to execute"],
42
+ join_string: " with: ",
43
+ before: _top_level_separator,
44
+ error_context: "logging before hook",
45
+ context_direction: :inbound,
46
+ context_instance: self,
47
+ )
48
+ end
49
+
50
+ def _log_after
51
+ # Check log_calls_level first (logs all outcomes)
52
+ if self.class.log_calls_level
53
+ _log_after_at_level(self.class.log_calls_level)
54
+ return
55
+ end
56
+
57
+ # Check log_errors_level (only logs when result.ok? is false)
58
+ return unless self.class.log_errors_level && !result.ok?
59
+
60
+ _log_after_at_level(self.class.log_errors_level)
61
+ end
62
+
63
+ def _log_after_at_level(level)
64
+ Axn::Util::Logging.log_at_level(
65
+ self.class,
66
+ level:,
67
+ message_parts: [
68
+ "Execution completed (with outcome: #{result.outcome}) in #{result.elapsed_time} milliseconds",
69
+ ],
70
+ join_string: ". Set: ",
71
+ after: _top_level_separator,
72
+ error_context: "logging after hook",
73
+ context_direction: :outbound,
74
+ context_instance: self,
75
+ )
76
+ end
77
+
78
+ def _top_level_separator
79
+ return if Axn.config.env.production?
80
+ return if Axn::Util::ExecutionContext.background?
81
+ return if Axn::Util::ExecutionContext.console?
82
+ return if NestingTracking._current_axn_stack.size > 1
83
+
84
+ "\n------\n"
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/parameter_filter"
4
+
5
+ module Axn
6
+ class ContextFacade
7
+ def initialize(action:, context:, declared_fields:, implicitly_allowed_fields: nil)
8
+ if self.class.name == "Axn::ContextFacade" # rubocop:disable Style/ClassEqualityComparison
9
+ raise "Axn::ContextFacade is an abstract class and should not be instantiated directly"
10
+ end
11
+
12
+ @context = context
13
+ @action = action
14
+ @declared_fields = declared_fields
15
+
16
+ (@declared_fields + Array(implicitly_allowed_fields)).each do |field|
17
+ if _model_fields.key?(field)
18
+ _define_model_field_method(field, _model_fields[field])
19
+ else
20
+ singleton_class.define_method(field) do
21
+ _context_data_source[field]
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ attr_reader :declared_fields
28
+
29
+ def inspect = ContextFacadeInspector.new(facade: self, action:, context:).call
30
+
31
+ def fail!(...)
32
+ raise Axn::ContractViolation::MethodNotAllowed, "Call fail! directly rather than on the context"
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :action, :context
38
+
39
+ def _model_fields
40
+ action.internal_field_configs.each_with_object({}) do |config, hash|
41
+ hash[config.field] = config.validations[:model] if config.validations.key?(:model)
42
+ end
43
+ end
44
+
45
+ def action_name = @action.class.name.presence || "The action"
46
+
47
+ def _define_model_field_method(field, options)
48
+ Axn::Util::Memoization.define_memoized_reader_method(singleton_class, field) do
49
+ Axn::Core::FieldResolvers.resolve(
50
+ type: :model,
51
+ field:,
52
+ options:,
53
+ provided_data: _context_data_source,
54
+ )
55
+ end
56
+ end
57
+
58
+ def _context_data_source = raise NotImplementedError
59
+
60
+ def _msg_resolver(event_type, exception:)
61
+ Axn::Core::Flow::Handlers::Resolvers::MessageResolver.new(
62
+ action._messages_registry,
63
+ event_type,
64
+ action:,
65
+ exception:,
66
+ )
67
+ end
68
+ end
69
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Action
3
+ module Axn
4
4
  class ContextFacadeInspector
5
5
  def initialize(action:, facade:, context:)
6
6
  @action = action
@@ -19,12 +19,12 @@ module Action
19
19
  attr_reader :action, :facade, :context
20
20
 
21
21
  def status
22
- return unless facade.is_a?(Action::Result)
22
+ return unless facade.is_a?(Axn::Result)
23
23
 
24
24
  return "[OK]" if context.ok?
25
25
 
26
- if context.exception.is_a?(Action::Failure)
27
- return context.exception.message.present? ? "[failed with '#{context.exception.message}']" : "[failed]"
26
+ if facade.outcome.failure?
27
+ return context.exception.default_message? ? "[failed]" : "[failed with '#{context.exception.message}']"
28
28
  end
29
29
 
30
30
  %([failed with #{context.exception.class.name}: '#{context.exception.message}'])
@@ -56,9 +56,36 @@ module Action
56
56
  value.inspect
57
57
  end
58
58
 
59
+ # Handle subfield filtering for hash values
60
+ if value.is_a?(Hash) && sensitive_subfields?(field)
61
+ filtered_value = filter_subfields(field, value)
62
+ return filtered_value.inspect
63
+ end
64
+
59
65
  inspection_filter.filter_param(field, inspected_value)
60
66
  end
61
67
 
62
- def inspection_filter = action.send(:inspection_filter)
68
+ def inspection_filter = action.class.inspection_filter
69
+
70
+ def sensitive_subfields?(field)
71
+ action.subfield_configs.any? { |config| config.on == field && config.sensitive }
72
+ end
73
+
74
+ def filter_subfields(field, value)
75
+ # Build a nested structure with subfield paths for filtering
76
+ nested_data = { field => value }
77
+
78
+ # Create a filter with the subfield paths
79
+ sensitive_subfield_paths = action.subfield_configs
80
+ .select { |config| config.on == field && config.sensitive }
81
+ .map { |config| "#{field}.#{config.field}" }
82
+
83
+ return value if sensitive_subfield_paths.empty?
84
+
85
+ subfield_filter = ActiveSupport::ParameterFilter.new(sensitive_subfield_paths)
86
+ filtered_data = subfield_filter.filter(nested_data)
87
+
88
+ filtered_data[field]
89
+ end
63
90
  end
64
91
  end
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "action/core/context/facade"
3
+ require "axn/core/context/facade"
4
4
 
5
- module Action
5
+ module Axn
6
6
  # Inbound / Internal ContextFacade
7
7
  class InternalContext < ContextFacade
8
- def default_error = _msg_resolver(:error, exception: Action::Failure.new).resolve_default_message
8
+ def default_error = _msg_resolver(:error, exception: Axn::Failure.new).resolve_default_message
9
9
  def default_success = _msg_resolver(:success, exception: nil).resolve_default_message
10
10
 
11
11
  private
@@ -15,13 +15,13 @@ module Action
15
15
  def method_missing(method_name, ...) # rubocop:disable Style/MissingRespondToMissing (because we're not actually responding to anything additional)
16
16
  if @context.__combined_data.key?(method_name.to_sym)
17
17
  msg = <<~MSG
18
- Method ##{method_name} is not available on Action::InternalContext!
18
+ Method ##{method_name} is not available on Axn::InternalContext!
19
19
 
20
20
  #{action_name} may be missing a line like:
21
21
  expects :#{method_name}
22
22
  MSG
23
23
 
24
- raise Action::ContractViolation::MethodNotAllowed, msg
24
+ raise Axn::ContractViolation::MethodNotAllowed, msg
25
25
  end
26
26
 
27
27
  super
@@ -3,11 +3,11 @@
3
3
  require "active_support/core_ext/enumerable"
4
4
  require "active_support/core_ext/module/delegation"
5
5
 
6
- require "action/core/validation/fields"
7
- require "action/result"
8
- require "action/core/context/internal"
6
+ require "axn/core/validation/fields"
7
+ require "axn/result"
8
+ require "axn/core/context/internal"
9
9
 
10
- module Action
10
+ module Axn
11
11
  module Core
12
12
  module Contract
13
13
  def self.included(base)
@@ -27,20 +27,21 @@ module Action
27
27
  on: nil,
28
28
  allow_blank: false,
29
29
  allow_nil: false,
30
+ optional: false,
30
31
  default: nil,
31
32
  preprocess: nil,
32
33
  sensitive: false,
33
34
  **validations
34
35
  )
35
- return _expects_subfields(*fields, on:, allow_blank:, allow_nil:, default:, preprocess:, sensitive:, **validations) if on.present?
36
-
37
36
  fields.each do |field|
38
37
  raise ContractViolation::ReservedAttributeError, field if RESERVED_FIELD_NAMES_FOR_EXPECTATIONS.include?(field.to_s)
39
38
  end
40
39
 
41
- _parse_field_configs(*fields, allow_blank:, allow_nil:, default:, preprocess:, sensitive:, **validations).tap do |configs|
40
+ return _expects_subfields(*fields, on:, allow_blank:, allow_nil:, optional:, default:, preprocess:, sensitive:, **validations) if on.present?
41
+
42
+ _parse_field_configs(*fields, allow_blank:, allow_nil:, optional:, default:, preprocess:, sensitive:, **validations).tap do |configs|
42
43
  duplicated = internal_field_configs.map(&:field) & configs.map(&:field)
43
- raise Action::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
44
+ raise Axn::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(', ')}" if duplicated.any?
44
45
 
45
46
  # NOTE: avoid <<, which would update value for parents and children
46
47
  self.internal_field_configs += configs
@@ -51,6 +52,7 @@ module Action
51
52
  *fields,
52
53
  allow_blank: false,
53
54
  allow_nil: false,
55
+ optional: false,
54
56
  default: nil,
55
57
  sensitive: false,
56
58
  **validations
@@ -59,92 +61,108 @@ module Action
59
61
  raise ContractViolation::ReservedAttributeError, field if RESERVED_FIELD_NAMES_FOR_EXPOSURES.include?(field.to_s)
60
62
  end
61
63
 
62
- _parse_field_configs(*fields, allow_blank:, allow_nil:, default:, preprocess: nil, sensitive:, **validations).tap do |configs|
64
+ _parse_field_configs(*fields, allow_blank:, allow_nil:, optional:, default:, preprocess: nil, sensitive:, **validations).tap do |configs|
63
65
  duplicated = external_field_configs.map(&:field) & configs.map(&:field)
64
- raise Action::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
66
+ raise Axn::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(', ')}" if duplicated.any?
65
67
 
66
68
  # NOTE: avoid <<, which would update value for parents and children
67
69
  self.external_field_configs += configs
68
70
  end
69
71
  end
70
72
 
73
+ def inspection_filter
74
+ @inspection_filter ||= ActiveSupport::ParameterFilter.new(sensitive_fields)
75
+ end
76
+
77
+ def sensitive_fields
78
+ (internal_field_configs + external_field_configs + subfield_configs).select(&:sensitive).map(&:field)
79
+ end
80
+
81
+ def _declared_fields(direction)
82
+ raise ArgumentError, "Invalid direction: #{direction}" unless direction.nil? || %i[inbound outbound].include?(direction)
83
+
84
+ configs = case direction
85
+ when :inbound then internal_field_configs
86
+ when :outbound then external_field_configs
87
+ else (internal_field_configs + external_field_configs)
88
+ end
89
+
90
+ configs.map(&:field)
91
+ end
92
+
93
+ def context_for_logging(data:, direction: nil)
94
+ inspection_filter.filter(data.slice(*_declared_fields(direction)))
95
+ end
96
+
71
97
  private
72
98
 
73
99
  RESERVED_FIELD_NAMES_FOR_EXPECTATIONS = %w[
74
100
  fail! ok?
75
101
  inspect default_error
76
102
  each_pair
103
+ default_success
104
+ action_name
77
105
  ].freeze
78
106
 
79
107
  RESERVED_FIELD_NAMES_FOR_EXPOSURES = %w[
80
108
  fail! ok?
81
109
  inspect each_pair default_error
82
110
  ok error success message
111
+ result
112
+ outcome
113
+ exception
114
+ elapsed_time
115
+ finalized?
116
+ __action__
83
117
  ].freeze
84
118
 
85
119
  def _parse_field_configs(
86
120
  *fields,
87
121
  allow_blank: false,
88
122
  allow_nil: false,
123
+ optional: false,
89
124
  default: nil,
90
125
  preprocess: nil,
91
126
  sensitive: false,
92
127
  **validations
93
128
  )
129
+ # Handle optional: true by setting allow_blank: true
130
+ allow_blank ||= optional
131
+
94
132
  _parse_field_validations(*fields, allow_nil:, allow_blank:, **validations).map do |field, parsed_validations|
95
133
  _define_field_reader(field)
96
- _define_model_reader(field, parsed_validations[:model]) if parsed_validations.key?(:model)
97
134
  FieldConfig.new(field:, validations: parsed_validations, default:, preprocess:, sensitive:)
98
135
  end
99
136
  end
100
137
 
101
- def define_memoized_reader_method(field, &block)
102
- define_method(field) do
103
- ivar = :"@_memoized_reader_#{field}"
104
- cached_val = instance_variable_get(ivar)
105
- return cached_val if cached_val.present?
106
-
107
- value = instance_exec(&block)
108
- instance_variable_set(ivar, value)
109
- end
110
- end
111
-
112
138
  def _define_field_reader(field)
113
139
  # Allow local access to explicitly-expected fields -- even externally-expected needs to be available locally
114
140
  # (e.g. to allow success message callable to reference exposed fields)
115
141
  define_method(field) { internal_context.public_send(field) }
116
142
  end
117
143
 
118
- def _define_model_reader(field, klass, &id_extractor)
119
- name = field.to_s.delete_suffix("_id")
120
- raise ArgumentError, "Model validation expects to be given a field ending in _id (given: #{field})" unless field.to_s.end_with?("_id")
121
- raise ArgumentError, "Failed to define model reader - #{name} is already defined" if method_defined?(name)
122
-
123
- id_extractor ||= -> { public_send(field) }
124
-
125
- define_memoized_reader_method(name) do
126
- Validators::ModelValidator.instance_for(field:, klass:, id: instance_exec(&id_extractor))
127
- end
128
- end
129
-
144
+ # This method applies any top-level options to each of the individual validations given.
145
+ # It also allows our custom validators to accept a direct value rather than a hash of options.
130
146
  def _parse_field_validations(
131
147
  *fields,
132
148
  allow_nil: false,
133
149
  allow_blank: false,
134
150
  **validations
135
151
  )
136
- if allow_blank
137
- validations.transform_values! do |v|
138
- v = { value: v } unless v.is_a?(Hash)
139
- { allow_blank: true }.merge(v)
140
- end
141
- elsif allow_nil
152
+ # Apply syntactic sugar for our custom validators (convert shorthand to full hash of options)
153
+ validations[:type] = Axn::Validators::TypeValidator.apply_syntactic_sugar(validations[:type], fields) if validations.key?(:type)
154
+ validations[:model] = Axn::Validators::ModelValidator.apply_syntactic_sugar(validations[:model], fields) if validations.key?(:model)
155
+ validations[:validate] = Axn::Validators::ValidateValidator.apply_syntactic_sugar(validations[:validate], fields) if validations.key?(:validate)
156
+
157
+ # Push allow_blank and allow_nil to the individual validations
158
+ if allow_blank || allow_nil
142
159
  validations.transform_values! do |v|
143
- v = { value: v } unless v.is_a?(Hash)
144
- { allow_nil: true }.merge(v)
160
+ { allow_blank:, allow_nil: }.merge(v)
145
161
  end
146
162
  else
147
- validations[:presence] = true unless validations.key?(:presence) || Array(validations[:type]).include?(:boolean)
163
+ # Apply default presence validation (unless the type is boolean or params)
164
+ type_values = Array(validations.dig(:type, :klass))
165
+ validations[:presence] = true unless validations.key?(:presence) || type_values.include?(:boolean) || type_values.include?(:params)
148
166
  end
149
167
 
150
168
  fields.map { |field| [field, validations] }
@@ -169,59 +187,79 @@ module Action
169
187
  end
170
188
 
171
189
  kwargs.each do |key, value|
172
- raise Action::ContractViolation::UnknownExposure, key unless result.respond_to?(key)
190
+ raise Axn::ContractViolation::UnknownExposure, key unless result.respond_to?(key)
173
191
 
174
192
  @__context.exposed_data[key] = value
175
193
  end
176
194
  end
177
195
 
196
+ # Set additional context to be included in exception logging
197
+ # This context is only used when exceptions occur, not in normal pre/post logging
198
+ def set_logging_context(**kwargs)
199
+ @__additional_logging_context ||= {}
200
+ @__additional_logging_context.merge!(kwargs)
201
+ end
202
+
203
+ # Clear any previously set additional logging context
204
+ def clear_logging_context
205
+ @__additional_logging_context = nil
206
+ end
207
+
178
208
  def context_for_logging(direction = nil)
179
- inspection_filter.filter(@__context.__combined_data.slice(*_declared_fields(direction)))
209
+ base_context = self.class.context_for_logging(
210
+ data: @__context.__combined_data,
211
+ direction:,
212
+ )
213
+
214
+ # Only merge additional context for exception logging (direction is nil)
215
+ # Pre/post logging don't need additional context since they only log inputs/outputs
216
+ return base_context if direction
217
+
218
+ # Merge both explicit setter context and hook method context (if both exist)
219
+ explicit_context = @__additional_logging_context || {}
220
+ hook_context = respond_to?(:additional_logging_context, true) ? additional_logging_context : {}
221
+ base_context.merge(explicit_context).merge(hook_context)
180
222
  end
181
223
 
182
224
  private
183
225
 
184
- def _with_contract
185
- _apply_inbound_preprocessing!
186
- _apply_defaults!(:inbound)
226
+ def _handle_early_completion_if_raised
227
+ yield
228
+ nil
229
+ rescue Axn::Internal::EarlyCompletion => e
230
+ @__context.__record_early_completion(e.message)
231
+ _trigger_on_success
232
+ true
233
+ end
234
+
235
+ def _with_contract(&)
236
+ return if _handle_early_completion_if_raised { _apply_inbound_preprocessing! }
237
+ return if _handle_early_completion_if_raised { _apply_defaults!(:inbound) }
238
+
187
239
  _validate_contract!(:inbound)
188
240
 
189
- yield
241
+ if _handle_early_completion_if_raised(&)
242
+ # Even with early completion, we need to validate outbound and apply defaults
243
+ _apply_defaults!(:outbound)
244
+ _validate_contract!(:outbound)
245
+ return
246
+ end
190
247
 
191
248
  _apply_defaults!(:outbound)
192
249
  _validate_contract!(:outbound)
193
250
 
194
251
  # TODO: improve location of this triggering
195
- _trigger_on_success if respond_to?(:_trigger_on_success)
252
+ @__context.__finalize! # Mark result as finalized
253
+ _trigger_on_success
196
254
  end
197
255
 
198
256
  def _build_context_facade(direction)
199
257
  raise ArgumentError, "Invalid direction: #{direction}" unless %i[inbound outbound].include?(direction)
200
258
 
201
- klass = direction == :inbound ? Action::InternalContext : Action::Result
202
- implicitly_allowed_fields = direction == :inbound ? _declared_fields(:outbound) : []
203
-
204
- klass.new(action: self, context: @__context, declared_fields: _declared_fields(direction), implicitly_allowed_fields:)
205
- end
206
-
207
- def inspection_filter
208
- @inspection_filter ||= ActiveSupport::ParameterFilter.new(sensitive_fields)
209
- end
210
-
211
- def sensitive_fields
212
- (internal_field_configs + external_field_configs).select(&:sensitive).map(&:field)
213
- end
214
-
215
- def _declared_fields(direction)
216
- raise ArgumentError, "Invalid direction: #{direction}" unless direction.nil? || %i[inbound outbound].include?(direction)
217
-
218
- configs = case direction
219
- when :inbound then internal_field_configs
220
- when :outbound then external_field_configs
221
- else (internal_field_configs + external_field_configs)
222
- end
259
+ klass = direction == :inbound ? Axn::InternalContext : Axn::Result
260
+ implicitly_allowed_fields = direction == :inbound ? self.class._declared_fields(:outbound) : []
223
261
 
224
- configs.map(&:field)
262
+ klass.new(action: self, context: @__context, declared_fields: self.class._declared_fields(direction), implicitly_allowed_fields:)
225
263
  end
226
264
  end
227
265
  end