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 +4 -4
- data/.rubocop.yml +6 -2
- data/CHANGELOG.md +18 -0
- data/docs/recipes/testing.md +50 -0
- data/docs/reference/class.md +29 -2
- data/docs/reference/configuration.md +9 -12
- data/docs/reference/instance.md +6 -0
- data/docs/usage/using.md +30 -0
- data/docs/usage/writing.md +10 -12
- data/lib/action/core/configuration.rb +5 -4
- data/lib/action/core/context_facade.rb +14 -5
- data/lib/action/core/contract.rb +22 -10
- data/lib/action/core/contract_validator.rb +4 -2
- data/lib/action/core/event_handlers.rb +64 -0
- data/lib/action/core/exceptions.rb +7 -6
- data/lib/action/core/hoist_errors.rb +4 -2
- data/lib/action/core/logging.rb +3 -9
- data/lib/action/core/swallow_exceptions.rb +61 -76
- data/lib/action/core/top_level_around_hook.rb +43 -27
- data/lib/action/enqueueable/enqueue_all_in_background.rb +17 -0
- data/lib/action/enqueueable/enqueue_all_worker.rb +21 -0
- data/lib/action/enqueueable/via_sidekiq.rb +76 -0
- data/lib/action/enqueueable.rb +15 -0
- data/lib/axn/factory.rb +1 -2
- data/lib/axn/version.rb +1 -1
- data/lib/axn.rb +4 -3
- metadata +7 -3
- data/lib/action/core/enqueueable.rb +0 -74
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ad00a162e22de940a1c6e4a4de1f75533fb1aa5440c6cf8808f81a964e2457d4
|
4
|
+
data.tar.gz: dfb840d11657f80f92d3e1aa19bef3cb64b1d28a84f70f0dd1b92c5726f4fd7f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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:
|
43
|
+
Max: 60
|
40
44
|
|
41
45
|
Metrics/CyclomaticComplexity:
|
42
|
-
Max:
|
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
|
|
data/docs/recipes/testing.md
CHANGED
@@ -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):
|
data/docs/reference/class.md
CHANGED
@@ -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
|
-
##
|
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.
|
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
|
-
## `
|
88
|
+
## `default_log_level`
|
88
89
|
|
89
|
-
|
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
|
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).
|
data/docs/reference/instance.md
CHANGED
@@ -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)
|
data/docs/usage/writing.md
CHANGED
@@ -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
|
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 `
|
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
|
-
|
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 :
|
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
|
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
|
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
|
-
|
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}
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
data/lib/action/core/contract.rb
CHANGED
@@ -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
|
-
|
61
|
-
|
62
|
-
default_error
|
63
|
-
|
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 ?
|
138
|
+
implicitly_allowed_fields = direction == :inbound ? _declared_fields(:outbound) : []
|
127
139
|
|
128
|
-
klass.new(action: self, context: @context, declared_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(*
|
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
|
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
|
-
#
|
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
|
-
|
6
|
+
DEFAULT_MESSAGE = "Execution was halted"
|
7
7
|
|
8
|
-
def initialize(
|
9
|
-
|
10
|
-
|
11
|
-
@context = context
|
8
|
+
def initialize(message = nil, **)
|
9
|
+
@message = message
|
10
|
+
super(**)
|
12
11
|
end
|
13
12
|
|
14
|
-
def message
|
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
|
33
|
-
#
|
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"
|
data/lib/action/core/logging.rb
CHANGED
@@ -14,8 +14,9 @@ module Action
|
|
14
14
|
end
|
15
15
|
|
16
16
|
module ClassMethods
|
17
|
-
def
|
18
|
-
|
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
|
45
|
-
|
18
|
+
def run
|
19
|
+
run!
|
46
20
|
rescue StandardError => e
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
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
|
-
|
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
|
-
|
55
|
-
|
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
|
-
|
58
|
-
|
37
|
+
@context.exception = e
|
38
|
+
end
|
59
39
|
|
60
|
-
|
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(
|
69
|
-
interceptor = self.class._error_interceptor_for(exception
|
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
|
-
|
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(
|
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
|
63
|
+
def call!(context = {})
|
87
64
|
result = call(context)
|
88
65
|
return result if result.ok?
|
89
66
|
|
90
|
-
raise 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
|
-
|
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 += [
|
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).
|
138
|
-
|
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
|
-
|
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
|
14
|
-
def
|
15
|
-
|
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
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
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
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
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|
|
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
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
|
+
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-
|
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/
|
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
|