axn 0.1.0.pre.alpha.2.8.1 → 0.1.0.pre.alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.cursor/commands/pr.md +36 -0
- data/.cursor/rules/axn-framework-patterns.mdc +43 -0
- data/.cursor/rules/general-coding-standards.mdc +27 -0
- data/.cursor/rules/spec/testing-patterns.mdc +40 -0
- data/CHANGELOG.md +57 -0
- data/Rakefile +114 -4
- data/docs/.vitepress/config.mjs +19 -10
- data/docs/advanced/conventions.md +3 -3
- data/docs/advanced/mountable.md +476 -0
- data/docs/advanced/profiling.md +351 -0
- data/docs/advanced/rough.md +27 -8
- data/docs/index.md +5 -3
- data/docs/intro/about.md +1 -1
- data/docs/intro/overview.md +6 -6
- data/docs/recipes/formatting-context-for-error-tracking.md +186 -0
- data/docs/recipes/memoization.md +103 -18
- data/docs/recipes/rubocop-integration.md +38 -284
- data/docs/recipes/testing.md +14 -14
- data/docs/recipes/validating-user-input.md +1 -1
- data/docs/reference/async.md +429 -0
- data/docs/reference/axn-result.md +107 -0
- data/docs/reference/class.md +225 -64
- data/docs/reference/configuration.md +366 -34
- data/docs/reference/form-object.md +252 -0
- data/docs/reference/instance.md +14 -29
- data/docs/strategies/client.md +212 -0
- data/docs/strategies/form.md +235 -0
- data/docs/strategies/index.md +21 -21
- data/docs/strategies/transaction.md +1 -1
- data/docs/usage/setup.md +16 -2
- data/docs/usage/steps.md +7 -7
- data/docs/usage/using.md +23 -12
- data/docs/usage/writing.md +191 -12
- data/lib/axn/async/adapters/active_job.rb +74 -0
- data/lib/axn/async/adapters/disabled.rb +41 -0
- data/lib/axn/async/adapters/sidekiq.rb +67 -0
- data/lib/axn/async/adapters.rb +26 -0
- data/lib/axn/async/batch_enqueue/config.rb +38 -0
- data/lib/axn/async/batch_enqueue.rb +99 -0
- data/lib/axn/async/enqueue_all_orchestrator.rb +363 -0
- data/lib/axn/async.rb +178 -0
- data/lib/axn/configuration.rb +113 -0
- data/lib/{action → axn}/context.rb +22 -4
- data/lib/axn/core/automatic_logging.rb +89 -0
- data/lib/axn/core/context/facade.rb +69 -0
- data/lib/{action → axn}/core/context/facade_inspector.rb +32 -5
- data/lib/{action → axn}/core/context/internal.rb +5 -5
- data/lib/{action → axn}/core/contract.rb +111 -73
- data/lib/{action → axn}/core/contract_for_subfields.rb +30 -35
- data/lib/{action → axn}/core/contract_validation.rb +27 -12
- data/lib/axn/core/contract_validation_for_subfields.rb +165 -0
- data/lib/axn/core/default_call.rb +63 -0
- data/lib/axn/core/field_resolvers/extract.rb +32 -0
- data/lib/axn/core/field_resolvers/model.rb +63 -0
- data/lib/axn/core/field_resolvers.rb +24 -0
- data/lib/{action → axn}/core/flow/callbacks.rb +7 -7
- data/lib/{action → axn}/core/flow/exception_execution.rb +9 -13
- data/lib/{action → axn}/core/flow/handlers/base_descriptor.rb +3 -2
- data/lib/{action → axn}/core/flow/handlers/descriptors/callback_descriptor.rb +2 -2
- data/lib/{action → axn}/core/flow/handlers/descriptors/message_descriptor.rb +23 -11
- data/lib/axn/core/flow/handlers/invoker.rb +47 -0
- data/lib/{action → axn}/core/flow/handlers/matcher.rb +9 -19
- data/lib/{action → axn}/core/flow/handlers/registry.rb +3 -1
- data/lib/{action → axn}/core/flow/handlers/resolvers/base_resolver.rb +1 -1
- data/lib/{action → axn}/core/flow/handlers/resolvers/callback_resolver.rb +2 -2
- data/lib/{action → axn}/core/flow/handlers/resolvers/message_resolver.rb +12 -3
- data/lib/axn/core/flow/handlers.rb +20 -0
- data/lib/{action → axn}/core/flow/messages.rb +8 -8
- data/lib/{action → axn}/core/flow.rb +4 -4
- data/lib/{action → axn}/core/hooks.rb +17 -5
- data/lib/axn/core/logging.rb +48 -0
- data/lib/axn/core/memoization.rb +53 -0
- data/lib/{action → axn}/core/nesting_tracking.rb +1 -1
- data/lib/{action → axn}/core/timing.rb +1 -1
- data/lib/axn/core/tracing.rb +90 -0
- data/lib/axn/core/use_strategy.rb +29 -0
- data/lib/{action → axn}/core/validation/fields.rb +26 -2
- data/lib/{action → axn}/core/validation/subfields.rb +14 -12
- data/lib/axn/core/validation/validators/model_validator.rb +36 -0
- data/lib/axn/core/validation/validators/type_validator.rb +80 -0
- data/lib/{action → axn}/core/validation/validators/validate_validator.rb +12 -2
- data/lib/{action → axn}/core.rb +55 -55
- data/lib/{action → axn}/exceptions.rb +12 -2
- data/lib/axn/extras/strategies/client.rb +150 -0
- data/lib/axn/extras/strategies/vernier.rb +121 -0
- data/lib/axn/extras.rb +4 -0
- data/lib/axn/factory.rb +122 -34
- data/lib/axn/form_object.rb +90 -0
- data/lib/axn/internal/logging.rb +30 -0
- data/lib/axn/internal/registry.rb +87 -0
- data/lib/axn/mountable/descriptor.rb +76 -0
- data/lib/axn/mountable/helpers/class_builder.rb +193 -0
- data/lib/axn/mountable/helpers/mounter.rb +33 -0
- data/lib/axn/mountable/helpers/namespace_manager.rb +38 -0
- data/lib/axn/mountable/helpers/validator.rb +112 -0
- data/lib/axn/mountable/inherit_profiles.rb +72 -0
- data/lib/axn/mountable/mounting_strategies/_base.rb +87 -0
- data/lib/axn/mountable/mounting_strategies/axn.rb +48 -0
- data/lib/axn/mountable/mounting_strategies/method.rb +95 -0
- data/lib/axn/mountable/mounting_strategies/step.rb +69 -0
- data/lib/axn/mountable/mounting_strategies.rb +32 -0
- data/lib/axn/mountable.rb +119 -0
- data/lib/axn/rails/engine.rb +51 -0
- data/lib/axn/rails/generators/axn_generator.rb +86 -0
- data/lib/axn/rails/generators/templates/action.rb.erb +17 -0
- data/lib/axn/rails/generators/templates/action_spec.rb.erb +25 -0
- data/lib/{action → axn}/result.rb +32 -13
- data/lib/axn/strategies/form.rb +98 -0
- data/lib/axn/strategies/transaction.rb +26 -0
- data/lib/axn/strategies.rb +20 -0
- data/lib/axn/testing/spec_helpers.rb +6 -8
- data/lib/axn/util/callable.rb +120 -0
- data/lib/axn/util/contract_error_handling.rb +32 -0
- data/lib/axn/util/execution_context.rb +34 -0
- data/lib/axn/util/global_id_serialization.rb +52 -0
- data/lib/axn/util/logging.rb +87 -0
- data/lib/axn/util/memoization.rb +20 -0
- data/lib/axn/version.rb +1 -1
- data/lib/axn.rb +26 -16
- data/lib/rubocop/cop/axn/README.md +23 -23
- data/lib/rubocop/cop/axn/unchecked_result.rb +138 -17
- metadata +106 -64
- data/.rspec +0 -3
- data/.rubocop.yml +0 -76
- data/.tool-versions +0 -1
- data/docs/reference/action-result.md +0 -37
- data/lib/action/attachable/base.rb +0 -43
- data/lib/action/attachable/steps.rb +0 -63
- data/lib/action/attachable/subactions.rb +0 -70
- data/lib/action/attachable.rb +0 -17
- data/lib/action/configuration.rb +0 -55
- data/lib/action/core/automatic_logging.rb +0 -93
- data/lib/action/core/context/facade.rb +0 -48
- data/lib/action/core/flow/handlers/invoker.rb +0 -73
- data/lib/action/core/flow/handlers.rb +0 -20
- data/lib/action/core/logging.rb +0 -37
- data/lib/action/core/tracing.rb +0 -17
- data/lib/action/core/use_strategy.rb +0 -30
- data/lib/action/core/validation/validators/model_validator.rb +0 -34
- data/lib/action/core/validation/validators/type_validator.rb +0 -30
- data/lib/action/enqueueable/via_sidekiq.rb +0 -76
- data/lib/action/enqueueable.rb +0 -13
- data/lib/action/strategies/transaction.rb +0 -19
- data/lib/action/strategies.rb +0 -48
- data/lib/axn/util.rb +0 -24
- data/package.json +0 -10
- data/yarn.lock +0 -1166
|
@@ -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
|
|
@@ -2,14 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
require "active_model"
|
|
4
4
|
|
|
5
|
-
module
|
|
5
|
+
module Axn
|
|
6
6
|
module Validators
|
|
7
7
|
class ValidateValidator < ActiveModel::EachValidator
|
|
8
|
+
def self.apply_syntactic_sugar(value, _fields)
|
|
9
|
+
return value if value.is_a?(Hash)
|
|
10
|
+
|
|
11
|
+
{ with: value }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def check_validity!
|
|
15
|
+
raise ArgumentError, "must supply :with" if options[:with].nil?
|
|
16
|
+
end
|
|
17
|
+
|
|
8
18
|
def validate_each(record, attribute, value)
|
|
9
19
|
msg = begin
|
|
10
20
|
options[:with].call(value)
|
|
11
21
|
rescue StandardError => e
|
|
12
|
-
Axn::
|
|
22
|
+
Axn::Internal::Logging.piping_error("applying custom validation on field '#{attribute}'", exception: e)
|
|
13
23
|
|
|
14
24
|
"failed validation: #{e.message}"
|
|
15
25
|
end
|
data/lib/{action → axn}/core.rb
RENAMED
|
@@ -1,28 +1,52 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
4
|
-
|
|
5
|
-
require "
|
|
6
|
-
|
|
7
|
-
require "
|
|
8
|
-
require "
|
|
9
|
-
require "
|
|
10
|
-
require "
|
|
11
|
-
require "
|
|
12
|
-
require "
|
|
13
|
-
require "
|
|
3
|
+
require "axn/internal/logging"
|
|
4
|
+
|
|
5
|
+
require "axn/context"
|
|
6
|
+
|
|
7
|
+
require "axn/strategies"
|
|
8
|
+
require "axn/extras"
|
|
9
|
+
require "axn/core/hooks"
|
|
10
|
+
require "axn/core/logging"
|
|
11
|
+
require "axn/core/flow"
|
|
12
|
+
require "axn/core/automatic_logging"
|
|
13
|
+
require "axn/core/use_strategy"
|
|
14
|
+
require "axn/core/timing"
|
|
15
|
+
require "axn/core/tracing"
|
|
16
|
+
require "axn/core/nesting_tracking"
|
|
17
|
+
require "axn/core/memoization"
|
|
14
18
|
|
|
15
19
|
# CONSIDER: make class names match file paths?
|
|
16
|
-
require "
|
|
17
|
-
require "
|
|
18
|
-
require "
|
|
20
|
+
require "axn/core/validation/validators/model_validator"
|
|
21
|
+
require "axn/core/validation/validators/type_validator"
|
|
22
|
+
require "axn/core/validation/validators/validate_validator"
|
|
23
|
+
|
|
24
|
+
require "axn/core/field_resolvers"
|
|
25
|
+
require "axn/core/contract_validation"
|
|
26
|
+
require "axn/core/contract_validation_for_subfields"
|
|
27
|
+
require "axn/core/contract"
|
|
28
|
+
require "axn/core/contract_for_subfields"
|
|
29
|
+
require "axn/core/default_call"
|
|
30
|
+
|
|
31
|
+
module Axn
|
|
32
|
+
module Core
|
|
33
|
+
module ClassMethods
|
|
34
|
+
def call(**)
|
|
35
|
+
new(**).tap(&:_run).result
|
|
36
|
+
end
|
|
19
37
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
38
|
+
def call!(**)
|
|
39
|
+
result = call(**)
|
|
40
|
+
return result if result.ok?
|
|
41
|
+
|
|
42
|
+
# When we're nested, we want to raise a failure that includes the source action to support
|
|
43
|
+
# the error message generation's `from` filter
|
|
44
|
+
raise Axn::Failure.new(result.error, source: result.__action__), cause: result.exception if _nested_in_another_axn?
|
|
45
|
+
|
|
46
|
+
raise result.exception
|
|
47
|
+
end
|
|
48
|
+
end
|
|
23
49
|
|
|
24
|
-
module Action
|
|
25
|
-
module Core
|
|
26
50
|
def self.included(base)
|
|
27
51
|
base.class_eval do
|
|
28
52
|
extend ClassMethods
|
|
@@ -35,35 +59,17 @@ module Action
|
|
|
35
59
|
include Core::Flow
|
|
36
60
|
|
|
37
61
|
include Core::ContractValidation
|
|
62
|
+
include Core::ContractValidationForSubfields
|
|
38
63
|
include Core::Contract
|
|
39
64
|
include Core::ContractForSubfields
|
|
40
65
|
include Core::NestingTracking
|
|
41
66
|
|
|
42
67
|
include Core::UseStrategy
|
|
68
|
+
include Core::Memoization
|
|
69
|
+
include Core::DefaultCall
|
|
43
70
|
end
|
|
44
71
|
end
|
|
45
72
|
|
|
46
|
-
module ClassMethods
|
|
47
|
-
def call(**)
|
|
48
|
-
new(**).tap(&:_run).result
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def call!(**)
|
|
52
|
-
result = call(**)
|
|
53
|
-
return result if result.ok?
|
|
54
|
-
|
|
55
|
-
# When we're nested, we want to raise a failure that includes the source action to support
|
|
56
|
-
# the error message generation's `from` filter
|
|
57
|
-
raise Action::Failure.new(result.error, source: result.__action__), cause: result.exception if _nested_in_another_axn?
|
|
58
|
-
|
|
59
|
-
raise result.exception
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def initialize(**)
|
|
64
|
-
@__context = Action::Context.new(**)
|
|
65
|
-
end
|
|
66
|
-
|
|
67
73
|
# Main entry point for action execution
|
|
68
74
|
def _run
|
|
69
75
|
_tracking_nesting(self) do
|
|
@@ -81,28 +87,22 @@ module Action
|
|
|
81
87
|
end
|
|
82
88
|
end
|
|
83
89
|
end
|
|
84
|
-
ensure
|
|
85
|
-
_emit_metrics
|
|
86
90
|
end
|
|
87
91
|
|
|
88
|
-
|
|
89
|
-
|
|
92
|
+
def fail!(message = nil, **exposures)
|
|
93
|
+
expose(**exposures) if exposures.any?
|
|
94
|
+
raise Axn::Failure, message
|
|
95
|
+
end
|
|
90
96
|
|
|
91
|
-
def
|
|
92
|
-
|
|
97
|
+
def done!(message = nil, **exposures)
|
|
98
|
+
expose(**exposures) if exposures.any?
|
|
99
|
+
raise Axn::Internal::EarlyCompletion, message
|
|
93
100
|
end
|
|
94
101
|
|
|
95
102
|
private
|
|
96
103
|
|
|
97
|
-
def
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
Action.config.emit_metrics.call(
|
|
101
|
-
self.class.name || "AnonymousClass",
|
|
102
|
-
result,
|
|
103
|
-
)
|
|
104
|
-
rescue StandardError => e
|
|
105
|
-
Axn::Util.piping_error("running metrics hook", action: self, exception: e)
|
|
104
|
+
def initialize(**)
|
|
105
|
+
@__context = Axn::Context.new(**)
|
|
106
106
|
end
|
|
107
107
|
end
|
|
108
108
|
end
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
4
|
-
|
|
3
|
+
module Axn
|
|
4
|
+
module Internal
|
|
5
|
+
# Internal only -- rescued before Axn::Result is returned
|
|
6
|
+
class EarlyCompletion < StandardError; end
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Raised when fail! is called
|
|
5
10
|
class Failure < StandardError
|
|
6
11
|
DEFAULT_MESSAGE = "Execution was halted"
|
|
7
12
|
|
|
@@ -22,6 +27,10 @@ module Action
|
|
|
22
27
|
def inspect = "#<#{self.class.name} '#{message}'>"
|
|
23
28
|
end
|
|
24
29
|
|
|
30
|
+
module Mountable
|
|
31
|
+
class MountingError < ArgumentError; end
|
|
32
|
+
end
|
|
33
|
+
|
|
25
34
|
class ContractViolation < StandardError
|
|
26
35
|
class ReservedAttributeError < ContractViolation
|
|
27
36
|
def initialize(name)
|
|
@@ -34,6 +43,7 @@ module Action
|
|
|
34
43
|
|
|
35
44
|
class MethodNotAllowed < ContractViolation; end
|
|
36
45
|
class PreprocessingError < ContractViolation; end
|
|
46
|
+
class DefaultAssignmentError < ContractViolation; end
|
|
37
47
|
|
|
38
48
|
class UnknownExposure < ContractViolation
|
|
39
49
|
def initialize(key)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockNesting
|
|
4
|
+
module Axn
|
|
5
|
+
module Extras
|
|
6
|
+
module Strategies
|
|
7
|
+
module Client
|
|
8
|
+
def self.configure(name: :client, prepend_config: nil, debug: false, user_agent: nil, error_handler: nil, **options, &block)
|
|
9
|
+
# Aliasing to avoid shadowing/any confusion
|
|
10
|
+
client_name = name
|
|
11
|
+
error_handler_config = error_handler
|
|
12
|
+
|
|
13
|
+
Module.new do
|
|
14
|
+
extend ActiveSupport::Concern
|
|
15
|
+
|
|
16
|
+
included do
|
|
17
|
+
raise ArgumentError, "client strategy: desired client name '#{client_name}' is already taken" if method_defined?(client_name)
|
|
18
|
+
|
|
19
|
+
define_method client_name do
|
|
20
|
+
# Hydrate options that are callable (e.g. procs), so we can set e.g. per-request expiration
|
|
21
|
+
# headers and/or other non-static values.
|
|
22
|
+
hydrated_options = options.transform_values do |value|
|
|
23
|
+
value.respond_to?(:call) ? value.call : value
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
::Faraday.new(**hydrated_options) do |conn|
|
|
27
|
+
conn.headers["Content-Type"] = "application/json"
|
|
28
|
+
conn.headers["User-Agent"] = user_agent || "#{client_name} / Axn Client Strategy / v#{Axn::VERSION}"
|
|
29
|
+
|
|
30
|
+
# Because middleware is executed in reverse order, downstream user may need flexibility in where to inject configs
|
|
31
|
+
prepend_config&.call(conn)
|
|
32
|
+
|
|
33
|
+
conn.response :raise_error
|
|
34
|
+
conn.request :url_encoded
|
|
35
|
+
conn.request :json
|
|
36
|
+
conn.response :json, content_type: /\bjson$/
|
|
37
|
+
|
|
38
|
+
# Enable for debugging
|
|
39
|
+
conn.response :logger if debug
|
|
40
|
+
|
|
41
|
+
# Inject error handler middleware if configured
|
|
42
|
+
if error_handler_config && defined?(Faraday)
|
|
43
|
+
unless Client.const_defined?(:ErrorHandlerMiddleware, false)
|
|
44
|
+
Client.const_set(:ErrorHandlerMiddleware, Class.new(::Faraday::Middleware) do
|
|
45
|
+
def initialize(app, config)
|
|
46
|
+
super(app)
|
|
47
|
+
@config = config
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def call(env)
|
|
51
|
+
@app.call(env).on_complete do |response_env|
|
|
52
|
+
body = parse_body(response_env.body)
|
|
53
|
+
condition = @config[:if] || -> { status != 200 }
|
|
54
|
+
|
|
55
|
+
@response_env = response_env
|
|
56
|
+
@body = body
|
|
57
|
+
should_handle = instance_exec(&condition)
|
|
58
|
+
|
|
59
|
+
handle_error(response_env, body) if should_handle
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def status
|
|
64
|
+
@response_env&.status
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
attr_reader :body, :response_env
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def parse_body(body)
|
|
72
|
+
return {} if body.blank?
|
|
73
|
+
|
|
74
|
+
body.is_a?(String) ? JSON.parse(body) : body
|
|
75
|
+
rescue JSON::ParserError
|
|
76
|
+
{}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def handle_error(response_env, body)
|
|
80
|
+
error = extract_value(body, @config[:error_key])
|
|
81
|
+
details = extract_value(body, @config[:detail_key]) if @config[:detail_key]
|
|
82
|
+
backtrace = extract_value(body, @config[:backtrace_key]) if @config[:backtrace_key]
|
|
83
|
+
|
|
84
|
+
formatted_message = if @config[:formatter]
|
|
85
|
+
@config[:formatter].call(error, details, response_env)
|
|
86
|
+
else
|
|
87
|
+
format_default_message(error, details)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
prefix = "Error while #{response_env.method.to_s.upcase}ing #{response_env.url}"
|
|
91
|
+
message = formatted_message.present? ? "#{prefix}: #{formatted_message}" : prefix
|
|
92
|
+
|
|
93
|
+
exception_class = @config[:exception_class] || ::Faraday::BadRequestError
|
|
94
|
+
exception = exception_class.new(message)
|
|
95
|
+
exception.set_backtrace(backtrace) if backtrace.present?
|
|
96
|
+
raise exception
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def extract_value(data, key)
|
|
100
|
+
return nil if key.blank?
|
|
101
|
+
|
|
102
|
+
keys = key.split(".")
|
|
103
|
+
keys.reduce(data) do |current, k|
|
|
104
|
+
return nil unless current.is_a?(Hash)
|
|
105
|
+
|
|
106
|
+
current[k.to_s] || current[k.to_sym]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def format_default_message(error, details)
|
|
111
|
+
parts = []
|
|
112
|
+
parts << error if error
|
|
113
|
+
|
|
114
|
+
if details
|
|
115
|
+
if @config[:extract_detail]
|
|
116
|
+
extracted = if details.is_a?(Hash)
|
|
117
|
+
details.map { |key, value| @config[:extract_detail].call(key, value) }.compact.to_sentence
|
|
118
|
+
else
|
|
119
|
+
Array(details).map { |node| @config[:extract_detail].call(node) }.compact.to_sentence
|
|
120
|
+
end
|
|
121
|
+
parts << extracted if extracted.present?
|
|
122
|
+
elsif details.present?
|
|
123
|
+
raise ArgumentError, "must provide extract_detail when detail_key is set and details is not a string" unless details.is_a?(String)
|
|
124
|
+
|
|
125
|
+
parts << details
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
parts.compact.join(" - ")
|
|
130
|
+
end
|
|
131
|
+
end)
|
|
132
|
+
end
|
|
133
|
+
conn.use Client::ErrorHandlerMiddleware, error_handler_config
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
block&.call(conn)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
memo client_name
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockNesting
|
|
148
|
+
|
|
149
|
+
# Register the strategy only if faraday is available
|
|
150
|
+
Axn::Strategies.register(:client, Axn::Extras::Strategies::Client) if defined?(Faraday)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "axn/core/flow/handlers/invoker"
|
|
5
|
+
|
|
6
|
+
module Axn
|
|
7
|
+
module Extras
|
|
8
|
+
module Strategies
|
|
9
|
+
module Vernier
|
|
10
|
+
# @param if [Proc, Symbol, #call, nil] Optional condition to determine when to profile
|
|
11
|
+
# @param sample_rate [Float] Sampling rate (0.0 to 1.0, default: 0.1)
|
|
12
|
+
# @param output_dir [String, Pathname] Output directory for profile files (default: Rails.root/tmp/profiles or tmp/profiles)
|
|
13
|
+
# @return [Module] A configured module that adds profiling to the action
|
|
14
|
+
def self.configure(if: nil, sample_rate: 0.1, output_dir: nil)
|
|
15
|
+
condition = binding.local_variable_get(:if)
|
|
16
|
+
sample_rate_value = sample_rate
|
|
17
|
+
output_dir_value = output_dir || _default_output_dir
|
|
18
|
+
|
|
19
|
+
Module.new do
|
|
20
|
+
extend ActiveSupport::Concern
|
|
21
|
+
|
|
22
|
+
included do
|
|
23
|
+
class_attribute :_vernier_condition, default: condition
|
|
24
|
+
class_attribute :_vernier_sample_rate, default: sample_rate_value
|
|
25
|
+
class_attribute :_vernier_output_dir, default: output_dir_value
|
|
26
|
+
|
|
27
|
+
around do |hooked|
|
|
28
|
+
_with_vernier_profiling { hooked.call }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def _with_vernier_profiling(&)
|
|
35
|
+
return yield unless _should_profile?
|
|
36
|
+
|
|
37
|
+
_profile_with_vernier(&)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def _profile_with_vernier(&)
|
|
41
|
+
_ensure_vernier_available!
|
|
42
|
+
|
|
43
|
+
class_name = self.class.name.presence || "AnonymousAction"
|
|
44
|
+
profile_name = "axn_#{class_name}_#{Time.now.to_i}"
|
|
45
|
+
|
|
46
|
+
# Ensure output directory exists (only once per instance)
|
|
47
|
+
_ensure_output_directory_exists
|
|
48
|
+
|
|
49
|
+
# Build output file path
|
|
50
|
+
output_dir = self.class._vernier_output_dir || _default_output_dir
|
|
51
|
+
output_file = File.join(output_dir, "#{profile_name}.json")
|
|
52
|
+
|
|
53
|
+
# Configure Vernier with our settings
|
|
54
|
+
collector_options = {
|
|
55
|
+
out: output_file,
|
|
56
|
+
allocation_sample_rate: (self.class._vernier_sample_rate * 1000).to_i,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
::Vernier.profile(**collector_options, &)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def _ensure_output_directory_exists
|
|
63
|
+
return if @_vernier_directory_created
|
|
64
|
+
|
|
65
|
+
output_dir = self.class._vernier_output_dir || _default_output_dir
|
|
66
|
+
FileUtils.mkdir_p(output_dir)
|
|
67
|
+
@_vernier_directory_created = true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def _should_profile?
|
|
71
|
+
# Fast path: no condition means always profile
|
|
72
|
+
return true unless self.class._vernier_condition
|
|
73
|
+
|
|
74
|
+
# Slow path: evaluate condition (only when needed)
|
|
75
|
+
Axn::Core::Flow::Handlers::Invoker.call(
|
|
76
|
+
action: self,
|
|
77
|
+
handler: self.class._vernier_condition,
|
|
78
|
+
operation: "determining if profiling should run",
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def _ensure_vernier_available!
|
|
83
|
+
return if defined?(::Vernier) && ::Vernier.is_a?(Module)
|
|
84
|
+
|
|
85
|
+
begin
|
|
86
|
+
require "vernier"
|
|
87
|
+
rescue LoadError
|
|
88
|
+
raise LoadError, <<~ERROR
|
|
89
|
+
Vernier profiler is not available. To use profiling, add 'vernier' to your Gemfile:
|
|
90
|
+
|
|
91
|
+
gem 'vernier', '~> 1.0'
|
|
92
|
+
|
|
93
|
+
Then run: bundle install
|
|
94
|
+
ERROR
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def _default_output_dir
|
|
99
|
+
if defined?(Rails) && Rails.respond_to?(:root)
|
|
100
|
+
Rails.root.join("tmp", "profiles")
|
|
101
|
+
else
|
|
102
|
+
Pathname.new("tmp/profiles")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private_class_method def self._default_output_dir
|
|
109
|
+
if defined?(Rails) && Rails.respond_to?(:root)
|
|
110
|
+
Rails.root.join("tmp", "profiles")
|
|
111
|
+
else
|
|
112
|
+
Pathname.new("tmp/profiles")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Register the strategy (it handles missing vernier dependency gracefully)
|
|
121
|
+
Axn::Strategies.register(:vernier, Axn::Extras::Strategies::Vernier)
|
data/lib/axn/extras.rb
ADDED