axn 0.1.0.pre.alpha.3 → 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/CHANGELOG.md +15 -1
- data/Rakefile +102 -2
- data/docs/.vitepress/config.mjs +12 -8
- data/docs/advanced/conventions.md +1 -1
- data/docs/advanced/mountable.md +4 -90
- data/docs/advanced/profiling.md +26 -30
- data/docs/advanced/rough.md +27 -8
- data/docs/intro/overview.md +1 -1
- data/docs/recipes/formatting-context-for-error-tracking.md +186 -0
- data/docs/recipes/memoization.md +102 -17
- data/docs/reference/async.md +269 -0
- data/docs/reference/class.md +113 -50
- data/docs/reference/configuration.md +226 -75
- data/docs/reference/form-object.md +252 -0
- data/docs/strategies/client.md +212 -0
- data/docs/strategies/form.md +235 -0
- data/docs/usage/setup.md +2 -2
- data/docs/usage/writing.md +99 -1
- data/lib/axn/async/adapters/active_job.rb +19 -10
- data/lib/axn/async/adapters/disabled.rb +15 -0
- data/lib/axn/async/adapters/sidekiq.rb +25 -32
- 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 +121 -4
- data/lib/axn/configuration.rb +53 -13
- data/lib/axn/context.rb +1 -0
- data/lib/axn/core/automatic_logging.rb +47 -51
- data/lib/axn/core/context/facade_inspector.rb +1 -1
- data/lib/axn/core/contract.rb +73 -30
- data/lib/axn/core/contract_for_subfields.rb +1 -1
- data/lib/axn/core/contract_validation.rb +14 -9
- data/lib/axn/core/contract_validation_for_subfields.rb +14 -7
- data/lib/axn/core/default_call.rb +63 -0
- data/lib/axn/core/flow/exception_execution.rb +5 -0
- data/lib/axn/core/flow/handlers/descriptors/message_descriptor.rb +19 -7
- data/lib/axn/core/flow/handlers/invoker.rb +4 -30
- data/lib/axn/core/flow/handlers/matcher.rb +4 -14
- data/lib/axn/core/flow/messages.rb +1 -1
- data/lib/axn/core/hooks.rb +1 -0
- data/lib/axn/core/logging.rb +16 -5
- data/lib/axn/core/memoization.rb +53 -0
- data/lib/axn/core/tracing.rb +77 -4
- data/lib/axn/core/validation/validators/type_validator.rb +1 -1
- data/lib/axn/core.rb +31 -46
- 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 +22 -2
- data/lib/axn/form_object.rb +90 -0
- data/lib/axn/internal/logging.rb +5 -1
- data/lib/axn/mountable/helpers/class_builder.rb +41 -10
- data/lib/axn/mountable/helpers/namespace_manager.rb +6 -34
- data/lib/axn/mountable/inherit_profiles.rb +2 -2
- data/lib/axn/mountable/mounting_strategies/_base.rb +10 -6
- data/lib/axn/mountable/mounting_strategies/method.rb +2 -2
- data/lib/axn/mountable.rb +41 -7
- data/lib/axn/rails/generators/axn_generator.rb +19 -1
- data/lib/axn/rails/generators/templates/action.rb.erb +1 -1
- data/lib/axn/result.rb +2 -2
- data/lib/axn/strategies/form.rb +98 -0
- data/lib/axn/strategies/transaction.rb +7 -0
- 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/version.rb +1 -1
- data/lib/axn.rb +9 -0
- metadata +22 -4
- data/lib/axn/core/profiling.rb +0 -124
- data/lib/axn/mountable/mounting_strategies/enqueue_all.rb +0 -55
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This is a base class for all form objects that are used with Axn actions.
|
|
4
|
+
#
|
|
5
|
+
# It provides a number of conveniences for working with form objects, including:
|
|
6
|
+
# - Automatically attr_accessor any attribute for which we add a validation
|
|
7
|
+
# - Add support for nested forms
|
|
8
|
+
# - Add support for money objects (analogous to 'include MoneyRails::ActiveRecord::Monetizable' for ActiveRecord models)
|
|
9
|
+
module Axn
|
|
10
|
+
class FormObject
|
|
11
|
+
include ActiveModel::Model
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
attr_accessor :field_names
|
|
15
|
+
|
|
16
|
+
def inherited(subclass)
|
|
17
|
+
# Inherit field_names from parent class, or initialize as empty array if parent doesn't have any
|
|
18
|
+
subclass.field_names = (field_names || []).dup
|
|
19
|
+
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Override attr_accessor to track field names for automatic #to_h support
|
|
24
|
+
def attr_accessor(*attributes)
|
|
25
|
+
# Initialize field_names if not already set
|
|
26
|
+
self.field_names ||= []
|
|
27
|
+
|
|
28
|
+
# Add new attributes to the field_names array
|
|
29
|
+
self.field_names += attributes.map(&:to_sym)
|
|
30
|
+
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Automatically attr_accessor any attribute for which we add a validation
|
|
35
|
+
def validates(*attributes)
|
|
36
|
+
our_attributes = attributes.dup
|
|
37
|
+
|
|
38
|
+
# Pulled from upstream: https://github.com/rails/rails/blob/6f0d1ad14b92b9f5906e44740fce8b4f1c7075dc/activemodel/lib/active_model/validations/validates.rb#L106
|
|
39
|
+
our_attributes.extract_options!
|
|
40
|
+
our_attributes.each { |attr| attr_accessor(attr) }
|
|
41
|
+
|
|
42
|
+
super
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Add support for nested forms
|
|
46
|
+
def nested_forms(**kwargs)
|
|
47
|
+
kwargs.each do |name, klass|
|
|
48
|
+
validates name, presence: true
|
|
49
|
+
|
|
50
|
+
define_method("#{name}=") do |params|
|
|
51
|
+
return instance_variable_set("@#{name}", nil) if params.nil?
|
|
52
|
+
|
|
53
|
+
child_params = params.dup
|
|
54
|
+
|
|
55
|
+
# Automatically inject the parent into the child form if it has a parent= method
|
|
56
|
+
child_params[:parent_form] = self if klass.instance_methods.include?(:parent_form=)
|
|
57
|
+
|
|
58
|
+
instance_variable_set("@#{name}", klass.new(child_params))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
validation_method_name = :"validate_#{name}_form"
|
|
62
|
+
validate validation_method_name
|
|
63
|
+
define_method(validation_method_name) do
|
|
64
|
+
return if public_send(name).nil? || public_send(name).valid?
|
|
65
|
+
|
|
66
|
+
public_send(name).errors.each do |error|
|
|
67
|
+
errors.add("#{name}.#{error.attribute}", error.message)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
private validation_method_name
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
alias nested_form nested_forms
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def to_h
|
|
77
|
+
return {} if self.class.field_names.nil?
|
|
78
|
+
|
|
79
|
+
self.class.field_names.each_with_object({}) do |field_name, hash|
|
|
80
|
+
next unless respond_to?(field_name)
|
|
81
|
+
|
|
82
|
+
# Skip parent_form to avoid infinite recursion with circular references
|
|
83
|
+
next if field_name == :parent_form
|
|
84
|
+
|
|
85
|
+
value = public_send(field_name)
|
|
86
|
+
hash[field_name] = value.is_a?(Axn::FormObject) ? value.to_h : value
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
data/lib/axn/internal/logging.rb
CHANGED
|
@@ -4,6 +4,10 @@ module Axn
|
|
|
4
4
|
module Internal
|
|
5
5
|
module Logging
|
|
6
6
|
def self.piping_error(desc, exception:, action: nil)
|
|
7
|
+
# If raise_piping_errors_in_dev is enabled and we're in development, raise instead of log.
|
|
8
|
+
# Test and production environments always swallow the error to match production behavior.
|
|
9
|
+
raise exception if Axn.config.raise_piping_errors_in_dev && Axn.config.env.development?
|
|
10
|
+
|
|
7
11
|
# Extract just filename/line number from backtrace
|
|
8
12
|
src = exception.backtrace.first.split.first.split("/").last.split(":")[0, 2].join(":")
|
|
9
13
|
|
|
@@ -14,7 +18,7 @@ module Axn
|
|
|
14
18
|
"\t* Exception: #{exception.class.name}\n" \
|
|
15
19
|
"\t* Message: #{exception.message}\n" \
|
|
16
20
|
"\t* From: #{src}"
|
|
17
|
-
"#{
|
|
21
|
+
"#{'⌵' * 30}\n\n#{msg}\n\n#{'^' * 30}"
|
|
18
22
|
end
|
|
19
23
|
|
|
20
24
|
(action || Axn.config.logger).send(:warn, message)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "axn/mountable/inherit_profiles"
|
|
4
|
+
require "axn/exceptions"
|
|
4
5
|
|
|
5
6
|
module Axn
|
|
6
7
|
module Mountable
|
|
@@ -23,15 +24,25 @@ module Axn
|
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
def build_and_configure_action_class(target, name, namespace)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
# Mark target as having an action class being created to prevent recursion
|
|
28
|
+
# This is necessary because create_superclass_for_inherit_mode may create
|
|
29
|
+
# classes that inherit from target, triggering the inherited callback
|
|
30
|
+
target.instance_variable_set(:@_axn_creating_action_class_for, target)
|
|
31
|
+
begin
|
|
32
|
+
# Pass target through to Factory.build so it can mark the superclass
|
|
33
|
+
# The superclass flag is checked by the inherited callback in mountable.rb
|
|
34
|
+
mounted_axn = build_action_class(target, _creating_action_class_for: target)
|
|
35
|
+
configure_class_name_and_constant(mounted_axn, name, namespace, target)
|
|
36
|
+
configure_axn_mounted_to(mounted_axn, target)
|
|
37
|
+
mounted_axn
|
|
38
|
+
ensure
|
|
39
|
+
target.instance_variable_set(:@_axn_creating_action_class_for, nil)
|
|
40
|
+
end
|
|
30
41
|
end
|
|
31
42
|
|
|
32
43
|
private
|
|
33
44
|
|
|
34
|
-
def build_action_class(target)
|
|
45
|
+
def build_action_class(target, _creating_action_class_for: nil) # rubocop:disable Lint/UnderscorePrefixedVariableName
|
|
35
46
|
existing_axn_klass = @descriptor.instance_variable_get(:@existing_axn_klass)
|
|
36
47
|
return existing_axn_klass if existing_axn_klass
|
|
37
48
|
|
|
@@ -49,12 +60,15 @@ module Axn
|
|
|
49
60
|
factory_kwargs[:superclass] = create_superclass_for_inherit_mode(target, inherit_config)
|
|
50
61
|
end
|
|
51
62
|
|
|
63
|
+
# Pass the target class through to Factory.build so it can mark the superclass
|
|
64
|
+
factory_kwargs[:_creating_action_class_for] = _creating_action_class_for
|
|
65
|
+
|
|
52
66
|
Axn::Factory.build(**factory_kwargs, &block)
|
|
53
67
|
end
|
|
54
68
|
|
|
55
|
-
def configure_class_name_and_constant(axn_klass, name, axn_namespace)
|
|
69
|
+
def configure_class_name_and_constant(axn_klass, name, axn_namespace, target)
|
|
56
70
|
configure_class_name(axn_klass, name, axn_namespace) if name.present?
|
|
57
|
-
register_constant(axn_klass, name, axn_namespace) if should_register_constant?(axn_namespace)
|
|
71
|
+
register_constant(axn_klass, name, axn_namespace, target) if should_register_constant?(axn_namespace)
|
|
58
72
|
end
|
|
59
73
|
|
|
60
74
|
def configure_axn_mounted_to(axn_klass, target)
|
|
@@ -148,12 +162,29 @@ module Axn
|
|
|
148
162
|
end
|
|
149
163
|
end
|
|
150
164
|
|
|
151
|
-
def register_constant(axn_klass, name, axn_namespace)
|
|
165
|
+
def register_constant(axn_klass, name, axn_namespace, target)
|
|
152
166
|
constant_name = generate_constant_name(name)
|
|
153
167
|
|
|
154
|
-
#
|
|
155
|
-
|
|
168
|
+
# Check if constant already exists - if so, only allow overwriting in inheritance scenarios
|
|
169
|
+
if axn_namespace.const_defined?(constant_name, false)
|
|
170
|
+
# Only allow overwriting if this is an inheritance scenario (child overriding parent)
|
|
171
|
+
# Check if the target's parent has the same method mounted (inheritance override)
|
|
172
|
+
parent = target.superclass
|
|
173
|
+
is_inheritance_override = parent.respond_to?(:_mounted_axn_descriptors) &&
|
|
174
|
+
parent._mounted_axn_descriptors.any? { |d| d.name.to_s == name.to_s }
|
|
175
|
+
|
|
176
|
+
# If it's not an inheritance override, this is a same-class collision
|
|
177
|
+
# Raise an error here for clarity, rather than silently skipping and failing later
|
|
178
|
+
# Use the same error message format as mount_method for consistency
|
|
179
|
+
unless is_inheritance_override
|
|
180
|
+
method_name = "#{name}!"
|
|
181
|
+
strategy_name = @descriptor.mount_strategy.name.split("::").last
|
|
182
|
+
raise Axn::Mountable::MountingError,
|
|
183
|
+
"#{strategy_name} unable to attach -- method '#{method_name}' is already taken"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
156
186
|
|
|
187
|
+
# Set the constant (either it doesn't exist, or it's an inheritance override)
|
|
157
188
|
axn_namespace.const_set(constant_name, axn_klass)
|
|
158
189
|
end
|
|
159
190
|
end
|
|
@@ -14,50 +14,22 @@ module Axn
|
|
|
14
14
|
return axn_class if axn_class.is_a?(Class)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
# Create a namespace class
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
# Only set the constant if it doesn't exist
|
|
23
|
-
return if target.const_defined?(:Axns, false)
|
|
24
|
-
|
|
25
|
-
target.const_set(:Axns, namespace_class)
|
|
17
|
+
# Create a fresh namespace class for this target
|
|
18
|
+
create_namespace_class(target).tap do |namespace_class|
|
|
19
|
+
target.const_set(:Axns, namespace_class) unless target.const_defined?(:Axns, false)
|
|
20
|
+
end
|
|
26
21
|
end
|
|
27
22
|
|
|
28
23
|
private
|
|
29
24
|
|
|
30
|
-
def
|
|
31
|
-
|
|
32
|
-
return nil unless client_class.superclass.respond_to?(:_mounted_axn_descriptors)
|
|
33
|
-
return nil unless client_class.superclass.const_defined?(:Axns, false)
|
|
34
|
-
|
|
35
|
-
client_class.superclass.const_get(:Axns)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def create_namespace_class(client_class, parent_axns)
|
|
39
|
-
base_class = parent_axns || Class.new
|
|
40
|
-
|
|
41
|
-
Class.new(base_class) do
|
|
25
|
+
def create_namespace_class(client_class)
|
|
26
|
+
Class.new do
|
|
42
27
|
define_singleton_method(:__axn_mounted_to__) { client_class }
|
|
43
28
|
|
|
44
29
|
define_singleton_method(:name) do
|
|
45
30
|
client_name = client_class.name.presence || "AnonymousClient_#{client_class.object_id}"
|
|
46
31
|
"#{client_name}::Axns"
|
|
47
32
|
end
|
|
48
|
-
end.tap do |ns|
|
|
49
|
-
update_inherited_action_classes(ns, client_class) if parent_axns
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def update_inherited_action_classes(namespace, client_class)
|
|
54
|
-
namespace.constants.each do |const_name|
|
|
55
|
-
const_value = namespace.const_get(const_name)
|
|
56
|
-
next unless const_value.is_a?(Class) && const_value.respond_to?(:__axn_mounted_to__)
|
|
57
|
-
|
|
58
|
-
# Update __axn_mounted_to__ method on the existing class
|
|
59
|
-
const_value.define_singleton_method(:__axn_mounted_to__) { client_class }
|
|
60
|
-
const_value.define_method(:__axn_mounted_to__) { client_class }
|
|
61
33
|
end
|
|
62
34
|
end
|
|
63
35
|
end
|
|
@@ -45,12 +45,12 @@ module Axn
|
|
|
45
45
|
case inherit
|
|
46
46
|
when Symbol
|
|
47
47
|
PROFILES.fetch(inherit) do
|
|
48
|
-
raise ArgumentError, "Unknown inherit profile: #{inherit.inspect}. Valid profiles: #{PROFILES.keys.join(
|
|
48
|
+
raise ArgumentError, "Unknown inherit profile: #{inherit.inspect}. Valid profiles: #{PROFILES.keys.join(', ')}"
|
|
49
49
|
end
|
|
50
50
|
when Hash
|
|
51
51
|
# Validate hash keys
|
|
52
52
|
invalid_keys = inherit.keys - PROFILES[:none].keys
|
|
53
|
-
raise ArgumentError, "Invalid inherit keys: #{invalid_keys.join(
|
|
53
|
+
raise ArgumentError, "Invalid inherit keys: #{invalid_keys.join(', ')}. Valid keys: #{PROFILES[:none].keys.join(', ')}" if invalid_keys.any?
|
|
54
54
|
|
|
55
55
|
# Merge with none profile to ensure all keys are present
|
|
56
56
|
PROFILES[:none].merge(inherit)
|
|
@@ -31,17 +31,24 @@ module Axn
|
|
|
31
31
|
# We allow overriding if this is a child class with a parent that has axn methods (inheritance scenario)
|
|
32
32
|
# Otherwise, we raise an error for same-class method collisions
|
|
33
33
|
if _should_raise_method_collision_error?(target, method_name)
|
|
34
|
-
raise MountingError, "#{name.split(
|
|
34
|
+
raise MountingError, "#{name.split('::').last} unable to attach -- method '#{method_name}' is already taken"
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
target.define_singleton_method(method_name, &)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
private
|
|
41
|
-
|
|
42
40
|
# Mount methods to the namespace and register the action class
|
|
43
41
|
def mount_to_namespace(descriptor:, target:)
|
|
42
|
+
define_namespace_methods(descriptor:, target:)
|
|
43
|
+
# Register the action class as a constant in the namespace
|
|
44
44
|
action_class_builder = Helpers::ClassBuilder.new(descriptor)
|
|
45
|
+
action_class_builder.mount(target, descriptor.name.to_s)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Define namespace methods on the target's Axns namespace
|
|
49
|
+
# This is public so it can be called from the inherited callback
|
|
50
|
+
# without re-registering the constant (which is already created by mounted_axn_for)
|
|
51
|
+
def define_namespace_methods(descriptor:, target:)
|
|
45
52
|
namespace = Helpers::NamespaceManager.get_or_create_namespace(target)
|
|
46
53
|
name = descriptor.name
|
|
47
54
|
descriptor_ref = descriptor
|
|
@@ -61,9 +68,6 @@ module Axn
|
|
|
61
68
|
axn = descriptor_ref.mounted_axn_for(target:)
|
|
62
69
|
axn.call_async(**kwargs)
|
|
63
70
|
end
|
|
64
|
-
|
|
65
|
-
# Register the action class as a constant in the namespace
|
|
66
|
-
action_class_builder.mount(target, name.to_s)
|
|
67
71
|
end
|
|
68
72
|
|
|
69
73
|
# Check if we should raise an error for method collision
|
|
@@ -46,7 +46,7 @@ module Axn
|
|
|
46
46
|
|
|
47
47
|
if exposed_fields.size > 1
|
|
48
48
|
raise MountingError,
|
|
49
|
-
"Cannot determine expose_return_as for existing axn class with multiple exposed fields: #{exposed_fields.join(
|
|
49
|
+
"Cannot determine expose_return_as for existing axn class with multiple exposed fields: #{exposed_fields.join(', ')}. " \
|
|
50
50
|
"Use a fresh block with mount_axn_method or ensure the axn class has exactly one exposed field."
|
|
51
51
|
end
|
|
52
52
|
end
|
|
@@ -85,7 +85,7 @@ module Axn
|
|
|
85
85
|
exposed_fields.first # Single field, assume it's expose_return_as
|
|
86
86
|
else
|
|
87
87
|
raise MountingError,
|
|
88
|
-
"Cannot determine expose_return_as for existing axn class with multiple exposed fields: #{exposed_fields.join(
|
|
88
|
+
"Cannot determine expose_return_as for existing axn class with multiple exposed fields: #{exposed_fields.join(', ')}. " \
|
|
89
89
|
"Use a fresh block with mount_axn_method or ensure the axn class has exactly one exposed field."
|
|
90
90
|
end
|
|
91
91
|
end
|
data/lib/axn/mountable.rb
CHANGED
|
@@ -21,7 +21,6 @@ module Axn
|
|
|
21
21
|
#
|
|
22
22
|
# - `mount_axn` and `mount_axn_method`: `:lifecycle` - Inherits hooks, callbacks, messages, and async config (but not fields)
|
|
23
23
|
# - `step`: `:none` - Completely independent to avoid conflicts
|
|
24
|
-
# - `enqueue_all_via`: `:async_only` - Only inherits async configuration
|
|
25
24
|
#
|
|
26
25
|
# ### Inheritance Profiles
|
|
27
26
|
#
|
|
@@ -53,12 +52,6 @@ module Axn
|
|
|
53
52
|
# # Will NOT run log_start or track_success
|
|
54
53
|
# end
|
|
55
54
|
#
|
|
56
|
-
# # enqueue_all_via uses :async_only (only inherits async config)
|
|
57
|
-
# MyClass.enqueue_all_via do
|
|
58
|
-
# # Can call enqueue (uses inherited async config)
|
|
59
|
-
# # Does NOT inherit hooks, callbacks, or messages
|
|
60
|
-
# end
|
|
61
|
-
#
|
|
62
55
|
# @example Custom inheritance control
|
|
63
56
|
# # Override step default to inherit lifecycle
|
|
64
57
|
# MyClass.step :my_step, inherit: :lifecycle do
|
|
@@ -75,6 +68,47 @@ module Axn
|
|
|
75
68
|
def self.included(base)
|
|
76
69
|
base.class_eval do
|
|
77
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
|
|
78
112
|
end
|
|
79
113
|
|
|
80
114
|
MountingStrategies.all.each do |(_name, klass)|
|
|
@@ -26,7 +26,25 @@ module Axn
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def file_path
|
|
29
|
-
@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
|
|
30
48
|
end
|
|
31
49
|
|
|
32
50
|
def expectations_with_types
|
data/lib/axn/result.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Axn
|
|
|
11
11
|
def ok(msg = nil, **exposures)
|
|
12
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
|
|
@@ -21,7 +21,7 @@ module Axn
|
|
|
21
21
|
def error(msg = nil, **exposures, &block)
|
|
22
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
|
|
@@ -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
|
|
@@ -9,9 +9,16 @@ module Axn
|
|
|
9
9
|
raise NotImplementedError, "Transaction strategy requires ActiveRecord" unless defined?(ActiveRecord)
|
|
10
10
|
|
|
11
11
|
around do |hooked|
|
|
12
|
+
early_completion = nil
|
|
12
13
|
ActiveRecord::Base.transaction do
|
|
13
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
|
|
14
19
|
end
|
|
20
|
+
# Re-raise EarlyCompletion after transaction commits successfully
|
|
21
|
+
raise early_completion if early_completion
|
|
15
22
|
end
|
|
16
23
|
end
|
|
17
24
|
end
|