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
@@ -0,0 +1,130 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpecForge
|
4
|
+
#
|
5
|
+
# Provides custom RSpec matchers for SpecForge
|
6
|
+
#
|
7
|
+
# This singleton class is responsible for defining custom RSpec matchers
|
8
|
+
# that can be used in SpecForge tests. It makes these matchers available
|
9
|
+
# through RSpec's matcher system.
|
10
|
+
#
|
11
|
+
# @example Defining all matchers
|
12
|
+
# SpecForge::Matchers.define
|
13
|
+
#
|
14
|
+
class Matchers
|
15
|
+
include Singleton
|
16
|
+
|
17
|
+
#
|
18
|
+
# Defines all custom matchers for use in SpecForge tests
|
19
|
+
#
|
20
|
+
# This is the main entry point that should be called once during
|
21
|
+
# initialization to make all custom matchers available.
|
22
|
+
#
|
23
|
+
def self.define
|
24
|
+
instance.define_all
|
25
|
+
end
|
26
|
+
|
27
|
+
#
|
28
|
+
# Defines all available custom matchers
|
29
|
+
#
|
30
|
+
# This method calls individual definition methods for each
|
31
|
+
# custom matcher supported by SpecForge.
|
32
|
+
#
|
33
|
+
def define_all
|
34
|
+
define_forge_and
|
35
|
+
define_have_size
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
#
|
41
|
+
# Defines the forge_and matcher for combining multiple matchers.
|
42
|
+
# Explicitly has "forge_" prefix to avoid potentially clashing with someone's
|
43
|
+
# existing custom matchers.
|
44
|
+
#
|
45
|
+
# This matcher allows chaining multiple matchers together with an AND
|
46
|
+
# condition, requiring all matchers to pass. It provides detailed
|
47
|
+
# failure messages showing which specific matchers failed.
|
48
|
+
#
|
49
|
+
# @example Using forge_and in a test
|
50
|
+
# expect(response.body).to forge_and(
|
51
|
+
# have_key("name"),
|
52
|
+
# have_key("email"),
|
53
|
+
# include("active" => be_truthy)
|
54
|
+
# )
|
55
|
+
#
|
56
|
+
# @private
|
57
|
+
#
|
58
|
+
def define_forge_and
|
59
|
+
RSpec::Matchers.define :forge_and do |*matchers|
|
60
|
+
match do |actual|
|
61
|
+
@failures = []
|
62
|
+
|
63
|
+
matchers.each do |matcher|
|
64
|
+
next if matcher.matches?(actual)
|
65
|
+
|
66
|
+
@failures << [matcher, matcher.failure_message]
|
67
|
+
end
|
68
|
+
|
69
|
+
@failures.empty?
|
70
|
+
end
|
71
|
+
|
72
|
+
failure_message do
|
73
|
+
pass_count = matchers.size - @failures.size
|
74
|
+
|
75
|
+
message = "Expected to satisfy ALL of these conditions on:\n #{actual.inspect}\n\n"
|
76
|
+
|
77
|
+
matchers.each_with_index do |matcher, i|
|
78
|
+
failure = @failures.find { |m, _| m == matcher }
|
79
|
+
|
80
|
+
if failure
|
81
|
+
message += "❌ #{i + 1}. #{matcher.description}\n"
|
82
|
+
message += " → #{failure[1].gsub(/\s+/, " ").strip}\n\n"
|
83
|
+
else
|
84
|
+
message += "✅ #{i + 1}. #{matcher.description}\n\n"
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
message += "#{pass_count}/#{matchers.size} conditions met"
|
89
|
+
message
|
90
|
+
end
|
91
|
+
|
92
|
+
description do
|
93
|
+
"match all: " + matchers.join_map(", ", &:description)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
#
|
99
|
+
# Defines the have_size matcher for checking collection sizes
|
100
|
+
#
|
101
|
+
# This matcher verifies that an object responds to the :size method
|
102
|
+
# and that its size matches the expected value.
|
103
|
+
#
|
104
|
+
# @example Using have_size in a test
|
105
|
+
# expect(response.body["items"]).to have_size(5)
|
106
|
+
#
|
107
|
+
# @private
|
108
|
+
#
|
109
|
+
def define_have_size
|
110
|
+
RSpec::Matchers.define :have_size do |expected|
|
111
|
+
expected = RSpec::Matchers::BuiltIn::Eq.new(expected) if expected.is_a?(Integer)
|
112
|
+
|
113
|
+
match do |actual|
|
114
|
+
actual.respond_to?(:size) && expected.matches?(actual.size)
|
115
|
+
end
|
116
|
+
|
117
|
+
failure_message do |actual|
|
118
|
+
if actual.respond_to?(:size)
|
119
|
+
"expected #{actual.inspect} size to #{expected.description}, but got #{actual.size}"
|
120
|
+
else
|
121
|
+
"expected #{actual.inspect} to respond to :size"
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
# Define the custom matchers
|
130
|
+
SpecForge::Matchers.define
|
@@ -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
|