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.
- checksums.yaml +4 -4
- data/.cursor/commands/pr.md +36 -0
- data/.cursor/rules/axn-framework-patterns.mdc +43 -0
- data/.cursor/rules/general-coding-standards.mdc +27 -0
- data/.cursor/rules/spec/testing-patterns.mdc +40 -0
- data/CHANGELOG.md +57 -0
- data/Rakefile +114 -4
- data/docs/.vitepress/config.mjs +19 -10
- data/docs/advanced/conventions.md +3 -3
- data/docs/advanced/mountable.md +476 -0
- data/docs/advanced/profiling.md +351 -0
- data/docs/advanced/rough.md +27 -8
- data/docs/index.md +5 -3
- data/docs/intro/about.md +1 -1
- data/docs/intro/overview.md +6 -6
- data/docs/recipes/formatting-context-for-error-tracking.md +186 -0
- data/docs/recipes/memoization.md +103 -18
- data/docs/recipes/rubocop-integration.md +38 -284
- data/docs/recipes/testing.md +14 -14
- data/docs/recipes/validating-user-input.md +1 -1
- data/docs/reference/async.md +429 -0
- data/docs/reference/axn-result.md +107 -0
- data/docs/reference/class.md +225 -64
- data/docs/reference/configuration.md +366 -34
- data/docs/reference/form-object.md +252 -0
- data/docs/reference/instance.md +14 -29
- data/docs/strategies/client.md +212 -0
- data/docs/strategies/form.md +235 -0
- data/docs/strategies/index.md +21 -21
- data/docs/strategies/transaction.md +1 -1
- data/docs/usage/setup.md +16 -2
- data/docs/usage/steps.md +7 -7
- data/docs/usage/using.md +23 -12
- data/docs/usage/writing.md +191 -12
- data/lib/axn/async/adapters/active_job.rb +74 -0
- data/lib/axn/async/adapters/disabled.rb +41 -0
- data/lib/axn/async/adapters/sidekiq.rb +67 -0
- data/lib/axn/async/adapters.rb +26 -0
- data/lib/axn/async/batch_enqueue/config.rb +38 -0
- data/lib/axn/async/batch_enqueue.rb +99 -0
- data/lib/axn/async/enqueue_all_orchestrator.rb +363 -0
- data/lib/axn/async.rb +178 -0
- data/lib/axn/configuration.rb +113 -0
- data/lib/{action → axn}/context.rb +22 -4
- data/lib/axn/core/automatic_logging.rb +89 -0
- data/lib/axn/core/context/facade.rb +69 -0
- data/lib/{action → axn}/core/context/facade_inspector.rb +32 -5
- data/lib/{action → axn}/core/context/internal.rb +5 -5
- data/lib/{action → axn}/core/contract.rb +111 -73
- data/lib/{action → axn}/core/contract_for_subfields.rb +30 -35
- data/lib/{action → axn}/core/contract_validation.rb +27 -12
- data/lib/axn/core/contract_validation_for_subfields.rb +165 -0
- data/lib/axn/core/default_call.rb +63 -0
- data/lib/axn/core/field_resolvers/extract.rb +32 -0
- data/lib/axn/core/field_resolvers/model.rb +63 -0
- data/lib/axn/core/field_resolvers.rb +24 -0
- data/lib/{action → axn}/core/flow/callbacks.rb +7 -7
- data/lib/{action → axn}/core/flow/exception_execution.rb +9 -13
- data/lib/{action → axn}/core/flow/handlers/base_descriptor.rb +3 -2
- data/lib/{action → axn}/core/flow/handlers/descriptors/callback_descriptor.rb +2 -2
- data/lib/{action → axn}/core/flow/handlers/descriptors/message_descriptor.rb +23 -11
- data/lib/axn/core/flow/handlers/invoker.rb +47 -0
- data/lib/{action → axn}/core/flow/handlers/matcher.rb +9 -19
- data/lib/{action → axn}/core/flow/handlers/registry.rb +3 -1
- data/lib/{action → axn}/core/flow/handlers/resolvers/base_resolver.rb +1 -1
- data/lib/{action → axn}/core/flow/handlers/resolvers/callback_resolver.rb +2 -2
- data/lib/{action → axn}/core/flow/handlers/resolvers/message_resolver.rb +12 -3
- data/lib/axn/core/flow/handlers.rb +20 -0
- data/lib/{action → axn}/core/flow/messages.rb +8 -8
- data/lib/{action → axn}/core/flow.rb +4 -4
- data/lib/{action → axn}/core/hooks.rb +17 -5
- data/lib/axn/core/logging.rb +48 -0
- data/lib/axn/core/memoization.rb +53 -0
- data/lib/{action → axn}/core/nesting_tracking.rb +1 -1
- data/lib/{action → axn}/core/timing.rb +1 -1
- data/lib/axn/core/tracing.rb +90 -0
- data/lib/axn/core/use_strategy.rb +29 -0
- data/lib/{action → axn}/core/validation/fields.rb +26 -2
- data/lib/{action → axn}/core/validation/subfields.rb +14 -12
- data/lib/axn/core/validation/validators/model_validator.rb +36 -0
- data/lib/axn/core/validation/validators/type_validator.rb +80 -0
- data/lib/{action → axn}/core/validation/validators/validate_validator.rb +12 -2
- data/lib/{action → axn}/core.rb +55 -55
- data/lib/{action → axn}/exceptions.rb +12 -2
- data/lib/axn/extras/strategies/client.rb +150 -0
- data/lib/axn/extras/strategies/vernier.rb +121 -0
- data/lib/axn/extras.rb +4 -0
- data/lib/axn/factory.rb +122 -34
- data/lib/axn/form_object.rb +90 -0
- data/lib/axn/internal/logging.rb +30 -0
- data/lib/axn/internal/registry.rb +87 -0
- data/lib/axn/mountable/descriptor.rb +76 -0
- data/lib/axn/mountable/helpers/class_builder.rb +193 -0
- data/lib/axn/mountable/helpers/mounter.rb +33 -0
- data/lib/axn/mountable/helpers/namespace_manager.rb +38 -0
- data/lib/axn/mountable/helpers/validator.rb +112 -0
- data/lib/axn/mountable/inherit_profiles.rb +72 -0
- data/lib/axn/mountable/mounting_strategies/_base.rb +87 -0
- data/lib/axn/mountable/mounting_strategies/axn.rb +48 -0
- data/lib/axn/mountable/mounting_strategies/method.rb +95 -0
- data/lib/axn/mountable/mounting_strategies/step.rb +69 -0
- data/lib/axn/mountable/mounting_strategies.rb +32 -0
- data/lib/axn/mountable.rb +119 -0
- data/lib/axn/rails/engine.rb +51 -0
- data/lib/axn/rails/generators/axn_generator.rb +86 -0
- data/lib/axn/rails/generators/templates/action.rb.erb +17 -0
- data/lib/axn/rails/generators/templates/action_spec.rb.erb +25 -0
- data/lib/{action → axn}/result.rb +32 -13
- data/lib/axn/strategies/form.rb +98 -0
- data/lib/axn/strategies/transaction.rb +26 -0
- data/lib/axn/strategies.rb +20 -0
- data/lib/axn/testing/spec_helpers.rb +6 -8
- data/lib/axn/util/callable.rb +120 -0
- data/lib/axn/util/contract_error_handling.rb +32 -0
- data/lib/axn/util/execution_context.rb +34 -0
- data/lib/axn/util/global_id_serialization.rb +52 -0
- data/lib/axn/util/logging.rb +87 -0
- data/lib/axn/util/memoization.rb +20 -0
- data/lib/axn/version.rb +1 -1
- data/lib/axn.rb +26 -16
- data/lib/rubocop/cop/axn/README.md +23 -23
- data/lib/rubocop/cop/axn/unchecked_result.rb +138 -17
- metadata +106 -64
- data/.rspec +0 -3
- data/.rubocop.yml +0 -76
- data/.tool-versions +0 -1
- data/docs/reference/action-result.md +0 -37
- data/lib/action/attachable/base.rb +0 -43
- data/lib/action/attachable/steps.rb +0 -63
- data/lib/action/attachable/subactions.rb +0 -70
- data/lib/action/attachable.rb +0 -17
- data/lib/action/configuration.rb +0 -55
- data/lib/action/core/automatic_logging.rb +0 -93
- data/lib/action/core/context/facade.rb +0 -48
- data/lib/action/core/flow/handlers/invoker.rb +0 -73
- data/lib/action/core/flow/handlers.rb +0 -20
- data/lib/action/core/logging.rb +0 -37
- data/lib/action/core/tracing.rb +0 -17
- data/lib/action/core/use_strategy.rb +0 -30
- data/lib/action/core/validation/validators/model_validator.rb +0 -34
- data/lib/action/core/validation/validators/type_validator.rb +0 -30
- data/lib/action/enqueueable/via_sidekiq.rb +0 -76
- data/lib/action/enqueueable.rb +0 -13
- data/lib/action/strategies/transaction.rb +0 -19
- data/lib/action/strategies.rb +0 -48
- data/lib/axn/util.rb +0 -24
- data/package.json +0 -10
- 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 "
|
|
4
|
-
require "
|
|
3
|
+
require "axn/core/context/facade"
|
|
4
|
+
require "axn/core/context/facade_inspector"
|
|
5
5
|
|
|
6
|
-
module
|
|
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, {
|
|
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, {
|
|
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?(
|
|
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
|
-
|
|
87
|
-
|
|
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?(
|
|
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
|
|
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
|
|
7
|
-
action = Class.new.send(:include,
|
|
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
|
|
20
|
-
|
|
15
|
+
if defined?(RSpec)
|
|
16
|
+
RSpec.configure do |config|
|
|
17
|
+
config.include Axn::Testing::SpecHelpers
|
|
18
|
+
end
|
|
21
19
|
end
|