axn 0.1.0.pre.alpha.2.5.3.1 → 0.1.0.pre.alpha.2.6.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -1
  3. data/CHANGELOG.md +25 -1
  4. data/README.md +2 -11
  5. data/docs/reference/action-result.md +2 -0
  6. data/docs/reference/class.md +12 -4
  7. data/docs/reference/configuration.md +53 -20
  8. data/docs/reference/instance.md +2 -2
  9. data/docs/strategies/index.md +1 -1
  10. data/docs/usage/setup.md +1 -1
  11. data/docs/usage/writing.md +9 -9
  12. data/lib/action/attachable/steps.rb +16 -1
  13. data/lib/action/attachable.rb +3 -3
  14. data/lib/action/{core/configuration.rb → configuration.rb} +3 -4
  15. data/lib/action/context.rb +38 -0
  16. data/lib/action/core/automatic_logging.rb +93 -0
  17. data/lib/action/core/context/facade.rb +69 -0
  18. data/lib/action/core/context/facade_inspector.rb +63 -0
  19. data/lib/action/core/context/internal.rb +32 -0
  20. data/lib/action/core/contract.rb +167 -211
  21. data/lib/action/core/contract_for_subfields.rb +84 -82
  22. data/lib/action/core/contract_validation.rb +62 -0
  23. data/lib/action/core/flow/callbacks.rb +54 -0
  24. data/lib/action/core/flow/exception_execution.rb +79 -0
  25. data/lib/action/core/flow/messages.rb +61 -0
  26. data/lib/action/core/flow.rb +19 -0
  27. data/lib/action/core/hoist_errors.rb +42 -40
  28. data/lib/action/core/hooks.rb +123 -0
  29. data/lib/action/core/logging.rb +22 -20
  30. data/lib/action/core/timing.rb +40 -0
  31. data/lib/action/core/tracing.rb +17 -0
  32. data/lib/action/core/use_strategy.rb +19 -17
  33. data/lib/action/core/validation/fields.rb +2 -0
  34. data/lib/action/core.rb +100 -0
  35. data/lib/action/enqueueable/via_sidekiq.rb +2 -2
  36. data/lib/action/enqueueable.rb +1 -1
  37. data/lib/action/{core/exceptions.rb → exceptions.rb} +1 -19
  38. data/lib/action/result.rb +95 -0
  39. data/lib/axn/factory.rb +27 -9
  40. data/lib/axn/version.rb +1 -1
  41. data/lib/axn.rb +10 -47
  42. metadata +19 -21
  43. data/lib/action/core/context_facade.rb +0 -209
  44. data/lib/action/core/handle_exceptions.rb +0 -163
  45. data/lib/action/core/top_level_around_hook.rb +0 -108
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action/core/context/facade"
4
+
5
+ module Action
6
+ # Inbound / Internal ContextFacade
7
+ class InternalContext < ContextFacade
8
+ # So can be referenced from within e.g. rescues callables
9
+ def default_error
10
+ [@context.error_prefix, determine_error_message(only_default: true)].compact.join(" ").squeeze(" ")
11
+ end
12
+
13
+ private
14
+
15
+ def context_data_source = @context.provided_data
16
+
17
+ def method_missing(method_name, ...) # rubocop:disable Style/MissingRespondToMissing (because we're not actually responding to anything additional)
18
+ if @context.__combined_data.key?(method_name.to_sym)
19
+ msg = <<~MSG
20
+ Method ##{method_name} is not available on Action::InternalContext!
21
+
22
+ #{action_name} may be missing a line like:
23
+ expects :#{method_name}
24
+ MSG
25
+
26
+ raise Action::ContractViolation::MethodNotAllowed, msg
27
+ end
28
+
29
+ super
30
+ end
31
+ end
32
+ end
@@ -4,267 +4,223 @@ require "active_support/core_ext/enumerable"
4
4
  require "active_support/core_ext/module/delegation"
5
5
 
6
6
  require "action/core/validation/fields"
7
- require "action/core/context_facade"
7
+ require "action/result"
8
+ require "action/core/context/internal"
8
9
 
9
10
  module Action
10
- module Contract
11
- def self.included(base)
12
- base.class_eval do
13
- class_attribute :internal_field_configs, :external_field_configs, default: []
14
-
15
- extend ClassMethods
16
- include InstanceMethods
17
- include ValidationInstanceMethods
18
-
19
- # Remove public context accessor
20
- remove_method :context
21
-
22
- around do |hooked|
23
- _apply_inbound_preprocessing!
24
- _apply_defaults!(:inbound)
25
- _validate_contract!(:inbound)
26
- hooked.call
27
- _apply_defaults!(:outbound)
28
- _validate_contract!(:outbound)
11
+ module Core
12
+ module Contract
13
+ def self.included(base)
14
+ base.class_eval do
15
+ class_attribute :internal_field_configs, :external_field_configs, default: []
16
+
17
+ extend ClassMethods
18
+ include InstanceMethods
29
19
  end
30
20
  end
31
- end
32
21
 
33
- FieldConfig = Data.define(:field, :validations, :default, :preprocess, :sensitive)
34
-
35
- module ClassMethods
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
22
+ FieldConfig = Data.define(:field, :validations, :default, :preprocess, :sensitive)
51
23
 
52
- _parse_field_configs(*fields, allow_blank:, allow_nil:, default:, preprocess:, sensitive:, **validations).tap do |configs|
53
- duplicated = internal_field_configs.map(&:field) & configs.map(&:field)
54
- raise Action::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
24
+ module ClassMethods
25
+ def expects(
26
+ *fields,
27
+ on: nil,
28
+ allow_blank: false,
29
+ allow_nil: false,
30
+ default: nil,
31
+ preprocess: nil,
32
+ sensitive: false,
33
+ **validations
34
+ )
35
+ return _expects_subfields(*fields, on:, allow_blank:, allow_nil:, default:, preprocess:, sensitive:, **validations) if on.present?
55
36
 
56
- # NOTE: avoid <<, which would update value for parents and children
57
- self.internal_field_configs += configs
58
- end
59
- end
60
-
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
37
+ fields.each do |field|
38
+ raise ContractViolation::ReservedAttributeError, field if RESERVED_FIELD_NAMES_FOR_EXPECTATIONS.include?(field.to_s)
39
+ end
72
40
 
73
- _parse_field_configs(*fields, allow_blank:, allow_nil:, default:, preprocess: nil, sensitive:, **validations).tap do |configs|
74
- duplicated = external_field_configs.map(&:field) & configs.map(&:field)
75
- raise Action::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
41
+ _parse_field_configs(*fields, allow_blank:, allow_nil:, default:, preprocess:, sensitive:, **validations).tap do |configs|
42
+ duplicated = internal_field_configs.map(&:field) & configs.map(&:field)
43
+ raise Action::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
76
44
 
77
- # NOTE: avoid <<, which would update value for parents and children
78
- self.external_field_configs += configs
45
+ # NOTE: avoid <<, which would update value for parents and children
46
+ self.internal_field_configs += configs
47
+ end
79
48
  end
80
- end
81
49
 
82
- private
83
-
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
94
- ].freeze
95
-
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
50
+ def exposes(
51
+ *fields,
52
+ allow_blank: false,
53
+ allow_nil: false,
54
+ default: nil,
55
+ sensitive: false,
56
+ **validations
57
+ )
58
+ fields.each do |field|
59
+ raise ContractViolation::ReservedAttributeError, field if RESERVED_FIELD_NAMES_FOR_EXPOSURES.include?(field.to_s)
60
+ end
111
61
 
112
- def define_memoized_reader_method(field, &block)
113
- define_method(field) do
114
- ivar = :"@_memoized_reader_#{field}"
115
- cached_val = instance_variable_get(ivar)
116
- return cached_val if cached_val.present?
62
+ _parse_field_configs(*fields, allow_blank:, allow_nil:, default:, preprocess: nil, sensitive:, **validations).tap do |configs|
63
+ duplicated = external_field_configs.map(&:field) & configs.map(&:field)
64
+ raise Action::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
117
65
 
118
- value = instance_exec(&block)
119
- instance_variable_set(ivar, value)
66
+ # NOTE: avoid <<, which would update value for parents and children
67
+ self.external_field_configs += configs
68
+ end
120
69
  end
121
- end
122
70
 
123
- def _define_field_reader(field)
124
- # Allow local access to explicitly-expected fields -- even externally-expected needs to be available locally
125
- # (e.g. to allow success message callable to reference exposed fields)
126
- define_method(field) { internal_context.public_send(field) }
127
- end
128
-
129
- def _define_model_reader(field, klass)
130
- name = field.to_s.delete_suffix("_id")
131
- raise ArgumentError, "Model validation expects to be given a field ending in _id (given: #{field})" unless field.to_s.end_with?("_id")
132
- raise ArgumentError, "Failed to define model reader - #{name} is already defined" if method_defined?(name)
133
-
134
- define_memoized_reader_method(name) do
135
- Validators::ModelValidator.instance_for(field:, klass:, id: public_send(field))
71
+ private
72
+
73
+ RESERVED_FIELD_NAMES_FOR_EXPECTATIONS = %w[
74
+ fail! ok?
75
+ inspect default_error
76
+ each_pair
77
+ ].freeze
78
+
79
+ RESERVED_FIELD_NAMES_FOR_EXPOSURES = %w[
80
+ fail! ok?
81
+ inspect each_pair default_error
82
+ ok error success message
83
+ ].freeze
84
+
85
+ def _parse_field_configs(
86
+ *fields,
87
+ allow_blank: false,
88
+ allow_nil: false,
89
+ default: nil,
90
+ preprocess: nil,
91
+ sensitive: false,
92
+ **validations
93
+ )
94
+ _parse_field_validations(*fields, allow_nil:, allow_blank:, **validations).map do |field, parsed_validations|
95
+ _define_field_reader(field)
96
+ _define_model_reader(field, parsed_validations[:model]) if parsed_validations.key?(:model)
97
+ FieldConfig.new(field:, validations: parsed_validations, default:, preprocess:, sensitive:)
98
+ end
136
99
  end
137
- end
138
100
 
139
- def _parse_field_validations(
140
- *fields,
141
- allow_nil: false,
142
- allow_blank: false,
143
- **validations
144
- )
145
- if allow_blank
146
- validations.transform_values! do |v|
147
- v = { value: v } unless v.is_a?(Hash)
148
- { allow_blank: true }.merge(v)
149
- end
150
- elsif allow_nil
151
- validations.transform_values! do |v|
152
- v = { value: v } unless v.is_a?(Hash)
153
- { allow_nil: true }.merge(v)
101
+ def define_memoized_reader_method(field, &block)
102
+ define_method(field) do
103
+ ivar = :"@_memoized_reader_#{field}"
104
+ cached_val = instance_variable_get(ivar)
105
+ return cached_val if cached_val.present?
106
+
107
+ value = instance_exec(&block)
108
+ instance_variable_set(ivar, value)
154
109
  end
155
- else
156
- validations[:presence] = true unless validations.key?(:presence) || Array(validations[:type]).include?(:boolean)
157
110
  end
158
111
 
159
- fields.map { |field| [field, validations] }
160
- end
161
- end
112
+ def _define_field_reader(field)
113
+ # Allow local access to explicitly-expected fields -- even externally-expected needs to be available locally
114
+ # (e.g. to allow success message callable to reference exposed fields)
115
+ define_method(field) { internal_context.public_send(field) }
116
+ end
162
117
 
163
- module InstanceMethods
164
- def internal_context = @internal_context ||= _build_context_facade(:inbound)
165
- def external_context = @external_context ||= _build_context_facade(:outbound)
118
+ def _define_model_reader(field, klass, &id_extractor)
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)
166
122
 
167
- # NOTE: ideally no direct access from client code, but we need to expose this for internal Interactor methods
168
- # (and passing through control methods to underlying context) in order to avoid rewriting internal methods.
169
- def context = external_context
123
+ id_extractor ||= -> { public_send(field) }
170
124
 
171
- # Accepts either two positional arguments (key, value) or a hash of key/value pairs
172
- def expose(*args, **kwargs)
173
- if args.any?
174
- if args.size != 2
175
- raise ArgumentError,
176
- "expose must be called with exactly two positional arguments (or a hash of key/value pairs)"
125
+ define_memoized_reader_method(name) do
126
+ Validators::ModelValidator.instance_for(field:, klass:, id: instance_exec(&id_extractor))
177
127
  end
178
-
179
- kwargs.merge!(args.first => args.last)
180
128
  end
181
129
 
182
- kwargs.each do |key, value|
183
- raise Action::ContractViolation::UnknownExposure, key unless external_context.respond_to?(key)
130
+ def _parse_field_validations(
131
+ *fields,
132
+ allow_nil: false,
133
+ allow_blank: false,
134
+ **validations
135
+ )
136
+ if allow_blank
137
+ validations.transform_values! do |v|
138
+ v = { value: v } unless v.is_a?(Hash)
139
+ { allow_blank: true }.merge(v)
140
+ end
141
+ elsif allow_nil
142
+ validations.transform_values! do |v|
143
+ v = { value: v } unless v.is_a?(Hash)
144
+ { allow_nil: true }.merge(v)
145
+ end
146
+ else
147
+ validations[:presence] = true unless validations.key?(:presence) || Array(validations[:type]).include?(:boolean)
148
+ end
184
149
 
185
- @context.public_send("#{key}=", value)
150
+ fields.map { |field| [field, validations] }
186
151
  end
187
152
  end
188
153
 
189
- private
190
-
191
- def _build_context_facade(direction)
192
- raise ArgumentError, "Invalid direction: #{direction}" unless %i[inbound outbound].include?(direction)
154
+ module InstanceMethods
155
+ def internal_context = @internal_context ||= _build_context_facade(:inbound)
156
+ def result = @result ||= _build_context_facade(:outbound)
193
157
 
194
- klass = direction == :inbound ? Action::InternalContext : Action::Result
195
- implicitly_allowed_fields = direction == :inbound ? _declared_fields(:outbound) : []
158
+ # Accepts either two positional arguments (key, value) or a hash of key/value pairs
159
+ def expose(*args, **kwargs)
160
+ if args.any?
161
+ if args.size != 2
162
+ raise ArgumentError,
163
+ "expose must be called with exactly two positional arguments (or a hash of key/value pairs)"
164
+ end
196
165
 
197
- klass.new(action: self, context: @context, declared_fields: _declared_fields(direction), implicitly_allowed_fields:)
198
- end
199
- end
166
+ kwargs.merge!(args.first => args.last)
167
+ end
200
168
 
201
- module ValidationInstanceMethods
202
- def _apply_inbound_preprocessing!
203
- internal_field_configs.each do |config|
204
- next unless config.preprocess
169
+ kwargs.each do |key, value|
170
+ raise Action::ContractViolation::UnknownExposure, key unless result.respond_to?(key)
205
171
 
206
- initial_value = @context.public_send(config.field)
207
- new_value = config.preprocess.call(initial_value)
208
- @context.public_send("#{config.field}=", new_value)
209
- rescue StandardError => e
210
- raise Action::ContractViolation::PreprocessingError, "Error preprocessing field '#{config.field}': #{e.message}"
172
+ @__context.exposed_data[key] = value
173
+ end
211
174
  end
212
- end
213
175
 
214
- def _validate_contract!(direction)
215
- raise ArgumentError, "Invalid direction: #{direction}" unless %i[inbound outbound].include?(direction)
216
-
217
- configs = direction == :inbound ? internal_field_configs : external_field_configs
218
- validations = configs.each_with_object({}) do |config, hash|
219
- hash[config.field] = config.validations
176
+ def context_for_logging(direction = nil)
177
+ inspection_filter.filter(@__context.__combined_data.slice(*_declared_fields(direction)))
220
178
  end
221
- context = direction == :inbound ? internal_context : external_context
222
- exception_klass = direction == :inbound ? Action::InboundValidationError : Action::OutboundValidationError
223
-
224
- Validation::Fields.validate!(validations:, context:, exception_klass:)
225
- end
226
179
 
227
- def _apply_defaults!(direction)
228
- raise ArgumentError, "Invalid direction: #{direction}" unless %i[inbound outbound].include?(direction)
180
+ private
229
181
 
230
- configs = direction == :inbound ? internal_field_configs : external_field_configs
231
- defaults_mapping = configs.each_with_object({}) do |config, hash|
232
- hash[config.field] = config.default
233
- end.compact
182
+ def _with_contract
183
+ _apply_inbound_preprocessing!
184
+ _apply_defaults!(:inbound)
185
+ _validate_contract!(:inbound)
234
186
 
235
- defaults_mapping.each do |field, default_value_getter|
236
- next if @context.public_send(field).present?
187
+ yield
237
188
 
238
- default_value = default_value_getter.respond_to?(:call) ? instance_exec(&default_value_getter) : default_value_getter
189
+ _apply_defaults!(:outbound)
190
+ _validate_contract!(:outbound)
239
191
 
240
- @context.public_send("#{field}=", default_value)
192
+ # TODO: improve location of this triggering
193
+ _trigger_on_success if respond_to?(:_trigger_on_success)
241
194
  end
242
- end
243
195
 
244
- def context_for_logging(direction = nil)
245
- inspection_filter.filter(@context.to_h.slice(*_declared_fields(direction)))
246
- end
196
+ def _build_context_facade(direction)
197
+ raise ArgumentError, "Invalid direction: #{direction}" unless %i[inbound outbound].include?(direction)
247
198
 
248
- protected
199
+ klass = direction == :inbound ? Action::InternalContext : Action::Result
200
+ implicitly_allowed_fields = direction == :inbound ? _declared_fields(:outbound) : []
249
201
 
250
- def inspection_filter
251
- @inspection_filter ||= ActiveSupport::ParameterFilter.new(sensitive_fields)
252
- end
202
+ klass.new(action: self, context: @__context, declared_fields: _declared_fields(direction), implicitly_allowed_fields:)
203
+ end
253
204
 
254
- def sensitive_fields
255
- (internal_field_configs + external_field_configs).select(&:sensitive).map(&:field)
256
- end
205
+ def inspection_filter
206
+ @inspection_filter ||= ActiveSupport::ParameterFilter.new(sensitive_fields)
207
+ end
257
208
 
258
- def _declared_fields(direction)
259
- raise ArgumentError, "Invalid direction: #{direction}" unless direction.nil? || %i[inbound outbound].include?(direction)
209
+ def sensitive_fields
210
+ (internal_field_configs + external_field_configs).select(&:sensitive).map(&:field)
211
+ end
212
+
213
+ def _declared_fields(direction)
214
+ raise ArgumentError, "Invalid direction: #{direction}" unless direction.nil? || %i[inbound outbound].include?(direction)
260
215
 
261
- configs = case direction
262
- when :inbound then internal_field_configs
263
- when :outbound then external_field_configs
264
- else (internal_field_configs + external_field_configs)
265
- end
216
+ configs = case direction
217
+ when :inbound then internal_field_configs
218
+ when :outbound then external_field_configs
219
+ else (internal_field_configs + external_field_configs)
220
+ end
266
221
 
267
- configs.map(&:field)
222
+ configs.map(&:field)
223
+ end
268
224
  end
269
225
  end
270
226
  end
@@ -3,103 +3,105 @@
3
3
  require "action/core/validation/subfields"
4
4
 
5
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)
6
+ module Core
7
+ module ContractForSubfields
8
+ # TODO: add default, preprocess, sensitive options for subfields?
9
+ # SubfieldConfig = Data.define(:field, :validations, :default, :preprocess, :sensitive)
10
+ SubfieldConfig = Data.define(:field, :validations, :on)
10
11
 
11
- def self.included(base)
12
- base.class_eval do
13
- class_attribute :subfield_configs, default: []
12
+ def self.included(base)
13
+ base.class_eval do
14
+ class_attribute :subfield_configs, default: []
14
15
 
15
- extend ClassMethods
16
- include InstanceMethods
16
+ extend ClassMethods
17
+ include InstanceMethods
17
18
 
18
- before { _validate_subfields_contract! }
19
+ before { _validate_subfields_contract! }
20
+ end
19
21
  end
20
- end
21
22
 
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}`?)"
23
+ module ClassMethods
24
+ def _expects_subfields(
25
+ *fields,
26
+ on:,
27
+ readers: true,
28
+ allow_blank: false,
29
+ allow_nil: false,
30
+
31
+ # TODO: add support for these three options for subfields
32
+ default: nil,
33
+ preprocess: nil,
34
+ sensitive: false,
35
+
36
+ **validations
37
+ )
38
+ # TODO: add support for these three options for subfields
39
+ raise ArgumentError, "expects does not support :default key when also given :on" if default.present?
40
+ raise ArgumentError, "expects does not support :preprocess key when also given :on" if preprocess.present?
41
+ raise ArgumentError, "expects does not support :sensitive key when also given :on" if sensitive.present?
42
+
43
+ unless internal_field_configs.map(&:field).include?(on) || subfield_configs.map(&:field).include?(on)
44
+ raise ArgumentError,
45
+ "expects called with `on: #{on}`, but no such method exists (are you sure you've declared `expects :#{on}`?)"
46
+ end
47
+
48
+ raise ArgumentError, "expects does not support expecting fields on nested attributes (i.e. `on` cannot contain periods)" if on.to_s.include?(".")
49
+
50
+ # TODO: consider adding support for default, preprocess, sensitive options for subfields?
51
+ _parse_subfield_configs(*fields, on:, readers:, allow_blank:, allow_nil:, **validations).tap do |configs|
52
+ duplicated = subfield_configs.map(&:field) & configs.map(&:field)
53
+ raise Action::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
54
+
55
+ # NOTE: avoid <<, which would update value for parents and children
56
+ self.subfield_configs += configs
57
+ end
45
58
  end
46
59
 
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
60
+ private
61
+
62
+ def _parse_subfield_configs(
63
+ *fields,
64
+ on:,
65
+ readers:,
66
+ allow_blank: false,
67
+ allow_nil: false,
68
+ # default: nil,
69
+ # preprocess: nil,
70
+ # sensitive: false,
71
+ **validations
72
+ )
73
+ _parse_field_validations(*fields, allow_nil:, allow_blank:, **validations).map do |field, parsed_validations|
74
+ _define_subfield_reader(field, on:, validations: parsed_validations) if readers
75
+ SubfieldConfig.new(field:, validations: parsed_validations, on:)
76
+ end
56
77
  end
57
- end
58
78
 
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
79
+ def _define_subfield_reader(field, on:, validations:)
80
+ # Don't create top-level readers for nested fields
81
+ return if field.to_s.include?(".")
77
82
 
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?(".")
83
+ raise ArgumentError, "expects does not support duplicate sub-keys (i.e. `#{field}` is already defined)" if method_defined?(field)
81
84
 
82
- raise ArgumentError, "expects does not support duplicate sub-keys (i.e. `#{field}` is already defined)" if method_defined?(field)
85
+ define_memoized_reader_method(field) do
86
+ Action::Validation::Subfields.extract(field, public_send(on))
87
+ end
83
88
 
84
- define_memoized_reader_method(field) do
85
- Action::Validation::Subfields.extract(field, public_send(on))
89
+ _define_model_reader(field, validations[:model]) { Action::Validation::Subfields.extract(field, public_send(on)) } if validations.key?(:model)
86
90
  end
87
-
88
- _define_model_reader(field, validations[:model]) if validations.key?(:model)
89
91
  end
90
- end
91
92
 
92
- module InstanceMethods
93
- def _validate_subfields_contract!
94
- return if subfield_configs.blank?
95
-
96
- subfield_configs.each do |config|
97
- Validation::Subfields.validate!(
98
- field: config.field,
99
- validations: config.validations,
100
- source: public_send(config.on),
101
- exception_klass: Action::InboundValidationError,
102
- )
93
+ module InstanceMethods
94
+ def _validate_subfields_contract!
95
+ return if subfield_configs.blank?
96
+
97
+ subfield_configs.each do |config|
98
+ Validation::Subfields.validate!(
99
+ field: config.field,
100
+ validations: config.validations,
101
+ source: public_send(config.on),
102
+ exception_klass: Action::InboundValidationError,
103
+ )
104
+ end
103
105
  end
104
106
  end
105
107
  end