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
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Mountable
5
+ class MountingStrategies
6
+ module Step
7
+ include Base
8
+ extend self # rubocop:disable Style/ModuleFunction -- module_function breaks inheritance
9
+
10
+ def default_inherit_mode = :none
11
+
12
+ module DSL
13
+ def steps(*steps)
14
+ Array(steps).compact.each do |step_class|
15
+ next unless step_class.is_a?(Class)
16
+ raise ArgumentError, "Step #{step_class} must include Axn module" if !step_class.included_modules.include?(::Axn) && !step_class < ::Axn
17
+
18
+ num_steps = _mounted_axn_descriptors.count { |descriptor| descriptor.mount_strategy.key == :step }
19
+ step("Step #{num_steps + 1}", step_class)
20
+ end
21
+ end
22
+
23
+ def step(name, axn_klass = nil, error_prefix: nil, inherit: MountingStrategies::Step.default_inherit_mode, **, &)
24
+ # Steps default to :none - they are isolated units of work
25
+ Helpers::Mounter.mount_via_strategy(
26
+ target: self,
27
+ as: :step,
28
+ name:,
29
+ axn_klass:,
30
+ error_prefix:,
31
+ inherit:,
32
+ **,
33
+ &
34
+ )
35
+ end
36
+ end
37
+
38
+ def strategy_specific_kwargs = super + [:error_prefix]
39
+
40
+ def mount_to_target(descriptor:, target:)
41
+ error_prefix = descriptor.options[:error_prefix] || "#{descriptor.name}: "
42
+ axn_klass = descriptor.mounted_axn_for(target:)
43
+
44
+ target.error from: axn_klass do |e|
45
+ "#{error_prefix}#{e.message}"
46
+ end
47
+
48
+ # Only define #call method once
49
+ return if target.instance_variable_defined?(:@_axn_call_method_defined_for_steps)
50
+
51
+ target.define_method(:call) do
52
+ step_descriptors = self.class._mounted_axn_descriptors.select { |d| d.mount_strategy.key == :step }
53
+
54
+ step_descriptors.each do |step_descriptor|
55
+ axn = step_descriptor.mounted_axn_for(target:)
56
+ step_result = axn.call!(**@__context.__combined_data)
57
+
58
+ # Extract exposed fields from step result and update exposed_data
59
+ step_result.declared_fields.each do |field|
60
+ @__context.exposed_data[field] = step_result.public_send(field)
61
+ end
62
+ end
63
+ end
64
+ target.instance_variable_set(:@_axn_call_method_defined_for_steps, true)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "axn/internal/registry"
4
+
5
+ module Axn
6
+ module Mountable
7
+ class MountingTypeNotFound < Axn::Internal::Registry::NotFound; end
8
+ class DuplicateMountingTypeError < Axn::Internal::Registry::DuplicateError; end
9
+
10
+ class MountingStrategies < Axn::Internal::Registry
11
+ class << self
12
+ def registry_directory = __dir__
13
+
14
+ private
15
+
16
+ def item_type = "Mounting Type"
17
+ def not_found_error_class = MountingTypeNotFound
18
+ def duplicate_error_class = DuplicateMountingTypeError
19
+
20
+ def select_constants_to_load(constants)
21
+ # Select modules that are not the Base module
22
+ constants.select do |const|
23
+ const.is_a?(Module) && const != Base
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ # Trigger registry loading to ensure mounting strategies are available
30
+ MountingStrategies.all
31
+ end
32
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "axn/mountable/inherit_profiles"
4
+ require "axn/mountable/mounting_strategies"
5
+ require "axn/mountable/descriptor"
6
+ require "axn/mountable/helpers/validator"
7
+ require "axn/mountable/helpers/class_builder"
8
+ require "axn/mountable/helpers/namespace_manager"
9
+ require "axn/mountable/helpers/mounter"
10
+
11
+ module Axn
12
+ # Mountable provides functionality for mounting actions to classes
13
+ #
14
+ # ## Inheritance Behavior
15
+ #
16
+ # Mounted actions inherit features from their target class in different ways depending on the
17
+ # mounting strategy. Each strategy has sensible defaults, but you can customize inheritance
18
+ # behavior using the `inherit` parameter.
19
+ #
20
+ # ### Default Inheritance Modes
21
+ #
22
+ # - `mount_axn` and `mount_axn_method`: `:lifecycle` - Inherits hooks, callbacks, messages, and async config (but not fields)
23
+ # - `step`: `:none` - Completely independent to avoid conflicts
24
+ #
25
+ # ### Inheritance Profiles
26
+ #
27
+ # - `:lifecycle` - Inherits everything except fields (hooks, callbacks, messages, async config)
28
+ # - `:async_only` - Only inherits async configuration
29
+ # - `:none` - Completely standalone with no inheritance
30
+ #
31
+ # You can also use a hash for granular control:
32
+ # `inherit: { fields: false, hooks: true, callbacks: false, messages: true, async: true }`
33
+ #
34
+ # Available hash keys: `:fields`, `:hooks`, `:callbacks`, `:messages`, `:async`
35
+ #
36
+ # @example Default inheritance behavior
37
+ # class MyClass
38
+ # include Axn
39
+ #
40
+ # before :log_start
41
+ # on_success :track_success
42
+ # async :sidekiq
43
+ # end
44
+ #
45
+ # # mount_axn uses :lifecycle (inherits hooks, callbacks, messages, async)
46
+ # MyClass.mount_axn :my_action do
47
+ # # Will run log_start before and track_success after
48
+ # end
49
+ #
50
+ # # step uses :none (completely independent)
51
+ # MyClass.step :my_step do
52
+ # # Will NOT run log_start or track_success
53
+ # end
54
+ #
55
+ # @example Custom inheritance control
56
+ # # Override step default to inherit lifecycle
57
+ # MyClass.step :my_step, inherit: :lifecycle do
58
+ # # Will now run hooks and callbacks
59
+ # end
60
+ #
61
+ # # Use granular control
62
+ # MyClass.mount_axn :my_action, inherit: { hooks: true, callbacks: false } do
63
+ # # Will run hooks but not callbacks
64
+ # end
65
+ module Mountable
66
+ extend ActiveSupport::Concern
67
+
68
+ def self.included(base)
69
+ base.class_eval do
70
+ class_attribute :_mounted_axn_descriptors, default: []
71
+
72
+ # Eagerly create action class constants for inherited descriptors
73
+ # (e.g. allow TeamsharesAPI::Company::Axns::Get.call to work *without* having to
74
+ # call TeamsharesAPI::Company.get! first)
75
+ def self.inherited(subclass)
76
+ super
77
+
78
+ # Only process if we have inherited descriptors from parent
79
+ return unless _mounted_axn_descriptors.any?
80
+
81
+ # Skip if subclass doesn't respond to _mounted_axn_descriptors
82
+ # This prevents recursion when creating action classes that inherit from target
83
+ return unless subclass.respond_to?(:_mounted_axn_descriptors)
84
+
85
+ # Skip if we're currently creating an action class (prevent infinite recursion)
86
+ # This is necessary because Axn::Factory.build creates classes that inherit from
87
+ # Axn (which includes Axn::Mountable), triggering inherited callbacks during
88
+ # action class creation.
89
+ superclass = subclass.superclass
90
+ creating_for = superclass&.instance_variable_get(:@_axn_creating_action_class_for)
91
+ return if creating_for
92
+
93
+ # Skip if this is an action class being created (they're in the Axns namespace)
94
+ # Action classes have names like "ParentClass::Axns::ActionName"
95
+ subclass_name = subclass.name
96
+ return if subclass_name&.include?("::Axns::")
97
+
98
+ # Eagerly create constants for all inherited descriptors
99
+ # mounted_axn_for will ensure namespace exists and create the constant
100
+ # If a child overrides, the new descriptor will replace the constant
101
+ _mounted_axn_descriptors.each do |descriptor|
102
+ # This will create the constant if it doesn't exist
103
+ descriptor.mounted_axn_for(target: subclass)
104
+ # Also define namespace methods on the child's namespace
105
+ # This ensures TeamsharesAPI::Company::Axns.get works even though
106
+ # the descriptor was originally mounted on TeamsharesAPI::Base
107
+ # We use define_namespace_methods instead of mount_to_namespace to
108
+ # avoid re-registering the constant (which is already created above)
109
+ descriptor.mount_strategy.define_namespace_methods(descriptor:, target: subclass)
110
+ end
111
+ end
112
+ end
113
+
114
+ MountingStrategies.all.each do |(_name, klass)|
115
+ base.extend klass::DSL if klass.const_defined?(:DSL)
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Only define the Engine if Rails is available
4
+ if defined?(Rails) && Rails.const_defined?(:Engine)
5
+ module Axn
6
+ module RailsIntegration
7
+ class Engine < Rails::Engine
8
+ # Set a custom engine name that's more concise than the module path
9
+ engine_name "axn_rails"
10
+
11
+ # This engine is automatically loaded when AXN is used in a Rails context
12
+ # It ensures proper initialization and integration with Rails
13
+
14
+ # The engine is intentionally minimal - AXN is designed to work
15
+ # as a standalone library that can be used in any Ruby context
16
+
17
+ # However, when used alongside Rails, we ensure that the app/actions
18
+ # directory is automatically added to the autoloader so that Rails can
19
+ # automatically load the actions.
20
+ initializer "axn.add_app_actions_to_autoload", after: :load_config_initializers do |app|
21
+ actions_path = app.root.join("app/actions")
22
+
23
+ # Only add if the directory exists
24
+ next unless File.directory?(actions_path)
25
+
26
+ # Use modern Rails autoloader API (Rails 7.2+)
27
+ # Namespace is configurable via Axn.config.rails.app_actions_autoload_namespace
28
+ autoload_namespace = Axn.config.rails.app_actions_autoload_namespace
29
+
30
+ if autoload_namespace
31
+ # Create the namespace module if it doesn't exist
32
+ namespace = Object.const_get(autoload_namespace) if Object.const_defined?(autoload_namespace)
33
+ unless namespace
34
+ namespace = Module.new
35
+ Object.const_set(autoload_namespace, namespace)
36
+ end
37
+ Rails.autoloaders.main.push_dir(actions_path, namespace:)
38
+ else
39
+ # No namespace - load directly
40
+ Rails.autoloaders.main.push_dir(actions_path)
41
+ end
42
+ end
43
+
44
+ # Register the generator
45
+ generators do
46
+ require_relative "generators/axn_generator"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module RailsIntegration
5
+ module Generators
6
+ class AxnGenerator < Rails::Generators::NamedBase
7
+ namespace "axn"
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ argument :expectations, type: :array, default: [], banner: "expectation1 expectation2 ..."
11
+
12
+ def create_action_file
13
+ template "action.rb.erb", "app/actions/#{file_path}.rb"
14
+ end
15
+
16
+ def create_spec_file
17
+ return unless spec_generation_enabled?
18
+
19
+ template "action_spec.rb.erb", "spec/actions/#{file_path}_spec.rb"
20
+ end
21
+
22
+ private
23
+
24
+ def class_name
25
+ @class_name ||= name.camelize
26
+ end
27
+
28
+ def file_path
29
+ @file_path ||= begin
30
+ path = name.underscore
31
+ # Strip the configured autoload namespace prefix if present
32
+ # e.g., if namespace is "Actions" and name is "Actions::Slack",
33
+ # strip "Actions::" to get "slack" instead of "actions/slack"
34
+ autoload_namespace = configured_autoload_namespace
35
+ if autoload_namespace && path.start_with?("#{autoload_namespace.underscore}/")
36
+ path.sub(%r{\A#{Regexp.escape(autoload_namespace.underscore)}/}, "")
37
+ else
38
+ path
39
+ end
40
+ end
41
+ end
42
+
43
+ def configured_autoload_namespace
44
+ return nil unless defined?(Axn) && Axn.config.rails.app_actions_autoload_namespace
45
+
46
+ namespace = Axn.config.rails.app_actions_autoload_namespace
47
+ namespace&.to_s
48
+ end
49
+
50
+ def expectations_with_types
51
+ expectations.map { |exp| { name: exp, type: "String" } }
52
+ end
53
+
54
+ def spec_generation_enabled?
55
+ return false unless rspec_available?
56
+ return false if spec_generation_skipped?
57
+
58
+ true
59
+ end
60
+
61
+ def rspec_available?
62
+ defined?(RSpec)
63
+ end
64
+
65
+ def spec_generation_skipped?
66
+ return false unless defined?(Rails) && Rails.application&.config&.generators
67
+
68
+ generators_config = Rails.application.config.generators
69
+
70
+ # Check individual boolean flags (modern style)
71
+ return true if generators_config.respond_to?(:test_framework) &&
72
+ generators_config.test_framework == false
73
+
74
+ # Check for specific spec-related flags
75
+ spec_flags = %w[specs axn_specs]
76
+ spec_flags.each do |flag|
77
+ return true if generators_config.respond_to?(flag) &&
78
+ generators_config.public_send(flag) == false
79
+ end
80
+
81
+ false
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %>
4
+ include Axn
5
+ <% if expectations.any? -%>
6
+
7
+ <% expectations.each do |expectation| -%>
8
+ expects :<%= expectation %>
9
+ <% end -%>
10
+
11
+ <% else -%>
12
+
13
+ <% end -%>
14
+ def call
15
+ # TODO: Implement logic
16
+ end
17
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe <%= class_name %> do
4
+ <% if expectations.any? -%>
5
+ <% expectations.each do |expectation| -%>
6
+ let(:<%= expectation %>) { "<%= expectation %>" }
7
+ <% end -%>
8
+
9
+ <% end -%>
10
+ describe ".call" do
11
+ subject(:result) { described_class.call(<%= expectations.any? ? expectations.map { |exp| "#{exp}:" }.join(", ") : "" %>) }
12
+
13
+ it "executes successfully" do
14
+ expect(result).to be_ok
15
+ end
16
+ <% if expectations.any? -%>
17
+
18
+ it "TODO: replace with a meaningful failure case" do
19
+ result = described_class.call
20
+ expect(result).not_to be_ok
21
+ expect(result.error).to eq("Something went wrong")
22
+ end
23
+ <% end -%>
24
+ end
25
+ end
@@ -1,17 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "action/core/context/facade"
4
- require "action/core/context/facade_inspector"
3
+ require "axn/core/context/facade"
4
+ require "axn/core/context/facade_inspector"
5
5
 
6
- module Action
6
+ module Axn
7
7
  # Outbound / External ContextFacade
8
8
  class Result < ContextFacade
9
9
  # For ease of mocking return results in tests
10
10
  class << self
11
11
  def ok(msg = nil, **exposures)
12
- exposes = exposures.keys.to_h { |key| [key, { allow_blank: true }] }
12
+ exposes = exposures.keys.to_h { |key| [key, { optional: true }] }
13
13
 
14
- Axn::Factory.build(exposes:, success: msg) do
14
+ Axn::Factory.build(exposes:, success: msg, log_calls: false, log_errors: false) do
15
15
  exposures.each do |key, value|
16
16
  expose(key, value)
17
17
  end
@@ -19,9 +19,9 @@ module Action
19
19
  end
20
20
 
21
21
  def error(msg = nil, **exposures, &block)
22
- exposes = exposures.keys.to_h { |key| [key, { allow_blank: true }] }
22
+ exposes = exposures.keys.to_h { |key| [key, { optional: true }] }
23
23
 
24
- Axn::Factory.build(exposes:, error: msg) do
24
+ Axn::Factory.build(exposes:, error: msg, log_calls: false, log_errors: false) do
25
25
  exposures.each do |key, value|
26
26
  expose(key, value)
27
27
  end
@@ -40,7 +40,7 @@ module Action
40
40
  end
41
41
 
42
42
  # External interface
43
- delegate :ok?, :exception, :elapsed_time, to: :context
43
+ delegate :ok?, :exception, :elapsed_time, :finalized?, to: :context
44
44
 
45
45
  def error
46
46
  return if ok?
@@ -64,7 +64,7 @@ module Action
64
64
  ].freeze
65
65
 
66
66
  def outcome
67
- label = if exception.is_a?(Action::Failure)
67
+ label = if exception.is_a?(Axn::Failure)
68
68
  OUTCOME_FAILURE
69
69
  elsif exception
70
70
  OUTCOME_EXCEPTION
@@ -79,15 +79,34 @@ module Action
79
79
  # TODO: exposed for errors :from support, but should be private if possible
80
80
  def __action__ = @action
81
81
 
82
+ # Enable pattern matching support for Ruby 3+
83
+ def deconstruct_keys(keys)
84
+ attrs = {
85
+ ok: ok?,
86
+ success:,
87
+ error:,
88
+ message:,
89
+ outcome: outcome.to_sym,
90
+ finalized: finalized?,
91
+ }
92
+
93
+ # Add all exposed data
94
+ attrs.merge!(@context.exposed_data)
95
+
96
+ # Return filtered attributes if keys specified
97
+ keys ? attrs.slice(*keys) : attrs
98
+ end
99
+
82
100
  private
83
101
 
84
102
  def _context_data_source = @context.exposed_data
85
103
 
86
- # TODO: hook for adding early-return success at some point
87
- def _user_provided_success_message = nil
104
+ def _user_provided_success_message
105
+ @context.__early_completion_message.presence
106
+ end
88
107
 
89
108
  def _user_provided_error_message
90
- return unless exception.is_a?(Action::Failure)
109
+ return unless exception.is_a?(Axn::Failure)
91
110
  return if exception.default_message?
92
111
  return if exception.cause # We raised this ourselves from nesting
93
112
 
@@ -103,7 +122,7 @@ module Action
103
122
  exposes :#{method_name}
104
123
  MSG
105
124
 
106
- raise Action::ContractViolation::MethodNotAllowed, msg
125
+ raise Axn::ContractViolation::MethodNotAllowed, msg
107
126
  end
108
127
 
109
128
  super
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ class Strategies
5
+ module Form
6
+ # @param expect [Symbol] the attribute name to expect in the context (e.g. :params)
7
+ # @param expose [Symbol] the attribute name to expose in the context (e.g. :form)
8
+ # @param type [Class, String] the form class to use, or a string constant path
9
+ # @param inject [Array<Symbol>] optional additional attributes to include in the form (e.g. [:user, :company])
10
+ # @yield block to define the form class when type is a string and the constant doesn't exist
11
+ def self.configure(expect: :params, expose: :form, type: nil, inject: nil, &block)
12
+ expect ||= :"#{expose.to_s.delete_suffix('_form')}_params"
13
+
14
+ # Aliasing to avoid shadowing/any confusion
15
+ expect_attr = expect
16
+ expose_attr = expose
17
+
18
+ Module.new do
19
+ extend ActiveSupport::Concern
20
+
21
+ included do
22
+ raise ArgumentError, "form strategy: must pass explicit :type parameter to `use :form` when applying to anonymous classes" if type.nil? && name.nil?
23
+
24
+ resolved_type = Axn::Strategies::Form.resolve_type(type, expose_attr, name, &block)
25
+
26
+ raise ArgumentError, "form strategy: #{resolved_type} must implement `valid?`" unless resolved_type.method_defined?(:valid?)
27
+
28
+ expects expect_attr, type: :params
29
+ exposes(expose_attr, type: resolved_type)
30
+
31
+ define_method expose_attr do
32
+ attrs_for_form = public_send(expect_attr)&.dup || {}
33
+
34
+ Array(inject).each do |ctx|
35
+ attrs_for_form[ctx] = public_send(ctx)
36
+ end
37
+
38
+ resolved_type.new(attrs_for_form)
39
+ end
40
+ memo expose_attr
41
+
42
+ before do
43
+ expose expose_attr => public_send(expose_attr)
44
+ fail! unless public_send(expose_attr).valid?
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ # Resolve the form type from the given parameters
51
+ # @param type [Class, String, nil] the form class, constant path, or nil for auto-detection
52
+ # @param expose_attr [Symbol] the attribute name to expose (used for auto-detection)
53
+ # @param action_name [String, nil] the name of the action class (used for auto-detection)
54
+ # @yield block to define the form class when type is a string and the constant doesn't exist
55
+ # @return [Class] the resolved form class
56
+ def self.resolve_type(type, expose_attr, action_name, &)
57
+ type ||= "#{action_name}::#{expose_attr.to_s.classify}"
58
+
59
+ if type.is_a?(Class)
60
+ raise ArgumentError, "form strategy: cannot provide block when type is a Class" if block_given?
61
+
62
+ return type
63
+ end
64
+
65
+ type.constantize.tap do
66
+ raise ArgumentError, "form strategy: cannot provide block when type constant #{type} already exists" if block_given?
67
+ end
68
+ rescue NameError
69
+ # Constant doesn't exist
70
+ raise ArgumentError, "form strategy: type constant #{type} does not exist and no block provided to define it" unless block_given?
71
+
72
+ # Create the class using the block, inheriting from Axn::FormObject
73
+ Class.new(Axn::FormObject).tap do |klass|
74
+ klass.class_eval(&)
75
+ assign_constant(type, klass)
76
+ end
77
+ end
78
+
79
+ # Helper method to assign a class to a constant path
80
+ # @param constant_path [String] the full constant path (e.g., "CreateUser::Form")
81
+ # @param klass [Class] the class to assign
82
+ def self.assign_constant(constant_path, klass)
83
+ parts = constant_path.split("::")
84
+ constant_name = parts.pop
85
+ parent_path = parts.join("::")
86
+
87
+ if parent_path.empty?
88
+ # Top-level constant
89
+ Object.const_set(constant_name, klass)
90
+ else
91
+ # Nested constant - ensure parent namespace exists
92
+ parent = parent_path.constantize
93
+ parent.const_set(constant_name, klass)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ class Strategies
5
+ module Transaction
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ raise NotImplementedError, "Transaction strategy requires ActiveRecord" unless defined?(ActiveRecord)
10
+
11
+ around do |hooked|
12
+ early_completion = nil
13
+ ActiveRecord::Base.transaction do
14
+ hooked.call
15
+ rescue Axn::Internal::EarlyCompletion => e
16
+ # EarlyCompletion is not an error - it's a control flow mechanism
17
+ # Store it to re-raise after transaction commits
18
+ early_completion = e
19
+ end
20
+ # Re-raise EarlyCompletion after transaction commits successfully
21
+ raise early_completion if early_completion
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "axn/internal/registry"
4
+
5
+ module Axn
6
+ class StrategyNotFound < Axn::Internal::Registry::NotFound; end
7
+ class DuplicateStrategyError < Axn::Internal::Registry::DuplicateError; end
8
+
9
+ class Strategies < Axn::Internal::Registry
10
+ class << self
11
+ def registry_directory = __dir__
12
+
13
+ private
14
+
15
+ def item_type = "Strategy"
16
+ def not_found_error_class = StrategyNotFound
17
+ def duplicate_error_class = DuplicateStrategyError
18
+ end
19
+ end
20
+ end
@@ -3,19 +3,17 @@
3
3
  module Axn
4
4
  module Testing
5
5
  module SpecHelpers
6
- def build_action(&block)
7
- action = Class.new.send(:include, Action)
6
+ def build_axn(&block)
7
+ action = Class.new.send(:include, Axn)
8
8
  action.class_eval(&block) if block
9
9
  action
10
10
  end
11
-
12
- def build_axn(**, &)
13
- Axn::Factory.build(**, &)
14
- end
15
11
  end
16
12
  end
17
13
  end
18
14
 
19
- RSpec.configure do |config|
20
- config.include Axn::Testing::SpecHelpers
15
+ if defined?(RSpec)
16
+ RSpec.configure do |config|
17
+ config.include Axn::Testing::SpecHelpers
18
+ end
21
19
  end