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.
- checksums.yaml +4 -4
- data/.standard.yml +3 -3
- data/CHANGELOG.md +217 -2
- data/README.md +162 -25
- data/flake.lock +3 -3
- data/flake.nix +11 -5
- data/lib/spec_forge/attribute/chainable.rb +208 -20
- data/lib/spec_forge/attribute/factory.rb +92 -15
- data/lib/spec_forge/attribute/faker.rb +62 -13
- data/lib/spec_forge/attribute/global.rb +96 -0
- data/lib/spec_forge/attribute/literal.rb +15 -2
- data/lib/spec_forge/attribute/matcher.rb +186 -11
- data/lib/spec_forge/attribute/parameterized.rb +45 -12
- data/lib/spec_forge/attribute/regex.rb +55 -5
- data/lib/spec_forge/attribute/resolvable.rb +48 -5
- data/lib/spec_forge/attribute/resolvable_array.rb +62 -4
- data/lib/spec_forge/attribute/resolvable_hash.rb +62 -4
- data/lib/spec_forge/attribute/store.rb +65 -0
- data/lib/spec_forge/attribute/transform.rb +33 -5
- data/lib/spec_forge/attribute/variable.rb +37 -6
- data/lib/spec_forge/attribute.rb +166 -66
- data/lib/spec_forge/backtrace_formatter.rb +26 -3
- data/lib/spec_forge/callbacks.rb +88 -0
- data/lib/spec_forge/cli/actions.rb +27 -0
- data/lib/spec_forge/cli/command.rb +78 -24
- 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 +51 -9
- data/lib/spec_forge/cli/new.rb +67 -6
- data/lib/spec_forge/cli/run.rb +32 -4
- data/lib/spec_forge/cli/serve.rb +155 -0
- data/lib/spec_forge/cli.rb +26 -7
- data/lib/spec_forge/configuration.rb +96 -24
- data/lib/spec_forge/context/callbacks.rb +91 -0
- data/lib/spec_forge/context/global.rb +72 -0
- data/lib/spec_forge/context/store.rb +131 -0
- data/lib/spec_forge/context/variables.rb +91 -0
- data/lib/spec_forge/context.rb +36 -0
- data/lib/spec_forge/core_ext/array.rb +27 -0
- data/lib/spec_forge/core_ext/rspec.rb +22 -4
- 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 +284 -113
- data/lib/spec_forge/factory.rb +35 -16
- data/lib/spec_forge/filter.rb +86 -0
- data/lib/spec_forge/forge.rb +171 -0
- data/lib/spec_forge/http/backend.rb +101 -29
- data/lib/spec_forge/http/client.rb +23 -13
- data/lib/spec_forge/http/request.rb +85 -62
- data/lib/spec_forge/http/verb.rb +79 -0
- data/lib/spec_forge/http.rb +105 -0
- data/lib/spec_forge/loader.rb +244 -0
- data/lib/spec_forge/matchers.rb +130 -0
- 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 +486 -115
- 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/callbacks.rb +246 -0
- data/lib/spec_forge/runner/debug_proxy.rb +213 -0
- data/lib/spec_forge/runner/listener.rb +54 -0
- data/lib/spec_forge/runner/metadata.rb +58 -0
- data/lib/spec_forge/runner/state.rb +98 -0
- data/lib/spec_forge/runner.rb +50 -125
- data/lib/spec_forge/spec/expectation/constraint.rb +100 -21
- data/lib/spec_forge/spec/expectation.rb +47 -51
- data/lib/spec_forge/spec.rb +50 -108
- data/lib/spec_forge/type.rb +36 -4
- data/lib/spec_forge/version.rb +4 -1
- data/lib/spec_forge.rb +168 -76
- 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 +109 -16
- data/lib/spec_forge/normalizer/configuration.rb +0 -77
- data/lib/spec_forge/normalizer/constraint.rb +0 -47
- data/lib/spec_forge/normalizer/expectation.rb +0 -86
- data/lib/spec_forge/normalizer/factory.rb +0 -65
- data/lib/spec_forge/normalizer/factory_reference.rb +0 -71
- data/lib/spec_forge/normalizer/spec.rb +0 -74
- data/spec_forge/factories/user.yml +0 -4
- data/spec_forge/forge_helper.rb +0 -48
- data/spec_forge/specs/users.yml +0 -65
- /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,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
|
-
# @
|
227
|
+
# @yield Block that returns [output, errors]
|
228
|
+
# @yieldreturn [Array<Object, Set>] The result and any errors
|
52
229
|
#
|
53
|
-
# @
|
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
|
-
|
72
|
-
#
|
73
|
-
# @private
|
74
|
-
#
|
75
|
-
def default
|
76
|
-
new("", "").default
|
77
|
-
end
|
251
|
+
# Private methods
|
252
|
+
include Default
|
78
253
|
end
|
79
254
|
|
80
|
-
|
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
|
-
|
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
|
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
|
-
|
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
|
-
#
|
296
|
+
# Extracts a value from a hash checking multiple keys
|
106
297
|
#
|
107
|
-
# @
|
298
|
+
# @param hash [Hash] The hash to extract from
|
299
|
+
# @param keys [Array<String, String>] The keys to check
|
108
300
|
#
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
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
|
-
|
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
|
-
|
126
|
-
|
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
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
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
|
-
|
136
|
-
|
351
|
+
error_label + " in #{label}"
|
352
|
+
end
|
137
353
|
|
138
|
-
|
139
|
-
|
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
|
-
|
142
|
-
if
|
143
|
-
|
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
|
-
|
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
|
-
|
169
|
-
|
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
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
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
|
-
#
|
184
|
-
|
185
|
-
|
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
|