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.
- checksums.yaml +4 -4
- data/.rubocop.yml +4 -1
- data/CHANGELOG.md +25 -1
- data/README.md +2 -11
- data/docs/reference/action-result.md +2 -0
- data/docs/reference/class.md +12 -4
- data/docs/reference/configuration.md +53 -20
- data/docs/reference/instance.md +2 -2
- data/docs/strategies/index.md +1 -1
- data/docs/usage/setup.md +1 -1
- data/docs/usage/writing.md +9 -9
- data/lib/action/attachable/steps.rb +16 -1
- data/lib/action/attachable.rb +3 -3
- data/lib/action/{core/configuration.rb → configuration.rb} +3 -4
- data/lib/action/context.rb +38 -0
- data/lib/action/core/automatic_logging.rb +93 -0
- data/lib/action/core/context/facade.rb +69 -0
- data/lib/action/core/context/facade_inspector.rb +63 -0
- data/lib/action/core/context/internal.rb +32 -0
- data/lib/action/core/contract.rb +167 -211
- data/lib/action/core/contract_for_subfields.rb +84 -82
- data/lib/action/core/contract_validation.rb +62 -0
- data/lib/action/core/flow/callbacks.rb +54 -0
- data/lib/action/core/flow/exception_execution.rb +79 -0
- data/lib/action/core/flow/messages.rb +61 -0
- data/lib/action/core/flow.rb +19 -0
- data/lib/action/core/hoist_errors.rb +42 -40
- data/lib/action/core/hooks.rb +123 -0
- data/lib/action/core/logging.rb +22 -20
- data/lib/action/core/timing.rb +40 -0
- data/lib/action/core/tracing.rb +17 -0
- data/lib/action/core/use_strategy.rb +19 -17
- data/lib/action/core/validation/fields.rb +2 -0
- data/lib/action/core.rb +100 -0
- data/lib/action/enqueueable/via_sidekiq.rb +2 -2
- data/lib/action/enqueueable.rb +1 -1
- data/lib/action/{core/exceptions.rb → exceptions.rb} +1 -19
- data/lib/action/result.rb +95 -0
- data/lib/axn/factory.rb +27 -9
- data/lib/axn/version.rb +1 -1
- data/lib/axn.rb +10 -47
- metadata +19 -21
- data/lib/action/core/context_facade.rb +0 -209
- data/lib/action/core/handle_exceptions.rb +0 -163
- 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
|
data/lib/action/core/contract.rb
CHANGED
@@ -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/
|
7
|
+
require "action/result"
|
8
|
+
require "action/core/context/internal"
|
8
9
|
|
9
10
|
module Action
|
10
|
-
module
|
11
|
-
|
12
|
-
base
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
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
|
-
|
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
|
-
|
53
|
-
|
54
|
-
|
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
|
-
|
57
|
-
|
58
|
-
|
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
|
-
|
74
|
-
|
75
|
-
|
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
|
-
|
78
|
-
|
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
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
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
|
-
|
113
|
-
|
114
|
-
|
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
|
-
|
119
|
-
|
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
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
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
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
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
|
-
|
160
|
-
|
161
|
-
|
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
|
-
|
164
|
-
|
165
|
-
|
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
|
-
|
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
|
-
|
172
|
-
|
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
|
-
|
183
|
-
|
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
|
-
|
150
|
+
fields.map { |field| [field, validations] }
|
186
151
|
end
|
187
152
|
end
|
188
153
|
|
189
|
-
|
190
|
-
|
191
|
-
|
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
|
-
|
195
|
-
|
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
|
-
|
198
|
-
|
199
|
-
end
|
166
|
+
kwargs.merge!(args.first => args.last)
|
167
|
+
end
|
200
168
|
|
201
|
-
|
202
|
-
|
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
|
-
|
207
|
-
|
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
|
-
|
215
|
-
|
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
|
-
|
228
|
-
raise ArgumentError, "Invalid direction: #{direction}" unless %i[inbound outbound].include?(direction)
|
180
|
+
private
|
229
181
|
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
182
|
+
def _with_contract
|
183
|
+
_apply_inbound_preprocessing!
|
184
|
+
_apply_defaults!(:inbound)
|
185
|
+
_validate_contract!(:inbound)
|
234
186
|
|
235
|
-
|
236
|
-
next if @context.public_send(field).present?
|
187
|
+
yield
|
237
188
|
|
238
|
-
|
189
|
+
_apply_defaults!(:outbound)
|
190
|
+
_validate_contract!(:outbound)
|
239
191
|
|
240
|
-
|
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
|
-
|
245
|
-
|
246
|
-
end
|
196
|
+
def _build_context_facade(direction)
|
197
|
+
raise ArgumentError, "Invalid direction: #{direction}" unless %i[inbound outbound].include?(direction)
|
247
198
|
|
248
|
-
|
199
|
+
klass = direction == :inbound ? Action::InternalContext : Action::Result
|
200
|
+
implicitly_allowed_fields = direction == :inbound ? _declared_fields(:outbound) : []
|
249
201
|
|
250
|
-
|
251
|
-
|
252
|
-
end
|
202
|
+
klass.new(action: self, context: @__context, declared_fields: _declared_fields(direction), implicitly_allowed_fields:)
|
203
|
+
end
|
253
204
|
|
254
|
-
|
255
|
-
|
256
|
-
|
205
|
+
def inspection_filter
|
206
|
+
@inspection_filter ||= ActiveSupport::ParameterFilter.new(sensitive_fields)
|
207
|
+
end
|
257
208
|
|
258
|
-
|
259
|
-
|
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
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
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
|
-
|
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
|
7
|
-
|
8
|
-
|
9
|
-
|
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
|
-
|
12
|
-
|
13
|
-
|
12
|
+
def self.included(base)
|
13
|
+
base.class_eval do
|
14
|
+
class_attribute :subfield_configs, default: []
|
14
15
|
|
15
|
-
|
16
|
-
|
16
|
+
extend ClassMethods
|
17
|
+
include InstanceMethods
|
17
18
|
|
18
|
-
|
19
|
+
before { _validate_subfields_contract! }
|
20
|
+
end
|
19
21
|
end
|
20
|
-
end
|
21
22
|
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
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
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
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
|
-
|
60
|
-
|
61
|
-
|
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
|
-
|
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
|
-
|
85
|
+
define_memoized_reader_method(field) do
|
86
|
+
Action::Validation::Subfields.extract(field, public_send(on))
|
87
|
+
end
|
83
88
|
|
84
|
-
|
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
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
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
|