axn 0.1.0.pre.alpha.2.8 → 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 +47 -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 +43 -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 +6 -6
- 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
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Axn
|
4
|
+
module Async
|
5
|
+
class Adapters
|
6
|
+
module Sidekiq
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
included do
|
10
|
+
raise LoadError, "Sidekiq is not available. Please add 'sidekiq' to your Gemfile." unless defined?(::Sidekiq)
|
11
|
+
|
12
|
+
# Use Sidekiq::Job if available (Sidekiq 7+), otherwise error
|
13
|
+
raise LoadError, "Sidekiq::Job is not available. Please check your Sidekiq version." unless defined?(::Sidekiq::Job)
|
14
|
+
|
15
|
+
include ::Sidekiq::Job
|
16
|
+
|
17
|
+
# Apply configuration block if present
|
18
|
+
class_eval(&_async_config_block) if _async_config_block
|
19
|
+
|
20
|
+
# Apply kwargs configuration if present
|
21
|
+
sidekiq_options(**_async_config) if _async_config&.any?
|
22
|
+
end
|
23
|
+
|
24
|
+
class_methods do
|
25
|
+
def call_async(**kwargs)
|
26
|
+
job_kwargs = _params_to_global_id(kwargs)
|
27
|
+
|
28
|
+
if kwargs[:_async].is_a?(Hash)
|
29
|
+
options = kwargs.delete(:_async)
|
30
|
+
if options[:wait_until]
|
31
|
+
return perform_at(options[:wait_until], job_kwargs)
|
32
|
+
elsif options[:wait]
|
33
|
+
return perform_in(options[:wait], job_kwargs)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
perform_async(job_kwargs)
|
38
|
+
end
|
39
|
+
|
40
|
+
def _params_to_global_id(context = {})
|
41
|
+
return {} if context.nil?
|
42
|
+
|
43
|
+
context.stringify_keys.each_with_object({}) do |(key, value), hash|
|
44
|
+
if value.respond_to?(:to_global_id)
|
45
|
+
hash["#{key}_as_global_id"] = value.to_global_id.to_s
|
46
|
+
else
|
47
|
+
hash[key] = value
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def _params_from_global_id(params)
|
53
|
+
return {} if params.nil?
|
54
|
+
|
55
|
+
params.each_with_object({}) do |(key, value), hash|
|
56
|
+
if key.end_with?("_as_global_id")
|
57
|
+
hash[key.delete_suffix("_as_global_id")] = GlobalID::Locator.locate(value)
|
58
|
+
else
|
59
|
+
hash[key] = value
|
60
|
+
end
|
61
|
+
end.symbolize_keys
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def perform(*args)
|
66
|
+
context = self.class._params_from_global_id(args.first)
|
67
|
+
|
68
|
+
# Always use bang version so sidekiq can retry if we failed
|
69
|
+
self.class.call!(**context)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "axn/internal/registry"
|
4
|
+
require "active_support/core_ext/string/inflections"
|
5
|
+
|
6
|
+
module Axn
|
7
|
+
module Async
|
8
|
+
class AdapterNotFound < Axn::Internal::Registry::NotFound; end
|
9
|
+
class DuplicateAdapterError < Axn::Internal::Registry::DuplicateError; end
|
10
|
+
|
11
|
+
class Adapters < Axn::Internal::Registry
|
12
|
+
class << self
|
13
|
+
def registry_directory = __dir__
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def item_type = "Adapter"
|
18
|
+
def not_found_error_class = AdapterNotFound
|
19
|
+
def duplicate_error_class = DuplicateAdapterError
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# Trigger registry loading to ensure adapters are available
|
24
|
+
Adapters.all
|
25
|
+
end
|
26
|
+
end
|
data/lib/axn/async.rb
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "axn/async/adapters"
|
4
|
+
|
5
|
+
module Axn
|
6
|
+
module Async
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
included do
|
10
|
+
class_attribute :_async_adapter, :_async_config, :_async_config_block, default: nil
|
11
|
+
end
|
12
|
+
|
13
|
+
class_methods do
|
14
|
+
def async(adapter = nil, **config, &block)
|
15
|
+
self._async_adapter = adapter
|
16
|
+
self._async_config = config
|
17
|
+
self._async_config_block = block
|
18
|
+
|
19
|
+
case adapter
|
20
|
+
when false
|
21
|
+
include Adapters.find(:disabled)
|
22
|
+
when nil
|
23
|
+
# Use default configuration
|
24
|
+
async Axn.config._default_async_adapter, **Axn.config._default_async_config, &Axn.config._default_async_config_block
|
25
|
+
else
|
26
|
+
# Look up adapter in registry
|
27
|
+
adapter_module = Adapters.find(adapter)
|
28
|
+
include adapter_module
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def call_async(**)
|
33
|
+
# Set up default async configuration if none is set
|
34
|
+
if _async_adapter.nil?
|
35
|
+
async Axn.config._default_async_adapter, **Axn.config._default_async_config, &Axn.config._default_async_config_block
|
36
|
+
# Call ourselves again now that the adapter is included
|
37
|
+
return call_async(**)
|
38
|
+
end
|
39
|
+
|
40
|
+
# This will be overridden by the included adapter module
|
41
|
+
raise NotImplementedError, "No async adapter configured. Use e.g. `async :sidekiq` or `async :active_job` to enable background processing."
|
42
|
+
end
|
43
|
+
|
44
|
+
# Ensure default async is applied when the class is first instantiated
|
45
|
+
# This is important for Sidekiq workers which load the class in a separate process
|
46
|
+
def new(*args, **kwargs)
|
47
|
+
_ensure_default_async_configured
|
48
|
+
super
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def _ensure_default_async_configured
|
54
|
+
return if _async_adapter.present?
|
55
|
+
return unless Axn.config._default_async_adapter.present?
|
56
|
+
|
57
|
+
async Axn.config._default_async_adapter, **Axn.config._default_async_config, &Axn.config._default_async_config_block
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -1,17 +1,35 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
module
|
3
|
+
module Axn
|
4
|
+
class RailsConfiguration
|
5
|
+
attr_accessor :app_actions_autoload_namespace
|
6
|
+
end
|
7
|
+
|
4
8
|
class Configuration
|
5
9
|
attr_accessor :wrap_with_trace, :emit_metrics
|
6
|
-
attr_writer :logger, :env, :on_exception, :additional_includes, :log_level
|
10
|
+
attr_writer :logger, :env, :on_exception, :additional_includes, :log_level, :rails
|
7
11
|
|
8
12
|
def log_level = @log_level ||= :info
|
9
13
|
|
10
14
|
def additional_includes = @additional_includes ||= []
|
11
15
|
|
16
|
+
def _default_async_adapter = @default_async_adapter ||= false
|
17
|
+
def _default_async_config = @default_async_config ||= {}
|
18
|
+
def _default_async_config_block = @default_async_config_block
|
19
|
+
|
20
|
+
def set_default_async(adapter = false, **config, &block) # rubocop:disable Style/OptionalBooleanParameter
|
21
|
+
raise ArgumentError, "Cannot set default async adapter to nil as it would cause infinite recursion" if adapter.nil?
|
22
|
+
|
23
|
+
@default_async_adapter = adapter unless adapter.nil?
|
24
|
+
@default_async_config = config.any? ? config : {}
|
25
|
+
@default_async_config_block = block_given? ? block : nil
|
26
|
+
end
|
27
|
+
|
28
|
+
def rails = @rails ||= RailsConfiguration.new
|
29
|
+
|
12
30
|
def on_exception(e, action:, context: {})
|
13
31
|
msg = "Handled exception (#{e.class.name}): #{e.message}"
|
14
|
-
msg = ("#" * 10) + " #{msg} " + ("#" * 10) unless
|
32
|
+
msg = ("#" * 10) + " #{msg} " + ("#" * 10) unless Axn.config.env.production?
|
15
33
|
action.log(msg)
|
16
34
|
|
17
35
|
return unless @on_exception
|
@@ -1,9 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
module
|
3
|
+
module Axn
|
4
4
|
class Context
|
5
|
-
attr_accessor :provided_data, :exposed_data
|
6
|
-
|
7
5
|
def initialize(**provided_data)
|
8
6
|
@provided_data = provided_data
|
9
7
|
@exposed_data = {}
|
@@ -17,18 +15,37 @@ module Action
|
|
17
15
|
# Framework state methods
|
18
16
|
def ok? = !@failure
|
19
17
|
def failed? = @failure || false
|
18
|
+
def finalized? = @finalized || false
|
20
19
|
|
21
20
|
# Framework field accessors
|
22
|
-
attr_accessor :elapsed_time
|
21
|
+
attr_accessor :provided_data, :exposed_data, :elapsed_time
|
23
22
|
attr_reader :exception
|
24
23
|
private :elapsed_time=
|
25
24
|
|
25
|
+
#
|
26
|
+
# Here down intended for internal use only
|
27
|
+
#
|
28
|
+
|
26
29
|
# INTERNAL: base for further filtering (for logging) or providing user with usage hints
|
27
30
|
def __combined_data = @provided_data.merge(@exposed_data)
|
28
31
|
|
32
|
+
def __early_completion? = @early_completion || false
|
33
|
+
|
29
34
|
def __record_exception(e)
|
30
35
|
@exception = e
|
31
36
|
@failure = true
|
37
|
+
@finalized = true
|
38
|
+
end
|
39
|
+
|
40
|
+
def __record_early_completion(message)
|
41
|
+
@early_completion_message = message unless message == Axn::Internal::EarlyCompletion.new.message
|
42
|
+
@early_completion = true
|
43
|
+
end
|
44
|
+
|
45
|
+
def __early_completion_message = @early_completion_message.presence
|
46
|
+
|
47
|
+
def __finalize!
|
48
|
+
@finalized = true
|
32
49
|
end
|
33
50
|
end
|
34
51
|
end
|
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
module
|
3
|
+
module Axn
|
4
4
|
module Core
|
5
5
|
module AutomaticLogging
|
6
6
|
def self.included(base)
|
@@ -9,7 +9,7 @@ module Action
|
|
9
9
|
include InstanceMethods
|
10
10
|
|
11
11
|
# Single class_attribute - nil means disabled, any level means enabled
|
12
|
-
class_attribute :auto_log_level, default:
|
12
|
+
class_attribute :auto_log_level, default: Axn.config.log_level
|
13
13
|
end
|
14
14
|
end
|
15
15
|
|
@@ -39,10 +39,10 @@ module Action
|
|
39
39
|
"About to execute",
|
40
40
|
_log_context(:inbound),
|
41
41
|
].compact.join(" with: "),
|
42
|
-
before:
|
42
|
+
before: Axn.config.env.production? ? nil : "\n------\n",
|
43
43
|
)
|
44
44
|
rescue StandardError => e
|
45
|
-
Axn::
|
45
|
+
Axn::Internal::Logging.piping_error("logging before hook", action: self, exception: e)
|
46
46
|
end
|
47
47
|
|
48
48
|
def _log_after
|
@@ -55,10 +55,10 @@ module Action
|
|
55
55
|
"Execution completed (with outcome: #{result.outcome}) in #{result.elapsed_time} milliseconds",
|
56
56
|
_log_context(:outbound),
|
57
57
|
].compact.join(". Set: "),
|
58
|
-
after:
|
58
|
+
after: Axn.config.env.production? ? nil : "\n------\n",
|
59
59
|
)
|
60
60
|
rescue StandardError => e
|
61
|
-
Axn::
|
61
|
+
Axn::Internal::Logging.piping_error("logging after hook", action: self, exception: e)
|
62
62
|
end
|
63
63
|
|
64
64
|
def _log_context(direction)
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/parameter_filter"
|
4
|
+
|
5
|
+
module Axn
|
6
|
+
class ContextFacade
|
7
|
+
def initialize(action:, context:, declared_fields:, implicitly_allowed_fields: nil)
|
8
|
+
if self.class.name == "Axn::ContextFacade" # rubocop:disable Style/ClassEqualityComparison
|
9
|
+
raise "Axn::ContextFacade is an abstract class and should not be instantiated directly"
|
10
|
+
end
|
11
|
+
|
12
|
+
@context = context
|
13
|
+
@action = action
|
14
|
+
@declared_fields = declared_fields
|
15
|
+
|
16
|
+
(@declared_fields + Array(implicitly_allowed_fields)).each do |field|
|
17
|
+
if _model_fields.key?(field)
|
18
|
+
_define_model_field_method(field, _model_fields[field])
|
19
|
+
else
|
20
|
+
singleton_class.define_method(field) do
|
21
|
+
_context_data_source[field]
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
attr_reader :declared_fields
|
28
|
+
|
29
|
+
def inspect = ContextFacadeInspector.new(facade: self, action:, context:).call
|
30
|
+
|
31
|
+
def fail!(...)
|
32
|
+
raise Axn::ContractViolation::MethodNotAllowed, "Call fail! directly rather than on the context"
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
attr_reader :action, :context
|
38
|
+
|
39
|
+
def _model_fields
|
40
|
+
action.internal_field_configs.each_with_object({}) do |config, hash|
|
41
|
+
hash[config.field] = config.validations[:model] if config.validations.key?(:model)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def action_name = @action.class.name.presence || "The action"
|
46
|
+
|
47
|
+
def _define_model_field_method(field, options)
|
48
|
+
Axn::Util::Memoization.define_memoized_reader_method(singleton_class, field) do
|
49
|
+
Axn::Core::FieldResolvers.resolve(
|
50
|
+
type: :model,
|
51
|
+
field:,
|
52
|
+
options:,
|
53
|
+
provided_data: _context_data_source,
|
54
|
+
)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def _context_data_source = raise NotImplementedError
|
59
|
+
|
60
|
+
def _msg_resolver(event_type, exception:)
|
61
|
+
Axn::Core::Flow::Handlers::Resolvers::MessageResolver.new(
|
62
|
+
action._messages_registry,
|
63
|
+
event_type,
|
64
|
+
action:,
|
65
|
+
exception:,
|
66
|
+
)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
module
|
3
|
+
module Axn
|
4
4
|
class ContextFacadeInspector
|
5
5
|
def initialize(action:, facade:, context:)
|
6
6
|
@action = action
|
@@ -19,12 +19,12 @@ module Action
|
|
19
19
|
attr_reader :action, :facade, :context
|
20
20
|
|
21
21
|
def status
|
22
|
-
return unless facade.is_a?(
|
22
|
+
return unless facade.is_a?(Axn::Result)
|
23
23
|
|
24
24
|
return "[OK]" if context.ok?
|
25
25
|
|
26
|
-
if
|
27
|
-
return context.exception.
|
26
|
+
if facade.outcome.failure?
|
27
|
+
return context.exception.default_message? ? "[failed]" : "[failed with '#{context.exception.message}']"
|
28
28
|
end
|
29
29
|
|
30
30
|
%([failed with #{context.exception.class.name}: '#{context.exception.message}'])
|
@@ -56,9 +56,36 @@ module Action
|
|
56
56
|
value.inspect
|
57
57
|
end
|
58
58
|
|
59
|
+
# Handle subfield filtering for hash values
|
60
|
+
if value.is_a?(Hash) && sensitive_subfields?(field)
|
61
|
+
filtered_value = filter_subfields(field, value)
|
62
|
+
return filtered_value.inspect
|
63
|
+
end
|
64
|
+
|
59
65
|
inspection_filter.filter_param(field, inspected_value)
|
60
66
|
end
|
61
67
|
|
62
68
|
def inspection_filter = action.send(:inspection_filter)
|
69
|
+
|
70
|
+
def sensitive_subfields?(field)
|
71
|
+
action.subfield_configs.any? { |config| config.on == field && config.sensitive }
|
72
|
+
end
|
73
|
+
|
74
|
+
def filter_subfields(field, value)
|
75
|
+
# Build a nested structure with subfield paths for filtering
|
76
|
+
nested_data = { field => value }
|
77
|
+
|
78
|
+
# Create a filter with the subfield paths
|
79
|
+
sensitive_subfield_paths = action.subfield_configs
|
80
|
+
.select { |config| config.on == field && config.sensitive }
|
81
|
+
.map { |config| "#{field}.#{config.field}" }
|
82
|
+
|
83
|
+
return value if sensitive_subfield_paths.empty?
|
84
|
+
|
85
|
+
subfield_filter = ActiveSupport::ParameterFilter.new(sensitive_subfield_paths)
|
86
|
+
filtered_data = subfield_filter.filter(nested_data)
|
87
|
+
|
88
|
+
filtered_data[field]
|
89
|
+
end
|
63
90
|
end
|
64
91
|
end
|
@@ -1,11 +1,11 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "
|
3
|
+
require "axn/core/context/facade"
|
4
4
|
|
5
|
-
module
|
5
|
+
module Axn
|
6
6
|
# Inbound / Internal ContextFacade
|
7
7
|
class InternalContext < ContextFacade
|
8
|
-
def default_error = _msg_resolver(:error, exception:
|
8
|
+
def default_error = _msg_resolver(:error, exception: Axn::Failure.new).resolve_default_message
|
9
9
|
def default_success = _msg_resolver(:success, exception: nil).resolve_default_message
|
10
10
|
|
11
11
|
private
|
@@ -15,13 +15,13 @@ module Action
|
|
15
15
|
def method_missing(method_name, ...) # rubocop:disable Style/MissingRespondToMissing (because we're not actually responding to anything additional)
|
16
16
|
if @context.__combined_data.key?(method_name.to_sym)
|
17
17
|
msg = <<~MSG
|
18
|
-
Method ##{method_name} is not available on
|
18
|
+
Method ##{method_name} is not available on Axn::InternalContext!
|
19
19
|
|
20
20
|
#{action_name} may be missing a line like:
|
21
21
|
expects :#{method_name}
|
22
22
|
MSG
|
23
23
|
|
24
|
-
raise
|
24
|
+
raise Axn::ContractViolation::MethodNotAllowed, msg
|
25
25
|
end
|
26
26
|
|
27
27
|
super
|
@@ -3,11 +3,11 @@
|
|
3
3
|
require "active_support/core_ext/enumerable"
|
4
4
|
require "active_support/core_ext/module/delegation"
|
5
5
|
|
6
|
-
require "
|
7
|
-
require "
|
8
|
-
require "
|
6
|
+
require "axn/core/validation/fields"
|
7
|
+
require "axn/result"
|
8
|
+
require "axn/core/context/internal"
|
9
9
|
|
10
|
-
module
|
10
|
+
module Axn
|
11
11
|
module Core
|
12
12
|
module Contract
|
13
13
|
def self.included(base)
|
@@ -27,20 +27,21 @@ module Action
|
|
27
27
|
on: nil,
|
28
28
|
allow_blank: false,
|
29
29
|
allow_nil: false,
|
30
|
+
optional: false,
|
30
31
|
default: nil,
|
31
32
|
preprocess: nil,
|
32
33
|
sensitive: false,
|
33
34
|
**validations
|
34
35
|
)
|
35
|
-
return _expects_subfields(*fields, on:, allow_blank:, allow_nil:, default:, preprocess:, sensitive:, **validations) if on.present?
|
36
|
-
|
37
36
|
fields.each do |field|
|
38
37
|
raise ContractViolation::ReservedAttributeError, field if RESERVED_FIELD_NAMES_FOR_EXPECTATIONS.include?(field.to_s)
|
39
38
|
end
|
40
39
|
|
41
|
-
|
40
|
+
return _expects_subfields(*fields, on:, allow_blank:, allow_nil:, optional:, default:, preprocess:, sensitive:, **validations) if on.present?
|
41
|
+
|
42
|
+
_parse_field_configs(*fields, allow_blank:, allow_nil:, optional:, default:, preprocess:, sensitive:, **validations).tap do |configs|
|
42
43
|
duplicated = internal_field_configs.map(&:field) & configs.map(&:field)
|
43
|
-
raise
|
44
|
+
raise Axn::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
|
44
45
|
|
45
46
|
# NOTE: avoid <<, which would update value for parents and children
|
46
47
|
self.internal_field_configs += configs
|
@@ -51,6 +52,7 @@ module Action
|
|
51
52
|
*fields,
|
52
53
|
allow_blank: false,
|
53
54
|
allow_nil: false,
|
55
|
+
optional: false,
|
54
56
|
default: nil,
|
55
57
|
sensitive: false,
|
56
58
|
**validations
|
@@ -59,9 +61,9 @@ module Action
|
|
59
61
|
raise ContractViolation::ReservedAttributeError, field if RESERVED_FIELD_NAMES_FOR_EXPOSURES.include?(field.to_s)
|
60
62
|
end
|
61
63
|
|
62
|
-
_parse_field_configs(*fields, allow_blank:, allow_nil:, default:, preprocess: nil, sensitive:, **validations).tap do |configs|
|
64
|
+
_parse_field_configs(*fields, allow_blank:, allow_nil:, optional:, default:, preprocess: nil, sensitive:, **validations).tap do |configs|
|
63
65
|
duplicated = external_field_configs.map(&:field) & configs.map(&:field)
|
64
|
-
raise
|
66
|
+
raise Axn::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
|
65
67
|
|
66
68
|
# NOTE: avoid <<, which would update value for parents and children
|
67
69
|
self.external_field_configs += configs
|
@@ -74,77 +76,69 @@ module Action
|
|
74
76
|
fail! ok?
|
75
77
|
inspect default_error
|
76
78
|
each_pair
|
79
|
+
default_success
|
80
|
+
action_name
|
77
81
|
].freeze
|
78
82
|
|
79
83
|
RESERVED_FIELD_NAMES_FOR_EXPOSURES = %w[
|
80
84
|
fail! ok?
|
81
85
|
inspect each_pair default_error
|
82
86
|
ok error success message
|
87
|
+
result
|
88
|
+
outcome
|
89
|
+
exception
|
90
|
+
elapsed_time
|
91
|
+
finalized?
|
92
|
+
__action__
|
83
93
|
].freeze
|
84
94
|
|
85
95
|
def _parse_field_configs(
|
86
96
|
*fields,
|
87
97
|
allow_blank: false,
|
88
98
|
allow_nil: false,
|
99
|
+
optional: false,
|
89
100
|
default: nil,
|
90
101
|
preprocess: nil,
|
91
102
|
sensitive: false,
|
92
103
|
**validations
|
93
104
|
)
|
105
|
+
# Handle optional: true by setting allow_blank: true
|
106
|
+
allow_blank ||= optional
|
107
|
+
|
94
108
|
_parse_field_validations(*fields, allow_nil:, allow_blank:, **validations).map do |field, parsed_validations|
|
95
109
|
_define_field_reader(field)
|
96
|
-
_define_model_reader(field, parsed_validations[:model]) if parsed_validations.key?(:model)
|
97
110
|
FieldConfig.new(field:, validations: parsed_validations, default:, preprocess:, sensitive:)
|
98
111
|
end
|
99
112
|
end
|
100
113
|
|
101
|
-
def define_memoized_reader_method(field, &block)
|
102
|
-
define_method(field) do
|
103
|
-
ivar = :"@_memoized_reader_#{field}"
|
104
|
-
cached_val = instance_variable_get(ivar)
|
105
|
-
return cached_val if cached_val.present?
|
106
|
-
|
107
|
-
value = instance_exec(&block)
|
108
|
-
instance_variable_set(ivar, value)
|
109
|
-
end
|
110
|
-
end
|
111
|
-
|
112
114
|
def _define_field_reader(field)
|
113
115
|
# Allow local access to explicitly-expected fields -- even externally-expected needs to be available locally
|
114
116
|
# (e.g. to allow success message callable to reference exposed fields)
|
115
117
|
define_method(field) { internal_context.public_send(field) }
|
116
118
|
end
|
117
119
|
|
118
|
-
|
119
|
-
|
120
|
-
raise ArgumentError, "Model validation expects to be given a field ending in _id (given: #{field})" unless field.to_s.end_with?("_id")
|
121
|
-
raise ArgumentError, "Failed to define model reader - #{name} is already defined" if method_defined?(name)
|
122
|
-
|
123
|
-
id_extractor ||= -> { public_send(field) }
|
124
|
-
|
125
|
-
define_memoized_reader_method(name) do
|
126
|
-
Validators::ModelValidator.instance_for(field:, klass:, id: instance_exec(&id_extractor))
|
127
|
-
end
|
128
|
-
end
|
129
|
-
|
120
|
+
# This method applies any top-level options to each of the individual validations given.
|
121
|
+
# It also allows our custom validators to accept a direct value rather than a hash of options.
|
130
122
|
def _parse_field_validations(
|
131
123
|
*fields,
|
132
124
|
allow_nil: false,
|
133
125
|
allow_blank: false,
|
134
126
|
**validations
|
135
127
|
)
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
128
|
+
# Apply syntactic sugar for our custom validators (convert shorthand to full hash of options)
|
129
|
+
validations[:type] = Axn::Validators::TypeValidator.apply_syntactic_sugar(validations[:type], fields) if validations.key?(:type)
|
130
|
+
validations[:model] = Axn::Validators::ModelValidator.apply_syntactic_sugar(validations[:model], fields) if validations.key?(:model)
|
131
|
+
validations[:validate] = Axn::Validators::ValidateValidator.apply_syntactic_sugar(validations[:validate], fields) if validations.key?(:validate)
|
132
|
+
|
133
|
+
# Push allow_blank and allow_nil to the individual validations
|
134
|
+
if allow_blank || allow_nil
|
142
135
|
validations.transform_values! do |v|
|
143
|
-
|
144
|
-
{ allow_nil: true }.merge(v)
|
136
|
+
{ allow_blank:, allow_nil: }.merge(v)
|
145
137
|
end
|
146
138
|
else
|
147
|
-
|
139
|
+
# Apply default presence validation (unless the type is boolean or params)
|
140
|
+
type_values = Array(validations.dig(:type, :klass))
|
141
|
+
validations[:presence] = true unless validations.key?(:presence) || type_values.include?(:boolean) || type_values.include?(:params)
|
148
142
|
end
|
149
143
|
|
150
144
|
fields.map { |field| [field, validations] }
|
@@ -155,6 +149,8 @@ module Action
|
|
155
149
|
def internal_context = @internal_context ||= _build_context_facade(:inbound)
|
156
150
|
def result = @result ||= _build_context_facade(:outbound)
|
157
151
|
|
152
|
+
delegate :default_error, :default_success, to: :internal_context
|
153
|
+
|
158
154
|
# Accepts either two positional arguments (key, value) or a hash of key/value pairs
|
159
155
|
def expose(*args, **kwargs)
|
160
156
|
if args.any?
|
@@ -167,7 +163,7 @@ module Action
|
|
167
163
|
end
|
168
164
|
|
169
165
|
kwargs.each do |key, value|
|
170
|
-
raise
|
166
|
+
raise Axn::ContractViolation::UnknownExposure, key unless result.respond_to?(key)
|
171
167
|
|
172
168
|
@__context.exposed_data[key] = value
|
173
169
|
end
|
@@ -190,13 +186,14 @@ module Action
|
|
190
186
|
_validate_contract!(:outbound)
|
191
187
|
|
192
188
|
# TODO: improve location of this triggering
|
189
|
+
@__context.__finalize! # Mark result as finalized
|
193
190
|
_trigger_on_success if respond_to?(:_trigger_on_success)
|
194
191
|
end
|
195
192
|
|
196
193
|
def _build_context_facade(direction)
|
197
194
|
raise ArgumentError, "Invalid direction: #{direction}" unless %i[inbound outbound].include?(direction)
|
198
195
|
|
199
|
-
klass = direction == :inbound ?
|
196
|
+
klass = direction == :inbound ? Axn::InternalContext : Axn::Result
|
200
197
|
implicitly_allowed_fields = direction == :inbound ? _declared_fields(:outbound) : []
|
201
198
|
|
202
199
|
klass.new(action: self, context: @__context, declared_fields: _declared_fields(direction), implicitly_allowed_fields:)
|
@@ -207,7 +204,7 @@ module Action
|
|
207
204
|
end
|
208
205
|
|
209
206
|
def sensitive_fields
|
210
|
-
(internal_field_configs + external_field_configs).select(&:sensitive).map(&:field)
|
207
|
+
(internal_field_configs + external_field_configs + subfield_configs).select(&:sensitive).map(&:field)
|
211
208
|
end
|
212
209
|
|
213
210
|
def _declared_fields(direction)
|