axn 0.1.0.pre.alpha.2.4.1 → 0.1.0.pre.alpha.2.5.1

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.
@@ -3,15 +3,16 @@
3
3
  module Action
4
4
  # Raised internally when fail! is called -- triggers failure + rollback handling
5
5
  class Failure < StandardError
6
- attr_reader :context
6
+ DEFAULT_MESSAGE = "Execution was halted"
7
7
 
8
- def initialize(context = nil, message: nil)
9
- super()
10
- @message = message if message.present?
11
- @context = context
8
+ def initialize(message = nil, **)
9
+ @message = message
10
+ super(**)
12
11
  end
13
12
 
14
- def message = @message.presence || @context.error_from_user.presence || "Execution was halted"
13
+ def message
14
+ @message.presence || DEFAULT_MESSAGE
15
+ end
15
16
 
16
17
  def inspect = "#<#{self.class.name} '#{message}'>"
17
18
  end
@@ -29,8 +29,10 @@ module Action
29
29
  MinimalFailedResult.new(error: nil, exception: e)
30
30
  end
31
31
 
32
- # This ensures the last line of hoist_errors was an Action call (CAUTION: if there are multiple
33
- # calls per block, only the last one will be checked!)
32
+ # This ensures the last line of hoist_errors was an Action call
33
+ #
34
+ # CAUTION: if there are multiple calls per block, only the last one will be checked!
35
+ #
34
36
  unless result.respond_to?(:ok?)
35
37
  raise ArgumentError,
36
38
  "#hoist_errors is expected to wrap an Action call, but it returned a #{result.class.name} instead"
@@ -14,8 +14,9 @@ module Action
14
14
  end
15
15
 
16
16
  module ClassMethods
17
- def log(message, level: :info)
18
- level = :info if level == :debug && _targeted_for_debug_logging?
17
+ def default_log_level = Action.config.default_log_level
18
+
19
+ def log(message, level: default_log_level)
19
20
  msg = [_log_prefix, message].compact_blank.join(" ")
20
21
 
21
22
  Action.config.logger.send(level, msg)
@@ -29,13 +30,6 @@ module Action
29
30
 
30
31
  # TODO: this is ugly, we should be able to override in the config class...
31
32
  def _log_prefix = name == "Action::Configuration" ? nil : "[#{name || "Anonymous Class"}]"
32
-
33
- def _targeted_for_debug_logging?
34
- return true if Action.config.global_debug_logging?
35
-
36
- target_class_names = (ENV["SA_DEBUG_TARGETS"] || "").split(",").map(&:strip)
37
- target_class_names.include?(name)
38
- end
39
33
  end
40
34
  end
41
35
  end
@@ -15,8 +15,8 @@ module Action
15
15
  include InstanceMethods
16
16
  extend ClassMethods
17
17
 
18
- def run_with_exception_swallowing!
19
- original_run!
18
+ def run
19
+ run!
20
20
  rescue StandardError => e
21
21
  # on_error handlers run for both unhandled exceptions and fail!
22
22
  self.class._error_handlers.each do |handler|
@@ -25,31 +25,19 @@ module Action
25
25
 
26
26
  # on_failure handlers run ONLY for fail!
27
27
  if e.is_a?(Action::Failure)
28
+ @context.instance_variable_set("@error_from_user", e.message) if e.message.present?
29
+
28
30
  self.class._failure_handlers.each do |handler|
29
31
  handler.execute_if_matches(exception: e, action: self)
30
32
  end
33
+ else
34
+ # on_exception handlers run for ONLY for unhandled exceptions. AND NOTE: may be skipped if the exception is rescued via `rescues`.
35
+ trigger_on_exception(e)
31
36
 
32
- # TODO: avoid raising if this was passed along from a child action (esp. if wrapped in hoist_errors)
33
- raise e
37
+ @context.exception = e
34
38
  end
35
39
 
36
- # on_exception handlers run for ONLY for unhandled exceptions. AND NOTE: may be skipped if the exception is rescued via `rescues`.
37
- trigger_on_exception(e)
38
-
39
- @context.exception = e
40
-
41
- fail!
42
- end
43
-
44
- alias_method :original_run!, :run!
45
- alias_method :run!, :run_with_exception_swallowing!
46
-
47
- # Tweaked to check @context.object_id rather than context (since forwarding object_id causes Ruby to complain)
48
- # TODO: do we actually need the object_id check? Do we need this override at all?
49
- def run
50
- run!
51
- rescue Action::Failure => e
52
- raise if @context.object_id != e.context.object_id
40
+ @context.instance_variable_set("@failure", true)
53
41
  end
54
42
 
55
43
  def trigger_on_exception(exception)
@@ -72,17 +60,12 @@ module Action
72
60
  end
73
61
 
74
62
  class << base
75
- def call_bang_with_unswallowed_exceptions(context = {})
63
+ def call!(context = {})
76
64
  result = call(context)
77
65
  return result if result.ok?
78
66
 
79
- raise result.exception if result.exception
80
-
81
- raise Action::Failure.new(result.instance_variable_get("@context"), message: result.error)
67
+ raise result.exception || Action::Failure.new(result.error)
82
68
  end
83
-
84
- alias_method :original_call!, :call!
85
- alias_method :call!, :call_bang_with_unswallowed_exceptions
86
69
  end
87
70
  end
88
71
  end
@@ -162,8 +145,7 @@ module Action
162
145
  @context.instance_variable_set("@failure", true)
163
146
  @context.error_from_user = message if message.present?
164
147
 
165
- # TODO: should we use context_for_logging here? But doublecheck the one place where we're checking object_id on it...
166
- raise Action::Failure.new(@context, message:)
148
+ raise Action::Failure, message
167
149
  end
168
150
 
169
151
  def try
@@ -6,46 +6,39 @@ module Action
6
6
  base.class_eval do
7
7
  around :__top_level_around_hook
8
8
 
9
+ extend AutologgingClassMethods
10
+ include AutologgingInstanceMethods
9
11
  include InstanceMethods
10
12
  end
11
13
  end
12
14
 
13
- module InstanceMethods
14
- def __top_level_around_hook(hooked)
15
- timing_start = Time.now
16
- _log_before
17
-
18
- _configurable_around_wrapper do
19
- (@outcome, @exception) = _call_and_return_outcome(hooked)
20
- end
21
-
22
- _log_after(timing_start:, outcome: @outcome)
23
-
24
- raise @exception if @exception
25
- end
15
+ module AutologgingClassMethods
16
+ def default_autolog_level = Action.config.default_autolog_level
17
+ end
26
18
 
19
+ module AutologgingInstanceMethods
27
20
  private
28
21
 
29
- def _configurable_around_wrapper(&)
30
- return yield unless Action.config.top_level_around_hook
31
-
32
- Action.config.top_level_around_hook.call(self.class.name || "AnonymousClass", &)
33
- end
34
-
35
22
  def _log_before
36
- debug [
37
- "About to execute",
38
- context_for_logging(:inbound).presence&.inspect,
39
- ].compact.join(" with: ")
23
+ public_send(
24
+ self.class.default_autolog_level,
25
+ [
26
+ "About to execute",
27
+ context_for_logging(:inbound).presence&.inspect,
28
+ ].compact.join(" with: "),
29
+ )
40
30
  end
41
31
 
42
32
  def _log_after(outcome:, timing_start:)
43
33
  elapsed_mils = ((Time.now - timing_start) * 1000).round(3)
44
34
 
45
- debug [
46
- "Execution completed (with outcome: #{outcome}) in #{elapsed_mils} milliseconds",
47
- _log_after_data_peak,
48
- ].compact.join(". Set: ")
35
+ public_send(
36
+ self.class.default_autolog_level,
37
+ [
38
+ "Execution completed (with outcome: #{outcome}) in #{elapsed_mils} milliseconds",
39
+ _log_after_data_peak,
40
+ ].compact.join(". Set: "),
41
+ )
49
42
  end
50
43
 
51
44
  def _log_after_data_peak
@@ -58,6 +51,29 @@ module Action
58
51
  return str[0, max_length - suffix.length] + suffix if str.length > max_length
59
52
  end
60
53
  end
54
+ end
55
+
56
+ module InstanceMethods
57
+ def __top_level_around_hook(hooked)
58
+ timing_start = Time.now
59
+ _log_before
60
+
61
+ _configurable_around_wrapper do
62
+ (@outcome, @exception) = _call_and_return_outcome(hooked)
63
+ end
64
+
65
+ _log_after(timing_start:, outcome: @outcome)
66
+
67
+ raise @exception if @exception
68
+ end
69
+
70
+ private
71
+
72
+ def _configurable_around_wrapper(&)
73
+ return yield unless Action.config.top_level_around_hook
74
+
75
+ Action.config.top_level_around_hook.call(self.class.name || "AnonymousClass", &)
76
+ end
61
77
 
62
78
  def _call_and_return_outcome(hooked)
63
79
  hooked.call
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module Validation
5
+ class Fields
6
+ include ActiveModel::Validations
7
+
8
+ # NOTE: defining classes where needed b/c we explicitly register it'll affect ALL the consuming apps' validators as well
9
+ ModelValidator = Validators::ModelValidator
10
+ TypeValidator = Validators::TypeValidator
11
+ ValidateValidator = Validators::ValidateValidator
12
+
13
+ def initialize(context)
14
+ @context = context
15
+ end
16
+
17
+ def read_attribute_for_validation(attr)
18
+ @context.public_send(attr)
19
+ end
20
+
21
+ def self.validate!(validations:, context:, exception_klass:)
22
+ validator = Class.new(self) do
23
+ def self.name = "Action::Validation::Fields::OneOff"
24
+
25
+ validations.each do |field, field_validations|
26
+ field_validations.each do |key, value|
27
+ validates field, key => value
28
+ end
29
+ end
30
+ end.new(context)
31
+
32
+ return if validator.valid?
33
+
34
+ raise exception_klass, validator.errors
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/hash/indifferent_access"
4
+
5
+ module Action
6
+ module Validation
7
+ class Subfields
8
+ include ActiveModel::Validations
9
+
10
+ # NOTE: defining classes where needed b/c we explicitly register it'll affect ALL the consuming apps' validators as well
11
+ ModelValidator = Validators::ModelValidator
12
+ TypeValidator = Validators::TypeValidator
13
+ ValidateValidator = Validators::ValidateValidator
14
+
15
+ def initialize(source)
16
+ @source = source
17
+ end
18
+
19
+ def read_attribute_for_validation(attr)
20
+ self.class.extract(attr, @source)
21
+ end
22
+
23
+ def self.extract(attr, source)
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:)
32
+ validator = Class.new(self) do
33
+ def self.name = "Action::Validation::Subfields::OneOff"
34
+
35
+ validates field, **validations
36
+ end.new(source)
37
+
38
+ return if validator.valid?
39
+
40
+ raise exception_klass, validator.errors
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module Action
6
+ module Validators
7
+ class ModelValidator < ActiveModel::EachValidator
8
+ def self.model_for(field:, klass: nil)
9
+ return klass if defined?(ActiveRecord::Base) && klass.is_a?(ActiveRecord::Base)
10
+
11
+ field.to_s.delete_suffix("_id").classify.constantize
12
+ end
13
+
14
+ def self.instance_for(field:, klass:, id:)
15
+ klass = model_for(field:, klass:)
16
+ return unless klass.respond_to?(:find_by)
17
+
18
+ klass.find_by(id:)
19
+ end
20
+
21
+ def validate_each(record, attribute, id)
22
+ klass = self.class.model_for(field: attribute, klass: options[:with])
23
+ instance = self.class.instance_for(field: attribute, klass:, id:)
24
+ return if instance.present?
25
+
26
+ msg = id.blank? ? "not found (given a blank ID)" : "not found for class #{klass.name} and ID #{id}"
27
+ record.errors.add(attribute, msg)
28
+ rescue StandardError => e
29
+ warn("Model validation on field '#{attribute}' raised #{e.class.name}: #{e.message}")
30
+
31
+ record.errors.add(attribute, "error raised while trying to find a valid #{klass.name}")
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module Action
6
+ module Validators
7
+ class TypeValidator < ActiveModel::EachValidator
8
+ def validate_each(record, attribute, value)
9
+ # NOTE: the last one (:value) might be my fault from the make-it-a-hash fallback in #parse_field_configs
10
+ types = options[:in].presence || Array(options[:with]).presence || Array(options[:value]).presence
11
+
12
+ return if value.blank? && !types.include?(:boolean) # Handled with a separate default presence validator
13
+
14
+ msg = types.size == 1 ? "is not a #{types.first}" : "is not one of #{types.join(", ")}"
15
+ record.errors.add attribute, (options[:message] || msg) unless types.any? do |type|
16
+ if type == :boolean
17
+ [true, false].include?(value)
18
+ elsif type == :uuid
19
+ value.is_a?(String) && 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)
20
+ else
21
+ # NOTE: allow mocks to pass type validation by default (much easier testing ergonomics)
22
+ next true if Action.config.env.test? && value.class.name.start_with?("RSpec::Mocks::")
23
+
24
+ value.is_a?(type)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module Action
6
+ module Validators
7
+ class ValidateValidator < ActiveModel::EachValidator
8
+ def validate_each(record, attribute, value)
9
+ msg = begin
10
+ options[:with].call(value)
11
+ rescue StandardError => e
12
+ Action.config.logger.warn("Custom validation on field '#{attribute}' raised #{e.class.name}: #{e.message}")
13
+
14
+ "failed validation: #{e.message}"
15
+ end
16
+
17
+ record.errors.add(attribute, msg) if msg.present?
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module Enqueueable
5
+ module EnqueueAllInBackground
6
+ extend ActiveSupport::Concern
7
+
8
+ module ClassMethods
9
+ def enqueue_all_in_background
10
+ raise NotImplementedError, "#{name} must implement a .enqueue_all method in order to use .enqueue_all_in_background" unless respond_to?(:enqueue_all)
11
+
12
+ ::Action::Enqueueable::EnqueueAllWorker.enqueue(klass_name: name)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # NOTE: this is a standalone worker for enqueueing all instances of a class.
4
+ # Unlike the other files in the folder, it is NOT included in the Action stack.
5
+
6
+ # Note it uses Axn-native enqueueing, so will automatically support additional
7
+ # backends as they are added (initially, just Sidekiq)
8
+
9
+ module Action
10
+ module Enqueueable
11
+ class EnqueueAllWorker
12
+ include Action
13
+
14
+ expects :klass_name, type: String
15
+
16
+ def call
17
+ klass_name.constantize.enqueue_all
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module Enqueueable
5
+ module ViaSidekiq
6
+ def self.included(base)
7
+ base.class_eval do
8
+ begin
9
+ require "sidekiq"
10
+ include Sidekiq::Job
11
+ rescue LoadError
12
+ puts "Sidekiq not available -- skipping Enqueueable"
13
+ return
14
+ end
15
+
16
+ define_method(:perform) do |*args|
17
+ context = self.class._params_from_global_id(args.first)
18
+ bang = args.size > 1 ? args.last : false
19
+
20
+ if bang
21
+ self.class.call!(context)
22
+ else
23
+ self.class.call(context)
24
+ end
25
+ end
26
+
27
+ def self.enqueue(context = {})
28
+ perform_async(_process_context_to_sidekiq_args(context))
29
+ end
30
+
31
+ def self.enqueue!(context = {})
32
+ perform_async(_process_context_to_sidekiq_args(context), true)
33
+ end
34
+
35
+ def self.queue_options(opts)
36
+ opts = opts.transform_keys(&:to_s)
37
+ self.sidekiq_options_hash = get_sidekiq_options.merge(opts)
38
+ end
39
+
40
+ private
41
+
42
+ def self._process_context_to_sidekiq_args(context)
43
+ client = Sidekiq::Client.new
44
+
45
+ _params_to_global_id(context).tap do |args|
46
+ if client.send(:json_unsafe?, args).present?
47
+ raise ArgumentError,
48
+ "Cannot pass non-JSON-serializable objects to Sidekiq. Make sure all expected arguments are serializable (or respond to to_global_id)."
49
+ end
50
+ end
51
+ end
52
+
53
+ def self._params_to_global_id(context)
54
+ context.stringify_keys.each_with_object({}) do |(key, value), hash|
55
+ if value.respond_to?(:to_global_id)
56
+ hash["#{key}_as_global_id"] = value.to_global_id.to_s
57
+ else
58
+ hash[key] = value
59
+ end
60
+ end
61
+ end
62
+
63
+ def self._params_from_global_id(params)
64
+ params.each_with_object({}) do |(key, value), hash|
65
+ if key.end_with?("_as_global_id")
66
+ hash[key.delete_suffix("_as_global_id")] = GlobalID::Locator.locate(value)
67
+ else
68
+ hash[key] = value
69
+ end
70
+ end.symbolize_keys
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "enqueueable/via_sidekiq"
4
+ require_relative "enqueueable/enqueue_all_in_background"
5
+
6
+ module Action
7
+ module Enqueueable
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ include ViaSidekiq
12
+ include EnqueueAllInBackground
13
+ end
14
+ end
15
+ end
data/lib/axn/factory.rb CHANGED
@@ -34,7 +34,6 @@ module Axn
34
34
  end
35
35
  raise ArgumentError, "[Axn::Factory] Cannot convert block to action: block expects a splat of keyword arguments" if args[:keyrest].present?
36
36
 
37
- # TODO: is there any way to support default arguments? (if so, set allow_blank: true for those)
38
37
  if args[:key].present?
39
38
  raise ArgumentError,
40
39
  "[Axn::Factory] Cannot convert block to action: block expects keyword arguments with defaults (ruby does not allow introspecting)"
@@ -66,7 +65,7 @@ module Axn
66
65
  retval = instance_exec(**unwrapped_kwargs, &block)
67
66
  expose(expose_return_as => retval) if expose_return_as.present?
68
67
  end
69
- end.tap do |axn| # rubocop: disable Style/MultilineBlockChain
68
+ end.tap do |axn|
70
69
  expects.each do |field, opts|
71
70
  axn.expects(field, **opts)
72
71
  end
data/lib/axn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Axn
4
- VERSION = "0.1.0-alpha.2.4.1"
4
+ VERSION = "0.1.0-alpha.2.5.1"
5
5
  end
data/lib/axn.rb CHANGED
@@ -6,18 +6,23 @@ require_relative "axn/version"
6
6
  require "interactor"
7
7
  require "active_support"
8
8
 
9
+ require_relative "action/core/validation/validators/model_validator"
10
+ require_relative "action/core/validation/validators/type_validator"
11
+ require_relative "action/core/validation/validators/validate_validator"
12
+
9
13
  require_relative "action/core/exceptions"
10
14
  require_relative "action/core/logging"
11
15
  require_relative "action/core/configuration"
12
16
  require_relative "action/core/top_level_around_hook"
13
17
  require_relative "action/core/contract"
18
+ require_relative "action/core/contract_for_subfields"
14
19
  require_relative "action/core/swallow_exceptions"
15
20
  require_relative "action/core/hoist_errors"
16
- require_relative "action/core/enqueueable"
17
21
 
18
22
  require_relative "axn/factory"
19
23
 
20
24
  require_relative "action/attachable"
25
+ require_relative "action/enqueueable"
21
26
 
22
27
  def Axn(callable, **) # rubocop:disable Naming/MethodName
23
28
  return callable if callable.is_a?(Class) && callable < Action
@@ -37,15 +42,15 @@ module Action
37
42
  # can include those hook executions in any traces set from this hook.
38
43
  include TopLevelAroundHook
39
44
 
40
- include Contract
41
45
  include SwallowExceptions
46
+ include Contract
47
+ include ContractForSubfields
42
48
 
43
49
  include HoistErrors
44
50
 
45
- include Enqueueable
46
-
47
51
  # --- Extensions ---
48
52
  include Attachable
53
+ include Enqueueable
49
54
 
50
55
  # Allow additional automatic includes to be configured
51
56
  Array(Action.config.additional_includes).each { |mod| include mod }
@@ -62,3 +67,5 @@ module Action
62
67
  end
63
68
  end
64
69
  end
70
+
71
+ require "action/enqueueable/enqueue_all_worker"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: axn
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.pre.alpha.2.4.1
4
+ version: 0.1.0.pre.alpha.2.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kali Donovan
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-06-04 00:00:00.000000000 Z
11
+ date: 2025-06-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activemodel
@@ -91,14 +91,22 @@ files:
91
91
  - lib/action/core/configuration.rb
92
92
  - lib/action/core/context_facade.rb
93
93
  - lib/action/core/contract.rb
94
- - lib/action/core/contract_validator.rb
95
- - lib/action/core/enqueueable.rb
94
+ - lib/action/core/contract_for_subfields.rb
96
95
  - lib/action/core/event_handlers.rb
97
96
  - lib/action/core/exceptions.rb
98
97
  - lib/action/core/hoist_errors.rb
99
98
  - lib/action/core/logging.rb
100
99
  - lib/action/core/swallow_exceptions.rb
101
100
  - lib/action/core/top_level_around_hook.rb
101
+ - lib/action/core/validation/fields.rb
102
+ - lib/action/core/validation/subfields.rb
103
+ - lib/action/core/validation/validators/model_validator.rb
104
+ - lib/action/core/validation/validators/type_validator.rb
105
+ - lib/action/core/validation/validators/validate_validator.rb
106
+ - lib/action/enqueueable.rb
107
+ - lib/action/enqueueable/enqueue_all_in_background.rb
108
+ - lib/action/enqueueable/enqueue_all_worker.rb
109
+ - lib/action/enqueueable/via_sidekiq.rb
102
110
  - lib/axn.rb
103
111
  - lib/axn/factory.rb
104
112
  - lib/axn/version.rb