spec_forge 0.5.0 → 0.7.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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/.standard.yml +3 -3
  3. data/CHANGELOG.md +217 -2
  4. data/README.md +162 -25
  5. data/flake.lock +3 -3
  6. data/flake.nix +11 -5
  7. data/lib/spec_forge/attribute/chainable.rb +208 -20
  8. data/lib/spec_forge/attribute/factory.rb +92 -15
  9. data/lib/spec_forge/attribute/faker.rb +62 -13
  10. data/lib/spec_forge/attribute/global.rb +96 -0
  11. data/lib/spec_forge/attribute/literal.rb +15 -2
  12. data/lib/spec_forge/attribute/matcher.rb +186 -11
  13. data/lib/spec_forge/attribute/parameterized.rb +45 -12
  14. data/lib/spec_forge/attribute/regex.rb +55 -5
  15. data/lib/spec_forge/attribute/resolvable.rb +48 -5
  16. data/lib/spec_forge/attribute/resolvable_array.rb +62 -4
  17. data/lib/spec_forge/attribute/resolvable_hash.rb +62 -4
  18. data/lib/spec_forge/attribute/store.rb +65 -0
  19. data/lib/spec_forge/attribute/transform.rb +33 -5
  20. data/lib/spec_forge/attribute/variable.rb +37 -6
  21. data/lib/spec_forge/attribute.rb +166 -66
  22. data/lib/spec_forge/backtrace_formatter.rb +26 -3
  23. data/lib/spec_forge/callbacks.rb +88 -0
  24. data/lib/spec_forge/cli/actions.rb +27 -0
  25. data/lib/spec_forge/cli/command.rb +78 -24
  26. data/lib/spec_forge/cli/docs/generate.rb +72 -0
  27. data/lib/spec_forge/cli/docs.rb +92 -0
  28. data/lib/spec_forge/cli/init.rb +51 -9
  29. data/lib/spec_forge/cli/new.rb +67 -6
  30. data/lib/spec_forge/cli/run.rb +32 -4
  31. data/lib/spec_forge/cli/serve.rb +155 -0
  32. data/lib/spec_forge/cli.rb +26 -7
  33. data/lib/spec_forge/configuration.rb +96 -24
  34. data/lib/spec_forge/context/callbacks.rb +91 -0
  35. data/lib/spec_forge/context/global.rb +72 -0
  36. data/lib/spec_forge/context/store.rb +131 -0
  37. data/lib/spec_forge/context/variables.rb +91 -0
  38. data/lib/spec_forge/context.rb +36 -0
  39. data/lib/spec_forge/core_ext/array.rb +27 -0
  40. data/lib/spec_forge/core_ext/rspec.rb +22 -4
  41. data/lib/spec_forge/documentation/builder.rb +383 -0
  42. data/lib/spec_forge/documentation/document/operation.rb +47 -0
  43. data/lib/spec_forge/documentation/document/parameter.rb +22 -0
  44. data/lib/spec_forge/documentation/document/request_body.rb +24 -0
  45. data/lib/spec_forge/documentation/document/response.rb +39 -0
  46. data/lib/spec_forge/documentation/document/response_body.rb +27 -0
  47. data/lib/spec_forge/documentation/document.rb +48 -0
  48. data/lib/spec_forge/documentation/generators/base.rb +81 -0
  49. data/lib/spec_forge/documentation/generators/openapi/base.rb +100 -0
  50. data/lib/spec_forge/documentation/generators/openapi/error_formatter.rb +149 -0
  51. data/lib/spec_forge/documentation/generators/openapi/v3_0.rb +65 -0
  52. data/lib/spec_forge/documentation/generators/openapi.rb +59 -0
  53. data/lib/spec_forge/documentation/generators.rb +17 -0
  54. data/lib/spec_forge/documentation/loader/cache.rb +138 -0
  55. data/lib/spec_forge/documentation/loader.rb +159 -0
  56. data/lib/spec_forge/documentation/openapi/base.rb +33 -0
  57. data/lib/spec_forge/documentation/openapi/v3_0/example.rb +44 -0
  58. data/lib/spec_forge/documentation/openapi/v3_0/media_type.rb +42 -0
  59. data/lib/spec_forge/documentation/openapi/v3_0/operation.rb +175 -0
  60. data/lib/spec_forge/documentation/openapi/v3_0/response.rb +65 -0
  61. data/lib/spec_forge/documentation/openapi/v3_0/schema.rb +80 -0
  62. data/lib/spec_forge/documentation/openapi/v3_0/tag.rb +71 -0
  63. data/lib/spec_forge/documentation/openapi.rb +23 -0
  64. data/lib/spec_forge/documentation.rb +27 -0
  65. data/lib/spec_forge/error.rb +284 -113
  66. data/lib/spec_forge/factory.rb +35 -16
  67. data/lib/spec_forge/filter.rb +86 -0
  68. data/lib/spec_forge/forge.rb +171 -0
  69. data/lib/spec_forge/http/backend.rb +101 -29
  70. data/lib/spec_forge/http/client.rb +23 -13
  71. data/lib/spec_forge/http/request.rb +85 -62
  72. data/lib/spec_forge/http/verb.rb +79 -0
  73. data/lib/spec_forge/http.rb +105 -0
  74. data/lib/spec_forge/loader.rb +244 -0
  75. data/lib/spec_forge/matchers.rb +130 -0
  76. data/lib/spec_forge/normalizer/default.rb +51 -0
  77. data/lib/spec_forge/normalizer/definition.rb +248 -0
  78. data/lib/spec_forge/normalizer/validators.rb +99 -0
  79. data/lib/spec_forge/normalizer.rb +486 -115
  80. data/lib/spec_forge/normalizers/_shared.yml +74 -0
  81. data/lib/spec_forge/normalizers/configuration.yml +23 -0
  82. data/lib/spec_forge/normalizers/constraint.yml +8 -0
  83. data/lib/spec_forge/normalizers/expectation.yml +47 -0
  84. data/lib/spec_forge/normalizers/factory.yml +12 -0
  85. data/lib/spec_forge/normalizers/factory_reference.yml +15 -0
  86. data/lib/spec_forge/normalizers/global_context.yml +28 -0
  87. data/lib/spec_forge/normalizers/spec.yml +50 -0
  88. data/lib/spec_forge/runner/adapter.rb +183 -0
  89. data/lib/spec_forge/runner/callbacks.rb +246 -0
  90. data/lib/spec_forge/runner/debug_proxy.rb +213 -0
  91. data/lib/spec_forge/runner/listener.rb +54 -0
  92. data/lib/spec_forge/runner/metadata.rb +58 -0
  93. data/lib/spec_forge/runner/state.rb +98 -0
  94. data/lib/spec_forge/runner.rb +50 -125
  95. data/lib/spec_forge/spec/expectation/constraint.rb +100 -21
  96. data/lib/spec_forge/spec/expectation.rb +47 -51
  97. data/lib/spec_forge/spec.rb +50 -108
  98. data/lib/spec_forge/type.rb +36 -4
  99. data/lib/spec_forge/version.rb +4 -1
  100. data/lib/spec_forge.rb +168 -76
  101. data/lib/templates/openapi.yml.tt +22 -0
  102. data/lib/templates/redoc.html.tt +28 -0
  103. data/lib/templates/swagger.html.tt +59 -0
  104. metadata +109 -16
  105. data/lib/spec_forge/normalizer/configuration.rb +0 -77
  106. data/lib/spec_forge/normalizer/constraint.rb +0 -47
  107. data/lib/spec_forge/normalizer/expectation.rb +0 -86
  108. data/lib/spec_forge/normalizer/factory.rb +0 -65
  109. data/lib/spec_forge/normalizer/factory_reference.rb +0 -71
  110. data/lib/spec_forge/normalizer/spec.rb +0 -74
  111. data/spec_forge/factories/user.yml +0 -4
  112. data/spec_forge/forge_helper.rb +0 -48
  113. data/spec_forge/specs/users.yml +0 -65
  114. /data/lib/templates/{forge_helper.tt → forge_helper.rb.tt} +0 -0
  115. /data/lib/templates/{new_factory.tt → new_factory.yml.tt} +0 -0
  116. /data/lib/templates/{new_spec.tt → new_spec.yml.tt} +0 -0
@@ -1,56 +1,237 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "normalizer/default"
4
+ require_relative "normalizer/definition"
5
+ require_relative "normalizer/validators"
6
+
3
7
  module SpecForge
8
+ #
9
+ # This class provides a powerful system for validating and normalizing input data
10
+ # according to defined structures. It handles type checking, default values,
11
+ # references between structures, and custom validation logic.
12
+ #
13
+ # == Structure Definitions
14
+ #
15
+ # Structures define validation rules as YAML files in the lib/spec_forge/normalizers directory:
16
+ #
17
+ # # Example structure (users.yml)
18
+ # name:
19
+ # type: String
20
+ # required: true
21
+ #
22
+ # age:
23
+ # type: Integer
24
+ # default: 0
25
+ #
26
+ # settings:
27
+ # type: Hash
28
+ # structure:
29
+ # notifications:
30
+ # type: Boolean
31
+ # default: true
32
+ #
33
+ # == Core Attribute Behaviors
34
+ #
35
+ # 1. With 'default:' - Always included in output, using default if nil
36
+ # 2. With 'required: false' - Omitted from output if nil
37
+ # 3. Default behavior - Required, errors if missing/nil
38
+ #
39
+ # == Available Options
40
+ #
41
+ # * type: - Required. Class name or array of class names (string, integer, hash, etc.)
42
+ # * default: - Optional. Default value if attribute is nil
43
+ # * required: - Optional. Set to false to make attribute optional
44
+ # * aliases: - Optional. Alternative keys to check for value
45
+ # * structure: - Optional. Sub-structure for nested objects
46
+ # * validator: - Optional. Custom validation method (see Validators)
47
+ # * reference: - Optional. Reference another structure definition (e.g., reference: headers)
48
+ #
49
+ # == Structure References
50
+ #
51
+ # References allow reusing common structures:
52
+ #
53
+ # # In your YAML definition:
54
+ # user_id:
55
+ # reference: id # Will inherit all properties from the 'id' structure
56
+ # required: false # Can override specific properties
57
+ #
58
+ # # Nested structure references:
59
+ # settings:
60
+ # type: Hash
61
+ # structure:
62
+ # email_prefs:
63
+ # reference: email_preferences # References another complete structure
64
+ #
65
+ # == Common Usage Patterns
66
+ #
67
+ # Basic Normalization:
68
+ # result = SpecForge::Normalizer.normalize!({name: "Test"}, using: :user)
69
+ #
70
+ # Using Custom Structure:
71
+ # structure = {count: {type: Integer, default: 0}}
72
+ # result = SpecForge::Normalizer.normalize!({}, using: structure, label: "counter")
73
+ #
74
+ # Getting Default Values:
75
+ # defaults = SpecForge::Normalizer.default(:user)
76
+ #
77
+ # == Error Handling
78
+ #
79
+ # Validation errors are collected during normalization and can be:
80
+ # - Raised via normalize! method
81
+ # - Returned as a set via normalize method
82
+ #
83
+ # == Creating Custom Structures
84
+ #
85
+ # Add YAML files to lib/spec_forge/normalizers/ directory:
86
+ # - Use '_shared.yml' for common structures that can be referenced
87
+ # - Create custom validators in Normalizer::Validators class
88
+ # - Specify labels for error messages with default_label method
89
+ #
4
90
  class Normalizer
5
- SHARED_ATTRIBUTES = {
6
- base_url: {
7
- type: String,
8
- default: ""
9
- },
10
- url: {
11
- type: String,
12
- aliases: %i[path],
13
- default: ""
14
- },
15
- http_method: {
16
- type: String,
17
- aliases: %i[method],
18
- default: ""
19
- },
20
- headers: {
21
- type: Hash,
22
- default: {}
23
- },
24
- query: {
25
- type: Hash,
26
- aliases: %i[params],
27
- default: {}
28
- },
29
- body: {
30
- type: Hash,
31
- aliases: %i[data],
32
- default: {}
33
- },
34
- variables: {
35
- type: Hash,
36
- default: {}
37
- },
38
- debug: {
39
- type: [TrueClass, FalseClass],
40
- default: false,
41
- aliases: %i[pry breakpoint]
42
- }
43
- }.freeze
44
-
45
- STRUCTURE = {}
46
-
47
91
  class << self
92
+ #
93
+ # Collection of structure definitions used for validation
94
+ #
95
+ # Contains all the structure definitions loaded from YAML files,
96
+ # indexed by their name. Each structure defines the expected format,
97
+ # types, and validation rules for a specific data structure.
98
+ #
99
+ # @return [Hash<Symbol, Hash>] Hash mapping structure names to their definitions
100
+ #
101
+ # @example Accessing a structure definition
102
+ # spec_structure = SpecForge::Normalizer.structures[:spec]
103
+ # url_definition = spec_structure[:structure][:url]
104
+ #
105
+ attr_reader :structures
106
+
107
+ #
108
+ # Normalizes input data against a structure with error raising
109
+ #
110
+ # Same as #normalize but raises an error if validation fails.
111
+ #
112
+ # @param input [Hash] The data to normalize
113
+ # @param using [Symbol, Hash] Either a predefined structure name or a custom structure
114
+ # @param label [String, nil] A descriptive label for error messages
115
+ #
116
+ # @return [Hash] The normalized data
117
+ #
118
+ # @raise [Error::InvalidStructureError] If validation fails
119
+ #
120
+ # @example Using a predefined structure
121
+ # SpecForge::Normalizer.normalize!({url: "/users"}, using: :spec)
122
+ #
123
+ # @example Using a custom structure
124
+ # structure = {name: {type: String}}
125
+ # SpecForge::Normalizer.normalize!({name: "Test"}, using: structure, label: "custom")
126
+ #
127
+ def normalize!(input, using:, label: nil)
128
+ raise_errors! { normalize(input, using:, label:) }
129
+ end
130
+
131
+ #
132
+ # Normalizes input data against a structure without raising errors
133
+ #
134
+ # Validates and transforms input data according to a structure definition,
135
+ # collecting any validation errors rather than raising them. This method
136
+ # is the underlying implementation used by normalize! but returns errors
137
+ # instead of raising them.
138
+ #
139
+ # @param input [Hash] The data to normalize
140
+ # @param using [Symbol, Hash] Either a predefined structure name or a custom structure
141
+ # @param label [String, nil] A descriptive label for error messages
142
+ #
143
+ # @return [Array<Hash, Set>] A two-element array containing:
144
+ # 1. The normalized data
145
+ # 2. A set of any validation errors encountered
146
+ #
147
+ def normalize(input, using:, label: nil)
148
+ # Since normalization is based on a structured hash, :using can be passed a Hash
149
+ # to skip using a predefined normalizer.
150
+ if using.is_a?(Hash)
151
+ structure = using
152
+
153
+ if label.blank?
154
+ raise ArgumentError, "A label must be provided when using a custom structure"
155
+ end
156
+ else
157
+ data = @structures[using.to_sym]
158
+
159
+ # We have a predefined structure and structures all have labels
160
+ label ||= data[:label]
161
+ structure = data[:structure]
162
+ end
163
+
164
+ # Ensure we have a structure
165
+ if !structure.is_a?(Hash)
166
+ structures = @structures.keys.map(&:in_quotes).to_or_sentence
167
+
168
+ raise ArgumentError,
169
+ "Invalid structure or name. Got #{using}, expected one of #{structures}"
170
+ end
171
+
172
+ # This is checked down here because it felt like it belonged...
173
+ # and because of that pesky label
174
+ raise Error::InvalidTypeError.new(input, Hash, for: label) if !Type.hash?(input)
175
+
176
+ new(label, input, structure:).normalize
177
+ end
178
+
179
+ #
180
+ # Returns the default values for a structure
181
+ #
182
+ # Creates a hash of defaults based on a structure definition. Handles optional
183
+ # values, nested structures, and type-specific default generation.
184
+ #
185
+ # @param name [Symbol, nil] Name of a predefined structure to use
186
+ # @param structure [Hash, nil] Custom structure definition (used if name not provided)
187
+ # @param include_optional [Boolean] Whether to include non-required fields with no default
188
+ #
189
+ # @return [Hash] A hash of default values based on the structure
190
+ #
191
+ # @example Getting defaults for a predefined structure
192
+ # SpecForge::Normalizer.default(:spec)
193
+ # # => {debug: false, variables: {}, headers: {}, ...}
194
+ #
195
+ # @example Getting defaults for a custom structure
196
+ # structure = {name: {type: String, default: "Unnamed"}}
197
+ # SpecForge::Normalizer.default(structure: structure)
198
+ # # => {name: "Unnamed"}
199
+ #
200
+ def default(name = nil, structure: nil, include_optional: false)
201
+ structure ||= @structures.dig(name.to_sym, :structure)
202
+
203
+ if !structure.is_a?(Hash)
204
+ raise ArgumentError, "Invalid structure. Provide either the name of the structure ('name') or a hash ('structure')"
205
+ end
206
+
207
+ default_from_structure(structure, include_optional:)
208
+ end
209
+
210
+ #
211
+ # Loads normalizer structure definitions from YAML files
212
+ #
213
+ # Reads YAML files in the normalizers directory and creates structure
214
+ # definitions for use in validation and normalization.
215
+ #
216
+ # @return [Hash] A hash of loaded structure definitions
217
+ #
218
+ # @api private
219
+ #
220
+ def load_from_files
221
+ @structures = Definition.from_files
222
+ end
223
+
48
224
  #
49
225
  # Raises any errors collected by the block
50
226
  #
51
- # @raises InvalidStructureError
227
+ # @yield Block that returns [output, errors]
228
+ # @yieldreturn [Array<Object, Set>] The result and any errors
52
229
  #
53
- # @private
230
+ # @return [Object] The normalized output if successful
231
+ #
232
+ # @raise [Error::InvalidStructureError] If any errors were encountered
233
+ #
234
+ # @api private
54
235
  #
55
236
  def raise_errors!(&block)
56
237
  errors = Set.new
@@ -62,22 +243,23 @@ module SpecForge
62
243
  errors << e
63
244
  end
64
245
 
65
- raise InvalidStructureError.new(errors) if errors.size > 0
246
+ raise Error::InvalidStructureError.new(errors) if errors.size > 0
66
247
 
67
248
  output
68
249
  end
69
250
 
70
- #
71
- # Returns a default version of this normalizer
72
- #
73
- # @private
74
- #
75
- def default
76
- new("", "").default
77
- end
251
+ # Private methods
252
+ include Default
78
253
  end
79
254
 
80
- attr_reader :label, :input, :structure
255
+ # @return [String] A label that describes the data itself
256
+ attr_reader :label
257
+
258
+ # @return [Hash] The data to normalize
259
+ attr_reader :input
260
+
261
+ # @return [Hash] The structure to normalize the data to
262
+ attr_reader :structure
81
263
 
82
264
  #
83
265
  # Creates a normalizer for normalizing Hash data based on a structure
@@ -86,101 +268,290 @@ module SpecForge
86
268
  # @param input [Hash] The data to normalize
87
269
  # @param structure [Hash] The structure to normalize the data to
88
270
  #
89
- def initialize(label, input, structure: self.class::STRUCTURE)
271
+ # @return [Normalizer] A new normalizer instance
272
+ #
273
+ def initialize(label, input, structure:)
90
274
  @label = label
91
275
  @input = input
92
276
  @structure = structure
93
277
  end
94
278
 
95
279
  #
96
- # Normalizes the data and returns the result
280
+ # Normalizes the data according to the defined structure
97
281
  #
98
- # @return [Hash] The normalized data
282
+ # @return [Array<Hash, Set>] The normalized data and any errors
99
283
  #
100
284
  def normalize
101
- normalize_to_structure
285
+ case input
286
+ when Hash
287
+ normalize_hash
288
+ when Array
289
+ normalize_array
290
+ end
102
291
  end
103
292
 
293
+ protected
294
+
104
295
  #
105
- # Returns a hash with the default structure
296
+ # Extracts a value from a hash checking multiple keys
106
297
  #
107
- # @return [Hash]
298
+ # @param hash [Hash] The hash to extract from
299
+ # @param keys [Array<String, String>] The keys to check
108
300
  #
109
- def default
110
- structure.transform_values do |value|
111
- if value.key?(:default)
112
- value[:default].dup
113
- elsif value[:type] == Integer # Can't call new on int
114
- 0
115
- elsif value[:type] == Proc # Sameeee
116
- -> {}
117
- else
118
- value[:type].new
119
- end
120
- end
301
+ # @return [Object, nil] The value if found, nil otherwise
302
+ #
303
+ # @private
304
+ #
305
+ def value_from_keys(hash, keys)
306
+ hash.find { |k, v| keys.include?(k.to_s) }&.second
121
307
  end
122
308
 
123
- protected
309
+ #
310
+ # Checks if a value is of the expected type
311
+ #
312
+ # @param value [Object] The value to check
313
+ # @param expected_type [Class, Array<Class>] The expected type(s)
314
+ # @param nilable [Boolean] Allow nil values
315
+ #
316
+ # @return [Boolean] Whether the value is of the expected type
317
+ #
318
+ # @private
319
+ #
320
+ def valid_class?(value, expected_type, nilable: false)
321
+ if expected_type.instance_of?(Array)
322
+ expected_type.any? { |type| value.is_a?(type) }
323
+ else
324
+ (nilable && value.nil?) || value.is_a?(expected_type)
325
+ end
326
+ end
124
327
 
125
- def normalize_to_structure
126
- output, errors = {}, Set.new
328
+ #
329
+ # Generates an error label with information about the key and its aliases
330
+ #
331
+ # Creates a descriptive label for error messages that includes the key name,
332
+ # any aliases it may have, and the context in which it appears.
333
+ #
334
+ # @param key [Symbol, String] The key that caused the error
335
+ # @param aliases [Array<Symbol, String>] Any aliases for the key
336
+ #
337
+ # @return [String] A formatted error label
338
+ #
339
+ # @example
340
+ # generate_error_label(:user_id, [:id, :uid])
341
+ # # => "\"user_id\" (aliases \"id\", \"uid\") in user config"
342
+ #
343
+ def generate_error_label(key, aliases)
344
+ error_label = key.to_s.in_quotes
127
345
 
128
- structure.each do |key, attribute|
129
- type_class = attribute[:type]
130
- aliases = attribute[:aliases] || []
131
- sub_structure = attribute[:structure]
132
- default = attribute[:default]
133
- required = !attribute.key?(:default)
346
+ if aliases.size > 0
347
+ aliases = aliases.join_map(", ") { |a| a.to_s.in_quotes }
348
+ error_label += " (aliases #{aliases})"
349
+ end
134
350
 
135
- # Get the value
136
- value = value_from_keys(input, [key] + aliases)
351
+ error_label + " in #{label}"
352
+ end
137
353
 
138
- # Default the value if needed
139
- value = default.dup if !required && value.nil?
354
+ #
355
+ # Normalizes a hash according to the structure definition
356
+ #
357
+ # Processes each key-value pair in the input hash according to the corresponding
358
+ # definition in the structure. Handles both explicitly defined keys and wildcard
359
+ # keys (specified with "*") that apply to any keys not otherwise defined.
360
+ #
361
+ # @return [Array<Hash, Set>] A two-element array containing:
362
+ # 1. The normalized hash with validated and transformed values
363
+ # 2. A set of any errors encountered during normalization
364
+ #
365
+ # @example Normalizing a hash with explicit definitions
366
+ # structure = {name: {type: String}, age: {type: Integer}}
367
+ # input = {name: "John", age: "25"}
368
+ # normalize_hash # => [{name: "John", age: 25}, #<Set: {}>]
369
+ #
370
+ # @private
371
+ #
372
+ def normalize_hash
373
+ output, errors = {}, Set.new
140
374
 
141
- # Type + existence check
142
- if !valid_class?(value, type_class)
143
- raise InvalidTypeError.new(value, type_class, for: "\"#{key}\" on #{label}")
144
- end
375
+ structure.each do |key, definition|
376
+ # Skip the wildcard key if it exists, handled below
377
+ next if key == :* || key == "*"
145
378
 
146
- value =
147
- case [value.class, sub_structure.class]
148
- when [Hash, Hash]
149
- new_value, new_errors = self.class
150
- .new(label, value, structure: sub_structure)
151
- .normalize
152
-
153
- errors += new_errors if new_errors.size > 0
154
- new_value
155
- else
156
- value
157
- end
379
+ continue, value = normalize_attribute(key, definition, errors:)
380
+ next unless continue
158
381
 
159
- # Store
160
382
  output[key] = value
161
383
  rescue => e
162
384
  errors << e
163
385
  end
164
386
 
387
+ # A wildcard will normalize the rest of the keys in the input
388
+ wildcard_structure = structure[:*] || structure["*"]
389
+
390
+ if wildcard_structure.present?
391
+ # We need to determine which keys we need to check
392
+ structure_keys = (structure.keys + structure.values.key_map(:aliases))
393
+ .compact
394
+ .flatten
395
+ .map(&:to_sym)
396
+
397
+ # Once we have which keys the structure used, we can get the remaining keys
398
+ keys_to_normalize = (input.keys - structure_keys)
399
+
400
+ # They are checked against the wildcard's structure
401
+ keys_to_normalize.each do |key|
402
+ continue, value = normalize_attribute(key, wildcard_structure, errors:)
403
+ next unless continue
404
+
405
+ output[key] = value
406
+ rescue => e
407
+ errors << e
408
+ end
409
+ end
410
+
165
411
  [output, errors]
166
412
  end
167
413
 
168
- def value_from_keys(hash, keys)
169
- hash.find { |k, v| keys.include?(k) }&.second
414
+ #
415
+ # Normalizes a single attribute according to its definition
416
+ #
417
+ # Validates the attribute against its type constraints, applies default values,
418
+ # runs custom validators, and recursively processes nested structures.
419
+ #
420
+ # @param key [Symbol, String] The attribute key to normalize
421
+ # @param definition [Hash] The definition specifying rules for the attribute
422
+ # @param errors [Set] A set to collect any errors encountered
423
+ #
424
+ # @return [Array<Boolean, Object>] A two-element array containing:
425
+ # 1. Boolean indicating if the attribute should be included in output
426
+ # 2. The normalized attribute value (if first element is true)
427
+ #
428
+ # @example Normalizing a simple attribute
429
+ # key = :name
430
+ # definition = {type: String, required: true}
431
+ # normalize_attribute(key, definition, errors: Set.new)
432
+ # # => [true, "John"]
433
+ #
434
+ # @private
435
+ #
436
+ def normalize_attribute(key, definition, errors:)
437
+ has_default = definition.key?(:default)
438
+
439
+ type_class = definition[:type]
440
+ aliases = definition[:aliases] || []
441
+ default = definition[:default]
442
+ required = definition[:required] != false
443
+
444
+ # Get the value
445
+ value = value_from_keys(input, [key.to_s] + aliases)
446
+
447
+ # Drop the key if needed
448
+ return [false] if value.nil? && !has_default && !required
449
+
450
+ # Default the value if needed
451
+ value = default.dup if has_default && value.nil?
452
+
453
+ error_label = generate_error_label(key, aliases)
454
+
455
+ # Type + existence check
456
+ if !valid_class?(value, type_class, nilable: has_default)
457
+ if (line_number = input[:line_number])
458
+ error_label += " (line #{line_number})"
459
+ end
460
+
461
+ raise Error::InvalidTypeError.new(value, type_class, for: error_label)
462
+ end
463
+
464
+ # Call the validator if it has one
465
+ if (name = definition[:validator]) && name.present?
466
+ Validators.call(name, value, label: error_label)
467
+ end
468
+
469
+ # Normalize any sub structures
470
+ if (substructure = definition[:structure]) && substructure.present?
471
+ value = normalize_substructure(error_label, value, substructure, errors)
472
+ end
473
+
474
+ [true, value]
170
475
  end
171
476
 
172
- def valid_class?(value, expected_type)
173
- if expected_type.instance_of?(Array)
174
- expected_type.any? { |type| value.is_a?(type) }
175
- else
176
- value.is_a?(expected_type)
477
+ #
478
+ # Normalizes a nested substructure within a parent structure
479
+ #
480
+ # Recursively processes nested Hash or Array structures according to
481
+ # their structure definitions, collecting any validation errors.
482
+ #
483
+ # @param new_label [String] The label to use for error messages
484
+ # @param value [Hash, Array] The nested structure to normalize
485
+ # @param substructure [Hash] The structure definition for validation
486
+ # @param errors [Set] A set to collect any encountered errors
487
+ #
488
+ # @return [Hash, Array] The normalized substructure
489
+ #
490
+ # @example
491
+ # value = {name: "Test", age: "25"}
492
+ # substructure = {name: {type: String}, age: {type: Integer}}
493
+ # normalize_substructure("user", value, substructure, Set.new)
494
+ # # => {name: "Test", age: 25}
495
+ #
496
+ def normalize_substructure(new_label, value, substructure, errors)
497
+ if substructure.is_a?(Proc)
498
+ return substructure.call(value, errors:, label:)
177
499
  end
500
+
501
+ return value unless value.is_a?(Hash) || value.is_a?(Array)
502
+
503
+ new_value, new_errors = self.class
504
+ .new(new_label, value, structure: substructure)
505
+ .normalize
506
+
507
+ errors.merge(new_errors) if new_errors.size > 0
508
+ new_value
178
509
  end
179
- end
180
- end
181
510
 
182
- ####################################################################################################
183
- # These need to be required after the base class due to them requiring constants on Normalizer
184
- Dir[File.expand_path("normalizer/*.rb", __dir__)].sort.each do |path|
185
- require path
511
+ #
512
+ # Normalizes an array according to its structure definition
513
+ #
514
+ # Processes each element in the input array, validating its type and
515
+ # recursively normalizing any nested structures.
516
+ #
517
+ # @return [Array<Object, Set>] A two-element array containing:
518
+ # 1. The normalized array
519
+ # 2. A set of any errors encountered during normalization
520
+ #
521
+ # @example
522
+ # input = [1, "string", 3]
523
+ # structure = {type: Numeric}
524
+ # normalize_array # => [[1, 3], #<Set: {Error}>]
525
+ #
526
+ def normalize_array
527
+ output, errors = [], Set.new
528
+
529
+ input.each_with_index do |value, index|
530
+ type_class = structure[:type]
531
+ error_label = "index #{index} of #{label}"
532
+
533
+ if !valid_class?(value, type_class)
534
+ raise Error::InvalidTypeError.new(value, type_class, for: error_label)
535
+ end
536
+
537
+ # Call the validator if it has one
538
+ if (name = structure[:validator]) && name.present?
539
+ Validators.call(name, value, label: error_label)
540
+ end
541
+
542
+ if (substructure = structure[:structure])
543
+ value = normalize_substructure(error_label, value, substructure, errors)
544
+ end
545
+
546
+ output << value
547
+ rescue => e
548
+ errors << e
549
+ end
550
+
551
+ [output, errors]
552
+ end
553
+
554
+ # Define the normalizers
555
+ load_from_files
556
+ end
186
557
  end