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
@@ -7,7 +7,15 @@ module SpecForge
|
|
7
7
|
#
|
8
8
|
# @return [Array<Symbol>]
|
9
9
|
#
|
10
|
-
REQUEST_ATTRIBUTES = [
|
10
|
+
REQUEST_ATTRIBUTES = %i[
|
11
|
+
base_url
|
12
|
+
url
|
13
|
+
http_verb
|
14
|
+
content_type
|
15
|
+
headers
|
16
|
+
query
|
17
|
+
body
|
18
|
+
].freeze
|
11
19
|
|
12
20
|
#
|
13
21
|
# Represents an HTTP request configuration
|
@@ -52,8 +60,9 @@ module SpecForge
|
|
52
60
|
query = Attribute.from(options[:query] || {})
|
53
61
|
body = Attribute.from(options[:body] || {})
|
54
62
|
headers = normalize_headers(options[:headers] || {})
|
63
|
+
content_type = "application/json"
|
55
64
|
|
56
|
-
super(base_url:, url:, http_verb:, headers:, query:, body:)
|
65
|
+
super(base_url:, url:, http_verb:, content_type:, headers:, query:, body:)
|
57
66
|
end
|
58
67
|
|
59
68
|
#
|
@@ -62,7 +71,9 @@ module SpecForge
|
|
62
71
|
# @return [Hash] The request data with all dynamic values resolved
|
63
72
|
#
|
64
73
|
def to_h
|
65
|
-
super.transform_values { |v| v.respond_to?(:resolved) ? v.resolved : v }
|
74
|
+
hash = super.transform_values { |v| v.respond_to?(:resolved) ? v.resolved : v }
|
75
|
+
hash[:http_verb] = hash[:http_verb].to_s
|
76
|
+
hash
|
66
77
|
end
|
67
78
|
|
68
79
|
private
|
data/lib/spec_forge/loader.rb
CHANGED
@@ -23,14 +23,14 @@ module SpecForge
|
|
23
23
|
load_specs_from_files.map do |global, metadata, specs|
|
24
24
|
global =
|
25
25
|
begin
|
26
|
-
Normalizer.
|
26
|
+
Normalizer.normalize!(global, using: :global_context)
|
27
27
|
rescue => e
|
28
28
|
raise Error::SpecLoadError.new(e, metadata[:relative_path])
|
29
29
|
end
|
30
30
|
|
31
31
|
specs =
|
32
32
|
specs.map do |spec|
|
33
|
-
Normalizer.
|
33
|
+
Normalizer.normalize!(spec, using: :spec, label: "spec \"#{spec[:name]}\"")
|
34
34
|
rescue => e
|
35
35
|
raise Error::SpecLoadError.new(e, metadata[:relative_path], spec:)
|
36
36
|
end
|
@@ -86,7 +86,7 @@ module SpecForge
|
|
86
86
|
files.map do |file_path, content|
|
87
87
|
relative_path = Pathname.new(file_path).relative_path_from(base_path)
|
88
88
|
|
89
|
-
hash = YAML.
|
89
|
+
hash = YAML.safe_load(content, symbolize_names: true)
|
90
90
|
|
91
91
|
file_line_numbers = extract_line_numbers(content, hash)
|
92
92
|
|
@@ -103,7 +103,7 @@ module SpecForge
|
|
103
103
|
hash.map do |spec_name, spec_hash|
|
104
104
|
line_number, *expectation_line_numbers = file_line_numbers[spec_name]
|
105
105
|
|
106
|
-
spec_hash[:id] = "spec_#{generate_id(spec_hash)}"
|
106
|
+
spec_hash[:id] = "spec_#{SpecForge.generate_id(spec_hash)}"
|
107
107
|
spec_hash[:name] = spec_name.to_s
|
108
108
|
spec_hash[:file_path] = metadata[:file_path]
|
109
109
|
spec_hash[:file_name] = metadata[:file_name]
|
@@ -112,7 +112,7 @@ module SpecForge
|
|
112
112
|
# Check for expectations instead of defaulting. I want it to error
|
113
113
|
if (expectations = spec_hash[:expectations])
|
114
114
|
expectations.zip(expectation_line_numbers) do |expectation_hash, line_number|
|
115
|
-
expectation_hash[:id] = "expect_#{generate_id(expectation_hash)}"
|
115
|
+
expectation_hash[:id] = "expect_#{SpecForge.generate_id(expectation_hash)}"
|
116
116
|
expectation_hash[:name] = build_expectation_name(spec_hash, expectation_hash)
|
117
117
|
expectation_hash[:line_number] = line_number
|
118
118
|
end
|
@@ -191,19 +191,6 @@ module SpecForge
|
|
191
191
|
keys
|
192
192
|
end
|
193
193
|
|
194
|
-
#
|
195
|
-
# Generates a unique ID for an object based on hash and object_id
|
196
|
-
#
|
197
|
-
# @param object [Object] The object to generate an ID for
|
198
|
-
#
|
199
|
-
# @return [String] A unique ID string
|
200
|
-
#
|
201
|
-
# @private
|
202
|
-
#
|
203
|
-
def generate_id(object)
|
204
|
-
"#{object.hash.abs.to_s(36)}_#{object.object_id.to_s(36)}"
|
205
|
-
end
|
206
|
-
|
207
194
|
#
|
208
195
|
# Builds a name for an expectation based on HTTP verb, URL, and optional name
|
209
196
|
#
|
@@ -215,14 +202,17 @@ module SpecForge
|
|
215
202
|
# @private
|
216
203
|
#
|
217
204
|
def build_expectation_name(spec_hash, expectation_hash)
|
218
|
-
# Create a structure for
|
219
|
-
# Removing the defaults and validators to avoid
|
220
|
-
structure = Normalizer
|
205
|
+
# Create a structure for http_verb and url
|
206
|
+
# Removing the defaults and validators to avoid triggering extra logic
|
207
|
+
structure = Normalizer.structures[:spec][:structure].slice(:http_verb, :url)
|
221
208
|
.transform_values { |v| v.except(:default, :validator) }
|
222
209
|
|
223
|
-
# Ignore any errors.
|
224
|
-
normalized_spec,
|
225
|
-
normalized_expectation,
|
210
|
+
# Ignore any errors. These will be validated later
|
211
|
+
normalized_spec, _ = Normalizer.normalize(spec_hash, using: structure, label: "n/a")
|
212
|
+
normalized_expectation, _ = Normalizer.normalize(
|
213
|
+
expectation_hash,
|
214
|
+
using: structure, label: "n/a"
|
215
|
+
)
|
226
216
|
|
227
217
|
request_data = normalized_spec.deep_merge(normalized_expectation)
|
228
218
|
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpecForge
|
4
|
+
class Normalizer
|
5
|
+
#
|
6
|
+
# Provides default value generation for Normalizer structures
|
7
|
+
#
|
8
|
+
# Contains helper methods for creating default values based on type
|
9
|
+
# definitions and structure specifications.
|
10
|
+
#
|
11
|
+
module Default
|
12
|
+
private
|
13
|
+
|
14
|
+
def default_from_structure(structure, include_optional: false)
|
15
|
+
structure.each_with_object({}) do |(attribute_name, attribute), hash|
|
16
|
+
type = attribute[:type]
|
17
|
+
has_default = attribute.key?(:default)
|
18
|
+
required = attribute[:required] != false
|
19
|
+
|
20
|
+
next if !(include_optional || required || has_default)
|
21
|
+
|
22
|
+
hash[attribute_name] =
|
23
|
+
if has_default
|
24
|
+
default = attribute[:default]
|
25
|
+
next if default.nil?
|
26
|
+
|
27
|
+
default.dup
|
28
|
+
elsif type.instance_of?(Array)
|
29
|
+
default_value_for_type(type.first)
|
30
|
+
else
|
31
|
+
default_value_for_type(type)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def default_value_for_type(type_class)
|
37
|
+
if type_class == Integer
|
38
|
+
0
|
39
|
+
elsif type_class == Proc
|
40
|
+
-> {}
|
41
|
+
elsif type_class == TrueClass
|
42
|
+
true
|
43
|
+
elsif type_class == FalseClass
|
44
|
+
false
|
45
|
+
else
|
46
|
+
type_class.new
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,248 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpecForge
|
4
|
+
class Normalizer
|
5
|
+
#
|
6
|
+
# Manages structure definitions for the Normalizer
|
7
|
+
#
|
8
|
+
# Handles loading structure definitions from YAML files, processing references
|
9
|
+
# between structures, and normalizing structure formats for consistent validation.
|
10
|
+
#
|
11
|
+
# @example Loading all structure definitions
|
12
|
+
# structures = SpecForge::Normalizer::Definition.from_files
|
13
|
+
#
|
14
|
+
class Definition
|
15
|
+
#
|
16
|
+
# Mapping of structure names to their human-readable labels
|
17
|
+
#
|
18
|
+
# @return [Hash<Symbol, String>]
|
19
|
+
#
|
20
|
+
LABELS = {
|
21
|
+
factory_reference: "factory reference",
|
22
|
+
global_context: "global context"
|
23
|
+
}.freeze
|
24
|
+
|
25
|
+
#
|
26
|
+
# Core structure definition used to validate other structures
|
27
|
+
#
|
28
|
+
# Defines the valid attributes and types for structure definitions,
|
29
|
+
# creating a meta-structure that validates other structure definitions.
|
30
|
+
#
|
31
|
+
# @return [Hash]
|
32
|
+
#
|
33
|
+
STRUCTURE = {
|
34
|
+
type: {
|
35
|
+
type: [String, Array, Class],
|
36
|
+
default: nil,
|
37
|
+
validator: :present?
|
38
|
+
},
|
39
|
+
default: {
|
40
|
+
type: [String, NilClass, Numeric, Array, Hash, TrueClass, FalseClass],
|
41
|
+
required: false
|
42
|
+
},
|
43
|
+
required: {
|
44
|
+
type: [TrueClass, FalseClass],
|
45
|
+
required: false
|
46
|
+
},
|
47
|
+
aliases: {
|
48
|
+
type: Array,
|
49
|
+
required: false,
|
50
|
+
structure: {type: String}
|
51
|
+
},
|
52
|
+
structure: {
|
53
|
+
type: Hash,
|
54
|
+
required: false
|
55
|
+
},
|
56
|
+
validator: {
|
57
|
+
type: String,
|
58
|
+
required: false
|
59
|
+
}
|
60
|
+
}.freeze
|
61
|
+
|
62
|
+
#
|
63
|
+
# Loads normalizer definitions from YAML files
|
64
|
+
#
|
65
|
+
# Reads all YAML files in the normalizers directory, processes shared
|
66
|
+
# references, and prepares them for use by the Normalizer.
|
67
|
+
#
|
68
|
+
# @return [Hash] A hash mapping structure names to their definitions
|
69
|
+
#
|
70
|
+
def self.from_files
|
71
|
+
base_path = Pathname.new(File.expand_path("../normalizers", __dir__))
|
72
|
+
paths = Dir[base_path.join("**/*.yml")].sort
|
73
|
+
|
74
|
+
normalizers =
|
75
|
+
paths.each_with_object({}) do |path, hash|
|
76
|
+
path = Pathname.new(path)
|
77
|
+
|
78
|
+
# Include the directory name in the path to include normalizers in directories
|
79
|
+
name = path.relative_path_from(base_path).to_s.delete_suffix(".yml").to_sym
|
80
|
+
|
81
|
+
input = YAML.safe_load_file(path, symbolize_names: true)
|
82
|
+
raise Error, "Normalizer defined at #{path.to_s.in_quotes} is empty" if input.blank?
|
83
|
+
|
84
|
+
hash[name] = new(input, label: LABELS[name] || name.to_s.humanize.downcase)
|
85
|
+
end
|
86
|
+
|
87
|
+
# Pull the shared structures and prepare it
|
88
|
+
structures = normalizers.delete(:_shared).normalize
|
89
|
+
|
90
|
+
# Merge in the normalizers to allow referencing other normalizers
|
91
|
+
structures.merge!(normalizers.transform_values(&:input))
|
92
|
+
|
93
|
+
# Now prepare all of the other definitions with access to references
|
94
|
+
normalizers.transform_values!(with_key: true) do |definition, name|
|
95
|
+
structure = definition.normalize(structures)
|
96
|
+
|
97
|
+
{
|
98
|
+
label: definition.label,
|
99
|
+
structure:
|
100
|
+
}
|
101
|
+
end
|
102
|
+
|
103
|
+
normalizers
|
104
|
+
end
|
105
|
+
|
106
|
+
##########################################################################
|
107
|
+
|
108
|
+
attr_reader :input, :label
|
109
|
+
|
110
|
+
def initialize(input, label: "")
|
111
|
+
@input = input
|
112
|
+
@label = label
|
113
|
+
end
|
114
|
+
|
115
|
+
#
|
116
|
+
# Normalizes a structure definition
|
117
|
+
#
|
118
|
+
# Processes references, resolves types, and ensures all attributes
|
119
|
+
# have a consistent format for validation.
|
120
|
+
#
|
121
|
+
# @param shared_structures [Hash] Optional shared structures for resolving references
|
122
|
+
#
|
123
|
+
# @return [Hash] The normalized structure definition
|
124
|
+
#
|
125
|
+
def normalize(shared_structures = {})
|
126
|
+
hash = @input.deep_dup
|
127
|
+
|
128
|
+
# First, we'll deeply replace any references
|
129
|
+
replace_references(hash, shared_structures)
|
130
|
+
|
131
|
+
# Second, normalize the root level keys
|
132
|
+
hash.transform_values!(with_key: true) do |attribute, name|
|
133
|
+
next if STRUCTURE.key?(name)
|
134
|
+
|
135
|
+
normalize_attribute(name, attribute)
|
136
|
+
end
|
137
|
+
|
138
|
+
# Third, normalize the underlying structures
|
139
|
+
hash.each do |name, attribute|
|
140
|
+
next unless attribute.is_a?(Hash)
|
141
|
+
|
142
|
+
structure = attribute[:structure]
|
143
|
+
next if structure.blank?
|
144
|
+
|
145
|
+
attribute[:structure] = normalize_structure(name, attribute)
|
146
|
+
end
|
147
|
+
|
148
|
+
hash
|
149
|
+
end
|
150
|
+
|
151
|
+
private
|
152
|
+
|
153
|
+
def replace_references(attributes, shared_structures)
|
154
|
+
return if shared_structures.blank?
|
155
|
+
|
156
|
+
# The goal is to walk down the hash and recursively replace any references
|
157
|
+
attributes.each do |attribute_name, attribute|
|
158
|
+
# Replace the top level reference
|
159
|
+
replace_with_reference(attribute_name, attribute, shared_structures:)
|
160
|
+
next unless attribute.is_a?(Hash) && attribute[:structure].present?
|
161
|
+
|
162
|
+
# Allow structures to reference other structures
|
163
|
+
if attribute.dig(:structure, :reference)
|
164
|
+
replace_with_reference(
|
165
|
+
"#{attribute_name}'s structure",
|
166
|
+
attribute[:structure],
|
167
|
+
shared_structures:
|
168
|
+
)
|
169
|
+
end
|
170
|
+
|
171
|
+
# Recursively replace any structures that have references
|
172
|
+
if [Array, "array"].include?(attribute[:type])
|
173
|
+
result = replace_references(attribute.slice(:structure), shared_structures)
|
174
|
+
attribute.merge!(result)
|
175
|
+
elsif [Hash, "hash"].include?(attribute[:type])
|
176
|
+
replace_references(attribute[:structure], shared_structures)
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
def replace_with_reference(attribute_name, attribute, shared_structures: {})
|
182
|
+
return unless attribute.is_a?(Hash) && attribute.key?(:reference)
|
183
|
+
|
184
|
+
reference_name = attribute.delete(:reference)
|
185
|
+
reference = shared_structures[reference_name.to_sym]
|
186
|
+
|
187
|
+
if reference.nil?
|
188
|
+
structures_names = shared_structures.keys.map(&:in_quotes).to_or_sentence
|
189
|
+
|
190
|
+
raise Error, "Attribute #{attribute_name.in_quotes}: Invalid reference name. Got #{reference_name&.in_quotes}, expected one of #{structures_names} in #{@label}"
|
191
|
+
end
|
192
|
+
|
193
|
+
# Allows overwriting data on the reference
|
194
|
+
attribute.reverse_merge!(reference)
|
195
|
+
end
|
196
|
+
|
197
|
+
def normalize_attribute(attribute_name, attribute)
|
198
|
+
case attribute
|
199
|
+
when String, Array # Array is multiple types
|
200
|
+
hash = {type: resolve_type(attribute)}
|
201
|
+
|
202
|
+
default = Normalizer.default(structure: STRUCTURE)
|
203
|
+
hash.merge!(default)
|
204
|
+
when Hash
|
205
|
+
hash = Normalizer.raise_errors! do
|
206
|
+
Normalizer.new(
|
207
|
+
"#{attribute_name.in_quotes} in #{@label}",
|
208
|
+
attribute,
|
209
|
+
structure: STRUCTURE
|
210
|
+
).normalize
|
211
|
+
end
|
212
|
+
|
213
|
+
hash[:type] = resolve_type(attribute[:type])
|
214
|
+
|
215
|
+
if hash[:structure].present?
|
216
|
+
hash[:structure] = normalize_structure(attribute_name, hash) || {}
|
217
|
+
end
|
218
|
+
|
219
|
+
hash
|
220
|
+
else
|
221
|
+
raise ArgumentError, "Attribute #{attribute_name.in_quotes}: Expected String or Hash, got #{attribute.inspect}"
|
222
|
+
end
|
223
|
+
end
|
224
|
+
|
225
|
+
def normalize_structure(name, hash)
|
226
|
+
if hash[:type] == Array
|
227
|
+
normalize_attribute(name, hash[:structure])
|
228
|
+
elsif hash[:type] == Hash
|
229
|
+
hash[:structure].transform_values(with_key: true) { |v, k| normalize_attribute(k, v) }
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
def resolve_type(type)
|
234
|
+
if type == "boolean"
|
235
|
+
[TrueClass, FalseClass]
|
236
|
+
elsif type.instance_of?(Array)
|
237
|
+
type.map { |t| resolve_type(t) }
|
238
|
+
elsif type.is_a?(String)
|
239
|
+
type.classify.constantize
|
240
|
+
else
|
241
|
+
type
|
242
|
+
end
|
243
|
+
rescue NameError => e
|
244
|
+
raise Error, "#{e}. #{type.inspect} is not a valid type found in #{@label}"
|
245
|
+
end
|
246
|
+
end
|
247
|
+
end
|
248
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpecForge
|
4
|
+
class Normalizer
|
5
|
+
#
|
6
|
+
# Provides validation methods for Normalizer structures
|
7
|
+
#
|
8
|
+
# Contains validation functions that can be referenced by name
|
9
|
+
# in structure definitions to perform custom validation logic.
|
10
|
+
#
|
11
|
+
# @example Validator in a structure definition
|
12
|
+
# http_verb: {type: String, validator: :http_verb}
|
13
|
+
#
|
14
|
+
class Validators
|
15
|
+
#
|
16
|
+
# Calls a validator method with the provided value and context
|
17
|
+
#
|
18
|
+
# @param method_name [Symbol, String] The validator method to call
|
19
|
+
# @param value [Object] The value to validate
|
20
|
+
# @param label [String] A descriptive label for error messages
|
21
|
+
#
|
22
|
+
# @return [void]
|
23
|
+
#
|
24
|
+
# @raise [Error] If validation fails
|
25
|
+
#
|
26
|
+
def self.call(method_name, value, label:)
|
27
|
+
new(label).public_send(method_name, value)
|
28
|
+
end
|
29
|
+
|
30
|
+
#
|
31
|
+
# Initializes a new validator instance with a context label
|
32
|
+
#
|
33
|
+
# @param label [String] A descriptive label for error messages
|
34
|
+
#
|
35
|
+
# @return [Validators] A new validator instance
|
36
|
+
#
|
37
|
+
def initialize(label)
|
38
|
+
@label = label
|
39
|
+
end
|
40
|
+
|
41
|
+
#
|
42
|
+
# Validates that a value is not blank
|
43
|
+
#
|
44
|
+
# Ensures the provided value is not nil, empty, or contains only whitespace.
|
45
|
+
# This validator is useful for required fields that must have meaningful content.
|
46
|
+
#
|
47
|
+
# @param value [Object] The value to validate
|
48
|
+
#
|
49
|
+
# @raise [Error] If the value is blank
|
50
|
+
#
|
51
|
+
# @example Using the validator in a structure
|
52
|
+
# name: {type: String, validator: :present?}
|
53
|
+
#
|
54
|
+
def present?(value)
|
55
|
+
raise Error, "Value cannot be blank for #{@label}" if value.blank?
|
56
|
+
end
|
57
|
+
|
58
|
+
#
|
59
|
+
# Validates that a value is a supported HTTP verb
|
60
|
+
#
|
61
|
+
# Ensures the provided value is one of the supported HTTP methods
|
62
|
+
# (GET, POST, PUT, PATCH, DELETE). Case-insensitive matching is used.
|
63
|
+
#
|
64
|
+
# @param value [String, Symbol, nil] The HTTP verb to validate, or nil
|
65
|
+
#
|
66
|
+
# @raise [Error] If the value is not a supported HTTP verb
|
67
|
+
#
|
68
|
+
# @example Using the validator in a structure
|
69
|
+
# http_verb: {type: String, validator: :http_verb}
|
70
|
+
#
|
71
|
+
def http_verb(value)
|
72
|
+
valid_verbs = HTTP::Verb::VERBS.values.map(&:to_s)
|
73
|
+
return if value.blank? || valid_verbs.include?(value.to_s.upcase)
|
74
|
+
|
75
|
+
raise Error, "Invalid HTTP verb #{value.in_quotes} for #{@label}. Valid values are: #{valid_verbs.join_map(", ", &:in_quotes)}"
|
76
|
+
end
|
77
|
+
|
78
|
+
#
|
79
|
+
# Validates that a callback is registered in the system
|
80
|
+
#
|
81
|
+
# Ensures the referenced callback name has been registered with SpecForge
|
82
|
+
# before it's used in a test configuration.
|
83
|
+
#
|
84
|
+
# @param value [String, Symbol, nil] The callback name to validate, or nil
|
85
|
+
#
|
86
|
+
# @raise [Error::UndefinedCallbackError] If the callback is not registered
|
87
|
+
#
|
88
|
+
# @example Using the validator in a structure
|
89
|
+
# before_file: {type: String, validator: :callback}
|
90
|
+
#
|
91
|
+
def callback(value)
|
92
|
+
return if value.blank?
|
93
|
+
return if SpecForge::Callbacks.registered?(value)
|
94
|
+
|
95
|
+
raise Error::UndefinedCallbackError.new(value, SpecForge::Callbacks.registered_names)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|