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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 19221983bd11ff17620830a4807b4080f8ea35dfb07050e2af2e6c1dac1e1b7d
4
- data.tar.gz: 7ac5fc8f9ae99bd9a5a189ad53f6113c4c544181ff760bbe923ca1867f011877
3
+ metadata.gz: bcb1f58a7413ccbfe7bca31932fb0b65a13c0e45dd1dcdb2c8477da829aa45d2
4
+ data.tar.gz: a26387dca45e8cd121ec50d39a137c9297a2f17b6d8270758a0212a0de42b450
5
5
  SHA512:
6
- metadata.gz: 4115cc144bfac9420ff85de1ff9198efd092f63594811aed1afbf3ac5a07a5b73a9017b9d9665aac78e128c312d79e996d8dc2f8710fb30a8b9825a51caa30e5
7
- data.tar.gz: 7528abe56dd99066409a59a57f07d04f9b9ea98fd399b62aab7318a762920027eb0870b7895b5f8bff525023a7d754c67f0d8c484661929c8a4dab1a360caf6f
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: 7
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
- ## UNRELEASED
4
- * N/A
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`
@@ -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):
@@ -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 two additional custom validators:
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.global_debug_logging = false
14
+ c.default_log_level = :info
15
+ c.default_autolog_level = :debug
15
16
 
16
17
  c.logger = ...
17
18
  end
@@ -84,21 +85,17 @@ For example:
84
85
 
85
86
  For a practical example of this in practice, see [our 'memoization' recipe](/recipes/memoization).
86
87
 
87
- ## `global_debug_logging`
88
+ ## `default_log_level`
88
89
 
89
- By default, every `action.call` will emit _debug_ log lines when it is called and after it completes:
90
+ Sets the log level used when you call `log "Some message"` in your Action. Note this is read via a `default_log_level` class method, so you can easily use inheritance to support different log levels for different sets of actions.
91
+
92
+ ## `default_autolog_level`
93
+
94
+ By default, every `action.call` will emit log lines when it is called and after it completes:
90
95
 
91
96
  ```
92
97
  [YourCustomAction] About to execute with: {:foo=>"bar"}
93
98
  [YourCustomAction] Execution completed (with outcome: success) in 0.957 milliseconds
94
99
  ```
95
100
 
96
- You can bump the log level from `debug` to `info` for specific actions by including their class name (comma separated, if multiple) in a `SA_DEBUG_TARGETS` ENV variable.
97
-
98
- You can also turn this on _globally_ by setting `global_debug_logging = true`.
99
-
100
- ```ruby
101
- Action.configure do |c|
102
- c.global_debug_logging = true
103
- end
104
- ```
101
+ You can change the default _auto_-log level separately from the log level used for your explicit `log` calls (just like above, via Action.config or a `default_autolog_level` class method).
@@ -61,6 +61,12 @@ Accepts a `prefix` keyword argument -- when set, prefixes the `error` message fr
61
61
 
62
62
  NOTE: expects a single action call in the block -- if there are multiple calls, only the last one will be checked for `ok?` (although anything _raised_ in the block will still be handled).
63
63
 
64
+ ::: tip Versus `call!`
65
+ * If you just want to make sure your action fails if the subaction fails: call subaction via `call!` (any failures will raise, which will fail the parent).
66
+ * Note this passes _child_ exception into _parent_ `messages :error` parsing.
67
+ * If you want _the child's_ `result.error` to become the _parent's_ `result.error` on failure, use `hoist_errors` + `call`
68
+ :::
69
+
64
70
  ### Example
65
71
 
66
72
  ```ruby
data/docs/usage/using.md CHANGED
@@ -55,3 +55,33 @@ Sidekiq integration is NOT YET TESTED/NOT YET USED IN OUR APP, and naming will V
55
55
  * enqueue will not retry even if fails
56
56
  * enqueue! will go through normal sidekiq retries on any failure (including user-facing `fail!`)
57
57
  * Note implicit GlobalID support (if not serializable, will get ArgumentError at callsite)
58
+
59
+
60
+ ### `.enqueue_all_in_background`
61
+
62
+ In practice it's fairly common to need to enqueue a bunch of sidekiq jobs from a clock process.
63
+
64
+ One approach is to define a class-level `.enqueue_all` method on your Action... but that ends up executing the enqueue_all logic directly from the clock process, which is undesirable.
65
+
66
+
67
+ ::: danger ALPHA
68
+ We are actively testing this pattern -- not yet certain we'll keep it past beta.
69
+ :::
70
+
71
+ Therefore we've added an `.enqueue_all_in_background` method that will automatically call your `.enqueue_all` _from a background job_ rather than directly on the active process.
72
+
73
+ ```ruby
74
+ class Foo
75
+ include Action
76
+
77
+ def self.enqueue_all
78
+ SomeModel.some_scope.find_each do |record|
79
+ enqueue(record:)
80
+ end
81
+ end
82
+
83
+ ...
84
+ end
85
+
86
+ Foo.enqueue_all # works, but `SomeModel.some_scope.find_each` is executed in the current context
87
+ Foo.enqueue_all_in_background # same, but runs in the background (via Action::Enqueueable::EnqueueAllWorker)
@@ -3,10 +3,11 @@
3
3
  module Action
4
4
  class Configuration
5
5
  include Action::Logging
6
- attr_accessor :global_debug_logging, :top_level_around_hook
7
- attr_writer :logger, :env, :on_exception, :additional_includes
6
+ attr_accessor :top_level_around_hook
7
+ attr_writer :logger, :env, :on_exception, :additional_includes, :default_log_level, :default_autolog_level
8
8
 
9
- def global_debug_logging? = !!global_debug_logging
9
+ def default_log_level = @default_log_level ||= :info
10
+ def default_autolog_level = @default_autolog_level ||= :info
10
11
 
11
12
  def additional_includes = @additional_includes ||= []
12
13
 
@@ -15,7 +16,7 @@ module Action
15
16
  # TODO: only pass action: or context: if requested (and update documentation)
16
17
  @on_exception.call(e, action:, context:)
17
18
  else
18
- log("[#{action.class.name.presence || "Anonymous Action"}] Exception swallowed: #{e.class.name} - #{e.message}")
19
+ log("[#{action.class.name.presence || "Anonymous Action"}] Exception raised: #{e.class.name} - #{e.message}")
19
20
  end
20
21
  end
21
22
 
@@ -51,7 +51,9 @@ module Action
51
51
  def determine_error_message(only_default: false)
52
52
  return @context.error_from_user if @context.error_from_user.present?
53
53
 
54
- exception = @context.exception || (only_default ? Action::Failure.new(@context) : nil)
54
+ # We need an exception for interceptors, and also in case the messages.error callable expects an argument
55
+ exception = @context.exception || Action::Failure.new
56
+
55
57
  msg = action._error_msg
56
58
 
57
59
  unless only_default
@@ -73,7 +75,7 @@ module Action
73
75
  action.instance_exec(&msg)
74
76
  end
75
77
  rescue StandardError => e
76
- action.warn("Ignoring #{e.class.name} raised while determining message callable: #{e.message}")
78
+ action.warn("Ignoring #{e.class.name} while determining message callable: #{e.message}")
77
79
  nil
78
80
  end
79
81
  end
@@ -95,7 +97,9 @@ module Action
95
97
  # For ease of mocking return results in tests
96
98
  class << self
97
99
  def ok(msg = nil, **exposures)
98
- Axn::Factory.build(exposes: exposures.keys, messages: { success: msg }) do
100
+ exposes = exposures.keys.to_h { |key| [key, { allow_blank: true }] }
101
+
102
+ Axn::Factory.build(exposes:, messages: { success: msg }) do
99
103
  exposures.each do |key, value|
100
104
  expose(key, value)
101
105
  end
@@ -103,7 +107,10 @@ module Action
103
107
  end
104
108
 
105
109
  def error(msg = nil, **exposures, &block)
106
- Axn::Factory.build(exposes: exposures.keys, rescues: [-> { true }, msg]) do
110
+ exposes = exposures.keys.to_h { |key| [key, { allow_blank: true }] }
111
+ rescues = [-> { true }, msg]
112
+
113
+ Axn::Factory.build(exposes:, rescues:) do
107
114
  exposures.each do |key, value|
108
115
  expose(key, value)
109
116
  end
@@ -162,7 +169,9 @@ module Action
162
169
  return unless facade.is_a?(Action::Result)
163
170
 
164
171
  return "[OK]" if context.success?
165
- return "[failed with '#{context.error_from_user}']" unless context.exception
172
+ unless context.exception
173
+ return context.error_from_user.present? ? "[failed with '#{context.error_from_user}']" : "[failed]"
174
+ end
166
175
 
167
176
  %([failed with #{context.exception.class.name}: '#{context.exception.message}'])
168
177
  end
@@ -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/contract_validator"
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(*fields, allow_blank: false, allow_nil: false, default: nil, preprocess: nil, sensitive: false,
38
- **validations)
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(*fields, allow_blank: false, allow_nil: false, default: nil, sensitive: false, **validations)
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
- RESERVED_FIELD_NAMES = %w[
61
- declared_fields inspect fail!
62
- default_error
63
- called! rollback! each_pair success? exception ok ok? error success message
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(*fields, allow_nil: false, allow_blank: false, default: nil, preprocess: nil, sensitive: false,
67
- **validations)
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
- fields.each do |field|
71
- raise ContractViolation::ReservedAttributeError, field if RESERVED_FIELD_NAMES.include?(field.to_s)
115
+ define_method(field) { internal_context.public_send(field) }
116
+ end
72
117
 
73
- define_method(field) { internal_context.public_send(field) }
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| FieldConfig.new(field:, validations:, default:, preprocess:, sensitive:) }
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 ? declared_fields(:outbound) : []
184
+ implicitly_allowed_fields = direction == :inbound ? _declared_fields(:outbound) : []
127
185
 
128
- klass.new(action: self, context: @context, declared_fields: declared_fields(direction), implicitly_allowed_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
- ContractValidator.validate!(validations:, context:, exception_klass:)
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(*declared_fields(direction)))
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 declared_fields(direction)
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} in when evaluating #{self.class.name} handler: #{e.message}")
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} raised while determining matcher: #{e.message}")
57
+ action.warn("Ignoring #{e.class.name} while determining matcher: #{e.message}")
58
58
  false
59
59
  end
60
60