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
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Async
5
+ class Adapters
6
+ module Sidekiq
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ raise LoadError, "Sidekiq is not available. Please add 'sidekiq' to your Gemfile." unless defined?(::Sidekiq)
11
+
12
+ # Use Sidekiq::Job if available (Sidekiq 7+), otherwise error
13
+ raise LoadError, "Sidekiq::Job is not available. Please check your Sidekiq version." unless defined?(::Sidekiq::Job)
14
+
15
+ include ::Sidekiq::Job
16
+
17
+ # Apply configuration block if present
18
+ class_eval(&_async_config_block) if _async_config_block
19
+
20
+ # Apply kwargs configuration if present
21
+ sidekiq_options(**_async_config) if _async_config&.any?
22
+ end
23
+
24
+ class_methods do
25
+ def call_async(**kwargs)
26
+ job_kwargs = _params_to_global_id(kwargs)
27
+
28
+ if kwargs[:_async].is_a?(Hash)
29
+ options = kwargs.delete(:_async)
30
+ if options[:wait_until]
31
+ return perform_at(options[:wait_until], job_kwargs)
32
+ elsif options[:wait]
33
+ return perform_in(options[:wait], job_kwargs)
34
+ end
35
+ end
36
+
37
+ perform_async(job_kwargs)
38
+ end
39
+
40
+ def _params_to_global_id(context = {})
41
+ return {} if context.nil?
42
+
43
+ context.stringify_keys.each_with_object({}) do |(key, value), hash|
44
+ if value.respond_to?(:to_global_id)
45
+ hash["#{key}_as_global_id"] = value.to_global_id.to_s
46
+ else
47
+ hash[key] = value
48
+ end
49
+ end
50
+ end
51
+
52
+ def _params_from_global_id(params)
53
+ return {} if params.nil?
54
+
55
+ params.each_with_object({}) do |(key, value), hash|
56
+ if key.end_with?("_as_global_id")
57
+ hash[key.delete_suffix("_as_global_id")] = GlobalID::Locator.locate(value)
58
+ else
59
+ hash[key] = value
60
+ end
61
+ end.symbolize_keys
62
+ end
63
+ end
64
+
65
+ def perform(*args)
66
+ context = self.class._params_from_global_id(args.first)
67
+
68
+ # Always use bang version so sidekiq can retry if we failed
69
+ self.class.call!(**context)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "axn/internal/registry"
4
+ require "active_support/core_ext/string/inflections"
5
+
6
+ module Axn
7
+ module Async
8
+ class AdapterNotFound < Axn::Internal::Registry::NotFound; end
9
+ class DuplicateAdapterError < Axn::Internal::Registry::DuplicateError; end
10
+
11
+ class Adapters < Axn::Internal::Registry
12
+ class << self
13
+ def registry_directory = __dir__
14
+
15
+ private
16
+
17
+ def item_type = "Adapter"
18
+ def not_found_error_class = AdapterNotFound
19
+ def duplicate_error_class = DuplicateAdapterError
20
+ end
21
+ end
22
+
23
+ # Trigger registry loading to ensure adapters are available
24
+ Adapters.all
25
+ end
26
+ end
data/lib/axn/async.rb ADDED
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "axn/async/adapters"
4
+
5
+ module Axn
6
+ module Async
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ class_attribute :_async_adapter, :_async_config, :_async_config_block, default: nil
11
+ end
12
+
13
+ class_methods do
14
+ def async(adapter = nil, **config, &block)
15
+ self._async_adapter = adapter
16
+ self._async_config = config
17
+ self._async_config_block = block
18
+
19
+ case adapter
20
+ when false
21
+ include Adapters.find(:disabled)
22
+ when nil
23
+ # Use default configuration
24
+ async Axn.config._default_async_adapter, **Axn.config._default_async_config, &Axn.config._default_async_config_block
25
+ else
26
+ # Look up adapter in registry
27
+ adapter_module = Adapters.find(adapter)
28
+ include adapter_module
29
+ end
30
+ end
31
+
32
+ def call_async(**)
33
+ # Set up default async configuration if none is set
34
+ if _async_adapter.nil?
35
+ async Axn.config._default_async_adapter, **Axn.config._default_async_config, &Axn.config._default_async_config_block
36
+ # Call ourselves again now that the adapter is included
37
+ return call_async(**)
38
+ end
39
+
40
+ # This will be overridden by the included adapter module
41
+ raise NotImplementedError, "No async adapter configured. Use e.g. `async :sidekiq` or `async :active_job` to enable background processing."
42
+ end
43
+
44
+ # Ensure default async is applied when the class is first instantiated
45
+ # This is important for Sidekiq workers which load the class in a separate process
46
+ def new(*args, **kwargs)
47
+ _ensure_default_async_configured
48
+ super
49
+ end
50
+
51
+ private
52
+
53
+ def _ensure_default_async_configured
54
+ return if _async_adapter.present?
55
+ return unless Axn.config._default_async_adapter.present?
56
+
57
+ async Axn.config._default_async_adapter, **Axn.config._default_async_config, &Axn.config._default_async_config_block
58
+ end
59
+ end
60
+ end
61
+ end
@@ -1,17 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Action
3
+ module Axn
4
+ class RailsConfiguration
5
+ attr_accessor :app_actions_autoload_namespace
6
+ end
7
+
4
8
  class Configuration
5
9
  attr_accessor :wrap_with_trace, :emit_metrics
6
- attr_writer :logger, :env, :on_exception, :additional_includes, :log_level
10
+ attr_writer :logger, :env, :on_exception, :additional_includes, :log_level, :rails
7
11
 
8
12
  def log_level = @log_level ||= :info
9
13
 
10
14
  def additional_includes = @additional_includes ||= []
11
15
 
16
+ def _default_async_adapter = @default_async_adapter ||= false
17
+ def _default_async_config = @default_async_config ||= {}
18
+ def _default_async_config_block = @default_async_config_block
19
+
20
+ def set_default_async(adapter = false, **config, &block) # rubocop:disable Style/OptionalBooleanParameter
21
+ raise ArgumentError, "Cannot set default async adapter to nil as it would cause infinite recursion" if adapter.nil?
22
+
23
+ @default_async_adapter = adapter unless adapter.nil?
24
+ @default_async_config = config.any? ? config : {}
25
+ @default_async_config_block = block_given? ? block : nil
26
+ end
27
+
28
+ def rails = @rails ||= RailsConfiguration.new
29
+
12
30
  def on_exception(e, action:, context: {})
13
31
  msg = "Handled exception (#{e.class.name}): #{e.message}"
14
- msg = ("#" * 10) + " #{msg} " + ("#" * 10) unless Action.config.env.production?
32
+ msg = ("#" * 10) + " #{msg} " + ("#" * 10) unless Axn.config.env.production?
15
33
  action.log(msg)
16
34
 
17
35
  return unless @on_exception
@@ -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,37 @@ 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
+ end
44
+
45
+ def __early_completion_message = @early_completion_message.presence
46
+
47
+ def __finalize!
48
+ @finalized = true
32
49
  end
33
50
  end
34
51
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Action
3
+ module Axn
4
4
  module Core
5
5
  module AutomaticLogging
6
6
  def self.included(base)
@@ -9,7 +9,7 @@ module Action
9
9
  include InstanceMethods
10
10
 
11
11
  # Single class_attribute - nil means disabled, any level means enabled
12
- class_attribute :auto_log_level, default: Action.config.log_level
12
+ class_attribute :auto_log_level, default: Axn.config.log_level
13
13
  end
14
14
  end
15
15
 
@@ -39,10 +39,10 @@ module Action
39
39
  "About to execute",
40
40
  _log_context(:inbound),
41
41
  ].compact.join(" with: "),
42
- before: Action.config.env.production? ? nil : "\n------\n",
42
+ before: Axn.config.env.production? ? nil : "\n------\n",
43
43
  )
44
44
  rescue StandardError => e
45
- Axn::Util.piping_error("logging before hook", action: self, exception: e)
45
+ Axn::Internal::Logging.piping_error("logging before hook", action: self, exception: e)
46
46
  end
47
47
 
48
48
  def _log_after
@@ -55,10 +55,10 @@ module Action
55
55
  "Execution completed (with outcome: #{result.outcome}) in #{result.elapsed_time} milliseconds",
56
56
  _log_context(:outbound),
57
57
  ].compact.join(". Set: "),
58
- after: Action.config.env.production? ? nil : "\n------\n",
58
+ after: Axn.config.env.production? ? nil : "\n------\n",
59
59
  )
60
60
  rescue StandardError => e
61
- Axn::Util.piping_error("logging after hook", action: self, exception: e)
61
+ Axn::Internal::Logging.piping_error("logging after hook", action: self, exception: e)
62
62
  end
63
63
 
64
64
  def _log_context(direction)
@@ -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
68
  def inspection_filter = action.send(: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,9 +61,9 @@ 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
@@ -74,77 +76,69 @@ module Action
74
76
  fail! ok?
75
77
  inspect default_error
76
78
  each_pair
79
+ default_success
80
+ action_name
77
81
  ].freeze
78
82
 
79
83
  RESERVED_FIELD_NAMES_FOR_EXPOSURES = %w[
80
84
  fail! ok?
81
85
  inspect each_pair default_error
82
86
  ok error success message
87
+ result
88
+ outcome
89
+ exception
90
+ elapsed_time
91
+ finalized?
92
+ __action__
83
93
  ].freeze
84
94
 
85
95
  def _parse_field_configs(
86
96
  *fields,
87
97
  allow_blank: false,
88
98
  allow_nil: false,
99
+ optional: false,
89
100
  default: nil,
90
101
  preprocess: nil,
91
102
  sensitive: false,
92
103
  **validations
93
104
  )
105
+ # Handle optional: true by setting allow_blank: true
106
+ allow_blank ||= optional
107
+
94
108
  _parse_field_validations(*fields, allow_nil:, allow_blank:, **validations).map do |field, parsed_validations|
95
109
  _define_field_reader(field)
96
- _define_model_reader(field, parsed_validations[:model]) if parsed_validations.key?(:model)
97
110
  FieldConfig.new(field:, validations: parsed_validations, default:, preprocess:, sensitive:)
98
111
  end
99
112
  end
100
113
 
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
114
  def _define_field_reader(field)
113
115
  # Allow local access to explicitly-expected fields -- even externally-expected needs to be available locally
114
116
  # (e.g. to allow success message callable to reference exposed fields)
115
117
  define_method(field) { internal_context.public_send(field) }
116
118
  end
117
119
 
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
-
120
+ # This method applies any top-level options to each of the individual validations given.
121
+ # It also allows our custom validators to accept a direct value rather than a hash of options.
130
122
  def _parse_field_validations(
131
123
  *fields,
132
124
  allow_nil: false,
133
125
  allow_blank: false,
134
126
  **validations
135
127
  )
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
128
+ # Apply syntactic sugar for our custom validators (convert shorthand to full hash of options)
129
+ validations[:type] = Axn::Validators::TypeValidator.apply_syntactic_sugar(validations[:type], fields) if validations.key?(:type)
130
+ validations[:model] = Axn::Validators::ModelValidator.apply_syntactic_sugar(validations[:model], fields) if validations.key?(:model)
131
+ validations[:validate] = Axn::Validators::ValidateValidator.apply_syntactic_sugar(validations[:validate], fields) if validations.key?(:validate)
132
+
133
+ # Push allow_blank and allow_nil to the individual validations
134
+ if allow_blank || allow_nil
142
135
  validations.transform_values! do |v|
143
- v = { value: v } unless v.is_a?(Hash)
144
- { allow_nil: true }.merge(v)
136
+ { allow_blank:, allow_nil: }.merge(v)
145
137
  end
146
138
  else
147
- validations[:presence] = true unless validations.key?(:presence) || Array(validations[:type]).include?(:boolean)
139
+ # Apply default presence validation (unless the type is boolean or params)
140
+ type_values = Array(validations.dig(:type, :klass))
141
+ validations[:presence] = true unless validations.key?(:presence) || type_values.include?(:boolean) || type_values.include?(:params)
148
142
  end
149
143
 
150
144
  fields.map { |field| [field, validations] }
@@ -169,7 +163,7 @@ module Action
169
163
  end
170
164
 
171
165
  kwargs.each do |key, value|
172
- raise Action::ContractViolation::UnknownExposure, key unless result.respond_to?(key)
166
+ raise Axn::ContractViolation::UnknownExposure, key unless result.respond_to?(key)
173
167
 
174
168
  @__context.exposed_data[key] = value
175
169
  end
@@ -192,13 +186,14 @@ module Action
192
186
  _validate_contract!(:outbound)
193
187
 
194
188
  # TODO: improve location of this triggering
189
+ @__context.__finalize! # Mark result as finalized
195
190
  _trigger_on_success if respond_to?(:_trigger_on_success)
196
191
  end
197
192
 
198
193
  def _build_context_facade(direction)
199
194
  raise ArgumentError, "Invalid direction: #{direction}" unless %i[inbound outbound].include?(direction)
200
195
 
201
- klass = direction == :inbound ? Action::InternalContext : Action::Result
196
+ klass = direction == :inbound ? Axn::InternalContext : Axn::Result
202
197
  implicitly_allowed_fields = direction == :inbound ? _declared_fields(:outbound) : []
203
198
 
204
199
  klass.new(action: self, context: @__context, declared_fields: _declared_fields(direction), implicitly_allowed_fields:)
@@ -209,7 +204,7 @@ module Action
209
204
  end
210
205
 
211
206
  def sensitive_fields
212
- (internal_field_configs + external_field_configs).select(&:sensitive).map(&:field)
207
+ (internal_field_configs + external_field_configs + subfield_configs).select(&:sensitive).map(&:field)
213
208
  end
214
209
 
215
210
  def _declared_fields(direction)