axn 0.1.0.pre.alpha.2.4 → 0.1.0.pre.alpha.2.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3313bd117a57aae98551088df5895b6db302830101dda0e2fb95f057f97000f
4
- data.tar.gz: 9517e1d297998af62a2fc2107d3a002a71d5b767dda250b6ebec4d29958902a6
3
+ metadata.gz: ad00a162e22de940a1c6e4a4de1f75533fb1aa5440c6cf8808f81a964e2457d4
4
+ data.tar.gz: dfb840d11657f80f92d3e1aa19bef3cb64b1d28a84f70f0dd1b92c5726f4fd7f
5
5
  SHA512:
6
- metadata.gz: e3b80de43c96e2362562dc35d88fd28d7c7b97a0dba2e73168031063fa8bed3d64ae3c46dd4f751af6ea051c17fc4592e6c246f505f9e319e5dd6e87caabfc67
7
- data.tar.gz: c451f8b99639f94bb38d982ab4913b5e126ee099ed97f31cf2ad70903efbc5acb69bc715e634d39e56aad0720d8bd4007a24794255ea9519307f268b4f312061
6
+ metadata.gz: 6704196ea530709faf393c51886f0ab4ed34fd5f9654cf4d086d08ba30a455140297bf695d29d0e7a15376b8b3064c9093e882f74f84139e21dc2bdc1e122298
7
+ data.tar.gz: e319dcdf830b8b49fafa2db5f8461b951bf96db8a57b1b1172075a69db2f1eacd4674807526480d7f56a5fbe03265bf549f755359986a02805a5e982f0c769d1
data/.rubocop.yml CHANGED
@@ -3,6 +3,10 @@ AllCops:
3
3
  SuggestExtensions: false
4
4
  NewCops: enable
5
5
 
6
+
7
+ Style/MultilineBlockChain:
8
+ Enabled: false
9
+
6
10
  Style/StringLiterals:
7
11
  Enabled: true
8
12
  EnforcedStyle: double_quotes
@@ -36,10 +40,10 @@ Metrics/PerceivedComplexity:
36
40
  Max: 15
37
41
 
38
42
  Metrics/AbcSize:
39
- Max: 51
43
+ Max: 60
40
44
 
41
45
  Metrics/CyclomaticComplexity:
42
- Max: 12
46
+ Max: 14
43
47
 
44
48
  Lint/EmptyBlock:
45
49
  Enabled: false
data/CHANGELOG.md CHANGED
@@ -3,6 +3,24 @@
3
3
  ## UNRELEASED
4
4
  * N/A
5
5
 
6
+ ## 0.1.0-alpha.2.5
7
+ * Support blank exposures for `Action::Result.ok`
8
+ * Modify Action::Failure's initialize signature (to better match StandardError)
9
+ * Reduce reserved fields to allow some `expects` (e.g. `message`) that would shadow internals if used as `exposes`
10
+ * Default logging changes:
11
+ * Add `default_log_level` and `default_autolog_level` class methods (so inheritable) via `Action.config`
12
+ * Remove `global_debug_logging?` from Configuration + unused `SA_DEBUG_TARGETS` approach to configuring logging
13
+ * Improved testing ergonomics: the `type` expectation will now return `true` for _any_ `RSpec::Mocks::` subclass
14
+ * Enqueueable improvements:
15
+ * Extracted out of Core
16
+ * Renamed to `Enqueueable::ViaSidekiq` (make it easier to support different background runners in the future)
17
+ * Added ability to call `.enqueue_all_in_background` to run an Action's class-level `.enqueue_all` method (if defined) on a background worker
18
+ (important if triggered via a clock process that is NOT intended to execute actual jobs)
19
+ * Restructure internals (call/call! + run/run! + Action::Failure) to simplify upstream implementation since we always wrap any raised exceptions
20
+
21
+ ## 0.1.0-alpha.2.4.1
22
+ * [FEAT] Adds full suite of per-Axn callbacks: `on_exception`, `on_failure`, `on_error`, `on_success`
23
+
6
24
  ## 0.1.0-alpha.2.4
7
25
  * [FEAT] Adds per-Axn `on_exception` handlers
8
26
 
@@ -31,6 +31,56 @@ before do
31
31
  end
32
32
  ```
33
33
 
34
+ ### `call!`
35
+
36
+ The semantics of call-bang are a little different -- if Subaction is called via `call!`, you'll need slightly different code to handle success vs failure:
37
+
38
+ ### Success
39
+
40
+ ```ruby
41
+ let(:subaction_response) { Action::Result.ok("custom message", foo: 1) }
42
+
43
+ before do
44
+ expect(Subaction).to receive(:call!).and_return(subaction_response)
45
+ end
46
+ ```
47
+
48
+ ### Failure
49
+
50
+ Because `call!` will _raise_, we need to use `and_raise` rather than `and_return`:
51
+
52
+ ```ruby
53
+ let(:subaction_exception) { SomeValidErrorClass.new("whatever you expect subclass to raise") }
54
+
55
+ before do
56
+ expect(Subaction).to receive(:call!).and_raise(subaction_exception)
57
+ end
58
+ ```
59
+
60
+ NOTE: to mock subaction failing via explicit `fail!` call, you'd use an `Action::Failure` exception class.
61
+
62
+ ## Mocking Axn arguments
63
+
64
+ Be aware that in order to improve testing ergonomics, the `type` validation will return `true` for _any_ `RSpec::Mocks::` subclass _as long as `Action.config.env.test?` is `true`_.
65
+
66
+ This makes it much easier to test Axns, as you can pass in mocks without immediately failing the inbound validation.
67
+
68
+ ```ruby
69
+ subject(:result) { action.call!(sym:) }
70
+
71
+ let(:action) { build_action { expects :sym, type: Symbol } }
72
+
73
+ context "with a symbol" do
74
+ let(:sym) { :hello }
75
+ it { is_expected.to be_ok }
76
+ end
77
+
78
+ context "with an RSpec double" do
79
+ let(:sym) { double(to_s: "hello") } # [!code focus:2]
80
+ it { is_expected.to be_ok }
81
+ end
82
+ ```
83
+
34
84
  ## RSpec configuration
35
85
 
36
86
  Configuring rspec to treat files in spec/actions as service specs (very optional):
@@ -72,11 +72,38 @@ messages error: "bad"
72
72
  rescues ActiveRecord::InvalidRecord => "Invalid params provided"
73
73
 
74
74
  # These WILL trigger error handler (second demonstrates callable matcher AND message)
75
- error_for ArgumentError, ->(e) { "Argument error: #{e.message}"
75
+ error_for ArgumentError, ->(e) { "Argument error: #{e.message}" }
76
76
  error_for -> { name == "bad" }, -> { "was given bad name: #{name}" }
77
77
  ```
78
78
 
79
- ## `on_exception`
79
+ ## Callbacks
80
+
81
+ In addition to the [global exception handler](/reference/configuration#on-exception), a number of custom callback are available for you as well, if you want to take specific actions when a given Axn succeeds or fails.
82
+
83
+ ::: danger ALPHA
84
+ * The callbacks themselves are functional. Note the ordering _between_ callbacks is not well defined (currently a side effect of the order they're defined).
85
+ * Ordering may change at any time so while in alpha DO NOT MAKE ASSUMPTIONS ABOUT THE ORDER OF CALLBACK EXECUTION!
86
+ :::
87
+
88
+
89
+ ::: tip Callbacks vs Hooks
90
+ * *Hooks* (`before`/`after`) are executed _as part of the `call`_ -- exceptions or `fail!`s here _will_ change a successful action call to a failure (i.e. `result.ok?` will be false)
91
+ * *Callbacks* (defined below) are executed _after_ the `call` -- exceptions or `fail!`s here will _not_ change `result.ok?`
92
+ :::
93
+
94
+ ### `on_success`
95
+
96
+ This is triggered after the Axn completes, if it was successful. Difference from `after`: if the given block raises an error, this WILL be reported to the global exception handler, but will NOT change `ok?` to false.
97
+
98
+ ### `on_error`
99
+
100
+ Triggered on ANY error (explicit `fail!` or uncaught exception). Optional filter argument works the same as `on_exception` (documented below).
101
+
102
+ ### `on_failure`
103
+
104
+ Triggered ONLY on explicit `fail!` (i.e. _not_ by an uncaught exception). Optional filter argument works the same as `on_exception` (documented below).
105
+
106
+ ### `on_exception`
80
107
 
81
108
  Much like the [globally-configured on_exception hook](/reference/configuration#on-exception), you can also specify exception handlers for a _specific_ Axn class:
82
109
 
@@ -11,7 +11,8 @@ Somewhere at boot (e.g. `config/initializers/actions.rb` in Rails), you can call
11
11
 
12
12
  c.additional_includes = []
13
13
 
14
- c.global_debug_logging = false
14
+ c.default_log_level = :info
15
+ c.default_autolog_level = :debug
15
16
 
16
17
  c.logger = ...
17
18
  end
@@ -84,21 +85,17 @@ For example:
84
85
 
85
86
  For a practical example of this in practice, see [our 'memoization' recipe](/recipes/memoization).
86
87
 
87
- ## `global_debug_logging`
88
+ ## `default_log_level`
88
89
 
89
- By default, every `action.call` will emit _debug_ log lines when it is called and after it completes:
90
+ Sets the log level used when you call `log "Some message"` in your Action. Note this is read via a `default_log_level` class method, so you can easily use inheritance to support different log levels for different sets of actions.
91
+
92
+ ## `default_autolog_level`
93
+
94
+ By default, every `action.call` will emit log lines when it is called and after it completes:
90
95
 
91
96
  ```
92
97
  [YourCustomAction] About to execute with: {:foo=>"bar"}
93
98
  [YourCustomAction] Execution completed (with outcome: success) in 0.957 milliseconds
94
99
  ```
95
100
 
96
- You can bump the log level from `debug` to `info` for specific actions by including their class name (comma separated, if multiple) in a `SA_DEBUG_TARGETS` ENV variable.
97
-
98
- You can also turn this on _globally_ by setting `global_debug_logging = true`.
99
-
100
- ```ruby
101
- Action.configure do |c|
102
- c.global_debug_logging = true
103
- end
104
- ```
101
+ You can change the default _auto_-log level separately from the log level used for your explicit `log` calls (just like above, via Action.config or a `default_autolog_level` class method).
@@ -61,6 +61,12 @@ Accepts a `prefix` keyword argument -- when set, prefixes the `error` message fr
61
61
 
62
62
  NOTE: expects a single action call in the block -- if there are multiple calls, only the last one will be checked for `ok?` (although anything _raised_ in the block will still be handled).
63
63
 
64
+ ::: tip Versus `call!`
65
+ * If you just want to make sure your action fails if the subaction fails: call subaction via `call!` (any failures will raise, which will fail the parent).
66
+ * Note this passes _child_ exception into _parent_ `messages :error` parsing.
67
+ * If you want _the child's_ `result.error` to become the _parent's_ `result.error` on failure, use `hoist_errors` + `call`
68
+ :::
69
+
64
70
  ### Example
65
71
 
66
72
  ```ruby
data/docs/usage/using.md CHANGED
@@ -55,3 +55,33 @@ Sidekiq integration is NOT YET TESTED/NOT YET USED IN OUR APP, and naming will V
55
55
  * enqueue will not retry even if fails
56
56
  * enqueue! will go through normal sidekiq retries on any failure (including user-facing `fail!`)
57
57
  * Note implicit GlobalID support (if not serializable, will get ArgumentError at callsite)
58
+
59
+
60
+ ### `.enqueue_all_in_background`
61
+
62
+ In practice it's fairly common to need to enqueue a bunch of sidekiq jobs from a clock process.
63
+
64
+ One approach is to define a class-level `.enqueue_all` method on your Action... but that ends up executing the enqueue_all logic directly from the clock process, which is undesirable.
65
+
66
+
67
+ ::: danger ALPHA
68
+ We are actively testing this pattern -- not yet certain we'll keep it past beta.
69
+ :::
70
+
71
+ Therefore we've added an `.enqueue_all_in_background` method that will automatically call your `.enqueue_all` _from a background job_ rather than directly on the active process.
72
+
73
+ ```ruby
74
+ class Foo
75
+ include Action
76
+
77
+ def self.enqueue_all
78
+ SomeModel.some_scope.find_each do |record|
79
+ enqueue(record:)
80
+ end
81
+ end
82
+
83
+ ...
84
+ end
85
+
86
+ Foo.enqueue_all # works, but `SomeModel.some_scope.find_each` is executed in the current context
87
+ Foo.enqueue_all_in_background # same, but runs in the background (via Action::Enqueueable::EnqueueAllWorker)
@@ -104,23 +104,22 @@ Foo.call(name: "Adams").meaning_of_life # => "Hello Adams, the meaning of life i
104
104
 
105
105
  In addition to `#call`, there are a few additional pieces to be aware of:
106
106
 
107
- ### `#rollback`
107
+ <!-- ### `#rollback`
108
+ *** TODO: rollback actually only applies to rolling back *completed* steps of a multi-step Axn chain. Do not document for now -- need to decide if adding a trigger-when-axn-itself-fails rollback path. ***
108
109
 
109
110
  ::: danger ALPHA
110
111
  * ⚠️ `#rollback` is _expected_ to be added shortly, but is not yet functional!
111
112
  :::
112
113
 
113
- If you define a `#rollback` method, it'll be called (_before_ returning an `Action::Result` to the caller) whenever your action fails.
114
+ If you define a `#rollback` method, it'll be called (_before_ returning an `Action::Result` to the caller) whenever your action fails. -->
114
115
 
115
116
  ### Hooks
116
117
 
117
- `before` and `after` hooks are also supported. They can receive a block directly, or the symbol name of a local method.
118
+ `before` and `after` hooks are supported. They can receive a block directly, or the symbol name of a local method.
118
119
 
119
- Note execution is halted whenever `fail!` is called or an exception is raised (so a `before` block failure won't execute `call` or `after`, while an `after` block failure will make `resuilt.ok?` be false even though `call` completed successfully).
120
+ Note execution is halted whenever `fail!` is called or an exception is raised (so a `before` block failure won't execute `call` or `after`, while an `after` block failure will make `result.ok?` be false even though `call` completed successfully).
120
121
 
121
- ### Concrete example
122
-
123
- Given this series of methods and hooks:
122
+ For instance, given this configuration:
124
123
 
125
124
  ```ruby
126
125
  class Foo
@@ -133,10 +132,6 @@ class Foo
133
132
  log("in call")
134
133
  end
135
134
 
136
- def rollback
137
- log("rolling back")
138
- end
139
-
140
135
  private
141
136
 
142
137
  def log_after
@@ -153,8 +148,11 @@ end
153
148
  before hook
154
149
  in call
155
150
  after hook
156
- rolling back
157
151
  ```
158
152
 
153
+ ### Callbacks
154
+
155
+ A number of custom callback are available for you as well, if you want to take specific actions when a given Axn succeeds or fails. See the [Class Interface docs](/reference/class#callbacks) for details.
156
+
159
157
  ## Debugging
160
158
  Remember you can [enable debug logging](/reference/configuration.html#global-debug-logging) to print log lines before and after each action is executed.
@@ -3,10 +3,11 @@
3
3
  module Action
4
4
  class Configuration
5
5
  include Action::Logging
6
- attr_accessor :global_debug_logging, :top_level_around_hook
7
- attr_writer :logger, :env, :on_exception, :additional_includes
6
+ attr_accessor :top_level_around_hook
7
+ attr_writer :logger, :env, :on_exception, :additional_includes, :default_log_level, :default_autolog_level
8
8
 
9
- def global_debug_logging? = !!global_debug_logging
9
+ def default_log_level = @default_log_level ||= :info
10
+ def default_autolog_level = @default_autolog_level ||= :info
10
11
 
11
12
  def additional_includes = @additional_includes ||= []
12
13
 
@@ -15,7 +16,7 @@ module Action
15
16
  # TODO: only pass action: or context: if requested (and update documentation)
16
17
  @on_exception.call(e, action:, context:)
17
18
  else
18
- log("[#{action.class.name.presence || "Anonymous Action"}] Exception swallowed: #{e.class.name} - #{e.message}")
19
+ log("[#{action.class.name.presence || "Anonymous Action"}] Exception raised: #{e.class.name} - #{e.message}")
19
20
  end
20
21
  end
21
22
 
@@ -51,7 +51,9 @@ module Action
51
51
  def determine_error_message(only_default: false)
52
52
  return @context.error_from_user if @context.error_from_user.present?
53
53
 
54
- exception = @context.exception || (only_default ? Action::Failure.new(@context) : nil)
54
+ # We need an exception for interceptors, and also in case the messages.error callable expects an argument
55
+ exception = @context.exception || Action::Failure.new
56
+
55
57
  msg = action._error_msg
56
58
 
57
59
  unless only_default
@@ -73,7 +75,7 @@ module Action
73
75
  action.instance_exec(&msg)
74
76
  end
75
77
  rescue StandardError => e
76
- action.warn("Ignoring #{e.class.name} raised while determining message callable: #{e.message}")
78
+ action.warn("Ignoring #{e.class.name} while determining message callable: #{e.message}")
77
79
  nil
78
80
  end
79
81
  end
@@ -95,7 +97,9 @@ module Action
95
97
  # For ease of mocking return results in tests
96
98
  class << self
97
99
  def ok(msg = nil, **exposures)
98
- Axn::Factory.build(exposes: exposures.keys, messages: { success: msg }) do
100
+ exposes = exposures.keys.to_h { |key| [key, { allow_blank: true }] }
101
+
102
+ Axn::Factory.build(exposes:, messages: { success: msg }) do
99
103
  exposures.each do |key, value|
100
104
  expose(key, value)
101
105
  end
@@ -103,7 +107,10 @@ module Action
103
107
  end
104
108
 
105
109
  def error(msg = nil, **exposures, &block)
106
- Axn::Factory.build(exposes: exposures.keys, rescues: [-> { true }, msg]) do
110
+ exposes = exposures.keys.to_h { |key| [key, { allow_blank: true }] }
111
+ rescues = [-> { true }, msg]
112
+
113
+ Axn::Factory.build(exposes:, rescues:) do
107
114
  exposures.each do |key, value|
108
115
  expose(key, value)
109
116
  end
@@ -162,7 +169,9 @@ module Action
162
169
  return unless facade.is_a?(Action::Result)
163
170
 
164
171
  return "[OK]" if context.success?
165
- return "[failed with '#{context.error_from_user}']" unless context.exception
172
+ unless context.exception
173
+ return context.error_from_user.present? ? "[failed with '#{context.error_from_user}']" : "[failed]"
174
+ end
166
175
 
167
176
  %([failed with #{context.exception.class.name}: '#{context.exception.message}'])
168
177
  end
@@ -36,6 +36,10 @@ module Action
36
36
  module ClassMethods
37
37
  def expects(*fields, allow_blank: false, allow_nil: false, default: nil, preprocess: nil, sensitive: false,
38
38
  **validations)
39
+ fields.each do |field|
40
+ raise ContractViolation::ReservedAttributeError, field if RESERVED_FIELD_NAMES_FOR_EXPECTATIONS.include?(field.to_s)
41
+ end
42
+
39
43
  _parse_field_configs(*fields, allow_blank:, allow_nil:, default:, preprocess:, sensitive:, **validations).tap do |configs|
40
44
  duplicated = internal_field_configs.map(&:field) & configs.map(&:field)
41
45
  raise Action::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
@@ -46,6 +50,10 @@ module Action
46
50
  end
47
51
 
48
52
  def exposes(*fields, allow_blank: false, allow_nil: false, default: nil, sensitive: false, **validations)
53
+ fields.each do |field|
54
+ raise ContractViolation::ReservedAttributeError, field if RESERVED_FIELD_NAMES_FOR_EXPOSURES.include?(field.to_s)
55
+ end
56
+
49
57
  _parse_field_configs(*fields, allow_blank:, allow_nil:, default:, preprocess: nil, sensitive:, **validations).tap do |configs|
50
58
  duplicated = external_field_configs.map(&:field) & configs.map(&:field)
51
59
  raise Action::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
@@ -57,10 +65,16 @@ module Action
57
65
 
58
66
  private
59
67
 
60
- RESERVED_FIELD_NAMES = %w[
61
- declared_fields inspect fail!
62
- default_error
63
- called! rollback! each_pair success? exception ok ok? error success message
68
+ RESERVED_FIELD_NAMES_FOR_EXPECTATIONS = %w[
69
+ called! fail! rollback! success? ok?
70
+ inspect default_error
71
+ each_pair
72
+ ].freeze
73
+
74
+ RESERVED_FIELD_NAMES_FOR_EXPOSURES = %w[
75
+ called! fail! rollback! success? ok?
76
+ inspect each_pair default_error
77
+ ok error success message
64
78
  ].freeze
65
79
 
66
80
  def _parse_field_configs(*fields, allow_nil: false, allow_blank: false, default: nil, preprocess: nil, sensitive: false,
@@ -68,8 +82,6 @@ module Action
68
82
  # Allow local access to explicitly-expected fields -- even externally-expected needs to be available locally
69
83
  # (e.g. to allow success message callable to reference exposed fields)
70
84
  fields.each do |field|
71
- raise ContractViolation::ReservedAttributeError, field if RESERVED_FIELD_NAMES.include?(field.to_s)
72
-
73
85
  define_method(field) { internal_context.public_send(field) }
74
86
  end
75
87
 
@@ -123,9 +135,9 @@ module Action
123
135
  raise ArgumentError, "Invalid direction: #{direction}" unless %i[inbound outbound].include?(direction)
124
136
 
125
137
  klass = direction == :inbound ? Action::InternalContext : Action::Result
126
- implicitly_allowed_fields = direction == :inbound ? declared_fields(:outbound) : []
138
+ implicitly_allowed_fields = direction == :inbound ? _declared_fields(:outbound) : []
127
139
 
128
- klass.new(action: self, context: @context, declared_fields: declared_fields(direction), implicitly_allowed_fields:)
140
+ klass.new(action: self, context: @context, declared_fields: _declared_fields(direction), implicitly_allowed_fields:)
129
141
  end
130
142
  end
131
143
 
@@ -173,7 +185,7 @@ module Action
173
185
  end
174
186
 
175
187
  def context_for_logging(direction = nil)
176
- inspection_filter.filter(@context.to_h.slice(*declared_fields(direction)))
188
+ inspection_filter.filter(@context.to_h.slice(*_declared_fields(direction)))
177
189
  end
178
190
 
179
191
  protected
@@ -186,7 +198,7 @@ module Action
186
198
  (internal_field_configs + external_field_configs).select(&:sensitive).map(&:field)
187
199
  end
188
200
 
189
- def declared_fields(direction)
201
+ def _declared_fields(direction)
190
202
  raise ArgumentError, "Invalid direction: #{direction}" unless direction.nil? || %i[inbound outbound].include?(direction)
191
203
 
192
204
  configs = case direction
@@ -34,7 +34,7 @@ module Action
34
34
  msg = begin
35
35
  options[:with].call(value)
36
36
  rescue StandardError => e
37
- warn("Custom validation on field '#{attribute}' raised #{e.class.name}: #{e.message}")
37
+ Action.config.logger.warn("Custom validation on field '#{attribute}' raised #{e.class.name}: #{e.message}")
38
38
 
39
39
  "failed validation: #{e.message}"
40
40
  end
@@ -45,7 +45,7 @@ module Action
45
45
 
46
46
  class TypeValidator < ActiveModel::EachValidator
47
47
  def validate_each(record, attribute, value)
48
- # TODO: the last one (:value) might be my fault from the make-it-a-hash fallback in #parse_field_configs
48
+ # NOTE: the last one (:value) might be my fault from the make-it-a-hash fallback in #parse_field_configs
49
49
  types = options[:in].presence || Array(options[:with]).presence || Array(options[:value]).presence
50
50
 
51
51
  return if value.blank? && !types.include?(:boolean) # Handled with a separate default presence validator
@@ -57,6 +57,8 @@ module Action
57
57
  elsif type == :uuid
58
58
  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)
59
59
  else
60
+ next true if Action.config.env.test? && value.class.name.start_with?("RSpec::Mocks::")
61
+
60
62
  value.is_a?(type)
61
63
  end
62
64
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module EventHandlers
5
+ class CustomErrorInterceptor
6
+ def initialize(matcher:, message:, should_report_error:)
7
+ @matcher = Matcher.new(matcher)
8
+ @message = message
9
+ @should_report_error = should_report_error
10
+ end
11
+
12
+ delegate :matches?, to: :@matcher
13
+ attr_reader :message, :should_report_error
14
+ end
15
+
16
+ class ConditionalHandler
17
+ def initialize(matcher:, handler:)
18
+ @matcher = Matcher.new(matcher)
19
+ @handler = handler
20
+ end
21
+
22
+ delegate :matches?, to: :@matcher
23
+
24
+ def execute_if_matches(action:, exception:)
25
+ return false unless matches?(exception:, action:)
26
+
27
+ action.instance_exec(exception, &@handler)
28
+ true
29
+ rescue StandardError => e
30
+ action.warn("Ignoring #{e.class.name} when evaluating handler: #{e.message}")
31
+ nil
32
+ end
33
+ end
34
+
35
+ class Matcher
36
+ def initialize(matcher)
37
+ @matcher = matcher
38
+ end
39
+
40
+ def matches?(exception:, action:)
41
+ if matcher.respond_to?(:call)
42
+ if matcher.arity == 1
43
+ !!action.instance_exec(exception, &matcher)
44
+ else
45
+ !!action.instance_exec(&matcher)
46
+ end
47
+ elsif matcher.is_a?(String) || matcher.is_a?(Symbol)
48
+ klass = Object.const_get(matcher.to_s)
49
+ klass && exception.is_a?(klass)
50
+ elsif matcher < Exception
51
+ exception.is_a?(matcher)
52
+ else
53
+ action.warn("Ignoring apparently-invalid matcher #{matcher.inspect} -- could not find way to apply it")
54
+ false
55
+ end
56
+ rescue StandardError => e
57
+ action.warn("Ignoring #{e.class.name} while determining matcher: #{e.message}")
58
+ false
59
+ end
60
+
61
+ private attr_reader :matcher
62
+ end
63
+ end
64
+ end
@@ -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 || "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
@@ -1,79 +1,56 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "event_handlers"
4
+
3
5
  module Action
4
6
  module SwallowExceptions
5
- CustomErrorInterceptor = Data.define(:matcher, :message, :should_report_error)
6
- CustomErrorHandler = Data.define(:matcher, :block)
7
-
8
- class CustomErrorInterceptor
9
- def self.matches?(matcher:, exception:, action:)
10
- if matcher.respond_to?(:call)
11
- if matcher.arity == 1
12
- !!action.instance_exec(exception, &matcher)
13
- else
14
- !!action.instance_exec(&matcher)
15
- end
16
- elsif matcher.is_a?(String) || matcher.is_a?(Symbol)
17
- klass = Object.const_get(matcher.to_s)
18
- klass && exception.is_a?(klass)
19
- elsif matcher < Exception
20
- exception.is_a?(matcher)
21
- else
22
- action.warn("Ignoring apparently-invalid matcher #{matcher.inspect} -- could not find way to apply it")
23
- false
24
- end
25
- rescue StandardError => e
26
- action.warn("Ignoring #{e.class.name} raised while determining matcher: #{e.message}")
27
- false
28
- end
29
-
30
- def matches?(exception:, action:)
31
- self.class.matches?(matcher:, exception:, action:)
32
- end
33
- end
34
-
35
7
  def self.included(base)
36
8
  base.class_eval do
37
9
  class_attribute :_success_msg, :_error_msg
38
10
  class_attribute :_custom_error_interceptors, default: []
11
+ class_attribute :_error_handlers, default: []
39
12
  class_attribute :_exception_handlers, default: []
13
+ class_attribute :_failure_handlers, default: []
40
14
 
41
15
  include InstanceMethods
42
16
  extend ClassMethods
43
17
 
44
- def run_with_exception_swallowing!
45
- original_run!
18
+ def run
19
+ run!
46
20
  rescue StandardError => e
47
- raise if e.is_a?(Action::Failure) # TODO: avoid raising if this was passed along from a child action (esp. if wrapped in hoist_errors)
48
-
49
- # Add custom hook for intercepting exceptions (e.g. Teamshares automatically logs to Honeybadger)
50
- trigger_on_exception(e)
21
+ # on_error handlers run for both unhandled exceptions and fail!
22
+ self.class._error_handlers.each do |handler|
23
+ handler.execute_if_matches(exception: e, action: self)
24
+ end
51
25
 
52
- @context.exception = e
26
+ # on_failure handlers run ONLY for fail!
27
+ if e.is_a?(Action::Failure)
28
+ @context.instance_variable_set("@error_from_user", e.message) if e.message.present?
53
29
 
54
- fail!
55
- end
30
+ self.class._failure_handlers.each do |handler|
31
+ handler.execute_if_matches(exception: e, action: self)
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)
56
36
 
57
- alias_method :original_run!, :run!
58
- alias_method :run!, :run_with_exception_swallowing!
37
+ @context.exception = e
38
+ end
59
39
 
60
- # Tweaked to check @context.object_id rather than context (since forwarding object_id causes Ruby to complain)
61
- # TODO: do we actually need the object_id check? Do we need this override at all?
62
- def run
63
- run!
64
- rescue Action::Failure => e
65
- raise if @context.object_id != e.context.object_id
40
+ @context.instance_variable_set("@failure", true)
66
41
  end
67
42
 
68
- def trigger_on_exception(e)
69
- interceptor = self.class._error_interceptor_for(exception: e, action: self)
43
+ def trigger_on_exception(exception)
44
+ interceptor = self.class._error_interceptor_for(exception:, action: self)
70
45
  return if interceptor&.should_report_error == false
71
46
 
72
47
  # Call any handlers registered on *this specific action* class
73
- _on_exception(e)
48
+ self.class._exception_handlers.each do |handler|
49
+ handler.execute_if_matches(exception:, action: self)
50
+ end
74
51
 
75
52
  # Call any global handlers
76
- Action.config.on_exception(e,
53
+ Action.config.on_exception(exception,
77
54
  action: self,
78
55
  context: respond_to?(:context_for_logging) ? context_for_logging : @context.to_h)
79
56
  rescue StandardError => e
@@ -83,17 +60,12 @@ module Action
83
60
  end
84
61
 
85
62
  class << base
86
- def call_bang_with_unswallowed_exceptions(context = {})
63
+ def call!(context = {})
87
64
  result = call(context)
88
65
  return result if result.ok?
89
66
 
90
- raise result.exception if result.exception
91
-
92
- raise Action::Failure.new(result.instance_variable_get("@context"), message: result.error)
67
+ raise result.exception || Action::Failure.new(result.error)
93
68
  end
94
-
95
- alias_method :original_call!, :call!
96
- alias_method :call!, :call_bang_with_unswallowed_exceptions
97
69
  end
98
70
  end
99
71
  end
@@ -114,10 +86,34 @@ module Action
114
86
  _register_error_interceptor(matcher, message, should_report_error: false, **match_and_messages)
115
87
  end
116
88
 
117
- def on_exception(matcher = StandardError, &block)
89
+ # ONLY raised exceptions (i.e. NOT fail!). Skipped if exception is rescued via .rescues.
90
+ def on_exception(matcher = -> { true }, &handler)
118
91
  raise ArgumentError, "on_exception must be called with a block" unless block_given?
119
92
 
120
- self._exception_handlers += [CustomErrorHandler.new(matcher:, block:)]
93
+ self._exception_handlers += [Action::EventHandlers::ConditionalHandler.new(matcher:, handler:)]
94
+ end
95
+
96
+ # ONLY raised on fail! (i.e. NOT unhandled exceptions).
97
+ def on_failure(matcher = -> { true }, &handler)
98
+ raise ArgumentError, "on_failure must be called with a block" unless block_given?
99
+
100
+ self._failure_handlers += [Action::EventHandlers::ConditionalHandler.new(matcher:, handler:)]
101
+ end
102
+
103
+ # Handles both fail! and unhandled exceptions... but is NOT affected by .rescues
104
+ def on_error(matcher = -> { true }, &handler)
105
+ raise ArgumentError, "on_error must be called with a block" unless block_given?
106
+
107
+ self._error_handlers += [Action::EventHandlers::ConditionalHandler.new(matcher:, handler:)]
108
+ end
109
+
110
+ # Syntactic sugar for "after { try" (after, but if it fails do NOT fail the action)
111
+ def on_success(&block)
112
+ raise ArgumentError, "on_success must be called with a block" unless block_given?
113
+
114
+ after do
115
+ try { instance_exec(&block) }
116
+ end
121
117
  end
122
118
 
123
119
  def default_error = new.internal_context.default_error
@@ -134,9 +130,11 @@ module Action
134
130
  method_name = should_report_error ? "error_from" : "rescues"
135
131
  raise ArgumentError, "#{method_name} must be called with a key/value pair, or else keyword args" if [matcher, message].compact.size == 1
136
132
 
137
- { matcher => message }.compact.merge(match_and_messages).each do |(matcher, message)| # rubocop:disable Lint/ShadowingOuterLocalVariable
138
- self._custom_error_interceptors += [CustomErrorInterceptor.new(matcher:, message:, should_report_error:)]
133
+ interceptors = { matcher => message }.compact.merge(match_and_messages).map do |(matcher, message)| # rubocop:disable Lint/ShadowingOuterLocalVariable
134
+ Action::EventHandlers::CustomErrorInterceptor.new(matcher:, message:, should_report_error:)
139
135
  end
136
+
137
+ self._custom_error_interceptors += interceptors
140
138
  end
141
139
  end
142
140
 
@@ -147,8 +145,7 @@ module Action
147
145
  @context.instance_variable_set("@failure", true)
148
146
  @context.error_from_user = message if message.present?
149
147
 
150
- # TODO: should we use context_for_logging here? But doublecheck the one place where we're checking object_id on it...
151
- raise Action::Failure.new(@context) # rubocop:disable Style/RaiseArgs
148
+ raise Action::Failure, message
152
149
  end
153
150
 
154
151
  def try
@@ -161,18 +158,6 @@ module Action
161
158
  end
162
159
 
163
160
  delegate :default_error, to: :internal_context
164
-
165
- def _on_exception(exception)
166
- handlers = self.class._exception_handlers.select do |this|
167
- CustomErrorInterceptor.matches?(matcher: this.matcher, exception:, action: self)
168
- end
169
-
170
- handlers.each do |handler|
171
- instance_exec(exception, &handler.block)
172
- rescue StandardError => e
173
- warn("Ignoring #{e.class.name} in on_exception hook: #{e.message}")
174
- end
175
- end
176
161
  end
177
162
  end
178
163
  end
@@ -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,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"
4
+ VERSION = "0.1.0-alpha.2.5"
5
5
  end
data/lib/axn.rb CHANGED
@@ -13,11 +13,11 @@ require_relative "action/core/top_level_around_hook"
13
13
  require_relative "action/core/contract"
14
14
  require_relative "action/core/swallow_exceptions"
15
15
  require_relative "action/core/hoist_errors"
16
- require_relative "action/core/enqueueable"
17
16
 
18
17
  require_relative "axn/factory"
19
18
 
20
19
  require_relative "action/attachable"
20
+ require_relative "action/enqueueable"
21
21
 
22
22
  def Axn(callable, **) # rubocop:disable Naming/MethodName
23
23
  return callable if callable.is_a?(Class) && callable < Action
@@ -42,10 +42,9 @@ module Action
42
42
 
43
43
  include HoistErrors
44
44
 
45
- include Enqueueable
46
-
47
45
  # --- Extensions ---
48
46
  include Attachable
47
+ include Enqueueable
49
48
 
50
49
  # Allow additional automatic includes to be configured
51
50
  Array(Action.config.additional_includes).each { |mod| include mod }
@@ -62,3 +61,5 @@ module Action
62
61
  end
63
62
  end
64
63
  end
64
+
65
+ 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
4
+ version: 0.1.0.pre.alpha.2.5
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-05-27 00:00:00.000000000 Z
11
+ date: 2025-06-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activemodel
@@ -92,12 +92,16 @@ files:
92
92
  - lib/action/core/context_facade.rb
93
93
  - lib/action/core/contract.rb
94
94
  - lib/action/core/contract_validator.rb
95
- - lib/action/core/enqueueable.rb
95
+ - lib/action/core/event_handlers.rb
96
96
  - lib/action/core/exceptions.rb
97
97
  - lib/action/core/hoist_errors.rb
98
98
  - lib/action/core/logging.rb
99
99
  - lib/action/core/swallow_exceptions.rb
100
100
  - lib/action/core/top_level_around_hook.rb
101
+ - lib/action/enqueueable.rb
102
+ - lib/action/enqueueable/enqueue_all_in_background.rb
103
+ - lib/action/enqueueable/enqueue_all_worker.rb
104
+ - lib/action/enqueueable/via_sidekiq.rb
101
105
  - lib/axn.rb
102
106
  - lib/axn/factory.rb
103
107
  - lib/axn/version.rb
@@ -1,74 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Action
4
- module Enqueueable
5
- def self.included(base)
6
- base.class_eval do
7
- begin
8
- require "sidekiq"
9
- include Sidekiq::Job
10
- rescue LoadError
11
- puts "Sidekiq not available -- skipping Enqueueable"
12
- return
13
- end
14
-
15
- define_method(:perform) do |*args|
16
- context = self.class._params_from_global_id(args.first)
17
- bang = args.size > 1 ? args.last : false
18
-
19
- if bang
20
- self.class.call!(context)
21
- else
22
- self.class.call(context)
23
- end
24
- end
25
-
26
- def self.enqueue(context = {})
27
- perform_async(_process_context_to_sidekiq_args(context))
28
- end
29
-
30
- def self.enqueue!(context = {})
31
- perform_async(_process_context_to_sidekiq_args(context), true)
32
- end
33
-
34
- def self.queue_options(opts)
35
- opts = opts.transform_keys(&:to_s)
36
- self.sidekiq_options_hash = get_sidekiq_options.merge(opts)
37
- end
38
-
39
- private
40
-
41
- def self._process_context_to_sidekiq_args(context)
42
- client = Sidekiq::Client.new
43
-
44
- _params_to_global_id(context).tap do |args|
45
- if client.send(:json_unsafe?, args).present?
46
- raise ArgumentError,
47
- "Cannot pass non-JSON-serializable objects to Sidekiq. Make sure all objects in the context are serializable (or respond to to_global_id)."
48
- end
49
- end
50
- end
51
-
52
- def self._params_to_global_id(context)
53
- context.stringify_keys.each_with_object({}) do |(key, value), hash|
54
- if value.respond_to?(:to_global_id)
55
- hash["#{key}_as_global_id"] = value.to_global_id.to_s
56
- else
57
- hash[key] = value
58
- end
59
- end
60
- end
61
-
62
- def self._params_from_global_id(params)
63
- params.each_with_object({}) do |(key, value), hash|
64
- if key.end_with?("_as_global_id")
65
- hash[key.delete_suffix("_as_global_id")] = GlobalID::Locator.locate(value)
66
- else
67
- hash[key] = value
68
- end
69
- end.symbolize_keys
70
- end
71
- end
72
- end
73
- end
74
- end