axn 0.1.0.pre.alpha.4.2 → 0.1.0.pre.alpha.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -2
- data/Rakefile +68 -0
- data/docs/.vitepress/config.mjs +1 -0
- data/docs/advanced/mountable.md +39 -9
- data/docs/recipes/suppressing-duplicate-async-reports.md +65 -0
- data/docs/reference/async/sidekiq.md +8 -0
- data/docs/reference/axn-result.md +1 -1
- data/docs/reference/class.md +100 -3
- data/docs/usage/writing.md +24 -1
- data/lib/axn/async/adapters/sidekiq/death_handler.rb +1 -1
- data/lib/axn/async/adapters/sidekiq/retry_helpers.rb +13 -2
- data/lib/axn/core/context/facade_inspector.rb +11 -5
- data/lib/axn/core/contract.rb +166 -13
- data/lib/axn/core/contract_for_subfields.rb +32 -7
- data/lib/axn/core/field_resolvers/extract.rb +11 -6
- data/lib/axn/core/validation/fields.rb +2 -0
- data/lib/axn/core/validation/subfields.rb +24 -5
- data/lib/axn/core/validation/validators/of_validator.rb +36 -0
- data/lib/axn/core/validation/validators/shape_validator.rb +76 -0
- data/lib/axn/core/validation/validators/type_validator.rb +11 -29
- data/lib/axn/core.rb +2 -0
- data/lib/axn/executor.rb +8 -2
- data/lib/axn/internal/exception_context.rb +9 -3
- data/lib/axn/internal/field_config.rb +8 -0
- data/lib/axn/internal/timing.rb +14 -0
- data/lib/axn/mountable/helpers/class_builder.rb +7 -1
- data/lib/axn/mountable.rb +44 -35
- data/lib/axn/result.rb +24 -0
- data/lib/axn/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a911da94d085860df8fc6aed9bbe869edae27c2ff0e5821b2c07a043644c4fb5
|
|
4
|
+
data.tar.gz: 0d56247c256cc52efc8258a1716b0c4274114411948b084fe872fc63285907c3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d39402f76d8df64e831007e046f0d5c8ac7fcd009246bbee9e7296db789b3afa270f5e63987eb23c88821f3f0ee2fecf3e7ec576b5dc9ce0f9e0972ba1811f93
|
|
7
|
+
data.tar.gz: 2347b225abbab7c3e705ca071b661e104c18c4d6333d5eb8563eadf26559035485f79682f4e4a23f3c6e3dabf30209475570fd07ab511a9b75f9d5537d320f41
|
data/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
4
|
-
*
|
|
3
|
+
## 0.1.0-alpha.4.3
|
|
4
|
+
* [FEAT] Plain namespace **modules** can now host mounted actions: `include Axn::Mountable` on a module (not just a class) exposes `mount_axn` / `mount_axn_method` / `step`. Class hosts keep the existing `class_attribute` + `inherited` behavior; module hosts use singleton accessors and skip the `inherited` hook (modules have no subclasses). Also fixes a `.name` clobber so passing an already-named class to `mount_axn` preserves its original name instead of rewriting it to the `Axns` namespace path.
|
|
5
|
+
* [BUGFIX] Fixed an off-by-one in `async.attempt` reported from the Sidekiq death handler: Sidekiq increments `retry_count` before invoking death handlers, so an exhausted `retry: 3` job (4 executions) reported attempt `5` instead of `4`. The bug was metadata-only — control flow (`retries_exhausted?`, `first_attempt?`, `should_trigger_on_exception?`) was unaffected. Also documents why framework-native integrations (e.g. Honeybadger's Sidekiq plugin) can produce duplicate async error reports, with a tag-and-filter suppression recipe.
|
|
6
|
+
* [BUGFIX] Subfield names that collide with a method on the parent value (e.g. `zip`, `count`, `first` — any `Hash`/`Enumerable` method) are now read as keys instead of being dispatched as method calls. Previously `expects :zip, on: :address` extracted `address.zip` (`Enumerable#zip`) and failed with a bogus error; `FieldResolvers::Extract` now digs the key first for Hash-like sources and only falls back to a reader method for non-diggable objects (e.g. `Data` instances).
|
|
7
|
+
* [FEAT] `expects … on:` now accepts a **dotted path** to reach a deeply-nested parent (e.g. `expects :zip, on: "address.billing", type: String` validates `address[:billing][:zip]` and defines a flat `zip` reader). The root segment must be a declared field/subfield. `default:`/`preprocess:`/`sensitive:` combined with a dotted `on:` raise `ArgumentError` (writing into — and redacting — an arbitrary nested path isn't supported yet); single-key `on:` is unchanged.
|
|
8
|
+
* [FEAT] Add block syntax for declaring the per-member shape of a structured field on `expects`/`exposes`. On a `type: Array`, `type: Hash`, or class-typed field, a block declares member contracts: `expects :items, type: Array do field :status, type: String, inclusion: { in: %w[a b] } end`. Members accept the same options as top-level fields (`type`, `inclusion`, `optional`, `description`, …) and recurse via nested blocks. For arrays each element is validated with indexed errors (`element at index 2: status …`); for a single Hash/object value its members are validated directly. The block requires a single structured `type:` (raises `ArgumentError` on scalars, unions, or no type), composes with `of:` (which still checks element class), and — unlike `on:` subfields — defines **no** reader methods. Downstream tooling reads members from `config.validations[:shape][:members]`.
|
|
9
|
+
* [FEAT] Add `of:` array-element validation for `expects`/`exposes`. On a `type: Array` field, `of:` validates each element: a single class (`of: String`), a union (`of: [String, Numeric]` — an element passes if it matches *any*), the `:boolean`/`:uuid`/`:params` symbols, or a `Data.define` class. Only valid alongside `type: Array` (raises `ArgumentError` otherwise, including for unions like `type: [Array, String]`). Error messages report the failing element's index (e.g. `element at index 2 is not a String`) and honor a custom `message:`. `optional`/`allow_blank`/`allow_nil` govern whether the whole field may be absent — they do not make individual elements blank-able. Downstream tooling can read the element type from `config.validations[:of][:klass]`.
|
|
10
|
+
* [FEAT] `exposes`-declared fields that are also `expects`-declared are now auto-copied from the input into the result on **all** outcome paths — success, `done!`, `fail!`, and unhandled exception. Previously, the auto-copy only ran on success/`done!` paths, leaving `result.field` as `nil` after `fail!` or an exception even when the field was provided as input. This is particularly useful for re-exposing mutated ActiveRecord objects (e.g. inspecting `user.errors` after a failed save). Explicit `expose` calls before a failure continue to work and take precedence.
|
|
11
|
+
* [FEAT] Execution log messages now display elapsed time in human-readable units (milliseconds, seconds, minutes, or hours) instead of always showing milliseconds
|
|
12
|
+
* [BREAKING] `exposes` no longer defines a direct reader method on the action instance. Exposed fields must be accessed via `result.field` (e.g., `result.greeting`). `expects` readers are unaffected. User-defined methods with the same name as an exposed field are preserved (DefaultCall still calls them). Use `result.field` in `success`/`error` message callables and `sensitive:` procs to access output values.
|
|
13
|
+
* [FEAT] Add boolean predicate readers for `expects` and `exposes`: `expects :enabled, type: :boolean` defines `enabled?` on the action instance; `exposes :enabled, type: :boolean` defines `result.enabled?`.
|
|
14
|
+
* [BUGFIX] `ExceptionContext.build` no longer raises `URI::GID::MissingModelIdError` when an exposed or expected value is an unpersisted ActiveRecord record. The formatter now renders such values as `#<ClassName (unpersisted)>` and the optional retry command falls back to `inspect` instead of generating `Model.find(nil)`.
|
|
15
|
+
* [FEAT] Add dynamic `sensitive:` option support for `expects` and `exposes` fields - accepts procs or symbols that are evaluated at runtime against the action instance, allowing conditional sensitivity based on input values (e.g., `exposes :data, sensitive: -> { redact_mode }`)
|
|
5
16
|
|
|
6
17
|
## 0.1.0-alpha.4.2
|
|
7
18
|
* [FEAT] Add extensible field metadata support for `expects`/`exposes`:
|
data/Rakefile
CHANGED
|
@@ -151,3 +151,71 @@ Rake::Task["release"].enhance do
|
|
|
151
151
|
Rake::Task["benchmark:release"].reenable
|
|
152
152
|
Rake::Task["benchmark:release"].invoke
|
|
153
153
|
end
|
|
154
|
+
|
|
155
|
+
# Downstream gem compatibility check
|
|
156
|
+
DOWNSTREAM_GEMS = {
|
|
157
|
+
"slack_sender" => File.expand_path("../slack_sender/slack_sender.gemspec", __dir__),
|
|
158
|
+
"data_shifter" => File.expand_path("../data_shifter/data_shifter.gemspec", __dir__),
|
|
159
|
+
"axn-mcp" => File.expand_path("../axn-mcp/axn-mcp.gemspec", __dir__),
|
|
160
|
+
"axn-ruby_llm" => File.expand_path("../axn-ruby_llm/axn-ruby_llm.gemspec", __dir__),
|
|
161
|
+
}.freeze
|
|
162
|
+
|
|
163
|
+
PARSE_AXN_REQUIREMENT = lambda { |gemspec_path|
|
|
164
|
+
content = File.read(gemspec_path)
|
|
165
|
+
match = content.match(/add_dependency\s+["']axn["']\s*,\s*(.+)$/)
|
|
166
|
+
return nil unless match
|
|
167
|
+
|
|
168
|
+
constraints = match[1].scan(/["']([^"']+)["']/).flatten
|
|
169
|
+
Gem::Requirement.new(constraints)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
namespace :downstream do
|
|
173
|
+
desc "Check whether downstream gems support the current axn version"
|
|
174
|
+
task :check do
|
|
175
|
+
require "rubygems"
|
|
176
|
+
require_relative "lib/axn/version"
|
|
177
|
+
|
|
178
|
+
current_version = Gem::Version.new(Axn::VERSION)
|
|
179
|
+
warnings = []
|
|
180
|
+
|
|
181
|
+
puts "Downstream gem compatibility with axn #{current_version}:"
|
|
182
|
+
puts ""
|
|
183
|
+
|
|
184
|
+
DOWNSTREAM_GEMS.each do |name, gemspec_path|
|
|
185
|
+
unless File.exist?(gemspec_path)
|
|
186
|
+
puts " #{name}: gemspec not found at #{gemspec_path}"
|
|
187
|
+
next
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
requirement = PARSE_AXN_REQUIREMENT.call(gemspec_path)
|
|
191
|
+
|
|
192
|
+
unless requirement
|
|
193
|
+
puts " #{name}: no axn dependency found in gemspec"
|
|
194
|
+
next
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
if requirement.satisfied_by?(current_version)
|
|
198
|
+
puts " #{name}: OK (#{requirement})"
|
|
199
|
+
else
|
|
200
|
+
puts " #{name}: NEEDS UPDATE (#{requirement} excludes #{current_version})"
|
|
201
|
+
warnings << " - #{name}: update axn constraint (currently #{requirement}) to include #{current_version}"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
puts ""
|
|
206
|
+
|
|
207
|
+
if warnings.any?
|
|
208
|
+
puts "WARNING: These downstream gems need axn version constraint updates before"
|
|
209
|
+
puts " they can use axn #{current_version}:"
|
|
210
|
+
warnings.each { |w| puts w }
|
|
211
|
+
else
|
|
212
|
+
puts "All downstream gems support axn #{current_version}."
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Warn (but don't block) about downstream gems that need updating before release
|
|
218
|
+
Rake::Task["release"].enhance do
|
|
219
|
+
Rake::Task["downstream:check"].reenable
|
|
220
|
+
Rake::Task["downstream:check"].invoke
|
|
221
|
+
end
|
data/docs/.vitepress/config.mjs
CHANGED
|
@@ -58,6 +58,7 @@ export default defineConfig({
|
|
|
58
58
|
{ text: 'Validating User Input', link: '/recipes/validating-user-input' },
|
|
59
59
|
{ text: 'Testing Actions', link: '/recipes/testing' },
|
|
60
60
|
{ text: 'RuboCop Integration', link: '/recipes/rubocop-integration' },
|
|
61
|
+
{ text: 'Suppressing Duplicate Async Reports', link: '/recipes/suppressing-duplicate-async-reports' },
|
|
61
62
|
]
|
|
62
63
|
},
|
|
63
64
|
{
|
data/docs/advanced/mountable.md
CHANGED
|
@@ -17,6 +17,39 @@ When you attach an action to a class, you get multiple ways to access it:
|
|
|
17
17
|
1. **Direct method calls** on the class (e.g., `SomeClass.foo`), which depend on how you told it to mount
|
|
18
18
|
3. **Namespace method calls** (e.g., `SomeClass::Axns.foo`) which always call the underlying axn directly (i.e. returning Axn::Result like a normal SomeAxn.call)
|
|
19
19
|
|
|
20
|
+
## Host Types
|
|
21
|
+
|
|
22
|
+
### Classes (`include Axn`)
|
|
23
|
+
|
|
24
|
+
The typical host is a class that also `include Axn`. It gets the full mounting DSL, inheritable descriptors (subclasses receive mounts from their parent), and the `::Axns` namespace:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
class UserService
|
|
28
|
+
include Axn
|
|
29
|
+
|
|
30
|
+
mount_axn(:create_user) { |email:| User.create!(email:) }
|
|
31
|
+
end
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Plain namespace modules (`include Axn::Mountable`)
|
|
35
|
+
|
|
36
|
+
If your host is a namespace module — not itself an action — include `Axn::Mountable` directly instead:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
module MyGem
|
|
40
|
+
include Axn::Mountable
|
|
41
|
+
|
|
42
|
+
mount_axn :process, MyGem::ProcessAction
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
MyGem.process(...) # => Axn::Result
|
|
46
|
+
MyGem.process!(...) # raises on failure
|
|
47
|
+
MyGem.process_async(...)
|
|
48
|
+
MyGem::Axns.process(...)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Mounting an existing named class on any host preserves the class's original `.name` — it will not be overwritten with the `::Axns::` namespace path.
|
|
52
|
+
|
|
20
53
|
## Attachment Strategies
|
|
21
54
|
|
|
22
55
|
### `axn` Strategy
|
|
@@ -28,15 +61,14 @@ class UserService
|
|
|
28
61
|
include Axn
|
|
29
62
|
|
|
30
63
|
mount_axn(:create_user) do |email:, name:|
|
|
31
|
-
|
|
32
|
-
expose :user_id, user.id
|
|
64
|
+
User.create!(email: email, name: name)
|
|
33
65
|
end
|
|
34
66
|
end
|
|
35
67
|
|
|
36
68
|
# Usage
|
|
37
69
|
result = UserService.create_user(email: "user@example.com", name: "John")
|
|
38
70
|
if result.ok?
|
|
39
|
-
puts "User created
|
|
71
|
+
puts "User created: #{result.value.inspect}"
|
|
40
72
|
else
|
|
41
73
|
puts "Error: #{result.error}"
|
|
42
74
|
end
|
|
@@ -130,8 +162,8 @@ class DataProcessor
|
|
|
130
162
|
async :sidekiq
|
|
131
163
|
|
|
132
164
|
mount_axn(:process_data, async: :sidekiq) do |data:|
|
|
133
|
-
# Processing logic
|
|
134
|
-
|
|
165
|
+
# Processing logic; return value is auto-exposed as result.value
|
|
166
|
+
data.count
|
|
135
167
|
end
|
|
136
168
|
end
|
|
137
169
|
|
|
@@ -185,13 +217,12 @@ class UserService
|
|
|
185
217
|
# Inherits lifecycle (hooks, callbacks, messages, async) but not fields
|
|
186
218
|
mount_axn :create_user do
|
|
187
219
|
# Will run log_start before and track_success after
|
|
188
|
-
|
|
220
|
+
User.create!(email: "example@example.com")
|
|
189
221
|
end
|
|
190
222
|
|
|
191
223
|
# Completely independent - no inheritance
|
|
192
224
|
step :validate_user do
|
|
193
225
|
# Will NOT run log_start or track_success
|
|
194
|
-
expose :valid, true
|
|
195
226
|
end
|
|
196
227
|
end
|
|
197
228
|
```
|
|
@@ -427,8 +458,7 @@ class UserService
|
|
|
427
458
|
include Axn
|
|
428
459
|
|
|
429
460
|
mount_axn(:create) do |email:, name:|
|
|
430
|
-
|
|
431
|
-
expose :user_id, user.id
|
|
461
|
+
User.create!(email: email, name: name)
|
|
432
462
|
end
|
|
433
463
|
|
|
434
464
|
mount_axn_method(:find_by_email) do |email:|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Suppressing Duplicate Async Error Reports
|
|
2
|
+
|
|
3
|
+
When an Axn async job fails, your error monitoring integration may report the exception **twice**: once via Axn's `on_exception` path, and once natively from the background job framework's own error integration.
|
|
4
|
+
|
|
5
|
+
For example, with Honeybadger + Sidekiq: Honeybadger's Sidekiq plugin independently catches the re-raised exception on every execution, producing a separate raw fault in Honeybadger regardless of your `async_exception_reporting` setting. If you've configured `:first_and_exhausted`, you'll still see a raw Sidekiq `RuntimeError` fault with one notice per retry — which makes the reporting look broken.
|
|
6
|
+
|
|
7
|
+
## General Approach
|
|
8
|
+
|
|
9
|
+
The fix belongs in your error reporter, not in Axn. Suppress framework-native reports for Axn actions (since Axn is already handling reporting via `on_exception`) while leaving non-Axn jobs unaffected.
|
|
10
|
+
|
|
11
|
+
The two signals you need:
|
|
12
|
+
|
|
13
|
+
1. **Is this an Axn action?** Check whether the job class includes `Axn::Core`.
|
|
14
|
+
2. **Was this notice sent by Axn or by the framework natively?** Tag Axn-authored notices in your `on_exception` handler so they can be distinguished from the native ones.
|
|
15
|
+
|
|
16
|
+
Add a known key when calling your error reporter from `on_exception`:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
Axn.configure do |c|
|
|
20
|
+
c.on_exception = proc do |e, action:, context:|
|
|
21
|
+
# Tag this notice as Axn-authored so we can identify it in before_notify filters
|
|
22
|
+
Honeybadger.notify(e, context: context.merge(axn: true))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Honeybadger + Sidekiq Example
|
|
28
|
+
|
|
29
|
+
Honeybadger's `before_notify` hook lets you inspect and halt notices before they're sent:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
# config/initializers/honeybadger.rb
|
|
33
|
+
Honeybadger.configure do |config|
|
|
34
|
+
config.before_notify do |notice|
|
|
35
|
+
# Axn-authored notices (tagged via on_exception) always pass through
|
|
36
|
+
next if notice.context[:axn] || notice.context["axn"]
|
|
37
|
+
|
|
38
|
+
# Halt native Sidekiq/ActiveJob notices for Axn actions —
|
|
39
|
+
# Axn's on_exception is handling reporting for these.
|
|
40
|
+
component = notice.component.to_s
|
|
41
|
+
|
|
42
|
+
# Direct Sidekiq worker: component is the Axn action class itself
|
|
43
|
+
klass = component.safe_constantize
|
|
44
|
+
next notice.halt! if klass&.include?(Axn::Core)
|
|
45
|
+
|
|
46
|
+
# ActiveJob proxy: component is "MyAction::ActiveJobProxy"
|
|
47
|
+
if component.end_with?("::ActiveJobProxy")
|
|
48
|
+
action_klass = component.delete_suffix("::ActiveJobProxy").safe_constantize
|
|
49
|
+
next notice.halt! if action_klass&.include?(Axn::Core)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**What this does:**
|
|
56
|
+
|
|
57
|
+
- Axn-authored notices (tagged `axn: true`) pass through unchanged
|
|
58
|
+
- Native Sidekiq/ActiveJob notices for Axn actions are halted
|
|
59
|
+
- Notices for non-Axn workers are unaffected
|
|
60
|
+
- Multiple `before_notify` hooks compose — this doesn't interfere with other app-specific filters
|
|
61
|
+
|
|
62
|
+
## Notes
|
|
63
|
+
|
|
64
|
+
- The exact implementation varies by error reporter and adapter. The pattern is the same: detect Axn ownership at the filter layer and suppress native reports, passing through Axn-authored ones.
|
|
65
|
+
- This fix is typically applied in your application or framework layer rather than in Axn itself, since it depends on which error reporter you're using.
|
|
@@ -212,6 +212,14 @@ Axn::Async::Adapters::Sidekiq::AutoConfigure.death_handler_registered?
|
|
|
212
212
|
# => should be true
|
|
213
213
|
```
|
|
214
214
|
|
|
215
|
+
### Duplicate error reports from Honeybadger or other error integrations
|
|
216
|
+
|
|
217
|
+
If you're seeing duplicate faults in your error tracker — for example, both an Axn-authored notice and a raw `RuntimeError` fault with one entry per retry — this is caused by your error monitoring integration reporting the re-raised exception independently of Axn's `on_exception` path.
|
|
218
|
+
|
|
219
|
+
Axn re-raises unexpected exceptions after reporting so that Sidekiq can retry them. Integrations like Honeybadger's Sidekiq plugin intercept that re-raised exception directly, bypassing your `async_exception_reporting` setting entirely.
|
|
220
|
+
|
|
221
|
+
See [Suppressing Duplicate Async Error Reports](/recipes/suppressing-duplicate-async-reports) for an explanation and a Honeybadger + Sidekiq filter example.
|
|
222
|
+
|
|
215
223
|
### Jobs not retrying on fail!
|
|
216
224
|
|
|
217
225
|
This is expected behavior. `fail!` indicates a business decision, not a transient error. If you need retries for a specific failure case, raise an exception instead:
|
|
@@ -12,7 +12,7 @@ Every `call` invocation on an Axn will return an `Axn::Result` instance, which p
|
|
|
12
12
|
| `outcome` | The execution outcome as a string inquirer (`success?`, `failure?`, `exception?`)
|
|
13
13
|
| `elapsed_time` | Execution time in milliseconds (Float)
|
|
14
14
|
| `finalized?` | `true` if the result has completed execution (either successfully or with an exception), `false` if still in progress
|
|
15
|
-
| any `expose`d values | guaranteed to be set if `ok
|
|
15
|
+
| any `expose`d values | guaranteed to be set if `ok?`; fields that are also `expects`-declared are auto-copied and available on `fail!` and exception paths too (see [Re-exposing an expected field](/usage/writing#re-exposing-an-expected-field-auto-copy))
|
|
16
16
|
|
|
17
17
|
NOTE: `success` and `error` (and so implicitly `message`) can be configured per-action via [the `success` and `error` declarations](/reference/class#success-and-error).
|
|
18
18
|
|
data/docs/reference/class.md
CHANGED
|
@@ -20,6 +20,58 @@ Both `expects` and `exposes` support the same core options:
|
|
|
20
20
|
| `type` | `expects :foo, type: String` | Custom type validation -- fail unless `name.is_a?(String)`
|
|
21
21
|
| anything else | `expects :foo, inclusion: { in: [:apple, :peach] }` | Any other arguments will be processed [as ActiveModel validations](https://guides.rubyonrails.org/active_record_validations.html) (i.e. as if passed to `validates :foo, <...>` on an ActiveRecord model)
|
|
22
22
|
|
|
23
|
+
### Dynamic `sensitive` fields
|
|
24
|
+
|
|
25
|
+
The `sensitive` option can accept a proc or symbol in addition to a boolean, allowing you to conditionally filter fields based on runtime values:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
class MyAction
|
|
29
|
+
include Axn
|
|
30
|
+
|
|
31
|
+
expects :include_pii, type: :boolean
|
|
32
|
+
expects :ssn, sensitive: -> { !include_pii }
|
|
33
|
+
|
|
34
|
+
exposes :api_response, sensitive: :should_redact?
|
|
35
|
+
|
|
36
|
+
def call
|
|
37
|
+
expose api_response: fetch_data
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def should_redact?
|
|
43
|
+
!include_pii || result.api_response[:contains_secrets]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# When include_pii is false, ssn is filtered
|
|
48
|
+
MyAction.call(include_pii: false, ssn: "123-45-6789")
|
|
49
|
+
#=> inputs: { ssn: [FILTERED], include_pii: false }
|
|
50
|
+
|
|
51
|
+
# When include_pii is true, ssn is visible
|
|
52
|
+
MyAction.call(include_pii: true, ssn: "123-45-6789")
|
|
53
|
+
#=> inputs: { ssn: "123-45-6789", include_pii: true }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The callable receives no arguments and is evaluated via `instance_exec`, so it has access to:
|
|
57
|
+
- All `expects` field values (via their reader methods, e.g., `include_pii`)
|
|
58
|
+
- Exposed values via `result.field` (e.g., `result.api_response`) — bare field names are **not** available for `exposes`-only fields
|
|
59
|
+
- Any instance methods defined on the action
|
|
60
|
+
|
|
61
|
+
::: warning Timing: sensitive evaluated before defaults
|
|
62
|
+
For `expects` fields, the `sensitive` callable is evaluated **before** defaults are applied. This means if your sensitivity logic depends on another field's value, that field should either be required or you should handle `nil` explicitly:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
# CAUTION: mode may be nil if caller doesn't provide it
|
|
66
|
+
expects :mode, default: "public"
|
|
67
|
+
expects :api_key, sensitive: -> { mode != "debug" } # mode could be nil here!
|
|
68
|
+
|
|
69
|
+
# SAFER: handle nil explicitly
|
|
70
|
+
expects :api_key, sensitive: -> { mode.nil? || mode != "debug" }
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
This is because automatic logging of inputs happens before defaults are applied in the execution flow. For `exposes` fields, this is not a concern since output logging happens after the action completes.
|
|
74
|
+
:::
|
|
23
75
|
|
|
24
76
|
### Validation details
|
|
25
77
|
|
|
@@ -27,11 +79,16 @@ Both `expects` and `exposes` support the same core options:
|
|
|
27
79
|
While we _support_ complex interface validations, in practice you usually just want a `type`, if anything. Remember this is your validation about how the action is called, _not_ pretty user-facing errors (there's [a different pattern for that](/recipes/validating-user-input)).
|
|
28
80
|
:::
|
|
29
81
|
|
|
30
|
-
In addition to the [standard ActiveModel validations](https://guides.rubyonrails.org/active_record_validations.html), we also support
|
|
82
|
+
In addition to the [standard ActiveModel validations](https://guides.rubyonrails.org/active_record_validations.html), we also support five additional custom validators:
|
|
31
83
|
* `type: Foo` - fails unless the provided value `.is_a?(Foo)`
|
|
32
84
|
* Edge case: use `type: :boolean` to handle a boolean field (since ruby doesn't have a Boolean class to pass in directly)
|
|
85
|
+
* Boolean `expects` fields also define a predicate reader, so `expects :enabled, type: :boolean` provides both `enabled` and `enabled?` on the action instance. The same applies to subfield readers unless `readers: false` is set. Boolean `exposes` fields provide predicate readers on the result, so `exposes :enabled, type: :boolean` provides `result.enabled?`.
|
|
33
86
|
* Edge case: use `type: :uuid` to handle a confirming given string is a UUID (with or without `-` chars)
|
|
34
87
|
* Edge case: use `type: :params` to accept either a Hash or ActionController::Parameters (Rails-compatible)
|
|
88
|
+
* `of: Foo` - for `type: Array` fields, validates each element (fails unless every element `.is_a?(Foo)`)
|
|
89
|
+
* Accepts the same forms as `type:`: a single class (`of: String`), a union array (`of: [String, Numeric]` — an element passes if it matches *any*), the `:boolean`/`:uuid`/`:params` symbols, or a `Data.define` class
|
|
90
|
+
* Only valid alongside `type: Array` (exactly) — using it on any other type, including a union like `type: [Array, String]`, raises `ArgumentError` at declaration time
|
|
91
|
+
* Error messages report the failing element's index (e.g. `element at index 2 is not a String`). Pass `of: { klass: Foo, message: "..." }` to override the type description while still reporting the index
|
|
35
92
|
* `validate: [callable]` - Support custom validations (fails if any string is returned OR if it raises an exception)
|
|
36
93
|
* Example:
|
|
37
94
|
```ruby
|
|
@@ -61,13 +118,38 @@ In addition to the [standard ActiveModel validations](https://guides.rubyonrails
|
|
|
61
118
|
* For external APIs, you can pass a `Method` object as the finder
|
|
62
119
|
:::
|
|
63
120
|
|
|
121
|
+
#### Describing the shape of structured fields (block syntax)
|
|
122
|
+
|
|
123
|
+
For a structured field — `type: Array`, `type: Hash`, or a class such as a `Data.define` — you can pass a block to declare per-member contracts (types, enums, descriptions, nesting). This works on both `expects` and `exposes`:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
exposes :integrations, type: Array, of: IntegrationRecord do
|
|
127
|
+
field :source, type: String
|
|
128
|
+
field :status, type: String, inclusion: { in: %w[connected connected_with_issues needs_reconnect incomplete error] }
|
|
129
|
+
|
|
130
|
+
field :config, type: Hash do # nested object
|
|
131
|
+
field :region, type: String
|
|
132
|
+
end
|
|
133
|
+
field :endpoints, type: Array do # nested array of objects
|
|
134
|
+
field :url, type: String
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
* The block requires a single, **structured** `type:` (Array, Hash, or a class). Declaring it on a scalar type (`String`, `Integer`, `:boolean`, …), a union (`type: [Array, String]`), or with no `type:` raises `ArgumentError` at declaration time.
|
|
140
|
+
* For `type: Array`, each element is validated and errors report the element's index (e.g. `element at index 2: status is not included in the list`). For a `type: Hash`/class, the single value's members are validated directly.
|
|
141
|
+
* Members accept validations (`type`, `inclusion`, …), `optional`/`allow_blank`/`allow_nil`, and `description`, and **recurse** — a member with its own block validates its nested members at any depth. Members are validation/schema-only, so `default:`, `preprocess:`, and `sensitive:` are **not** supported on a member (they raise at declaration time).
|
|
142
|
+
* Unlike `expects … on:` subfields, a shape block does **not** define reader methods — there is no single value to bind (an array has many elements). It is a contract on structure only.
|
|
143
|
+
* Composes with `of:`: `of:` checks each element's *class*, while the block describes the element's *fields*. `of:` is optional.
|
|
144
|
+
|
|
64
145
|
#### How `optional`, `allow_blank` and `allow_nil` work with validators
|
|
65
146
|
|
|
66
147
|
When you specify `optional: true`, `allow_blank: true`, or `allow_nil: true` on a field, these options are automatically passed through to **all validators** applied to that field. This means:
|
|
67
148
|
|
|
68
149
|
- **ActiveModel validations** (like `inclusion`, `length`, etc.) will respect these options
|
|
69
|
-
- **Custom validators** (`type`, `validate`, `model`) will also respect these options
|
|
150
|
+
- **Custom validators** (`type`, `validate`, `model`, `of`) will also respect these options
|
|
70
151
|
- **Type validator edge case**: Note passing `allow_blank` is nonsensical for type: :params and type: :boolean
|
|
152
|
+
- **`of` validator note**: these options govern whether the whole Array field may be absent — they do **not** make individual elements optional. A `nil` (or blank) element is still validated against `of:` regardless.
|
|
71
153
|
|
|
72
154
|
**Recommended approach**: Use `optional: true` instead of `allow_blank: true` for better clarity. The `optional` parameter is equivalent to `allow_blank: true` and makes the intent clearer.
|
|
73
155
|
|
|
@@ -75,7 +157,7 @@ If neither `optional`, `allow_blank` nor `allow_nil` is specified, a default pre
|
|
|
75
157
|
|
|
76
158
|
### Details specific to `.exposes`
|
|
77
159
|
|
|
78
|
-
|
|
160
|
+
For fields you declare via `exposes`, you'll need [a corresponding `expose` call](/reference/instance#expose) — unless the field is also declared via `expects`, in which case axn auto-copies it from the input into the result on all outcome paths (success, `fail!`, and exception). See [Re-exposing an expected field](/usage/writing#re-exposing-an-expected-field-auto-copy).
|
|
79
161
|
|
|
80
162
|
|
|
81
163
|
### Details specific to `.expects`
|
|
@@ -101,6 +183,21 @@ end
|
|
|
101
183
|
Defaults work the same way for subfields as they do for top-level fields - they are applied when the subfield is missing or explicitly `nil`, but not for blank values.
|
|
102
184
|
:::
|
|
103
185
|
|
|
186
|
+
#### Reaching into nested parents
|
|
187
|
+
|
|
188
|
+
`on:` accepts a **dotted path** to declare a subfield of a deeply-nested parent, with a clean flat reader named after the field:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
expects :address, type: Hash
|
|
192
|
+
expects :zip, on: "address.billing", type: String # validates address[:billing][:zip]; defines a `zip` reader
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The **root** segment (`address`) must be a declared field (or subfield); intermediate segments are assumed to be hashes. The reader is named after the subfield (`zip`) — there's no ambiguity, since the field name itself has no dots.
|
|
196
|
+
|
|
197
|
+
::: warning
|
|
198
|
+
`default:`, `preprocess:`, and `sensitive:` are **not** supported on a dotted `on:` (they raise at declaration time) — `default:`/`preprocess:` write into the parent, and `sensitive:` relies on the log filter matching a top-level field, neither of which handles an arbitrary nested path yet. Use them on a single-key `on:`, or declare the intermediate levels explicitly.
|
|
199
|
+
:::
|
|
200
|
+
|
|
104
201
|
#### Disabling subfield readers
|
|
105
202
|
|
|
106
203
|
By default, subfields create top-level reader methods (e.g., `random` in the example above). You can disable this with `readers: false`:
|
data/docs/usage/writing.md
CHANGED
|
@@ -47,7 +47,7 @@ To abort execution with a specific error message, call `fail!`. You can also pro
|
|
|
47
47
|
|
|
48
48
|
To complete execution early with a success result, call `done!` with an optional success message and exposures as keyword arguments.
|
|
49
49
|
|
|
50
|
-
If you declare that your action `exposes` anything, you need to actually `expose` it.
|
|
50
|
+
If you declare that your action `exposes` anything, you need to actually `expose` it — unless you're re-exposing a field you also `expects`, in which case axn auto-copies it for you (see below).
|
|
51
51
|
|
|
52
52
|
```ruby
|
|
53
53
|
class Foo
|
|
@@ -67,6 +67,29 @@ end
|
|
|
67
67
|
|
|
68
68
|
See [the reference doc](/reference/instance) for a few more handy helper methods (e.g. `#log`).
|
|
69
69
|
|
|
70
|
+
### Re-exposing an expected field (auto-copy)
|
|
71
|
+
|
|
72
|
+
When a field is declared with both `expects` and `exposes`, axn automatically copies it from the input into the result — no manual `expose` call needed. This works on **all outcome paths**: success, `done!`, `fail!`, and unhandled exceptions.
|
|
73
|
+
|
|
74
|
+
This is particularly useful when an action mutates an ActiveRecord object in-place (e.g. `user.valid?` populates `user.errors`) and the caller needs to inspect the object after a failure:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
class UpdateUser
|
|
78
|
+
include Axn
|
|
79
|
+
|
|
80
|
+
expects :user, model: true
|
|
81
|
+
exposes :user # auto-copied — no expose call needed
|
|
82
|
+
|
|
83
|
+
def call
|
|
84
|
+
user.assign_attributes(params)
|
|
85
|
+
fail! unless user.save # user.errors is available on result.user even on failure
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
result = UpdateUser.call(user:, params:)
|
|
90
|
+
result.user.errors.full_messages # populated on both ok? and !ok?
|
|
91
|
+
```
|
|
92
|
+
|
|
70
93
|
### Convenient failure with context
|
|
71
94
|
|
|
72
95
|
Both `fail!` and `done!` can accept keyword arguments to expose data before halting execution:
|
|
@@ -27,7 +27,7 @@ module Axn
|
|
|
27
27
|
config_mode = klass.try(:_async_exception_reporting) || Axn.config.async_exception_reporting
|
|
28
28
|
return if config_mode == :every_attempt # Already reported on each attempt
|
|
29
29
|
|
|
30
|
-
retry_context = RetryHelpers.build_retry_context(job)
|
|
30
|
+
retry_context = RetryHelpers.build_retry_context(job, from_death_handler: true)
|
|
31
31
|
|
|
32
32
|
# For :first_and_exhausted, we need to report now (exhausted)
|
|
33
33
|
# For :only_exhausted, we need to report now (only time)
|
|
@@ -43,10 +43,21 @@ module Axn
|
|
|
43
43
|
|
|
44
44
|
# Builds an Axn::Async::RetryContext from a Sidekiq job hash.
|
|
45
45
|
# Used by both middleware and death handler to ensure consistent context.
|
|
46
|
-
|
|
46
|
+
#
|
|
47
|
+
# @param from_death_handler [Boolean] When true, subtracts 1 from the computed attempt
|
|
48
|
+
# because Sidekiq increments retry_count before calling death handlers, so the value
|
|
49
|
+
# in the job hash is one higher than the retry_count present during the last execution.
|
|
50
|
+
def build_retry_context(job, from_death_handler: false)
|
|
51
|
+
attempt = extract_attempt_number(job)
|
|
52
|
+
# Sidekiq increments retry_count before calling retries_exhausted/death handlers,
|
|
53
|
+
# so the job hash has retry_count = last_execution_retry_count + 1. Subtract 1 to
|
|
54
|
+
# recover the actual last execution's attempt number. Guard on non-nil: when Sidekiq
|
|
55
|
+
# calls death handlers directly (retry: false), retry_count is absent and attempt is
|
|
56
|
+
# already correct.
|
|
57
|
+
attempt -= 1 if from_death_handler && !job["retry_count"].nil?
|
|
47
58
|
RetryContext.new(
|
|
48
59
|
adapter: :sidekiq,
|
|
49
|
-
attempt
|
|
60
|
+
attempt:,
|
|
50
61
|
max_retries: extract_max_retries(job),
|
|
51
62
|
job_id: job["jid"],
|
|
52
63
|
)
|
|
@@ -65,19 +65,25 @@ module Axn
|
|
|
65
65
|
inspection_filter.filter_param(field, inspected_value)
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
-
def inspection_filter
|
|
68
|
+
def inspection_filter
|
|
69
|
+
@inspection_filter ||= if action.class._has_dynamic_sensitive_fields?
|
|
70
|
+
action.class._build_instance_filter(action)
|
|
71
|
+
else
|
|
72
|
+
action.class.inspection_filter
|
|
73
|
+
end
|
|
74
|
+
end
|
|
69
75
|
|
|
70
76
|
def sensitive_subfields?(field)
|
|
71
|
-
action.subfield_configs.any?
|
|
77
|
+
action.subfield_configs.any? do |config|
|
|
78
|
+
config.on == field && action.class._resolve_sensitive_value(config.sensitive, action)
|
|
79
|
+
end
|
|
72
80
|
end
|
|
73
81
|
|
|
74
82
|
def filter_subfields(field, value)
|
|
75
|
-
# Build a nested structure with subfield paths for filtering
|
|
76
83
|
nested_data = { field => value }
|
|
77
84
|
|
|
78
|
-
# Create a filter with the subfield paths
|
|
79
85
|
sensitive_subfield_paths = action.subfield_configs
|
|
80
|
-
.select { |config| config.on == field && config.sensitive }
|
|
86
|
+
.select { |config| config.on == field && action.class._resolve_sensitive_value(config.sensitive, action) }
|
|
81
87
|
.map { |config| "#{field}.#{config.field}" }
|
|
82
88
|
|
|
83
89
|
return value if sensitive_subfield_paths.empty?
|