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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +112 -2
- data/README.md +133 -8
- data/flake.lock +3 -3
- data/flake.nix +3 -3
- data/lib/spec_forge/attribute/factory.rb +1 -1
- data/lib/spec_forge/callbacks.rb +9 -0
- data/lib/spec_forge/cli/docs/generate.rb +72 -0
- data/lib/spec_forge/cli/docs.rb +92 -0
- data/lib/spec_forge/cli/init.rb +39 -7
- data/lib/spec_forge/cli/new.rb +13 -3
- data/lib/spec_forge/cli/run.rb +12 -4
- data/lib/spec_forge/cli/serve.rb +155 -0
- data/lib/spec_forge/cli.rb +14 -6
- data/lib/spec_forge/configuration.rb +2 -2
- data/lib/spec_forge/context/store.rb +23 -40
- data/lib/spec_forge/core_ext/array.rb +27 -0
- data/lib/spec_forge/documentation/builder.rb +383 -0
- data/lib/spec_forge/documentation/document/operation.rb +47 -0
- data/lib/spec_forge/documentation/document/parameter.rb +22 -0
- data/lib/spec_forge/documentation/document/request_body.rb +24 -0
- data/lib/spec_forge/documentation/document/response.rb +39 -0
- data/lib/spec_forge/documentation/document/response_body.rb +27 -0
- data/lib/spec_forge/documentation/document.rb +48 -0
- data/lib/spec_forge/documentation/generators/base.rb +81 -0
- data/lib/spec_forge/documentation/generators/openapi/base.rb +100 -0
- data/lib/spec_forge/documentation/generators/openapi/error_formatter.rb +149 -0
- data/lib/spec_forge/documentation/generators/openapi/v3_0.rb +65 -0
- data/lib/spec_forge/documentation/generators/openapi.rb +59 -0
- data/lib/spec_forge/documentation/generators.rb +17 -0
- data/lib/spec_forge/documentation/loader/cache.rb +138 -0
- data/lib/spec_forge/documentation/loader.rb +159 -0
- data/lib/spec_forge/documentation/openapi/base.rb +33 -0
- data/lib/spec_forge/documentation/openapi/v3_0/example.rb +44 -0
- data/lib/spec_forge/documentation/openapi/v3_0/media_type.rb +42 -0
- data/lib/spec_forge/documentation/openapi/v3_0/operation.rb +175 -0
- data/lib/spec_forge/documentation/openapi/v3_0/response.rb +65 -0
- data/lib/spec_forge/documentation/openapi/v3_0/schema.rb +80 -0
- data/lib/spec_forge/documentation/openapi/v3_0/tag.rb +71 -0
- data/lib/spec_forge/documentation/openapi.rb +23 -0
- data/lib/spec_forge/documentation.rb +27 -0
- data/lib/spec_forge/error.rb +17 -0
- data/lib/spec_forge/factory.rb +2 -2
- data/lib/spec_forge/filter.rb +3 -4
- data/lib/spec_forge/forge.rb +5 -4
- data/lib/spec_forge/http/backend.rb +2 -0
- data/lib/spec_forge/http/request.rb +14 -3
- data/lib/spec_forge/loader.rb +14 -24
- data/lib/spec_forge/normalizer/default.rb +51 -0
- data/lib/spec_forge/normalizer/definition.rb +248 -0
- data/lib/spec_forge/normalizer/validators.rb +99 -0
- data/lib/spec_forge/normalizer.rb +356 -199
- data/lib/spec_forge/normalizers/_shared.yml +74 -0
- data/lib/spec_forge/normalizers/configuration.yml +23 -0
- data/lib/spec_forge/normalizers/constraint.yml +8 -0
- data/lib/spec_forge/normalizers/expectation.yml +47 -0
- data/lib/spec_forge/normalizers/factory.yml +12 -0
- data/lib/spec_forge/normalizers/factory_reference.yml +15 -0
- data/lib/spec_forge/normalizers/global_context.yml +28 -0
- data/lib/spec_forge/normalizers/spec.yml +50 -0
- data/lib/spec_forge/runner/adapter.rb +183 -0
- data/lib/spec_forge/runner/debug_proxy.rb +3 -3
- data/lib/spec_forge/runner/state.rb +4 -5
- data/lib/spec_forge/runner.rb +40 -124
- data/lib/spec_forge/spec/expectation/constraint.rb +13 -5
- data/lib/spec_forge/spec/expectation.rb +7 -3
- data/lib/spec_forge/spec.rb +13 -58
- data/lib/spec_forge/version.rb +1 -1
- data/lib/spec_forge.rb +30 -23
- data/lib/templates/openapi.yml.tt +22 -0
- data/lib/templates/redoc.html.tt +28 -0
- data/lib/templates/swagger.html.tt +59 -0
- metadata +92 -14
- data/lib/spec_forge/normalizer/configuration.rb +0 -90
- data/lib/spec_forge/normalizer/constraint.rb +0 -60
- data/lib/spec_forge/normalizer/expectation.rb +0 -105
- data/lib/spec_forge/normalizer/factory.rb +0 -78
- data/lib/spec_forge/normalizer/factory_reference.rb +0 -85
- data/lib/spec_forge/normalizer/global_context.rb +0 -88
- data/lib/spec_forge/normalizer/spec.rb +0 -97
- /data/lib/templates/{forge_helper.tt → forge_helper.rb.tt} +0 -0
- /data/lib/templates/{new_factory.tt → new_factory.yml.tt} +0 -0
- /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
|
-
#
|
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
|
-
#
|
8
|
-
#
|
9
|
-
#
|
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
|
-
#
|
12
|
-
#
|
13
|
-
#
|
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
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
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
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
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
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
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
|
-
|
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:
|
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<
|
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:
|
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]
|
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(
|
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
|
-
|
398
|
-
|
399
|
-
require path
|
554
|
+
# Define the normalizers
|
555
|
+
load_from_files
|
556
|
+
end
|
400
557
|
end
|