axn 0.1.0.pre.alpha.2.8.1 → 0.1.0.pre.alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- 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 +43 -0
- data/Rakefile +12 -2
- data/docs/.vitepress/config.mjs +8 -3
- data/docs/advanced/conventions.md +2 -2
- data/docs/advanced/mountable.md +562 -0
- data/docs/advanced/profiling.md +355 -0
- data/docs/advanced/rough.md +1 -1
- data/docs/index.md +5 -3
- data/docs/intro/about.md +1 -1
- data/docs/intro/overview.md +5 -5
- data/docs/recipes/memoization.md +2 -2
- 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 +160 -0
- data/docs/reference/axn-result.md +107 -0
- data/docs/reference/class.md +123 -25
- data/docs/reference/configuration.md +191 -10
- data/docs/reference/instance.md +14 -29
- data/docs/strategies/index.md +21 -21
- data/docs/strategies/transaction.md +1 -1
- data/docs/usage/setup.md +14 -0
- data/docs/usage/steps.md +7 -7
- data/docs/usage/using.md +23 -12
- data/docs/usage/writing.md +92 -11
- data/lib/axn/async/adapters/active_job.rb +65 -0
- data/lib/axn/async/adapters/disabled.rb +26 -0
- data/lib/axn/async/adapters/sidekiq.rb +74 -0
- data/lib/axn/async/adapters.rb +26 -0
- data/lib/axn/async.rb +61 -0
- data/lib/{action → axn}/configuration.rb +21 -3
- data/lib/{action → axn}/context.rb +21 -4
- data/lib/{action → axn}/core/automatic_logging.rb +6 -6
- data/lib/axn/core/context/facade.rb +69 -0
- data/lib/{action → axn}/core/context/facade_inspector.rb +31 -4
- data/lib/{action → axn}/core/context/internal.rb +5 -5
- data/lib/{action → axn}/core/contract.rb +41 -46
- data/lib/{action → axn}/core/contract_for_subfields.rb +30 -35
- data/lib/{action → axn}/core/contract_validation.rb +16 -6
- data/lib/axn/core/contract_validation_for_subfields.rb +158 -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 +4 -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 +6 -6
- data/lib/{action → axn}/core/flow/handlers/invoker.rb +2 -2
- data/lib/{action → axn}/core/flow/handlers/matcher.rb +5 -5
- 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 +7 -7
- data/lib/{action → axn}/core/flow.rb +4 -4
- data/lib/{action → axn}/core/hooks.rb +16 -5
- data/lib/{action → axn}/core/logging.rb +3 -3
- data/lib/{action → axn}/core/nesting_tracking.rb +1 -1
- data/lib/axn/core/profiling.rb +124 -0
- data/lib/{action → axn}/core/timing.rb +1 -1
- data/lib/axn/core/tracing.rb +17 -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/axn/core.rb +123 -0
- data/lib/{action → axn}/exceptions.rb +12 -2
- data/lib/axn/factory.rb +102 -34
- data/lib/axn/internal/logging.rb +26 -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 +162 -0
- data/lib/axn/mountable/helpers/mounter.rb +33 -0
- data/lib/axn/mountable/helpers/namespace_manager.rb +66 -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 +83 -0
- data/lib/axn/mountable/mounting_strategies/axn.rb +48 -0
- data/lib/axn/mountable/mounting_strategies/enqueue_all.rb +55 -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 +85 -0
- data/lib/axn/rails/engine.rb +51 -0
- data/lib/axn/rails/generators/axn_generator.rb +68 -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 +30 -11
- data/lib/{action → axn}/strategies/transaction.rb +1 -1
- data/lib/axn/strategies.rb +20 -0
- data/lib/axn/testing/spec_helpers.rb +6 -8
- data/lib/axn/util/memoization.rb +20 -0
- data/lib/axn/version.rb +1 -1
- data/lib/axn.rb +17 -16
- data/lib/rubocop/cop/axn/README.md +23 -23
- data/lib/rubocop/cop/axn/unchecked_result.rb +138 -17
- metadata +88 -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/core/context/facade.rb +0 -48
- data/lib/action/core/flow/handlers.rb +0 -20
- 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/core.rb +0 -108
- data/lib/action/enqueueable/via_sidekiq.rb +0 -76
- data/lib/action/enqueueable.rb +0 -13
- data/lib/action/strategies.rb +0 -48
- data/lib/axn/util.rb +0 -24
- data/package.json +0 -10
- data/yarn.lock +0 -1166
@@ -1,8 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "
|
3
|
+
require "axn/core/flow/handlers/invoker"
|
4
4
|
|
5
|
-
module
|
5
|
+
module Axn
|
6
6
|
module Core
|
7
7
|
module Flow
|
8
8
|
module Handlers
|
@@ -16,7 +16,7 @@ module Action
|
|
16
16
|
result = matches?(exception:, action:)
|
17
17
|
@invert ? !result : result
|
18
18
|
rescue StandardError => e
|
19
|
-
Axn::
|
19
|
+
Axn::Internal::Logging.piping_error("determining if handler applies to exception", action:, exception: e)
|
20
20
|
end
|
21
21
|
|
22
22
|
private
|
@@ -92,7 +92,7 @@ module Action
|
|
92
92
|
def call(exception:, action:)
|
93
93
|
matches?(exception:, action:)
|
94
94
|
rescue StandardError => e
|
95
|
-
Axn::
|
95
|
+
Axn::Internal::Logging.piping_error("determining if handler applies to exception", action:, exception: e)
|
96
96
|
end
|
97
97
|
|
98
98
|
def static? = @rules.empty?
|
@@ -103,7 +103,7 @@ module Action
|
|
103
103
|
if_condition = binding.local_variable_get(:if)
|
104
104
|
unless_condition = binding.local_variable_get(:unless)
|
105
105
|
|
106
|
-
raise
|
106
|
+
raise Axn::UnsupportedArgument, "providing both :if and :unless" if if_condition && unless_condition
|
107
107
|
|
108
108
|
new(Array(if_condition || unless_condition).compact, invert: !!unless_condition)
|
109
109
|
end
|
@@ -1,11 +1,13 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
module
|
3
|
+
module Axn
|
4
4
|
module Core
|
5
5
|
module Flow
|
6
6
|
module Handlers
|
7
7
|
# Small, immutable, copy-on-write registry keyed by event_type.
|
8
8
|
# Stores arrays of entries (handlers/interceptors) in insertion order.
|
9
|
+
#
|
10
|
+
# NOTE: serves different need than user-mutable e.g. Axn::Async::Adapters
|
9
11
|
class Registry
|
10
12
|
def self.empty = new({})
|
11
13
|
|
@@ -1,8 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "
|
3
|
+
require "axn/core/flow/handlers/invoker"
|
4
4
|
|
5
|
-
module
|
5
|
+
module Axn
|
6
6
|
module Core
|
7
7
|
module Flow
|
8
8
|
module Handlers
|
@@ -33,7 +33,7 @@ module Action
|
|
33
33
|
message = resolved_message_body(descriptor)
|
34
34
|
return nil unless message.present?
|
35
35
|
|
36
|
-
|
36
|
+
"#{resolved_prefix(descriptor)}#{message}"
|
37
37
|
end
|
38
38
|
|
39
39
|
def resolved_message_body(descriptor)
|
@@ -49,6 +49,15 @@ module Action
|
|
49
49
|
end
|
50
50
|
end
|
51
51
|
|
52
|
+
def resolved_prefix(descriptor)
|
53
|
+
return nil unless descriptor.prefix
|
54
|
+
return descriptor.prefix if descriptor.prefix.is_a?(String)
|
55
|
+
|
56
|
+
Invoker.call(action:, handler: descriptor.prefix, exception:, operation: "determining prefix callable")
|
57
|
+
rescue StandardError
|
58
|
+
nil
|
59
|
+
end
|
60
|
+
|
52
61
|
def invoke_handler(handler) = handler ? Invoker.call(operation: "determining message callable", action:, handler:, exception:).presence : nil
|
53
62
|
def fallback_message = event_type == :success ? DEFAULT_SUCCESS : DEFAULT_ERROR
|
54
63
|
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Axn
|
4
|
+
module Core
|
5
|
+
module Flow
|
6
|
+
module Handlers
|
7
|
+
end
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
require "axn/core/flow/handlers/base_descriptor"
|
13
|
+
require "axn/core/flow/handlers/matcher"
|
14
|
+
require "axn/core/flow/handlers/resolvers/base_resolver"
|
15
|
+
require "axn/core/flow/handlers/descriptors/message_descriptor"
|
16
|
+
require "axn/core/flow/handlers/descriptors/callback_descriptor"
|
17
|
+
require "axn/core/flow/handlers/invoker"
|
18
|
+
require "axn/core/flow/handlers/resolvers/callback_resolver"
|
19
|
+
require "axn/core/flow/handlers/registry"
|
20
|
+
require "axn/core/flow/handlers/resolvers/message_resolver"
|
@@ -1,14 +1,14 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "
|
3
|
+
require "axn/core/flow/handlers"
|
4
4
|
|
5
|
-
module
|
5
|
+
module Axn
|
6
6
|
module Core
|
7
7
|
module Flow
|
8
8
|
module Messages
|
9
9
|
def self.included(base)
|
10
10
|
base.class_eval do
|
11
|
-
class_attribute :_messages_registry, default:
|
11
|
+
class_attribute :_messages_registry, default: Axn::Core::Flow::Handlers::Registry.empty
|
12
12
|
|
13
13
|
extend ClassMethods
|
14
14
|
end
|
@@ -21,19 +21,19 @@ module Action
|
|
21
21
|
private
|
22
22
|
|
23
23
|
def _add_message(kind, message:, **kwargs, &block)
|
24
|
-
raise
|
25
|
-
raise
|
24
|
+
raise Axn::UnsupportedArgument, "calling #{kind} with both :if and :unless" if kwargs.key?(:if) && kwargs.key?(:unless)
|
25
|
+
raise Axn::UnsupportedArgument, "Combining from: with if: or unless:" if kwargs.key?(:from) && (kwargs.key?(:if) || kwargs.key?(:unless))
|
26
26
|
raise ArgumentError, "Provide either a message or a block, not both" if message && block_given?
|
27
27
|
raise ArgumentError, "Provide a message, block, or prefix" unless message || block_given? || kwargs[:prefix]
|
28
28
|
raise ArgumentError, "from: only applies to error messages" if kwargs.key?(:from) && kind != :error
|
29
29
|
|
30
30
|
# If message is already a descriptor, use it directly
|
31
|
-
entry = if message.is_a?(
|
31
|
+
entry = if message.is_a?(Axn::Core::Flow::Handlers::Descriptors::MessageDescriptor)
|
32
32
|
raise ArgumentError, "Cannot pass additional configuration with prebuilt descriptor" if kwargs.any? || block_given?
|
33
33
|
|
34
34
|
message
|
35
35
|
else
|
36
|
-
|
36
|
+
Axn::Core::Flow::Handlers::Descriptors::MessageDescriptor.build(
|
37
37
|
handler: block_given? ? block : message,
|
38
38
|
**kwargs,
|
39
39
|
)
|
@@ -1,10 +1,10 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "
|
4
|
-
require "
|
5
|
-
require "
|
3
|
+
require "axn/core/flow/messages"
|
4
|
+
require "axn/core/flow/callbacks"
|
5
|
+
require "axn/core/flow/exception_execution"
|
6
6
|
|
7
|
-
module
|
7
|
+
module Axn
|
8
8
|
module Core
|
9
9
|
module Flow
|
10
10
|
def self.included(base)
|
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
module
|
3
|
+
module Axn
|
4
4
|
module Core
|
5
5
|
module Hooks
|
6
6
|
def self.included(base)
|
@@ -68,10 +68,15 @@ module Action
|
|
68
68
|
private
|
69
69
|
|
70
70
|
def _with_hooks
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
71
|
+
# Outer is needed in the unlikely case done! is called in around hooks
|
72
|
+
__respecting_early_completion do
|
73
|
+
_run_around_hooks do
|
74
|
+
__respecting_early_completion do
|
75
|
+
_run_before_hooks
|
76
|
+
yield
|
77
|
+
_run_after_hooks
|
78
|
+
end
|
79
|
+
end
|
75
80
|
end
|
76
81
|
end
|
77
82
|
|
@@ -118,6 +123,12 @@ module Action
|
|
118
123
|
def _run_hook(hook, *)
|
119
124
|
hook.is_a?(Symbol) ? send(hook, *) : instance_exec(*, &hook)
|
120
125
|
end
|
126
|
+
|
127
|
+
def __respecting_early_completion
|
128
|
+
yield
|
129
|
+
rescue Axn::Internal::EarlyCompletion => e
|
130
|
+
@__context.__record_early_completion(e.message)
|
131
|
+
end
|
121
132
|
end
|
122
133
|
end
|
123
134
|
end
|
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
require "active_support/core_ext/module/delegation"
|
4
4
|
|
5
|
-
module
|
5
|
+
module Axn
|
6
6
|
module Core
|
7
7
|
module Logging
|
8
8
|
LEVELS = %i[debug info warn error fatal].freeze
|
@@ -15,13 +15,13 @@ module Action
|
|
15
15
|
end
|
16
16
|
|
17
17
|
module ClassMethods
|
18
|
-
def log_level =
|
18
|
+
def log_level = Axn.config.log_level
|
19
19
|
|
20
20
|
def log(message, level: log_level, before: nil, after: nil)
|
21
21
|
msg = [_log_prefix, message].compact_blank.join(" ")
|
22
22
|
msg = [before, msg, after].compact_blank.join if before || after
|
23
23
|
|
24
|
-
|
24
|
+
Axn.config.logger.send(level, msg)
|
25
25
|
end
|
26
26
|
|
27
27
|
LEVELS.each do |level|
|
@@ -0,0 +1,124 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fileutils"
|
4
|
+
require "axn/core/flow/handlers/invoker"
|
5
|
+
|
6
|
+
module Axn
|
7
|
+
module Core
|
8
|
+
module Profiling
|
9
|
+
def self.included(base)
|
10
|
+
base.class_eval do
|
11
|
+
class_attribute :_profiling_enabled, default: false
|
12
|
+
class_attribute :_profiling_condition, default: nil
|
13
|
+
class_attribute :_profiling_sample_rate, default: 0.1
|
14
|
+
class_attribute :_profiling_output_dir, default: nil
|
15
|
+
|
16
|
+
extend ClassMethods
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
module ClassMethods
|
21
|
+
# Enable profiling for this action class
|
22
|
+
#
|
23
|
+
# @param if [Proc, Symbol, #call, nil] Optional condition to determine when to profile
|
24
|
+
# @param sample_rate [Float] Sampling rate (0.0 to 1.0, default: 0.1)
|
25
|
+
# @param output_dir [String, Pathname] Output directory for profile files (default: Rails.root/tmp/profiles or tmp/profiles)
|
26
|
+
# @return [void]
|
27
|
+
def profile(if: nil, sample_rate: 0.1, output_dir: nil)
|
28
|
+
self._profiling_enabled = true
|
29
|
+
self._profiling_condition = binding.local_variable_get(:if)
|
30
|
+
self._profiling_sample_rate = sample_rate
|
31
|
+
self._profiling_output_dir = output_dir || _default_profiling_output_dir
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def _default_profiling_output_dir
|
37
|
+
if defined?(Rails) && Rails.respond_to?(:root)
|
38
|
+
Rails.root.join("tmp", "profiles")
|
39
|
+
else
|
40
|
+
Pathname.new("tmp/profiles")
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def _with_profiling(&)
|
48
|
+
# Check if this specific action should be profiled
|
49
|
+
return yield unless _should_profile?
|
50
|
+
|
51
|
+
_profile_with_vernier(&)
|
52
|
+
end
|
53
|
+
|
54
|
+
def _profile_with_vernier(&)
|
55
|
+
_ensure_vernier_available!
|
56
|
+
|
57
|
+
class_name = self.class.name.presence || "AnonymousAction"
|
58
|
+
profile_name = "axn_#{class_name}_#{Time.now.to_i}"
|
59
|
+
|
60
|
+
# Ensure output directory exists (only once per instance)
|
61
|
+
_ensure_output_directory_exists
|
62
|
+
|
63
|
+
# Build output file path
|
64
|
+
output_dir = self.class._profiling_output_dir || _default_profiling_output_dir
|
65
|
+
output_file = File.join(output_dir, "#{profile_name}.json")
|
66
|
+
|
67
|
+
# Configure Vernier with our settings
|
68
|
+
collector_options = {
|
69
|
+
out: output_file,
|
70
|
+
allocation_sample_rate: (self.class._profiling_sample_rate * 1000).to_i,
|
71
|
+
}
|
72
|
+
|
73
|
+
Vernier.profile(**collector_options, &)
|
74
|
+
end
|
75
|
+
|
76
|
+
def _ensure_output_directory_exists
|
77
|
+
return if @_profiling_directory_created
|
78
|
+
|
79
|
+
output_dir = self.class._profiling_output_dir || _default_profiling_output_dir
|
80
|
+
FileUtils.mkdir_p(output_dir)
|
81
|
+
@_profiling_directory_created = true
|
82
|
+
end
|
83
|
+
|
84
|
+
def _should_profile?
|
85
|
+
# Fast path: check if action has profiling enabled
|
86
|
+
return false unless self.class._profiling_enabled
|
87
|
+
|
88
|
+
# Fast path: no condition means always profile
|
89
|
+
return true unless self.class._profiling_condition
|
90
|
+
|
91
|
+
# Slow path: evaluate condition (only when needed)
|
92
|
+
Axn::Core::Flow::Handlers::Invoker.call(
|
93
|
+
action: self,
|
94
|
+
handler: self.class._profiling_condition,
|
95
|
+
operation: "determining if profiling should run",
|
96
|
+
)
|
97
|
+
end
|
98
|
+
|
99
|
+
def _ensure_vernier_available!
|
100
|
+
return if defined?(Vernier) && Vernier.is_a?(Module)
|
101
|
+
|
102
|
+
begin
|
103
|
+
require "vernier"
|
104
|
+
rescue LoadError
|
105
|
+
raise LoadError, <<~ERROR
|
106
|
+
Vernier profiler is not available. To use profiling, add 'vernier' to your Gemfile:
|
107
|
+
|
108
|
+
gem 'vernier', '~> 0.1'
|
109
|
+
|
110
|
+
Then run: bundle install
|
111
|
+
ERROR
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def _default_profiling_output_dir
|
116
|
+
if defined?(Rails) && Rails.respond_to?(:root)
|
117
|
+
Rails.root.join("tmp", "profiles")
|
118
|
+
else
|
119
|
+
Pathname.new("tmp/profiles")
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Axn
|
4
|
+
module Core
|
5
|
+
module Tracing
|
6
|
+
private
|
7
|
+
|
8
|
+
def _with_tracing(&)
|
9
|
+
return yield unless Axn.config.wrap_with_trace
|
10
|
+
|
11
|
+
Axn.config.wrap_with_trace.call(self.class.name || "AnonymousClass", &)
|
12
|
+
rescue StandardError => e
|
13
|
+
Axn::Internal::Logging.piping_error("running trace hook", action: self, exception: e)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Axn
|
4
|
+
module Core
|
5
|
+
module UseStrategy
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
class_methods do
|
9
|
+
def use(strategy_name, **config, &block)
|
10
|
+
strategy = Axn::Strategies.find(strategy_name)
|
11
|
+
raise ArgumentError, "Strategy #{strategy_name} does not support config" if config.any? && !strategy.respond_to?(:configure)
|
12
|
+
|
13
|
+
# Allow dynamic configuration of strategy (i.e. dynamically define module before returning)
|
14
|
+
if strategy.respond_to?(:configure)
|
15
|
+
configured = strategy.configure(**config, &block)
|
16
|
+
raise ArgumentError, "Strategy #{strategy_name} configure method must return a module" unless configured.is_a?(Module)
|
17
|
+
|
18
|
+
strategy = configured
|
19
|
+
else
|
20
|
+
raise ArgumentError, "Strategy #{strategy_name} does not support config (define #configure method)" if config.any?
|
21
|
+
raise ArgumentError, "Strategy #{strategy_name} does not support blocks (define #configure method)" if block_given?
|
22
|
+
end
|
23
|
+
|
24
|
+
include strategy
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
module
|
3
|
+
module Axn
|
4
4
|
module Validation
|
5
5
|
class Fields
|
6
6
|
include ActiveModel::Validations
|
@@ -20,9 +20,25 @@ module Action
|
|
20
20
|
@context.public_send(attr)
|
21
21
|
end
|
22
22
|
|
23
|
+
def method_missing(method_name, ...)
|
24
|
+
# Delegate method calls to the action instance to support symbol-based validations
|
25
|
+
# like inclusion: { in: :valid_channels_for_number }
|
26
|
+
action = _action_for_validation
|
27
|
+
return super unless action && action.respond_to?(method_name, true) # rubocop:disable Style/SafeNavigation
|
28
|
+
|
29
|
+
action.send(method_name, ...)
|
30
|
+
end
|
31
|
+
|
32
|
+
def respond_to_missing?(method_name, include_private = false)
|
33
|
+
action = _action_for_validation
|
34
|
+
return super unless action
|
35
|
+
|
36
|
+
action.respond_to?(method_name, include_private) || super
|
37
|
+
end
|
38
|
+
|
23
39
|
def self.validate!(validations:, context:, exception_klass:)
|
24
40
|
validator = Class.new(self) do
|
25
|
-
def self.name = "
|
41
|
+
def self.name = "Axn::Validation::Fields::OneOff"
|
26
42
|
|
27
43
|
validations.each do |field, field_validations|
|
28
44
|
field_validations.each do |key, value|
|
@@ -35,6 +51,14 @@ module Action
|
|
35
51
|
|
36
52
|
raise exception_klass, validator.errors
|
37
53
|
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def _action_for_validation
|
58
|
+
return unless @context.respond_to?(:action, true)
|
59
|
+
|
60
|
+
@context.send(:action)
|
61
|
+
end
|
38
62
|
end
|
39
63
|
end
|
40
64
|
end
|
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
require "active_support/core_ext/hash/indifferent_access"
|
4
4
|
|
5
|
-
module
|
5
|
+
module Axn
|
6
6
|
module Validation
|
7
7
|
class Subfields
|
8
8
|
include ActiveModel::Validations
|
@@ -17,24 +17,26 @@ module Action
|
|
17
17
|
end
|
18
18
|
|
19
19
|
def read_attribute_for_validation(attr)
|
20
|
-
|
20
|
+
# Only use action's reader methods for model fields that need special resolution
|
21
|
+
# For all other fields, use the unified FieldResolvers system
|
22
|
+
if @action && @validations&.key?(:model) && @action.respond_to?(attr)
|
23
|
+
@action.public_send(attr)
|
24
|
+
else
|
25
|
+
Axn::Core::FieldResolvers.resolve(type: :extract, field: attr, provided_data: @source)
|
26
|
+
end
|
21
27
|
end
|
22
28
|
|
23
|
-
def self.
|
24
|
-
return source.public_send(attr) if source.respond_to?(attr)
|
25
|
-
raise "Unclear how to extract #{attr} from #{source.inspect}" unless source.respond_to?(:dig)
|
26
|
-
|
27
|
-
base = source.respond_to?(:with_indifferent_access) ? source.with_indifferent_access : source
|
28
|
-
base.dig(*attr.to_s.split("."))
|
29
|
-
end
|
30
|
-
|
31
|
-
def self.validate!(field:, validations:, source:, exception_klass:)
|
29
|
+
def self.validate!(field:, validations:, source:, exception_klass:, action: nil)
|
32
30
|
validator = Class.new(self) do
|
33
|
-
def self.name = "
|
31
|
+
def self.name = "Axn::Validation::Subfields::OneOff"
|
34
32
|
|
35
33
|
validates field, **validations
|
36
34
|
end.new(source)
|
37
35
|
|
36
|
+
# Set the action context for model field resolution
|
37
|
+
validator.instance_variable_set(:@action, action)
|
38
|
+
validator.instance_variable_set(:@validations, validations)
|
39
|
+
|
38
40
|
return if validator.valid?
|
39
41
|
|
40
42
|
raise exception_klass, validator.errors
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_model"
|
4
|
+
|
5
|
+
module Axn
|
6
|
+
module Validators
|
7
|
+
class ModelValidator < ActiveModel::EachValidator
|
8
|
+
# Syntactic sugar: model: User -> model: { klass: User }
|
9
|
+
def self.apply_syntactic_sugar(value, fields)
|
10
|
+
(value.is_a?(Hash) ? value.dup : { klass: value }).tap do |options|
|
11
|
+
# Set default klass based on field name if not provided
|
12
|
+
options[:klass] = nil if options[:klass] == true
|
13
|
+
options[:klass] ||= fields.first.to_s.classify
|
14
|
+
|
15
|
+
# Constantize string klass names
|
16
|
+
options[:klass] = options[:klass].constantize if options[:klass].is_a?(String)
|
17
|
+
|
18
|
+
# Set default finder if not provided
|
19
|
+
options[:finder] ||= :find
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def check_validity!
|
24
|
+
return unless options[:klass].nil?
|
25
|
+
|
26
|
+
raise ArgumentError, "must supply :klass"
|
27
|
+
end
|
28
|
+
|
29
|
+
def validate_each(record, attribute, value)
|
30
|
+
# The value is already resolved by the facade, just validate the type
|
31
|
+
type_validator = TypeValidator.new(attributes: [attribute], **options)
|
32
|
+
type_validator.validate_each(record, attribute, value)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_model"
|
4
|
+
|
5
|
+
module Axn
|
6
|
+
module Validators
|
7
|
+
class TypeValidator < ActiveModel::EachValidator
|
8
|
+
def self.apply_syntactic_sugar(value, _fields)
|
9
|
+
return value if value.is_a?(Hash)
|
10
|
+
|
11
|
+
{ klass: value }
|
12
|
+
end
|
13
|
+
|
14
|
+
def check_validity!
|
15
|
+
raise ArgumentError, "must supply :klass" if options[:klass].nil?
|
16
|
+
end
|
17
|
+
|
18
|
+
# NOTE: we override the default validate method to allow for custom allow_blank logic
|
19
|
+
# (e.g. type: Hash should fail if given false or "", but by default EachValidator would skip)
|
20
|
+
def validate(record)
|
21
|
+
attributes.each do |attribute|
|
22
|
+
value = record.read_attribute_for_validation(attribute)
|
23
|
+
validate_each(record, attribute, value)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def validate_each(record, attribute, value)
|
28
|
+
# Custom allow_blank logic: only skip validation for nil, not other blank values
|
29
|
+
return if value.nil? && (options[:allow_nil] || options[:allow_blank])
|
30
|
+
|
31
|
+
# Check if any of the types are valid
|
32
|
+
valid = types.any? do |type|
|
33
|
+
valid_type?(type:, value:, allow_blank: options[:allow_blank])
|
34
|
+
end
|
35
|
+
|
36
|
+
record.errors.add attribute, (options[:message] || msg) unless valid
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def types = Array(options[:klass])
|
42
|
+
def msg = types.size == 1 ? "is not a #{types.first}" : "is not one of #{types.join(", ")}"
|
43
|
+
|
44
|
+
def valid_type?(type:, value:, allow_blank:)
|
45
|
+
# NOTE: allow mocks to pass type validation by default (much easier testing ergonomics)
|
46
|
+
return true if Axn.config.env.test? && value.class.name&.start_with?("RSpec::Mocks::")
|
47
|
+
|
48
|
+
case type
|
49
|
+
when :boolean
|
50
|
+
boolean_type?(value)
|
51
|
+
when :uuid
|
52
|
+
uuid_type?(value, allow_blank:)
|
53
|
+
when :params
|
54
|
+
params_type?(value)
|
55
|
+
else
|
56
|
+
class_type?(type, value)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def boolean_type?(value)
|
61
|
+
[true, false].include?(value)
|
62
|
+
end
|
63
|
+
|
64
|
+
def uuid_type?(value, allow_blank: false)
|
65
|
+
return false unless value.is_a?(String)
|
66
|
+
return true if value.blank? && allow_blank
|
67
|
+
|
68
|
+
value.match?(/\A[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}\z/i)
|
69
|
+
end
|
70
|
+
|
71
|
+
def params_type?(value)
|
72
|
+
value.is_a?(Hash) || (defined?(ActionController::Parameters) && value.is_a?(ActionController::Parameters))
|
73
|
+
end
|
74
|
+
|
75
|
+
def class_type?(type, value)
|
76
|
+
value.is_a?(type)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|