spec_forge 0.6.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +112 -2
  3. data/README.md +133 -8
  4. data/flake.lock +3 -3
  5. data/flake.nix +3 -3
  6. data/lib/spec_forge/attribute/factory.rb +1 -1
  7. data/lib/spec_forge/callbacks.rb +9 -0
  8. data/lib/spec_forge/cli/docs/generate.rb +72 -0
  9. data/lib/spec_forge/cli/docs.rb +92 -0
  10. data/lib/spec_forge/cli/init.rb +39 -7
  11. data/lib/spec_forge/cli/new.rb +13 -3
  12. data/lib/spec_forge/cli/run.rb +12 -4
  13. data/lib/spec_forge/cli/serve.rb +155 -0
  14. data/lib/spec_forge/cli.rb +14 -6
  15. data/lib/spec_forge/configuration.rb +2 -2
  16. data/lib/spec_forge/context/store.rb +23 -40
  17. data/lib/spec_forge/core_ext/array.rb +27 -0
  18. data/lib/spec_forge/documentation/builder.rb +383 -0
  19. data/lib/spec_forge/documentation/document/operation.rb +47 -0
  20. data/lib/spec_forge/documentation/document/parameter.rb +22 -0
  21. data/lib/spec_forge/documentation/document/request_body.rb +24 -0
  22. data/lib/spec_forge/documentation/document/response.rb +39 -0
  23. data/lib/spec_forge/documentation/document/response_body.rb +27 -0
  24. data/lib/spec_forge/documentation/document.rb +48 -0
  25. data/lib/spec_forge/documentation/generators/base.rb +81 -0
  26. data/lib/spec_forge/documentation/generators/openapi/base.rb +100 -0
  27. data/lib/spec_forge/documentation/generators/openapi/error_formatter.rb +149 -0
  28. data/lib/spec_forge/documentation/generators/openapi/v3_0.rb +65 -0
  29. data/lib/spec_forge/documentation/generators/openapi.rb +59 -0
  30. data/lib/spec_forge/documentation/generators.rb +17 -0
  31. data/lib/spec_forge/documentation/loader/cache.rb +138 -0
  32. data/lib/spec_forge/documentation/loader.rb +159 -0
  33. data/lib/spec_forge/documentation/openapi/base.rb +33 -0
  34. data/lib/spec_forge/documentation/openapi/v3_0/example.rb +44 -0
  35. data/lib/spec_forge/documentation/openapi/v3_0/media_type.rb +42 -0
  36. data/lib/spec_forge/documentation/openapi/v3_0/operation.rb +175 -0
  37. data/lib/spec_forge/documentation/openapi/v3_0/response.rb +65 -0
  38. data/lib/spec_forge/documentation/openapi/v3_0/schema.rb +80 -0
  39. data/lib/spec_forge/documentation/openapi/v3_0/tag.rb +71 -0
  40. data/lib/spec_forge/documentation/openapi.rb +23 -0
  41. data/lib/spec_forge/documentation.rb +27 -0
  42. data/lib/spec_forge/error.rb +17 -0
  43. data/lib/spec_forge/factory.rb +2 -2
  44. data/lib/spec_forge/filter.rb +3 -4
  45. data/lib/spec_forge/forge.rb +5 -4
  46. data/lib/spec_forge/http/backend.rb +2 -0
  47. data/lib/spec_forge/http/request.rb +14 -3
  48. data/lib/spec_forge/loader.rb +14 -24
  49. data/lib/spec_forge/normalizer/default.rb +51 -0
  50. data/lib/spec_forge/normalizer/definition.rb +248 -0
  51. data/lib/spec_forge/normalizer/validators.rb +99 -0
  52. data/lib/spec_forge/normalizer.rb +356 -199
  53. data/lib/spec_forge/normalizers/_shared.yml +74 -0
  54. data/lib/spec_forge/normalizers/configuration.yml +23 -0
  55. data/lib/spec_forge/normalizers/constraint.yml +8 -0
  56. data/lib/spec_forge/normalizers/expectation.yml +47 -0
  57. data/lib/spec_forge/normalizers/factory.yml +12 -0
  58. data/lib/spec_forge/normalizers/factory_reference.yml +15 -0
  59. data/lib/spec_forge/normalizers/global_context.yml +28 -0
  60. data/lib/spec_forge/normalizers/spec.yml +50 -0
  61. data/lib/spec_forge/runner/adapter.rb +183 -0
  62. data/lib/spec_forge/runner/debug_proxy.rb +3 -3
  63. data/lib/spec_forge/runner/state.rb +4 -5
  64. data/lib/spec_forge/runner.rb +40 -124
  65. data/lib/spec_forge/spec/expectation/constraint.rb +13 -5
  66. data/lib/spec_forge/spec/expectation.rb +7 -3
  67. data/lib/spec_forge/spec.rb +13 -58
  68. data/lib/spec_forge/version.rb +1 -1
  69. data/lib/spec_forge.rb +30 -23
  70. data/lib/templates/openapi.yml.tt +22 -0
  71. data/lib/templates/redoc.html.tt +28 -0
  72. data/lib/templates/swagger.html.tt +59 -0
  73. metadata +92 -14
  74. data/lib/spec_forge/normalizer/configuration.rb +0 -90
  75. data/lib/spec_forge/normalizer/constraint.rb +0 -60
  76. data/lib/spec_forge/normalizer/expectation.rb +0 -105
  77. data/lib/spec_forge/normalizer/factory.rb +0 -78
  78. data/lib/spec_forge/normalizer/factory_reference.rb +0 -85
  79. data/lib/spec_forge/normalizer/global_context.rb +0 -88
  80. data/lib/spec_forge/normalizer/spec.rb +0 -97
  81. /data/lib/templates/{forge_helper.tt → forge_helper.rb.tt} +0 -0
  82. /data/lib/templates/{new_factory.tt → new_factory.yml.tt} +0 -0
  83. /data/lib/templates/{new_spec.tt → new_spec.yml.tt} +0 -0
@@ -1,112 +1,226 @@
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
4
8
  #
5
- # Base class for normalizing various data structures in SpecForge
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:
6
52
  #
7
- # The Normalizer validates and standardizes user input from YAML files,
8
- # ensuring it meets the expected structure and types before processing.
9
- # It supports default values, type checking, aliases, and nested structures.
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
10
57
  #
11
- # @example Normalizing a hash
12
- # normalizer = Normalizer.new("spec", input_hash)
13
- # output, errors = normalizer.normalize
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
14
89
  #
15
90
  class Normalizer
16
- #
17
- # Shared attributes used by the various normalizers
18
- #
19
- # @return [Hash<Symbol, Hash>]
20
- #
21
- SHARED_ATTRIBUTES = {
22
- id: {type: String},
23
- name: {type: String},
24
- line_number: {type: Integer},
25
- base_url: {
26
- type: String,
27
- default: nil
28
- },
29
- url: {
30
- type: String,
31
- aliases: %i[path],
32
- default: nil
33
- },
34
- http_verb: {
35
- type: String,
36
- aliases: %i[method http_method],
37
- default: nil, # Do not default this to "GET". Leave it nil. Seriously.
38
- validator: lambda do |value|
39
- valid_verbs = HTTP::Verb::VERBS.values
40
- return if value.blank? || valid_verbs.include?(value.to_s.upcase)
41
-
42
- raise Error, "Invalid HTTP verb: #{value}. Valid values are: #{valid_verbs.join(", ")}"
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]
43
162
  end
44
- },
45
- headers: {
46
- type: Hash,
47
- default: {}
48
- },
49
- query: {
50
- type: [Hash, String],
51
- aliases: %i[params],
52
- default: {}
53
- },
54
- body: {
55
- type: [Hash, String],
56
- aliases: %i[data],
57
- default: {}
58
- },
59
- variables: {
60
- type: [Hash, String],
61
- default: {}
62
- },
63
- debug: {
64
- type: [TrueClass, FalseClass],
65
- default: false,
66
- aliases: %i[pry breakpoint]
67
- },
68
- callback: {
69
- type: [String, NilClass],
70
- validator: lambda do |value|
71
- return if value.blank?
72
- return if SpecForge::Callbacks.registered?(value)
73
-
74
- raise Error::UndefinedCallbackError.new(value, SpecForge::Callbacks.registered_names)
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}"
75
170
  end
76
- }
77
- }.freeze
78
171
 
79
- #
80
- # Defines the normalized structure for validating and parsing input data
81
- #
82
- # Each key represents an attribute with its validation and transformation rules.
83
- # The structure supports defining:
84
- # - Expected data type(s)
85
- # - Default values
86
- # - Aliases for alternative key names
87
- # - Optional validation logic
88
- # - Nested sub-structures
89
- #
90
- # @return [Hash] A configuration hash defining attribute validation rules
91
- #
92
- # @example Basic structure definition
93
- # STRUCTURE = {
94
- # name: {
95
- # type: String, # Must be a String
96
- # default: "", # Default to empty string if not provided
97
- # aliases: [:title] # Allows using 'title' as an alternative key
98
- # },
99
- # age: {
100
- # type: Integer, # Must be an Integer
101
- # default: 0 # Default to 0 if not provided
102
- # }
103
- # }
104
- #
105
- # @see Normalizer
106
- #
107
- STRUCTURE = {}
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
108
223
 
109
- class << self
110
224
  #
111
225
  # Raises any errors collected by the block
112
226
  #
@@ -117,7 +231,7 @@ module SpecForge
117
231
  #
118
232
  # @raise [Error::InvalidStructureError] If any errors were encountered
119
233
  #
120
- # @private
234
+ # @api private
121
235
  #
122
236
  def raise_errors!(&block)
123
237
  errors = Set.new
@@ -134,16 +248,8 @@ module SpecForge
134
248
  output
135
249
  end
136
250
 
137
- #
138
- # Returns a default version of this normalizer
139
- #
140
- # @return [Hash] Default structure with default values
141
- #
142
- # @private
143
- #
144
- def default
145
- new("", "").default
146
- end
251
+ # Private methods
252
+ include Default
147
253
  end
148
254
 
149
255
  # @return [String] A label that describes the data itself
@@ -164,7 +270,7 @@ module SpecForge
164
270
  #
165
271
  # @return [Normalizer] A new normalizer instance
166
272
  #
167
- def initialize(label, input, structure: self.class::STRUCTURE)
273
+ def initialize(label, input, structure:)
168
274
  @label = label
169
275
  @input = input
170
276
  @structure = structure
@@ -184,97 +290,20 @@ module SpecForge
184
290
  end
185
291
  end
186
292
 
187
- #
188
- # Returns a hash with the default structure
189
- #
190
- # @return [Hash] A hash with default values for all structure keys
191
- #
192
- def default
193
- structure.each_with_object({}) do |(key, value), hash|
194
- hash[key] =
195
- if value.key?(:default)
196
- default = value[:default]
197
- next if default.nil?
198
-
199
- default.dup
200
- elsif value[:type] == Integer # Can't call new on int
201
- 0
202
- elsif value[:type] == Proc # Sameeee
203
- -> {}
204
- else
205
- value[:type].new
206
- end
207
- end
208
- end
209
-
210
293
  protected
211
294
 
212
- #
213
- # Normalizes the input hash according to the structure definition
214
- #
215
- # @return [Array<Hash, Set>] Normalized hash and any errors
216
- #
217
- # @private
218
- #
219
- def normalize_hash
220
- output, errors = {}, Set.new
221
-
222
- structure.each do |key, attribute|
223
- type_class = attribute[:type]
224
- aliases = attribute[:aliases] || []
225
- default = attribute[:default]
226
-
227
- has_default = attribute.key?(:default)
228
- nilable = has_default && default.nil?
229
-
230
- # Get the value
231
- value = value_from_keys(input, [key] + aliases)
232
- next if nilable && value.nil?
233
-
234
- # Default the value if needed
235
- value = default.dup if has_default && value.nil?
236
-
237
- # Type + existence check
238
- if !valid_class?(value, type_class)
239
- for_context = generate_error_label(key, aliases)
240
-
241
- if (line_number = input[:line_number])
242
- for_context += " (line #{line_number})"
243
- end
244
-
245
- raise Error::InvalidTypeError.new(value, type_class, for: for_context)
246
- end
247
-
248
- # Call the validator if it has one
249
- attribute[:validator]&.call(value)
250
-
251
- # Normalize any sub structures
252
- if (substructure = attribute[:structure])
253
- new_label = generate_error_label(key, aliases)
254
- value = normalize_substructure(new_label, value, substructure, errors)
255
- end
256
-
257
- # Store
258
- output[key] = value
259
- rescue => e
260
- errors << e
261
- end
262
-
263
- [output, errors]
264
- end
265
-
266
295
  #
267
296
  # Extracts a value from a hash checking multiple keys
268
297
  #
269
298
  # @param hash [Hash] The hash to extract from
270
- # @param keys [Array<Symbol, String>] The keys to check
299
+ # @param keys [Array<String, String>] The keys to check
271
300
  #
272
301
  # @return [Object, nil] The value if found, nil otherwise
273
302
  #
274
303
  # @private
275
304
  #
276
305
  def value_from_keys(hash, keys)
277
- hash.find { |k, v| keys.include?(k) }&.second
306
+ hash.find { |k, v| keys.include?(k.to_s) }&.second
278
307
  end
279
308
 
280
309
  #
@@ -282,16 +311,17 @@ module SpecForge
282
311
  #
283
312
  # @param value [Object] The value to check
284
313
  # @param expected_type [Class, Array<Class>] The expected type(s)
314
+ # @param nilable [Boolean] Allow nil values
285
315
  #
286
316
  # @return [Boolean] Whether the value is of the expected type
287
317
  #
288
318
  # @private
289
319
  #
290
- def valid_class?(value, expected_type)
320
+ def valid_class?(value, expected_type, nilable: false)
291
321
  if expected_type.instance_of?(Array)
292
322
  expected_type.any? { |type| value.is_a?(type) }
293
323
  else
294
- value.is_a?(expected_type)
324
+ (nilable && value.nil?) || value.is_a?(expected_type)
295
325
  end
296
326
  end
297
327
 
@@ -321,6 +351,129 @@ module SpecForge
321
351
  error_label + " in #{label}"
322
352
  end
323
353
 
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
374
+
375
+ structure.each do |key, definition|
376
+ # Skip the wildcard key if it exists, handled below
377
+ next if key == :* || key == "*"
378
+
379
+ continue, value = normalize_attribute(key, definition, errors:)
380
+ next unless continue
381
+
382
+ output[key] = value
383
+ rescue => e
384
+ errors << e
385
+ end
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
+
411
+ [output, errors]
412
+ end
413
+
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]
475
+ end
476
+
324
477
  #
325
478
  # Normalizes a nested substructure within a parent structure
326
479
  #
@@ -341,6 +494,10 @@ module SpecForge
341
494
  # # => {name: "Test", age: 25}
342
495
  #
343
496
  def normalize_substructure(new_label, value, substructure, errors)
497
+ if substructure.is_a?(Proc)
498
+ return substructure.call(value, errors:, label:)
499
+ end
500
+
344
501
  return value unless value.is_a?(Hash) || value.is_a?(Array)
345
502
 
346
503
  new_value, new_errors = self.class
@@ -371,16 +528,19 @@ module SpecForge
371
528
 
372
529
  input.each_with_index do |value, index|
373
530
  type_class = structure[:type]
531
+ error_label = "index #{index} of #{label}"
374
532
 
375
533
  if !valid_class?(value, type_class)
376
- raise Error::InvalidTypeError.new(value, type_class, for: "index #{index} of #{label}")
534
+ raise Error::InvalidTypeError.new(value, type_class, for: error_label)
377
535
  end
378
536
 
379
537
  # Call the validator if it has one
380
- structure[:validator]&.call(value)
538
+ if (name = structure[:validator]) && name.present?
539
+ Validators.call(name, value, label: error_label)
540
+ end
381
541
 
382
542
  if (substructure = structure[:structure])
383
- value = normalize_substructure("index #{index} of #{label}", value, substructure, errors)
543
+ value = normalize_substructure(error_label, value, substructure, errors)
384
544
  end
385
545
 
386
546
  output << value
@@ -390,11 +550,8 @@ module SpecForge
390
550
 
391
551
  [output, errors]
392
552
  end
393
- end
394
- end
395
553
 
396
- ####################################################################################################
397
- # These need to be required after the base class due to them requiring constants on Normalizer
398
- Dir[File.expand_path("normalizer/*.rb", __dir__)].sort.each do |path|
399
- require path
554
+ # Define the normalizers
555
+ load_from_files
556
+ end
400
557
  end