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
@@ -1,22 +1,17 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "
|
3
|
+
require "axn/core/validation/subfields"
|
4
4
|
|
5
|
-
module
|
5
|
+
module Axn
|
6
6
|
module Core
|
7
7
|
module ContractForSubfields
|
8
|
-
|
9
|
-
# SubfieldConfig = Data.define(:field, :validations, :default, :preprocess, :sensitive)
|
10
|
-
SubfieldConfig = Data.define(:field, :validations, :on)
|
8
|
+
SubfieldConfig = Data.define(:field, :validations, :on, :sensitive, :preprocess, :default)
|
11
9
|
|
12
10
|
def self.included(base)
|
13
11
|
base.class_eval do
|
14
12
|
class_attribute :subfield_configs, default: []
|
15
13
|
|
16
14
|
extend ClassMethods
|
17
|
-
include InstanceMethods
|
18
|
-
|
19
|
-
before { _validate_subfields_contract! }
|
20
15
|
end
|
21
16
|
end
|
22
17
|
|
@@ -27,19 +22,13 @@ module Action
|
|
27
22
|
readers: true,
|
28
23
|
allow_blank: false,
|
29
24
|
allow_nil: false,
|
30
|
-
|
31
|
-
# TODO: add support for these three options for subfields
|
25
|
+
optional: false,
|
32
26
|
default: nil,
|
33
27
|
preprocess: nil,
|
34
28
|
sensitive: false,
|
35
29
|
|
36
30
|
**validations
|
37
31
|
)
|
38
|
-
# TODO: add support for these three options for subfields
|
39
|
-
raise ArgumentError, "expects does not support :default key when also given :on" if default.present?
|
40
|
-
raise ArgumentError, "expects does not support :preprocess key when also given :on" if preprocess.present?
|
41
|
-
raise ArgumentError, "expects does not support :sensitive key when also given :on" if sensitive.present?
|
42
|
-
|
43
32
|
unless internal_field_configs.map(&:field).include?(on) || subfield_configs.map(&:field).include?(on)
|
44
33
|
raise ArgumentError,
|
45
34
|
"expects called with `on: #{on}`, but no such method exists (are you sure you've declared `expects :#{on}`?)"
|
@@ -47,10 +36,10 @@ module Action
|
|
47
36
|
|
48
37
|
raise ArgumentError, "expects does not support expecting fields on nested attributes (i.e. `on` cannot contain periods)" if on.to_s.include?(".")
|
49
38
|
|
50
|
-
|
51
|
-
|
39
|
+
_parse_subfield_configs(*fields, on:, readers:, allow_blank:, allow_nil:, optional:, preprocess:, sensitive:, default:,
|
40
|
+
**validations).tap do |configs|
|
52
41
|
duplicated = subfield_configs.map(&:field) & configs.map(&:field)
|
53
|
-
raise
|
42
|
+
raise Axn::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
|
54
43
|
|
55
44
|
# NOTE: avoid <<, which would update value for parents and children
|
56
45
|
self.subfield_configs += configs
|
@@ -65,14 +54,18 @@ module Action
|
|
65
54
|
readers:,
|
66
55
|
allow_blank: false,
|
67
56
|
allow_nil: false,
|
68
|
-
|
69
|
-
|
70
|
-
|
57
|
+
optional: false,
|
58
|
+
preprocess: nil,
|
59
|
+
sensitive: false,
|
60
|
+
default: nil,
|
71
61
|
**validations
|
72
62
|
)
|
63
|
+
# Handle optional: true by setting allow_blank: true
|
64
|
+
allow_blank ||= optional
|
65
|
+
|
73
66
|
_parse_field_validations(*fields, allow_nil:, allow_blank:, **validations).map do |field, parsed_validations|
|
74
67
|
_define_subfield_reader(field, on:, validations: parsed_validations) if readers
|
75
|
-
SubfieldConfig.new(field:, validations: parsed_validations, on:)
|
68
|
+
SubfieldConfig.new(field:, validations: parsed_validations, on:, sensitive:, preprocess:, default:)
|
76
69
|
end
|
77
70
|
end
|
78
71
|
|
@@ -82,24 +75,26 @@ module Action
|
|
82
75
|
|
83
76
|
raise ArgumentError, "expects does not support duplicate sub-keys (i.e. `#{field}` is already defined)" if method_defined?(field)
|
84
77
|
|
85
|
-
define_memoized_reader_method(field) do
|
86
|
-
|
78
|
+
Axn::Util::Memoization.define_memoized_reader_method(self, field) do
|
79
|
+
Axn::Core::FieldResolvers.resolve(type: :extract, field:, provided_data: public_send(on))
|
87
80
|
end
|
88
81
|
|
89
|
-
|
82
|
+
_define_subfield_model_reader(field, validations[:model], on:) if validations.key?(:model)
|
90
83
|
end
|
91
|
-
end
|
92
84
|
|
93
|
-
|
94
|
-
|
95
|
-
|
85
|
+
def _define_subfield_model_reader(field, options, on:)
|
86
|
+
# Apply the same syntactic sugar processing as the main contract system
|
87
|
+
processed_options = Axn::Validators::ModelValidator.apply_syntactic_sugar(options, [field])
|
88
|
+
|
89
|
+
Axn::Util::Memoization.define_memoized_reader_method(self, field) do
|
90
|
+
# Create a data source that contains the subfield data for the resolver
|
91
|
+
subfield_data = public_send(on)
|
96
92
|
|
97
|
-
|
98
|
-
|
99
|
-
field
|
100
|
-
|
101
|
-
|
102
|
-
exception_klass: Action::InboundValidationError,
|
93
|
+
Axn::Core::FieldResolvers.resolve(
|
94
|
+
type: :model,
|
95
|
+
field:,
|
96
|
+
options: processed_options,
|
97
|
+
provided_data: subfield_data,
|
103
98
|
)
|
104
99
|
end
|
105
100
|
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 ContractValidation
|
6
6
|
private
|
@@ -13,8 +13,10 @@ module Action
|
|
13
13
|
new_value = config.preprocess.call(initial_value)
|
14
14
|
@__context.provided_data[config.field] = new_value
|
15
15
|
rescue StandardError => e
|
16
|
-
raise
|
16
|
+
raise Axn::ContractViolation::PreprocessingError, "Error preprocessing field '#{config.field}': #{e.message}", cause: e
|
17
17
|
end
|
18
|
+
|
19
|
+
_apply_inbound_preprocessing_for_subfields!
|
18
20
|
end
|
19
21
|
|
20
22
|
def _validate_contract!(direction)
|
@@ -25,9 +27,12 @@ module Action
|
|
25
27
|
hash[config.field] = config.validations
|
26
28
|
end
|
27
29
|
context = direction == :inbound ? internal_context : result
|
28
|
-
exception_klass = direction == :inbound ?
|
30
|
+
exception_klass = direction == :inbound ? Axn::InboundValidationError : Axn::OutboundValidationError
|
29
31
|
|
30
32
|
Validation::Fields.validate!(validations:, context:, exception_klass:)
|
33
|
+
|
34
|
+
# Validate subfields for inbound direction
|
35
|
+
_validate_subfields_contract! if direction == :inbound
|
31
36
|
end
|
32
37
|
|
33
38
|
def _apply_defaults!(direction)
|
@@ -37,9 +42,9 @@ module Action
|
|
37
42
|
# For outbound defaults, first copy values from provided_data for fields that are both expected and exposed
|
38
43
|
external_field_configs.each do |config|
|
39
44
|
field = config.field
|
40
|
-
next if @__context.exposed_data
|
45
|
+
next if @__context.exposed_data.key?(field) # Already has a value
|
41
46
|
|
42
|
-
@__context.exposed_data[field] = @__context.provided_data[field] if @__context.provided_data
|
47
|
+
@__context.exposed_data[field] = @__context.provided_data[field] if @__context.provided_data.key?(field)
|
43
48
|
end
|
44
49
|
end
|
45
50
|
|
@@ -50,12 +55,17 @@ module Action
|
|
50
55
|
|
51
56
|
defaults_mapping.each do |field, default_value_getter|
|
52
57
|
data_hash = direction == :inbound ? @__context.provided_data : @__context.exposed_data
|
53
|
-
next if data_hash[field].
|
58
|
+
next if data_hash.key?(field) && !data_hash[field].nil?
|
54
59
|
|
55
60
|
default_value = default_value_getter.respond_to?(:call) ? instance_exec(&default_value_getter) : default_value_getter
|
56
61
|
|
57
62
|
data_hash[field] = default_value
|
63
|
+
rescue StandardError => e
|
64
|
+
raise Axn::ContractViolation::DefaultAssignmentError, "Error applying default for field '#{field}': #{e.message}", cause: e
|
58
65
|
end
|
66
|
+
|
67
|
+
# Apply subfield defaults for inbound direction
|
68
|
+
_apply_defaults_for_subfields! if direction == :inbound
|
59
69
|
end
|
60
70
|
end
|
61
71
|
end
|
@@ -0,0 +1,158 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Axn
|
4
|
+
module Core
|
5
|
+
module ContractValidationForSubfields
|
6
|
+
private
|
7
|
+
|
8
|
+
# Applies preprocessing to all subfield configurations
|
9
|
+
def _apply_inbound_preprocessing_for_subfields!
|
10
|
+
_for_each_relevant_subfield_config(:preprocess) do |config, parent_field, subfield, parent_value|
|
11
|
+
current_subfield_value = Axn::Core::FieldResolvers.resolve(type: :extract, field: subfield, provided_data: parent_value)
|
12
|
+
preprocessed_value = config.preprocess.call(current_subfield_value)
|
13
|
+
_update_subfield_value(parent_field, subfield, preprocessed_value)
|
14
|
+
rescue StandardError => e
|
15
|
+
raise Axn::ContractViolation::PreprocessingError, "Error preprocessing subfield '#{config.field}' on '#{config.on}': #{e.message}", cause: e
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# Applies default values to all subfield configurations
|
20
|
+
def _apply_defaults_for_subfields!
|
21
|
+
_for_each_relevant_subfield_config(:default) do |config, parent_field, subfield, parent_value|
|
22
|
+
next if parent_value && !Axn::Core::FieldResolvers.resolve(type: :extract, field: subfield, provided_data: parent_value).nil?
|
23
|
+
|
24
|
+
@__context.provided_data[parent_field] = {} if parent_value.nil?
|
25
|
+
|
26
|
+
default_value = config.default.respond_to?(:call) ? instance_exec(&config.default) : config.default
|
27
|
+
_update_subfield_value(parent_field, subfield, default_value)
|
28
|
+
rescue StandardError => e
|
29
|
+
raise Axn::ContractViolation::DefaultAssignmentError, "Error applying default for subfield '#{config.field}' on '#{config.on}': #{e.message}",
|
30
|
+
cause: e
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# Validates all subfield configurations against their defined validations
|
35
|
+
def _validate_subfields_contract!
|
36
|
+
_for_each_relevant_subfield_config do |config, parent_field, subfield, _parent_value|
|
37
|
+
Validation::Subfields.validate!(
|
38
|
+
field: subfield,
|
39
|
+
validations: config.validations,
|
40
|
+
source: public_send(parent_field),
|
41
|
+
exception_klass: Axn::InboundValidationError,
|
42
|
+
action: self,
|
43
|
+
)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
#
|
48
|
+
# Here down - helpers for the above methods
|
49
|
+
#
|
50
|
+
|
51
|
+
# Iterates over subfield configurations, optionally filtered by attribute, yielding to a block
|
52
|
+
def _for_each_relevant_subfield_config(attribute = nil)
|
53
|
+
subfield_configs.each do |config|
|
54
|
+
next if attribute && !config.public_send(attribute)
|
55
|
+
|
56
|
+
parent_field = config.on
|
57
|
+
subfield = config.field
|
58
|
+
parent_value = @__context.provided_data[parent_field]
|
59
|
+
|
60
|
+
yield(config, parent_field, subfield, parent_value)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Updates a subfield value, handling nested paths, hash objects, and method-based setters
|
65
|
+
def _update_subfield_value(parent_field, subfield, new_value)
|
66
|
+
parent_value = @__context.provided_data[parent_field]
|
67
|
+
|
68
|
+
if _is_nested_subfield?(subfield)
|
69
|
+
_update_nested_subfield_value(parent_field, subfield, new_value)
|
70
|
+
elsif parent_value.is_a?(Hash)
|
71
|
+
_update_simple_hash_subfield(parent_field, subfield, new_value)
|
72
|
+
elsif parent_value.respond_to?("#{subfield}=")
|
73
|
+
_update_object_subfield(parent_value, subfield, new_value)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Checks if a subfield path contains nested access (e.g., "user.profile.name")
|
78
|
+
def _is_nested_subfield?(subfield) = subfield.to_s.include?(".")
|
79
|
+
|
80
|
+
# Parses a subfield path into an array of parts
|
81
|
+
def _parse_subfield_path(subfield) = subfield.to_s.split(".")
|
82
|
+
|
83
|
+
# Updates a simple hash subfield value
|
84
|
+
def _update_simple_hash_subfield(parent_field, subfield, new_value)
|
85
|
+
parent_value = @__context.provided_data[parent_field].dup
|
86
|
+
parent_value[subfield] = new_value
|
87
|
+
@__context.provided_data[parent_field] = parent_value
|
88
|
+
end
|
89
|
+
|
90
|
+
# Updates an object subfield using method assignment
|
91
|
+
def _update_object_subfield(parent_value, subfield, new_value)
|
92
|
+
parent_value.public_send("#{subfield}=", new_value)
|
93
|
+
end
|
94
|
+
|
95
|
+
# Updates a nested subfield value by navigating the path and creating intermediate hashes
|
96
|
+
def _update_nested_subfield_value(parent_field, subfield, new_value)
|
97
|
+
parent_value = @__context.provided_data[parent_field]
|
98
|
+
path_parts = _parse_subfield_path(subfield)
|
99
|
+
|
100
|
+
target_parent = _navigate_to_parent(parent_value, path_parts)
|
101
|
+
target_parent[path_parts.last.to_sym] = new_value
|
102
|
+
end
|
103
|
+
|
104
|
+
# Navigates to the parent of the target field, creating intermediate hashes as needed
|
105
|
+
def _navigate_to_parent(parent_value, path_parts)
|
106
|
+
path_parts[0..-2].reduce(parent_value) do |current, part|
|
107
|
+
current[part.to_sym] || current[part] || (current[part.to_sym] = {})
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
# Checks if a subfield exists in the parent value, handling both hash and object types
|
112
|
+
def _subfield_exists?(parent_value, subfield)
|
113
|
+
if parent_value.is_a?(Hash)
|
114
|
+
_hash_subfield_exists?(parent_value, subfield)
|
115
|
+
elsif parent_value.respond_to?(subfield)
|
116
|
+
_object_subfield_exists?(parent_value, subfield)
|
117
|
+
else
|
118
|
+
false
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# Checks if a subfield exists in a hash, handling both simple and nested paths
|
123
|
+
def _hash_subfield_exists?(parent_value, subfield)
|
124
|
+
if _is_nested_subfield?(subfield)
|
125
|
+
_nested_hash_subfield_exists?(parent_value, subfield)
|
126
|
+
else
|
127
|
+
_simple_hash_subfield_exists?(parent_value, subfield)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
# Checks if a simple (non-nested) hash subfield exists
|
132
|
+
def _simple_hash_subfield_exists?(parent_value, subfield)
|
133
|
+
parent_value.key?(subfield.to_sym) || parent_value.key?(subfield)
|
134
|
+
end
|
135
|
+
|
136
|
+
# Checks if a nested hash subfield exists by navigating the path
|
137
|
+
def _nested_hash_subfield_exists?(parent_value, subfield)
|
138
|
+
path_parts = _parse_subfield_path(subfield)
|
139
|
+
current = parent_value
|
140
|
+
|
141
|
+
path_parts.each do |part|
|
142
|
+
return false unless current.is_a?(Hash)
|
143
|
+
return false unless current.key?(part.to_sym) || current.key?(part)
|
144
|
+
|
145
|
+
current = current[part.to_sym] || current[part]
|
146
|
+
end
|
147
|
+
|
148
|
+
true
|
149
|
+
end
|
150
|
+
|
151
|
+
# Checks if an object subfield exists (not nil)
|
152
|
+
# This ensures we apply defaults for nil values on objects
|
153
|
+
def _object_subfield_exists?(parent_value, subfield)
|
154
|
+
!parent_value.public_send(subfield).nil?
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/core_ext/hash/indifferent_access"
|
4
|
+
|
5
|
+
module Axn
|
6
|
+
module Core
|
7
|
+
module FieldResolvers
|
8
|
+
class Extract
|
9
|
+
def initialize(field:, provided_data:, options: {})
|
10
|
+
@field = field
|
11
|
+
@options = options
|
12
|
+
@provided_data = provided_data
|
13
|
+
end
|
14
|
+
|
15
|
+
def call
|
16
|
+
# Handle method calls if the source responds to the field
|
17
|
+
return provided_data.public_send(field) if provided_data.respond_to?(field)
|
18
|
+
|
19
|
+
# For hash-like objects, use digging with indifferent access
|
20
|
+
raise "Unclear how to extract #{field} from #{provided_data.inspect}" unless provided_data.respond_to?(:dig)
|
21
|
+
|
22
|
+
base = provided_data.respond_to?(:with_indifferent_access) ? provided_data.with_indifferent_access : provided_data
|
23
|
+
base.dig(*field.to_s.split("."))
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
attr_reader :field, :options, :provided_data
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Axn
|
4
|
+
module Core
|
5
|
+
module FieldResolvers
|
6
|
+
class Model
|
7
|
+
def initialize(field:, options:, provided_data:)
|
8
|
+
@field = field
|
9
|
+
@options = options
|
10
|
+
@provided_data = provided_data
|
11
|
+
end
|
12
|
+
|
13
|
+
def call
|
14
|
+
provided_value.presence || derive_value
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
attr_reader :field, :options, :provided_data
|
20
|
+
|
21
|
+
def provided_value
|
22
|
+
@provided_value ||= provided_data[field]
|
23
|
+
end
|
24
|
+
|
25
|
+
def derive_value
|
26
|
+
return nil if id_value.blank?
|
27
|
+
|
28
|
+
# Handle different finder types
|
29
|
+
if finder.is_a?(Method)
|
30
|
+
# Method object - call it directly
|
31
|
+
finder.call(id_value)
|
32
|
+
elsif klass.respond_to?(finder)
|
33
|
+
# Symbol/string method name on the klass
|
34
|
+
klass.public_send(finder, id_value)
|
35
|
+
else
|
36
|
+
raise "Unknown finder: #{finder}"
|
37
|
+
end
|
38
|
+
rescue StandardError => e
|
39
|
+
# Log the exception but don't re-raise
|
40
|
+
finder_name = finder.is_a?(Method) ? finder.name : finder
|
41
|
+
Axn::Internal::Logging.piping_error("finding #{field} with #{finder_name}", exception: e)
|
42
|
+
nil
|
43
|
+
end
|
44
|
+
|
45
|
+
def klass
|
46
|
+
@klass ||= options[:klass]
|
47
|
+
end
|
48
|
+
|
49
|
+
def finder
|
50
|
+
@finder ||= options[:finder]
|
51
|
+
end
|
52
|
+
|
53
|
+
def id_field
|
54
|
+
@id_field ||= :"#{field}_id"
|
55
|
+
end
|
56
|
+
|
57
|
+
def id_value
|
58
|
+
@id_value ||= provided_data[id_field]
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "axn/core/field_resolvers/model"
|
4
|
+
require "axn/core/field_resolvers/extract"
|
5
|
+
|
6
|
+
module Axn
|
7
|
+
module Core
|
8
|
+
module FieldResolvers
|
9
|
+
# Registry for field resolvers
|
10
|
+
# This allows us to easily add new field types in the future
|
11
|
+
RESOLVERS = {
|
12
|
+
model: FieldResolvers::Model,
|
13
|
+
extract: FieldResolvers::Extract,
|
14
|
+
}.freeze
|
15
|
+
|
16
|
+
def self.resolve(type:, field:, provided_data:, options: {})
|
17
|
+
resolver_class = RESOLVERS[type]
|
18
|
+
raise ArgumentError, "Unknown field resolver type: #{type}" unless resolver_class
|
19
|
+
|
20
|
+
resolver_class.new(field:, options:, provided_data:).call
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -1,15 +1,15 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "
|
4
|
-
require "
|
3
|
+
require "axn/core/flow/handlers"
|
4
|
+
require "axn/core/flow/handlers/resolvers/callback_resolver"
|
5
5
|
|
6
|
-
module
|
6
|
+
module Axn
|
7
7
|
module Core
|
8
8
|
module Flow
|
9
9
|
module Callbacks
|
10
10
|
def self.included(base)
|
11
11
|
base.class_eval do
|
12
|
-
class_attribute :_callbacks_registry, default:
|
12
|
+
class_attribute :_callbacks_registry, default: Axn::Core::Flow::Handlers::Registry.empty
|
13
13
|
|
14
14
|
extend ClassMethods
|
15
15
|
end
|
@@ -18,7 +18,7 @@ module Action
|
|
18
18
|
module ClassMethods
|
19
19
|
# Internal dispatcher
|
20
20
|
def _dispatch_callbacks(event_type, action:, exception: nil)
|
21
|
-
resolver =
|
21
|
+
resolver = Axn::Core::Flow::Handlers::Resolvers::CallbackResolver.new(
|
22
22
|
_callbacks_registry,
|
23
23
|
event_type,
|
24
24
|
action:,
|
@@ -48,12 +48,12 @@ module Action
|
|
48
48
|
raise ArgumentError, "on_#{event_type} must be called with a block or symbol" unless block || handler
|
49
49
|
|
50
50
|
# If handler is already a descriptor, use it directly
|
51
|
-
entry = if handler.is_a?(
|
51
|
+
entry = if handler.is_a?(Axn::Core::Flow::Handlers::Descriptors::CallbackDescriptor)
|
52
52
|
raise ArgumentError, "Cannot pass additional configuration with prebuilt descriptor" if kwargs.any? || block
|
53
53
|
|
54
54
|
handler
|
55
55
|
else
|
56
|
-
|
56
|
+
Axn::Core::Flow::Handlers::Descriptors::CallbackDescriptor.build(
|
57
57
|
handler: handler || block,
|
58
58
|
**kwargs,
|
59
59
|
)
|
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
module
|
3
|
+
module Axn
|
4
4
|
module Core
|
5
5
|
module Flow
|
6
6
|
module ExceptionExecution
|
@@ -13,11 +13,11 @@ module Action
|
|
13
13
|
self.class._dispatch_callbacks(:exception, action: self, exception:)
|
14
14
|
|
15
15
|
# Call any global handlers
|
16
|
-
|
16
|
+
Axn.config.on_exception(exception, action: self, context: context_for_logging)
|
17
17
|
rescue StandardError => e
|
18
18
|
# No action needed -- downstream #on_exception implementation should ideally log any internal failures, but
|
19
19
|
# we don't want exception *handling* failures to cascade and overwrite the original exception.
|
20
|
-
Axn::
|
20
|
+
Axn::Internal::Logging.piping_error("executing on_exception hooks", action: self, exception: e)
|
21
21
|
end
|
22
22
|
|
23
23
|
def _trigger_on_success
|
@@ -39,22 +39,13 @@ module Action
|
|
39
39
|
self.class._dispatch_callbacks(:error, action: self, exception: e)
|
40
40
|
|
41
41
|
# on_failure handlers run ONLY for fail!
|
42
|
-
if e.is_a?(
|
42
|
+
if e.is_a?(Axn::Failure)
|
43
43
|
self.class._dispatch_callbacks(:failure, action: self, exception: e)
|
44
44
|
else
|
45
45
|
# on_exception handlers run for ONLY for unhandled exceptions.
|
46
46
|
_trigger_on_exception(e)
|
47
47
|
end
|
48
48
|
end
|
49
|
-
|
50
|
-
def try
|
51
|
-
yield
|
52
|
-
rescue Action::Failure => e
|
53
|
-
# NOTE: re-raising so we can still fail! from inside the block
|
54
|
-
raise e
|
55
|
-
rescue StandardError => e
|
56
|
-
_trigger_on_exception(e)
|
57
|
-
end
|
58
49
|
end
|
59
50
|
end
|
60
51
|
end
|
@@ -1,8 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "
|
3
|
+
require "axn/core/flow/handlers/matcher"
|
4
4
|
|
5
|
-
module
|
5
|
+
module Axn
|
6
6
|
module Core
|
7
7
|
module Flow
|
8
8
|
# "Handlers" doesn't feel like *quite* the right name for this, but basically things in this namespace
|
@@ -12,6 +12,7 @@ module Action
|
|
12
12
|
def initialize(matcher: nil, handler: nil)
|
13
13
|
@matcher = matcher
|
14
14
|
@handler = handler
|
15
|
+
freeze
|
15
16
|
end
|
16
17
|
|
17
18
|
attr_reader :handler, :matcher
|
@@ -1,8 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "
|
3
|
+
require "axn/core/flow/handlers/base_descriptor"
|
4
4
|
|
5
|
-
module
|
5
|
+
module Axn
|
6
6
|
module Core
|
7
7
|
module Flow
|
8
8
|
module Handlers
|
@@ -12,8 +12,8 @@ module Action
|
|
12
12
|
attr_reader :prefix
|
13
13
|
|
14
14
|
def initialize(matcher:, handler:, prefix: nil)
|
15
|
-
super(matcher:, handler:)
|
16
15
|
@prefix = prefix
|
16
|
+
super(matcher:, handler:)
|
17
17
|
end
|
18
18
|
|
19
19
|
def self.build(handler: nil, if: nil, unless: nil, prefix: nil, from: nil, **)
|
@@ -31,7 +31,7 @@ module Action
|
|
31
31
|
_build_rule_for_from_condition(from),
|
32
32
|
].compact
|
33
33
|
|
34
|
-
|
34
|
+
Axn::Core::Flow::Handlers::Matcher.new(rules, invert: !!binding.local_variable_get(:unless))
|
35
35
|
end
|
36
36
|
|
37
37
|
def self._build_rule_for_from_condition(from_class)
|
@@ -39,10 +39,10 @@ module Action
|
|
39
39
|
|
40
40
|
if from_class.is_a?(String)
|
41
41
|
lambda { |exception:, **|
|
42
|
-
exception.is_a?(
|
42
|
+
exception.is_a?(Axn::Failure) && exception.source&.class&.name == from_class
|
43
43
|
}
|
44
44
|
else
|
45
|
-
->(exception:, **) { exception.is_a?(
|
45
|
+
->(exception:, **) { exception.is_a?(Axn::Failure) && exception.source.is_a?(from_class) }
|
46
46
|
end
|
47
47
|
end
|
48
48
|
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 Flow
|
6
6
|
module Handlers
|
@@ -14,7 +14,7 @@ module Action
|
|
14
14
|
|
15
15
|
literal_value(handler)
|
16
16
|
rescue StandardError => e
|
17
|
-
Axn::
|
17
|
+
Axn::Internal::Logging.piping_error(operation, action:, exception: e)
|
18
18
|
end
|
19
19
|
|
20
20
|
# Shared introspection helpers
|
@@ -40,18 +40,18 @@ module Action
|
|
40
40
|
def callable?(value) = value.respond_to?(:arity)
|
41
41
|
|
42
42
|
def call_symbol_handler(action:, symbol:, exception: nil)
|
43
|
-
unless action.respond_to?(symbol)
|
43
|
+
unless action.respond_to?(symbol, true)
|
44
44
|
action.warn("Ignoring apparently-invalid symbol #{symbol.inspect} -- action does not respond to method")
|
45
45
|
return nil
|
46
46
|
end
|
47
47
|
|
48
48
|
method = action.method(symbol)
|
49
49
|
if exception && accepts_exception_keyword?(method)
|
50
|
-
action.
|
50
|
+
action.send(symbol, exception:)
|
51
51
|
elsif exception && accepts_positional_exception?(method)
|
52
|
-
action.
|
52
|
+
action.send(symbol, exception)
|
53
53
|
else
|
54
|
-
action.
|
54
|
+
action.send(symbol)
|
55
55
|
end
|
56
56
|
end
|
57
57
|
|