axn 0.1.0.pre.alpha.2.4.1 → 0.1.0.pre.alpha.2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +6 -2
- data/CHANGELOG.md +18 -2
- data/docs/recipes/testing.md +50 -0
- data/docs/reference/class.md +42 -6
- data/docs/reference/configuration.md +9 -12
- data/docs/reference/instance.md +6 -0
- data/docs/usage/using.md +30 -0
- data/lib/action/core/configuration.rb +5 -4
- data/lib/action/core/context_facade.rb +14 -5
- data/lib/action/core/contract.rb +78 -20
- data/lib/action/core/contract_for_subfields.rb +118 -0
- data/lib/action/core/event_handlers.rb +2 -2
- 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 +12 -30
- data/lib/action/core/top_level_around_hook.rb +43 -27
- data/lib/action/core/validation/fields.rb +38 -0
- data/lib/action/core/validation/subfields.rb +44 -0
- data/lib/action/core/validation/validators/model_validator.rb +35 -0
- data/lib/action/core/validation/validators/type_validator.rb +30 -0
- data/lib/action/core/validation/validators/validate_validator.rb +21 -0
- 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 +11 -4
- metadata +12 -4
- data/lib/action/core/contract_validator.rb +0 -66
- 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: bcb1f58a7413ccbfe7bca31932fb0b65a13c0e45dd1dcdb2c8477da829aa45d2
|
4
|
+
data.tar.gz: a26387dca45e8cd121ec50d39a137c9297a2f17b6d8270758a0212a0de42b450
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a07a81a6525e3c08ac68579166b782e0c589e41be6a0714cd4275f3f3b44d88570bd1ce43d7fac2596529d508a57cf52e6c0b4339f48ddfa8395848a9e67b899
|
7
|
+
data.tar.gz: f90dab3ada8f0fbb047227699e9a41ca43abf183b407af0b002bb58a033cd10ac09061353983b067d31a4ccee2802f3da705b210ece7be298cc921f5e8384553
|
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
|
@@ -45,10 +49,10 @@ Lint/EmptyBlock:
|
|
45
49
|
Enabled: false
|
46
50
|
|
47
51
|
Naming/MethodParameterName:
|
48
|
-
AllowedNames: e
|
52
|
+
AllowedNames: e, on, id
|
49
53
|
|
50
54
|
Metrics/ParameterLists:
|
51
|
-
Max:
|
55
|
+
Max: 9
|
52
56
|
|
53
57
|
Layout/LineLength:
|
54
58
|
Max: 160
|
data/CHANGELOG.md
CHANGED
@@ -1,7 +1,23 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
-
##
|
4
|
-
*
|
3
|
+
## 0.1.0-alpha.2.5.1
|
4
|
+
* Added new `model` validator for expectations
|
5
|
+
* [FEAT] Extended `expects` with the `on:` key to allow declaring nested data shapes/validations
|
6
|
+
|
7
|
+
## 0.1.0-alpha.2.5
|
8
|
+
* Support blank exposures for `Action::Result.ok`
|
9
|
+
* Modify Action::Failure's initialize signature (to better match StandardError)
|
10
|
+
* Reduce reserved fields to allow some `expects` (e.g. `message`) that would shadow internals if used as `exposes`
|
11
|
+
* Default logging changes:
|
12
|
+
* Add `default_log_level` and `default_autolog_level` class methods (so inheritable) via `Action.config`
|
13
|
+
* Remove `global_debug_logging?` from Configuration + unused `SA_DEBUG_TARGETS` approach to configuring logging
|
14
|
+
* Improved testing ergonomics: the `type` expectation will now return `true` for _any_ `RSpec::Mocks::` subclass
|
15
|
+
* Enqueueable improvements:
|
16
|
+
* Extracted out of Core
|
17
|
+
* Renamed to `Enqueueable::ViaSidekiq` (make it easier to support different background runners in the future)
|
18
|
+
* Added ability to call `.enqueue_all_in_background` to run an Action's class-level `.enqueue_all` method (if defined) on a background worker
|
19
|
+
(important if triggered via a clock process that is NOT intended to execute actual jobs)
|
20
|
+
* Restructure internals (call/call! + run/run! + Action::Failure) to simplify upstream implementation since we always wrap any raised exceptions
|
5
21
|
|
6
22
|
## 0.1.0-alpha.2.4.1
|
7
23
|
* [FEAT] Adds full suite of per-Axn callbacks: `on_exception`, `on_failure`, `on_error`, `on_success`
|
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
@@ -1,3 +1,7 @@
|
|
1
|
+
---
|
2
|
+
outline: deep
|
3
|
+
---
|
4
|
+
|
1
5
|
# Class Methods
|
2
6
|
|
3
7
|
## `.expects` and `.exposes`
|
@@ -22,7 +26,7 @@ Both `expects` and `exposes` support the same core options:
|
|
22
26
|
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)).
|
23
27
|
:::
|
24
28
|
|
25
|
-
In addition to the [standard ActiveModel validations](https://guides.rubyonrails.org/active_record_validations.html), we also support
|
29
|
+
In addition to the [standard ActiveModel validations](https://guides.rubyonrails.org/active_record_validations.html), we also support three additional custom validators:
|
26
30
|
* `type: Foo` - fails unless the provided value `.is_a?(Foo)`
|
27
31
|
* Edge case: use `type: :boolean` to handle a boolean field (since ruby doesn't have a Boolean class to pass in directly)
|
28
32
|
* Edge case: use `type: :uuid` to handle a confirming given string is a UUID (with or without `-` chars)
|
@@ -31,11 +35,48 @@ In addition to the [standard ActiveModel validations](https://guides.rubyonrails
|
|
31
35
|
```ruby
|
32
36
|
expects :foo, validate: ->(value) { "must be pretty big" unless value > 10 }
|
33
37
|
```
|
38
|
+
* `model: true` (or `model: TheModelClass`) - allows auto-hydrating a record when only given its ID
|
39
|
+
* Example:
|
40
|
+
```ruby
|
41
|
+
expects :user_id, model: true
|
42
|
+
```
|
43
|
+
This line will add expectations that:
|
44
|
+
* `user_id` is provided
|
45
|
+
* `User.find(user_id)` returns a record
|
46
|
+
|
47
|
+
And, when used on `expects`, will create two reader methods for you:
|
48
|
+
* `user_id` (normal), _and_
|
49
|
+
* `user` (for the auto-found record)
|
50
|
+
|
51
|
+
::: info NOTES
|
52
|
+
* The field name must end in `_id`
|
53
|
+
* This was designed for ActiveRecord models, but will work on any class that returns an instance from `find_by(id: <the provided ID>)`
|
54
|
+
:::
|
34
55
|
|
56
|
+
### Details specific to `.exposes`
|
57
|
+
|
58
|
+
Remember that you'll need [a corresponding `expose` call](/reference/instance#expose) for every variable you declare via `exposes`.
|
35
59
|
|
36
60
|
|
37
61
|
### Details specific to `.expects`
|
38
62
|
|
63
|
+
#### Nested/Subfield expectations
|
64
|
+
|
65
|
+
`expects` is for defining the inbound interface. Usually it's enough to declare the top-level fields you receive, but sometimes you want to make expectations about the shape of that data, and/or to define easy accessor methods for deeply nested fields. `expects` supports the `on` option for this (all the normal attributes can be applied as well, _except default, preprocess, and sensitive_):
|
66
|
+
|
67
|
+
```ruby
|
68
|
+
class Foo
|
69
|
+
expects :event
|
70
|
+
expects :data, type: Hash, on: :event # [!code focus:2]
|
71
|
+
expects :some, :random, :fields, on: :data
|
72
|
+
|
73
|
+
def call
|
74
|
+
puts "THe event.data.random field's value is: #{random}"
|
75
|
+
end
|
76
|
+
end
|
77
|
+
```
|
78
|
+
|
79
|
+
#### `preprocess`
|
39
80
|
`expects` also supports a `preprocess` option that, if set to a callable, will be executed _before_ applying any validations. This can be useful for type coercion, e.g.:
|
40
81
|
|
41
82
|
```ruby
|
@@ -44,11 +85,6 @@ expects :date, type: Date, preprocess: ->(d) { d.is_a?(Date) ? d : Date.parse(d)
|
|
44
85
|
|
45
86
|
will succeed if given _either_ an actual Date object _or_ a string that Date.parse can convert into one. If the preprocess callable raises an exception, that'll be swallowed and the action failed.
|
46
87
|
|
47
|
-
### Details specific to `.exposes`
|
48
|
-
|
49
|
-
Remember that you'll need [a corresponding `expose` call](/reference/instance#expose) for every variable you declare via `exposes`.
|
50
|
-
|
51
|
-
|
52
88
|
## `.messages`
|
53
89
|
|
54
90
|
The `messages` declaration allows you to customize the `error` and `success` messages on the returned result.
|
@@ -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)
|
@@ -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
@@ -1,10 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "active_model"
|
4
3
|
require "active_support/core_ext/enumerable"
|
5
4
|
require "active_support/core_ext/module/delegation"
|
6
5
|
|
7
|
-
require "action/core/
|
6
|
+
require "action/core/validation/fields"
|
8
7
|
require "action/core/context_facade"
|
9
8
|
|
10
9
|
module Action
|
@@ -34,8 +33,22 @@ module Action
|
|
34
33
|
FieldConfig = Data.define(:field, :validations, :default, :preprocess, :sensitive)
|
35
34
|
|
36
35
|
module ClassMethods
|
37
|
-
def expects(
|
38
|
-
|
36
|
+
def expects(
|
37
|
+
*fields,
|
38
|
+
on: nil,
|
39
|
+
allow_blank: false,
|
40
|
+
allow_nil: false,
|
41
|
+
default: nil,
|
42
|
+
preprocess: nil,
|
43
|
+
sensitive: false,
|
44
|
+
**validations
|
45
|
+
)
|
46
|
+
return _expects_subfields(*fields, on:, allow_blank:, allow_nil:, default:, preprocess:, sensitive:, **validations) if on.present?
|
47
|
+
|
48
|
+
fields.each do |field|
|
49
|
+
raise ContractViolation::ReservedAttributeError, field if RESERVED_FIELD_NAMES_FOR_EXPECTATIONS.include?(field.to_s)
|
50
|
+
end
|
51
|
+
|
39
52
|
_parse_field_configs(*fields, allow_blank:, allow_nil:, default:, preprocess:, sensitive:, **validations).tap do |configs|
|
40
53
|
duplicated = internal_field_configs.map(&:field) & configs.map(&:field)
|
41
54
|
raise Action::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
|
@@ -45,7 +58,18 @@ module Action
|
|
45
58
|
end
|
46
59
|
end
|
47
60
|
|
48
|
-
def exposes(
|
61
|
+
def exposes(
|
62
|
+
*fields,
|
63
|
+
allow_blank: false,
|
64
|
+
allow_nil: false,
|
65
|
+
default: nil,
|
66
|
+
sensitive: false,
|
67
|
+
**validations
|
68
|
+
)
|
69
|
+
fields.each do |field|
|
70
|
+
raise ContractViolation::ReservedAttributeError, field if RESERVED_FIELD_NAMES_FOR_EXPOSURES.include?(field.to_s)
|
71
|
+
end
|
72
|
+
|
49
73
|
_parse_field_configs(*fields, allow_blank:, allow_nil:, default:, preprocess: nil, sensitive:, **validations).tap do |configs|
|
50
74
|
duplicated = external_field_configs.map(&:field) & configs.map(&:field)
|
51
75
|
raise Action::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
|
@@ -57,22 +81,56 @@ module Action
|
|
57
81
|
|
58
82
|
private
|
59
83
|
|
60
|
-
|
61
|
-
|
62
|
-
default_error
|
63
|
-
|
84
|
+
RESERVED_FIELD_NAMES_FOR_EXPECTATIONS = %w[
|
85
|
+
called! fail! rollback! success? ok?
|
86
|
+
inspect default_error
|
87
|
+
each_pair
|
88
|
+
].freeze
|
89
|
+
|
90
|
+
RESERVED_FIELD_NAMES_FOR_EXPOSURES = %w[
|
91
|
+
called! fail! rollback! success? ok?
|
92
|
+
inspect each_pair default_error
|
93
|
+
ok error success message
|
64
94
|
].freeze
|
65
95
|
|
66
|
-
def _parse_field_configs(
|
67
|
-
|
96
|
+
def _parse_field_configs(
|
97
|
+
*fields,
|
98
|
+
allow_blank: false,
|
99
|
+
allow_nil: false,
|
100
|
+
default: nil,
|
101
|
+
preprocess: nil,
|
102
|
+
sensitive: false,
|
103
|
+
**validations
|
104
|
+
)
|
105
|
+
_parse_field_validations(*fields, allow_nil:, allow_blank:, **validations).map do |field, parsed_validations|
|
106
|
+
_define_field_reader(field)
|
107
|
+
_define_model_reader(field, parsed_validations[:model]) if parsed_validations.key?(:model)
|
108
|
+
FieldConfig.new(field:, validations: parsed_validations, default:, preprocess:, sensitive:)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def _define_field_reader(field)
|
68
113
|
# Allow local access to explicitly-expected fields -- even externally-expected needs to be available locally
|
69
114
|
# (e.g. to allow success message callable to reference exposed fields)
|
70
|
-
|
71
|
-
|
115
|
+
define_method(field) { internal_context.public_send(field) }
|
116
|
+
end
|
72
117
|
|
73
|
-
|
118
|
+
def _define_model_reader(field, klass)
|
119
|
+
name = field.to_s.delete_suffix("_id")
|
120
|
+
raise ArgumentError, "Model validation expects to be given a field ending in _id (given: #{field})" unless field.to_s.end_with?("_id")
|
121
|
+
raise ArgumentError, "Failed to define model reader - #{name} is already defined" if method_defined?(name)
|
122
|
+
|
123
|
+
define_method(name) do
|
124
|
+
Validators::ModelValidator.instance_for(field:, klass:, id: public_send(field))
|
74
125
|
end
|
126
|
+
end
|
75
127
|
|
128
|
+
def _parse_field_validations(
|
129
|
+
*fields,
|
130
|
+
allow_nil: false,
|
131
|
+
allow_blank: false,
|
132
|
+
**validations
|
133
|
+
)
|
76
134
|
if allow_blank
|
77
135
|
validations.transform_values! do |v|
|
78
136
|
v = { value: v } unless v.is_a?(Hash)
|
@@ -87,7 +145,7 @@ module Action
|
|
87
145
|
validations[:presence] = true unless validations.key?(:presence) || Array(validations[:type]).include?(:boolean)
|
88
146
|
end
|
89
147
|
|
90
|
-
fields.map { |field|
|
148
|
+
fields.map { |field| [field, validations] }
|
91
149
|
end
|
92
150
|
end
|
93
151
|
|
@@ -123,9 +181,9 @@ module Action
|
|
123
181
|
raise ArgumentError, "Invalid direction: #{direction}" unless %i[inbound outbound].include?(direction)
|
124
182
|
|
125
183
|
klass = direction == :inbound ? Action::InternalContext : Action::Result
|
126
|
-
implicitly_allowed_fields = direction == :inbound ?
|
184
|
+
implicitly_allowed_fields = direction == :inbound ? _declared_fields(:outbound) : []
|
127
185
|
|
128
|
-
klass.new(action: self, context: @context, declared_fields:
|
186
|
+
klass.new(action: self, context: @context, declared_fields: _declared_fields(direction), implicitly_allowed_fields:)
|
129
187
|
end
|
130
188
|
end
|
131
189
|
|
@@ -152,7 +210,7 @@ module Action
|
|
152
210
|
context = direction == :inbound ? internal_context : external_context
|
153
211
|
exception_klass = direction == :inbound ? Action::InboundValidationError : Action::OutboundValidationError
|
154
212
|
|
155
|
-
|
213
|
+
Validation::Fields.validate!(validations:, context:, exception_klass:)
|
156
214
|
end
|
157
215
|
|
158
216
|
def _apply_defaults!(direction)
|
@@ -173,7 +231,7 @@ module Action
|
|
173
231
|
end
|
174
232
|
|
175
233
|
def context_for_logging(direction = nil)
|
176
|
-
inspection_filter.filter(@context.to_h.slice(*
|
234
|
+
inspection_filter.filter(@context.to_h.slice(*_declared_fields(direction)))
|
177
235
|
end
|
178
236
|
|
179
237
|
protected
|
@@ -186,7 +244,7 @@ module Action
|
|
186
244
|
(internal_field_configs + external_field_configs).select(&:sensitive).map(&:field)
|
187
245
|
end
|
188
246
|
|
189
|
-
def
|
247
|
+
def _declared_fields(direction)
|
190
248
|
raise ArgumentError, "Invalid direction: #{direction}" unless direction.nil? || %i[inbound outbound].include?(direction)
|
191
249
|
|
192
250
|
configs = case direction
|
@@ -0,0 +1,118 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "action/core/validation/subfields"
|
4
|
+
|
5
|
+
module Action
|
6
|
+
module ContractForSubfields
|
7
|
+
# TODO: add default, preprocess, sensitive options for subfields?
|
8
|
+
# SubfieldConfig = Data.define(:field, :validations, :default, :preprocess, :sensitive)
|
9
|
+
SubfieldConfig = Data.define(:field, :validations, :on)
|
10
|
+
|
11
|
+
def self.included(base)
|
12
|
+
base.class_eval do
|
13
|
+
class_attribute :subfield_configs, default: []
|
14
|
+
|
15
|
+
extend ClassMethods
|
16
|
+
include InstanceMethods
|
17
|
+
|
18
|
+
before { _validate_subfields_contract! }
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
module ClassMethods
|
23
|
+
def _expects_subfields(
|
24
|
+
*fields,
|
25
|
+
on:,
|
26
|
+
readers: true,
|
27
|
+
allow_blank: false,
|
28
|
+
allow_nil: false,
|
29
|
+
|
30
|
+
# TODO: add support for these three options for subfields
|
31
|
+
default: nil,
|
32
|
+
preprocess: nil,
|
33
|
+
sensitive: false,
|
34
|
+
|
35
|
+
**validations
|
36
|
+
)
|
37
|
+
# TODO: add support for these three options for subfields
|
38
|
+
raise ArgumentError, "expects does not support :default key when also given :on" if default.present?
|
39
|
+
raise ArgumentError, "expects does not support :preprocess key when also given :on" if preprocess.present?
|
40
|
+
raise ArgumentError, "expects does not support :sensitive key when also given :on" if sensitive.present?
|
41
|
+
|
42
|
+
unless internal_field_configs.map(&:field).include?(on) || subfield_configs.map(&:field).include?(on)
|
43
|
+
raise ArgumentError,
|
44
|
+
"expects called with `on: #{on}`, but no such method exists (are you sure you've declared `expects :#{on}`?)"
|
45
|
+
end
|
46
|
+
|
47
|
+
raise ArgumentError, "expects does not support expecting fields on nested attributes (i.e. `on` cannot contain periods)" if on.to_s.include?(".")
|
48
|
+
|
49
|
+
# TODO: consider adding support for default, preprocess, sensitive options for subfields?
|
50
|
+
_parse_subfield_configs(*fields, on:, readers:, allow_blank:, allow_nil:, **validations).tap do |configs|
|
51
|
+
duplicated = subfield_configs.map(&:field) & configs.map(&:field)
|
52
|
+
raise Action::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
|
53
|
+
|
54
|
+
# NOTE: avoid <<, which would update value for parents and children
|
55
|
+
self.subfield_configs += configs
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def _parse_subfield_configs(
|
62
|
+
*fields,
|
63
|
+
on:,
|
64
|
+
readers:,
|
65
|
+
allow_blank: false,
|
66
|
+
allow_nil: false,
|
67
|
+
# default: nil,
|
68
|
+
# preprocess: nil,
|
69
|
+
# sensitive: false,
|
70
|
+
**validations
|
71
|
+
)
|
72
|
+
_parse_field_validations(*fields, allow_nil:, allow_blank:, **validations).map do |field, parsed_validations|
|
73
|
+
_define_subfield_reader(field, on:, validations: parsed_validations) if readers
|
74
|
+
SubfieldConfig.new(field:, validations: parsed_validations, on:)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def _define_subfield_reader(field, on:, validations:)
|
79
|
+
# Don't create top-level readers for nested fields
|
80
|
+
return if field.to_s.include?(".")
|
81
|
+
|
82
|
+
raise ArgumentError, "expects does not support duplicate sub-keys (i.e. `#{field}` is already defined)" if method_defined?(field)
|
83
|
+
|
84
|
+
define_memoized_reader_method(field) do
|
85
|
+
public_send(on).fetch(field)
|
86
|
+
end
|
87
|
+
|
88
|
+
_define_model_reader(field, validations[:model]) if validations.key?(:model)
|
89
|
+
end
|
90
|
+
|
91
|
+
def define_memoized_reader_method(field, &block)
|
92
|
+
define_method(field) do
|
93
|
+
ivar = :"@_memoized_reader_#{field}"
|
94
|
+
cached_val = instance_variable_get(ivar)
|
95
|
+
return cached_val if cached_val.present?
|
96
|
+
|
97
|
+
value = instance_exec(&block)
|
98
|
+
instance_variable_set(ivar, value)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
module InstanceMethods
|
104
|
+
def _validate_subfields_contract!
|
105
|
+
return if subfield_configs.blank?
|
106
|
+
|
107
|
+
subfield_configs.each do |config|
|
108
|
+
Validation::Subfields.validate!(
|
109
|
+
field: config.field,
|
110
|
+
validations: config.validations,
|
111
|
+
source: public_send(config.on),
|
112
|
+
exception_klass: Action::InboundValidationError,
|
113
|
+
)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
@@ -27,7 +27,7 @@ module Action
|
|
27
27
|
action.instance_exec(exception, &@handler)
|
28
28
|
true
|
29
29
|
rescue StandardError => e
|
30
|
-
action.warn("Ignoring #{e.class.name}
|
30
|
+
action.warn("Ignoring #{e.class.name} when evaluating handler: #{e.message}")
|
31
31
|
nil
|
32
32
|
end
|
33
33
|
end
|
@@ -54,7 +54,7 @@ module Action
|
|
54
54
|
false
|
55
55
|
end
|
56
56
|
rescue StandardError => e
|
57
|
-
action.warn("Ignoring #{e.class.name}
|
57
|
+
action.warn("Ignoring #{e.class.name} while determining matcher: #{e.message}")
|
58
58
|
false
|
59
59
|
end
|
60
60
|
|