treaty 0.17.0 → 0.19.0

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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/config/locales/en.yml +6 -2
  4. data/lib/treaty/engine.rb +1 -1
  5. data/lib/treaty/{attribute/entity → entity/attribute}/attribute.rb +4 -4
  6. data/lib/treaty/entity/attribute/base.rb +184 -0
  7. data/lib/treaty/entity/attribute/builder/base.rb +275 -0
  8. data/lib/treaty/entity/attribute/collection.rb +67 -0
  9. data/lib/treaty/entity/attribute/dsl.rb +92 -0
  10. data/lib/treaty/entity/attribute/helper_mapper.rb +74 -0
  11. data/lib/treaty/entity/attribute/option/base.rb +190 -0
  12. data/lib/treaty/entity/attribute/option/conditionals/base.rb +92 -0
  13. data/lib/treaty/entity/attribute/option/conditionals/if_conditional.rb +136 -0
  14. data/lib/treaty/entity/attribute/option/conditionals/unless_conditional.rb +153 -0
  15. data/lib/treaty/entity/attribute/option/modifiers/as_modifier.rb +93 -0
  16. data/lib/treaty/entity/attribute/option/modifiers/cast_modifier.rb +285 -0
  17. data/lib/treaty/entity/attribute/option/modifiers/computed_modifier.rb +128 -0
  18. data/lib/treaty/entity/attribute/option/modifiers/default_modifier.rb +105 -0
  19. data/lib/treaty/entity/attribute/option/modifiers/transform_modifier.rb +114 -0
  20. data/lib/treaty/entity/attribute/option/registry.rb +157 -0
  21. data/lib/treaty/entity/attribute/option/registry_initializer.rb +117 -0
  22. data/lib/treaty/entity/attribute/option/validators/format_validator.rb +222 -0
  23. data/lib/treaty/entity/attribute/option/validators/inclusion_validator.rb +94 -0
  24. data/lib/treaty/entity/attribute/option/validators/required_validator.rb +100 -0
  25. data/lib/treaty/entity/attribute/option/validators/type_validator.rb +219 -0
  26. data/lib/treaty/entity/attribute/option_normalizer.rb +168 -0
  27. data/lib/treaty/entity/attribute/option_orchestrator.rb +192 -0
  28. data/lib/treaty/entity/attribute/validation/attribute_validator.rb +147 -0
  29. data/lib/treaty/entity/attribute/validation/base.rb +76 -0
  30. data/lib/treaty/entity/attribute/validation/nested_array_validator.rb +207 -0
  31. data/lib/treaty/entity/attribute/validation/nested_object_validator.rb +105 -0
  32. data/lib/treaty/entity/attribute/validation/nested_transformer.rb +432 -0
  33. data/lib/treaty/entity/attribute/validation/orchestrator/base.rb +262 -0
  34. data/lib/treaty/entity/base.rb +90 -0
  35. data/lib/treaty/entity/builder.rb +44 -0
  36. data/lib/treaty/{info/entity → entity/info}/builder.rb +8 -8
  37. data/lib/treaty/{info/entity → entity/info}/dsl.rb +2 -2
  38. data/lib/treaty/{info/entity → entity/info}/result.rb +2 -2
  39. data/lib/treaty/entity.rb +7 -75
  40. data/lib/treaty/request/attribute/attribute.rb +1 -1
  41. data/lib/treaty/request/attribute/builder.rb +24 -1
  42. data/lib/treaty/request/entity.rb +1 -1
  43. data/lib/treaty/request/factory.rb +6 -6
  44. data/lib/treaty/request/validator.rb +1 -1
  45. data/lib/treaty/response/attribute/attribute.rb +1 -1
  46. data/lib/treaty/response/attribute/builder.rb +24 -1
  47. data/lib/treaty/response/entity.rb +1 -1
  48. data/lib/treaty/response/factory.rb +6 -6
  49. data/lib/treaty/response/validator.rb +1 -1
  50. data/lib/treaty/version.rb +1 -1
  51. metadata +35 -34
  52. data/lib/treaty/attribute/base.rb +0 -182
  53. data/lib/treaty/attribute/builder/base.rb +0 -143
  54. data/lib/treaty/attribute/collection.rb +0 -65
  55. data/lib/treaty/attribute/dsl.rb +0 -90
  56. data/lib/treaty/attribute/entity/builder.rb +0 -23
  57. data/lib/treaty/attribute/helper_mapper.rb +0 -72
  58. data/lib/treaty/attribute/option/base.rb +0 -188
  59. data/lib/treaty/attribute/option/conditionals/base.rb +0 -90
  60. data/lib/treaty/attribute/option/conditionals/if_conditional.rb +0 -134
  61. data/lib/treaty/attribute/option/conditionals/unless_conditional.rb +0 -151
  62. data/lib/treaty/attribute/option/modifiers/as_modifier.rb +0 -91
  63. data/lib/treaty/attribute/option/modifiers/cast_modifier.rb +0 -283
  64. data/lib/treaty/attribute/option/modifiers/computed_modifier.rb +0 -126
  65. data/lib/treaty/attribute/option/modifiers/default_modifier.rb +0 -103
  66. data/lib/treaty/attribute/option/modifiers/transform_modifier.rb +0 -112
  67. data/lib/treaty/attribute/option/registry.rb +0 -155
  68. data/lib/treaty/attribute/option/registry_initializer.rb +0 -115
  69. data/lib/treaty/attribute/option/validators/format_validator.rb +0 -220
  70. data/lib/treaty/attribute/option/validators/inclusion_validator.rb +0 -92
  71. data/lib/treaty/attribute/option/validators/required_validator.rb +0 -98
  72. data/lib/treaty/attribute/option/validators/type_validator.rb +0 -217
  73. data/lib/treaty/attribute/option_normalizer.rb +0 -166
  74. data/lib/treaty/attribute/option_orchestrator.rb +0 -190
  75. data/lib/treaty/attribute/validation/attribute_validator.rb +0 -145
  76. data/lib/treaty/attribute/validation/base.rb +0 -74
  77. data/lib/treaty/attribute/validation/nested_array_validator.rb +0 -205
  78. data/lib/treaty/attribute/validation/nested_object_validator.rb +0 -103
  79. data/lib/treaty/attribute/validation/nested_transformer.rb +0 -430
  80. data/lib/treaty/attribute/validation/orchestrator/base.rb +0 -260
@@ -0,0 +1,432 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Treaty
4
+ module Entity
5
+ module Attribute
6
+ module Validation
7
+ # Handles transformation of nested attributes (objects and arrays).
8
+ # Extracted from Orchestrator::Base to reduce complexity.
9
+ class NestedTransformer
10
+ SELF_OBJECT = :_self
11
+ private_constant :SELF_OBJECT
12
+
13
+ attr_reader :attribute
14
+
15
+ # Creates a new nested transformer
16
+ #
17
+ # @param attribute [Attribute::Base] The attribute with nested structure
18
+ def initialize(attribute)
19
+ @attribute = attribute
20
+ end
21
+
22
+ # Transforms nested attribute value (object or array)
23
+ # Returns original value if nil or not nested
24
+ #
25
+ # @param value [Object] The value to transform
26
+ # @param root_data [Hash] Full raw data from root level (for computed modifier)
27
+ # @return [Object] Transformed value
28
+ def transform(value, root_data = {})
29
+ return value if value.nil?
30
+
31
+ case attribute.type
32
+ when :object
33
+ transform_object(value, root_data)
34
+ when :array
35
+ transform_array(value, root_data)
36
+ else
37
+ value
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ # Transforms object (hash) value
44
+ #
45
+ # @param value [Hash] The hash to transform
46
+ # @param root_data [Hash] Full raw data from root level (for computed modifier)
47
+ # @return [Hash] Transformed hash
48
+ def transform_object(value, root_data = {})
49
+ return value unless attribute.nested?
50
+
51
+ transformer = ObjectTransformer.new(attribute)
52
+ transformer.transform(value, root_data)
53
+ end
54
+
55
+ # Transforms array value
56
+ #
57
+ # @param value [Array] The array to transform
58
+ # @param root_data [Hash] Full raw data from root level (for computed modifier)
59
+ # @return [Array] Transformed array
60
+ def transform_array(value, root_data = {})
61
+ return value unless attribute.nested?
62
+
63
+ transformer = ArrayTransformer.new(attribute)
64
+ transformer.transform(value, root_data)
65
+ end
66
+
67
+ # Transforms object (hash) with nested attributes
68
+ class ObjectTransformer
69
+ attr_reader :attribute
70
+
71
+ # Creates a new object transformer
72
+ #
73
+ # @param attribute [Attribute::Base] The object-type attribute
74
+ def initialize(attribute)
75
+ @attribute = attribute
76
+ end
77
+
78
+ # Transforms hash by processing all nested attributes
79
+ #
80
+ # @param value [Hash] The source hash
81
+ # @param root_data [Hash] Full raw data from root level (for computed modifier)
82
+ # @return [Hash] Transformed hash with processed attributes
83
+ def transform(value, root_data = {})
84
+ transformed = {}
85
+
86
+ attribute.collection_of_attributes.each do |nested_attribute|
87
+ # Check if conditional (if/unless option) - skip attribute if condition evaluates to skip
88
+ next unless should_process_attribute?(nested_attribute, value)
89
+
90
+ process_attribute(nested_attribute, value, transformed, root_data)
91
+ end
92
+
93
+ transformed
94
+ end
95
+
96
+ private
97
+
98
+ # Returns the conditional option name if present (:if or :unless)
99
+ # Raises error if both are present (mutual exclusivity)
100
+ #
101
+ # @param nested_attribute [Attribute::Base] The attribute to check
102
+ # @raise [Treaty::Exceptions::Validation] If both :if and :unless are present
103
+ # @return [Symbol, nil] :if, :unless, or nil
104
+ def conditional_option_for(nested_attribute) # rubocop:disable Metrics/MethodLength
105
+ has_if = nested_attribute.options.key?(:if)
106
+ has_unless = nested_attribute.options.key?(:unless)
107
+
108
+ if has_if && has_unless
109
+ raise Treaty::Exceptions::Validation,
110
+ I18n.t(
111
+ "treaty.attributes.conditionals.mutual_exclusivity_error",
112
+ attribute: nested_attribute.name
113
+ )
114
+ end
115
+
116
+ return :if if has_if
117
+ return :unless if has_unless
118
+
119
+ nil
120
+ end
121
+
122
+ # Gets cached conditional processors for attributes or builds them
123
+ #
124
+ # @return [Hash] Hash of attribute => conditional processor
125
+ def conditionals_for_attributes
126
+ @conditionals_for_attributes ||= build_conditionals_for_attributes
127
+ end
128
+
129
+ # Builds conditional processors for attributes with :if or :unless option
130
+ # Validates schema at definition time for performance
131
+ #
132
+ # @return [Hash] Hash of attribute => conditional processor
133
+ def build_conditionals_for_attributes # rubocop:disable Metrics/MethodLength
134
+ attribute.collection_of_attributes.each_with_object({}) do |nested_attribute, cache|
135
+ # Get conditional option name (:if or :unless)
136
+ conditional_type = conditional_option_for(nested_attribute)
137
+ next if conditional_type.nil?
138
+
139
+ processor_class = Option::Registry.processor_for(conditional_type)
140
+ next if processor_class.nil?
141
+
142
+ # Create processor instance
143
+ conditional = processor_class.new(
144
+ attribute_name: nested_attribute.name,
145
+ attribute_type: nested_attribute.type,
146
+ option_schema: nested_attribute.options.fetch(conditional_type)
147
+ )
148
+
149
+ # Validate schema at definition time (not runtime)
150
+ conditional.validate_schema!
151
+
152
+ cache[nested_attribute] = conditional
153
+ end
154
+ end
155
+
156
+ # Checks if an attribute should be processed based on its conditional (if/unless option)
157
+ # Returns true if no conditional is defined or if conditional evaluates appropriately
158
+ #
159
+ # @param nested_attribute [Attribute::Base] The attribute to check
160
+ # @param source_hash [Hash] Source data to pass to conditional
161
+ # @return [Boolean] True if attribute should be processed, false to skip it
162
+ def should_process_attribute?(nested_attribute, source_hash)
163
+ # Check if attribute has a conditional option
164
+ conditional_type = conditional_option_for(nested_attribute)
165
+ return true if conditional_type.nil?
166
+
167
+ # Get cached conditional processor
168
+ conditional = conditionals_for_attributes[nested_attribute]
169
+ return true if conditional.nil?
170
+
171
+ # Evaluate condition with source hash data wrapped with parent object name
172
+ wrapped_data = { attribute.name => source_hash }
173
+ conditional.evaluate_condition(wrapped_data)
174
+ rescue StandardError
175
+ # If conditional evaluation fails, skip the attribute
176
+ false
177
+ end
178
+
179
+ # Processes a single nested attribute
180
+ # Validates, transforms, and adds to target hash
181
+ #
182
+ # @param nested_attribute [Attribute::Base] Attribute to process
183
+ # @param source_hash [Hash] Source data
184
+ # @param target_hash [Hash] Target hash to populate
185
+ # @param root_data [Hash] Full raw data from root level (for computed modifier)
186
+ # @return [void]
187
+ def process_attribute(nested_attribute, source_hash, target_hash, root_data = {}) # rubocop:disable Metrics/MethodLength
188
+ source_name = nested_attribute.name
189
+ nested_value = source_hash.fetch(source_name, nil)
190
+
191
+ validator = AttributeValidator.new(nested_attribute)
192
+ validator.validate_schema!
193
+
194
+ transformed_value = if nested_attribute.nested?
195
+ nested_transformer = NestedTransformer.new(nested_attribute)
196
+ validator.validate_type!(nested_value) unless nested_value.nil?
197
+ validator.validate_required!(nested_value)
198
+ nested_transformer.transform(nested_value, root_data)
199
+ else
200
+ validator.validate_value!(nested_value)
201
+ validator.transform_value(nested_value, root_data)
202
+ end
203
+
204
+ target_name = validator.target_name
205
+ target_hash[target_name] = transformed_value
206
+ end
207
+ end
208
+
209
+ # Transforms array with nested attributes
210
+ class ArrayTransformer # rubocop:disable Metrics/ClassLength
211
+ SELF_OBJECT = :_self
212
+ private_constant :SELF_OBJECT
213
+
214
+ attr_reader :attribute
215
+
216
+ # Creates a new array transformer
217
+ #
218
+ # @param attribute [Attribute::Base] The array-type attribute
219
+ def initialize(attribute)
220
+ @attribute = attribute
221
+ end
222
+
223
+ # Transforms array by processing each element
224
+ # Handles both simple arrays (:_self) and complex arrays (objects)
225
+ #
226
+ # @param value [Array] The source array
227
+ # @param root_data [Hash] Full raw data from root level (for computed modifier)
228
+ # @return [Array] Transformed array
229
+ def transform(value, root_data = {})
230
+ value.each_with_index.map do |item, index|
231
+ if simple_array?
232
+ transform_simple_element(item, index, root_data)
233
+ else
234
+ transform_array_item(item, index, root_data)
235
+ end
236
+ end
237
+ end
238
+
239
+ private
240
+
241
+ # Returns the conditional option name if present (:if or :unless)
242
+ # Raises error if both are present (mutual exclusivity)
243
+ #
244
+ # @param nested_attribute [Attribute::Base] The attribute to check
245
+ # @raise [Treaty::Exceptions::Validation] If both :if and :unless are present
246
+ # @return [Symbol, nil] :if, :unless, or nil
247
+ def conditional_option_for(nested_attribute) # rubocop:disable Metrics/MethodLength
248
+ has_if = nested_attribute.options.key?(:if)
249
+ has_unless = nested_attribute.options.key?(:unless)
250
+
251
+ if has_if && has_unless
252
+ raise Treaty::Exceptions::Validation,
253
+ I18n.t(
254
+ "treaty.attributes.conditionals.mutual_exclusivity_error",
255
+ attribute: nested_attribute.name
256
+ )
257
+ end
258
+
259
+ return :if if has_if
260
+ return :unless if has_unless
261
+
262
+ nil
263
+ end
264
+
265
+ # Gets cached conditional processors for attributes or builds them
266
+ #
267
+ # @return [Hash] Hash of attribute => conditional processor
268
+ def conditionals_for_attributes
269
+ @conditionals_for_attributes ||= build_conditionals_for_attributes
270
+ end
271
+
272
+ # Builds conditional processors for attributes with :if or :unless option
273
+ # Validates schema at definition time for performance
274
+ #
275
+ # @return [Hash] Hash of attribute => conditional processor
276
+ def build_conditionals_for_attributes # rubocop:disable Metrics/MethodLength
277
+ attribute.collection_of_attributes.each_with_object({}) do |nested_attribute, cache|
278
+ # Get conditional option name (:if or :unless)
279
+ conditional_type = conditional_option_for(nested_attribute)
280
+ next if conditional_type.nil?
281
+
282
+ processor_class = Option::Registry.processor_for(conditional_type)
283
+ next if processor_class.nil?
284
+
285
+ # Create processor instance
286
+ conditional = processor_class.new(
287
+ attribute_name: nested_attribute.name,
288
+ attribute_type: nested_attribute.type,
289
+ option_schema: nested_attribute.options.fetch(conditional_type)
290
+ )
291
+
292
+ # Validate schema at definition time (not runtime)
293
+ conditional.validate_schema!
294
+
295
+ cache[nested_attribute] = conditional
296
+ end
297
+ end
298
+
299
+ # Checks if an attribute should be processed based on its conditional (if/unless option)
300
+ # Returns true if no conditional is defined or if conditional evaluates appropriately
301
+ #
302
+ # @param nested_attribute [Attribute::Base] The attribute to check
303
+ # @param source_hash [Hash] Source data to pass to conditional
304
+ # @return [Boolean] True if attribute should be processed, false to skip it
305
+ def should_process_attribute?(nested_attribute, source_hash)
306
+ # Check if attribute has a conditional option
307
+ conditional_type = conditional_option_for(nested_attribute)
308
+ return true if conditional_type.nil?
309
+
310
+ # Get cached conditional processor
311
+ conditional = conditionals_for_attributes[nested_attribute]
312
+ return true if conditional.nil?
313
+
314
+ # Evaluate condition with source hash data wrapped with parent array attribute name
315
+ wrapped_data = { attribute.name => source_hash }
316
+ conditional.evaluate_condition(wrapped_data)
317
+ rescue StandardError
318
+ # If conditional evaluation fails, skip the attribute
319
+ false
320
+ end
321
+
322
+ # Checks if this is a simple array (primitive values)
323
+ #
324
+ # @return [Boolean] True if array contains primitive values with :_self attribute
325
+ def simple_array?
326
+ attribute.collection_of_attributes.size == 1 &&
327
+ attribute.collection_of_attributes.first.name == SELF_OBJECT
328
+ end
329
+
330
+ # Transforms a simple array element (primitive value)
331
+ # Validates and applies transformations to the element
332
+ #
333
+ # @param item [Object] Array element to transform
334
+ # @param index [Integer] Element index for error messages
335
+ # @param root_data [Hash] Full raw data from root level (for computed modifier)
336
+ # @raise [Treaty::Exceptions::Validation] If validation fails
337
+ # @return [Object] Transformed element value
338
+ def transform_simple_element(item, index, root_data = {}) # rubocop:disable Metrics/MethodLength
339
+ self_attribute = attribute.collection_of_attributes.first
340
+ validator = AttributeValidator.new(self_attribute)
341
+ validator.validate_schema!
342
+
343
+ begin
344
+ validator.validate_value!(item)
345
+ validator.transform_value(item, root_data)
346
+ rescue Treaty::Exceptions::Validation => e
347
+ raise Treaty::Exceptions::Validation,
348
+ I18n.t(
349
+ "treaty.attributes.validators.nested.array.element_validation_error",
350
+ attribute: attribute.name,
351
+ index:,
352
+ errors: e.message
353
+ )
354
+ end
355
+ end
356
+
357
+ # Transforms a complex array element (hash object)
358
+ #
359
+ # @param item [Hash] Array element to transform
360
+ # @param index [Integer] Element index for error messages
361
+ # @param root_data [Hash] Full raw data from root level (for computed modifier)
362
+ # @raise [Treaty::Exceptions::Validation] If item is not a Hash
363
+ # @return [Hash] Transformed hash
364
+ def transform_array_item(item, index, root_data = {}) # rubocop:disable Metrics/MethodLength
365
+ unless item.is_a?(Hash)
366
+ raise Treaty::Exceptions::Validation,
367
+ I18n.t(
368
+ "treaty.attributes.validators.nested.array.element_type_error",
369
+ attribute: attribute.name,
370
+ index:,
371
+ actual: item.class
372
+ )
373
+ end
374
+
375
+ transformed = {}
376
+
377
+ attribute.collection_of_attributes.each do |nested_attribute|
378
+ # Check if conditional (if/unless option) - skip attribute if condition evaluates to skip
379
+ next unless should_process_attribute?(nested_attribute, item)
380
+
381
+ process_attribute(nested_attribute, item, transformed, index, root_data)
382
+ end
383
+
384
+ transformed
385
+ end
386
+
387
+ # Processes a single nested attribute in array element
388
+ # Validates, transforms, and adds to target hash
389
+ #
390
+ # @param nested_attribute [Attribute::Base] Attribute to process
391
+ # @param source_hash [Hash] Source data
392
+ # @param target_hash [Hash] Target hash to populate
393
+ # @param index [Integer] Array index for error messages
394
+ # @param root_data [Hash] Full raw data from root level (for computed modifier)
395
+ # @raise [Treaty::Exceptions::Validation] If validation fails
396
+ # @return [void]
397
+ def process_attribute(nested_attribute, source_hash, target_hash, index, root_data = {}) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
398
+ source_name = nested_attribute.name
399
+ nested_value = source_hash.fetch(source_name, nil)
400
+
401
+ validator = AttributeValidator.new(nested_attribute)
402
+ validator.validate_schema!
403
+
404
+ begin
405
+ transformed_value = if nested_attribute.nested?
406
+ nested_transformer = NestedTransformer.new(nested_attribute)
407
+ validator.validate_type!(nested_value) unless nested_value.nil?
408
+ validator.validate_required!(nested_value)
409
+ nested_transformer.transform(nested_value, root_data)
410
+ else
411
+ validator.validate_value!(nested_value)
412
+ validator.transform_value(nested_value, root_data)
413
+ end
414
+ rescue Treaty::Exceptions::Validation => e
415
+ raise Treaty::Exceptions::Validation,
416
+ I18n.t(
417
+ "treaty.attributes.validators.nested.array.attribute_error",
418
+ attribute: attribute.name,
419
+ index:,
420
+ message: e.message
421
+ )
422
+ end
423
+
424
+ target_name = validator.target_name
425
+ target_hash[target_name] = transformed_value
426
+ end
427
+ end
428
+ end
429
+ end
430
+ end
431
+ end
432
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Treaty
4
+ module Entity
5
+ module Attribute
6
+ module Validation
7
+ module Orchestrator
8
+ # Base orchestrator for validating and transforming data according to treaty definitions.
9
+ #
10
+ # ## Purpose
11
+ #
12
+ # Coordinates the validation and transformation of request/response data for a specific
13
+ # API version. Processes all attributes, applying validations and transformations
14
+ # defined in the treaty DSL.
15
+ #
16
+ # ## Responsibilities
17
+ #
18
+ # 1. **Attribute Processing** - Iterates through all defined attributes
19
+ # 2. **Attribute Validation** - Validates each attribute's value
20
+ # 3. **Data Transformation** - Transforms values (defaults, renaming)
21
+ # 4. **Nested Handling** - Delegates nested structures to NestedTransformer
22
+ # 5. **Result Assembly** - Builds final transformed data structure
23
+ #
24
+ # ## Usage
25
+ #
26
+ # Subclasses must implement:
27
+ # - `collection_of_attributes` - Returns attributes for this context (request/response)
28
+ #
29
+ # Example:
30
+ # orchestrator = Request::Orchestrator.new(version_factory: factory, data: params)
31
+ # validated_data = orchestrator.validate!
32
+ #
33
+ # ## Special Case: object :_self
34
+ #
35
+ # - Normal object: `{ object_name: { ... } }`
36
+ # - Self object (`:_self`): Attributes merged directly into parent
37
+ #
38
+ # ## Architecture
39
+ #
40
+ # Uses:
41
+ # - `AttributeValidator` - Validates individual attributes
42
+ # - `NestedTransformer` - Handles nested objects and arrays
43
+ #
44
+ # The design separates concerns:
45
+ # - Orchestrator: High-level flow and attribute iteration
46
+ # - Validator: Individual attribute validation
47
+ # - Transformer: Nested structure transformation
48
+ class Base
49
+ SELF_OBJECT = :_self
50
+ private_constant :SELF_OBJECT
51
+
52
+ attr_reader :version_factory, :data
53
+
54
+ # Class-level factory method for validation
55
+ # Creates instance and calls validate!
56
+ #
57
+ # @param args [Hash] Arguments passed to initialize
58
+ # @return [Hash] Validated and transformed data
59
+ def self.validate!(...)
60
+ new(...).validate!
61
+ end
62
+
63
+ # Creates a new orchestrator instance
64
+ #
65
+ # @param version_factory [VersionFactory] Factory containing version info
66
+ # @param data [Hash] Data to validate and transform (default: {})
67
+ def initialize(version_factory:, data: {})
68
+ @version_factory = version_factory
69
+ @data = data
70
+ end
71
+
72
+ # Validates and transforms all attributes
73
+ # Iterates through attributes, processes them, handles :_self objects
74
+ # Skips attributes with false conditional (if/unless option)
75
+ #
76
+ # @return [Hash] Transformed data with all attributes processed
77
+ def validate! # rubocop:disable Metrics/MethodLength
78
+ transformed_data = {}
79
+
80
+ collection_of_attributes.each do |attribute|
81
+ # Check if conditional (if/unless option) - skip attribute if condition evaluates to skip
82
+ next unless should_process_attribute?(attribute)
83
+
84
+ transformed_value = validate_and_transform_attribute!(attribute)
85
+
86
+ if attribute.name == SELF_OBJECT && attribute.type == :object
87
+ # For object :_self, merge nested attributes to root
88
+ transformed_data.merge!(transformed_value)
89
+ else
90
+ transformed_data[attribute.name] = transformed_value
91
+ end
92
+ end
93
+
94
+ transformed_data
95
+ end
96
+
97
+ private
98
+
99
+ # Returns the conditional option name if present (:if or :unless)
100
+ # Raises error if both are present (mutual exclusivity)
101
+ #
102
+ # @param attribute [Attribute::Base] The attribute to check
103
+ # @raise [Treaty::Exceptions::Validation] If both :if and :unless are present
104
+ # @return [Symbol, nil] :if, :unless, or nil
105
+ def conditional_option_for(attribute) # rubocop:disable Metrics/MethodLength
106
+ has_if = attribute.options.key?(:if)
107
+ has_unless = attribute.options.key?(:unless)
108
+
109
+ if has_if && has_unless
110
+ raise Treaty::Exceptions::Validation,
111
+ I18n.t(
112
+ "treaty.attributes.conditionals.mutual_exclusivity_error",
113
+ attribute: attribute.name
114
+ )
115
+ end
116
+
117
+ return :if if has_if
118
+ return :unless if has_unless
119
+
120
+ nil
121
+ end
122
+
123
+ # Checks if an attribute should be processed based on its conditional (if/unless option)
124
+ # Returns true if no conditional is defined or if conditional evaluates appropriately
125
+ #
126
+ # @param attribute [Attribute::Base] The attribute to check
127
+ # @return [Boolean] True if attribute should be processed, false to skip it
128
+ def should_process_attribute?(attribute)
129
+ # Check if attribute has a conditional option
130
+ conditional_type = conditional_option_for(attribute)
131
+ return true if conditional_type.nil?
132
+
133
+ # Get cached conditional processor
134
+ conditional = conditionals_for_attributes[attribute]
135
+ return true if conditional.nil?
136
+
137
+ # Evaluate condition with raw data
138
+ # The processor's evaluate_condition already handles if/unless logic
139
+ conditional.evaluate_condition(data)
140
+ end
141
+
142
+ # Returns collection of attributes for this context
143
+ # Must be implemented in subclasses
144
+ #
145
+ # @raise [Treaty::Exceptions::Validation] If not implemented
146
+ # @return [Treaty::Entity::Attribute::Collection] Collection of attributes
147
+ def collection_of_attributes
148
+ raise Treaty::Exceptions::Validation,
149
+ I18n.t("treaty.attributes.validators.nested.orchestrator.collection_not_implemented")
150
+ end
151
+
152
+ # Gets cached validators for attributes or builds them
153
+ #
154
+ # @return [Hash] Hash of attribute => validator
155
+ def validators_for_attributes
156
+ @validators_for_attributes ||= build_validators_for_attributes
157
+ end
158
+
159
+ # Builds validators for all attributes
160
+ #
161
+ # @return [Hash] Hash of attribute => validator
162
+ def build_validators_for_attributes
163
+ collection_of_attributes.each_with_object({}) do |attribute, cache|
164
+ validator = AttributeValidator.new(attribute)
165
+ validator.validate_schema!
166
+ cache[attribute] = validator
167
+ end
168
+ end
169
+
170
+ # Gets cached conditional processors for attributes or builds them
171
+ #
172
+ # @return [Hash] Hash of attribute => conditional processor
173
+ def conditionals_for_attributes
174
+ @conditionals_for_attributes ||= build_conditionals_for_attributes
175
+ end
176
+
177
+ # Builds conditional processors for attributes with :if or :unless option
178
+ # Validates schema at definition time for performance
179
+ #
180
+ # @return [Hash] Hash of attribute => conditional processor
181
+ def build_conditionals_for_attributes # rubocop:disable Metrics/MethodLength
182
+ collection_of_attributes.each_with_object({}) do |attribute, cache|
183
+ # Get conditional option name (:if or :unless)
184
+ conditional_type = conditional_option_for(attribute)
185
+ next if conditional_type.nil?
186
+
187
+ processor_class = Option::Registry.processor_for(conditional_type)
188
+ next if processor_class.nil?
189
+
190
+ # Create processor instance
191
+ conditional = processor_class.new(
192
+ attribute_name: attribute.name,
193
+ attribute_type: attribute.type,
194
+ option_schema: attribute.options.fetch(conditional_type)
195
+ )
196
+
197
+ # Validate schema at definition time (not runtime)
198
+ conditional.validate_schema!
199
+
200
+ cache[attribute] = conditional
201
+ end
202
+ end
203
+
204
+ # Validates and transforms a single attribute
205
+ # Handles both nested and regular attributes
206
+ #
207
+ # @param attribute [Attribute] The attribute to process
208
+ # @return [Object] Transformed attribute value
209
+ def validate_and_transform_attribute!(attribute) # rubocop:disable Metrics/MethodLength
210
+ validator = validators_for_attributes.fetch(attribute)
211
+
212
+ # For :_self object, get data from root; otherwise from attribute key
213
+ value = if attribute.name == SELF_OBJECT && attribute.type == :object
214
+ data
215
+ else
216
+ data.fetch(attribute.name, nil)
217
+ end
218
+
219
+ if attribute.nested?
220
+ validate_and_transform_nested(attribute, value, validator)
221
+ else
222
+ validator.validate_value!(value)
223
+ validator.transform_value(value, data)
224
+ end
225
+ end
226
+
227
+ # Validates and transforms nested attribute (object/array)
228
+ # Delegates transformation to NestedTransformer
229
+ #
230
+ # @param attribute [Attribute::Base] The nested attribute
231
+ # @param value [Object, nil] The value to validate and transform
232
+ # @param validator [AttributeValidator] The validator instance
233
+ # @return [Object, nil] Transformed nested value or nil
234
+ #
235
+ # @note Flow control:
236
+ # - If value is nil and attribute is required → validate_required! raises exception
237
+ # - If value is nil and attribute is optional → validate_required! does nothing, returns nil
238
+ # - If value is not nil → proceeds to transformation (value guaranteed non-nil)
239
+ def validate_and_transform_nested(attribute, value, validator)
240
+ # Step 1: Validate type if value is present
241
+ validator.validate_type!(value) unless value.nil?
242
+
243
+ # Step 2: Validate required constraint
244
+ # This will raise an exception if attribute is required and value is nil
245
+ validator.validate_required!(value)
246
+
247
+ # Step 3: Early return for nil values
248
+ # Only reaches here if attribute is optional and value is nil
249
+ return nil if value.nil?
250
+
251
+ # Step 4: Transform non-nil value
252
+ # At this point, value is guaranteed to be non-nil
253
+ # Pass full root data as context for computed modifiers
254
+ transformer = NestedTransformer.new(attribute)
255
+ transformer.transform(value, data)
256
+ end
257
+ end
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end