spec_forge 0.7.1 → 1.0.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 +75 -1
- data/README.md +124 -202
- data/bin/spec_forge +1 -1
- data/flake.lock +76 -4
- data/flake.nix +5 -4
- data/lib/spec_forge/attribute/chainable.rb +6 -6
- data/lib/spec_forge/attribute/environment.rb +45 -0
- data/lib/spec_forge/attribute/factory.rb +26 -17
- data/lib/spec_forge/attribute/faker.rb +6 -1
- data/lib/spec_forge/attribute/generate.rb +114 -0
- data/lib/spec_forge/attribute/literal.rb +1 -14
- data/lib/spec_forge/attribute/matcher.rb +6 -2
- data/lib/spec_forge/attribute/parameterized.rb +20 -22
- data/lib/spec_forge/attribute/resolvable_array.rb +16 -16
- data/lib/spec_forge/attribute/resolvable_hash.rb +17 -16
- data/lib/spec_forge/attribute/resolvable_struct.rb +67 -0
- data/lib/spec_forge/attribute/template.rb +118 -0
- data/lib/spec_forge/attribute/transform.rb +14 -19
- data/lib/spec_forge/attribute/variable.rb +31 -31
- data/lib/spec_forge/attribute.rb +54 -100
- data/lib/spec_forge/blueprint.rb +27 -0
- data/lib/spec_forge/cli/docs/generate.rb +28 -8
- data/lib/spec_forge/cli/docs.rb +5 -2
- data/lib/spec_forge/cli/init.rb +4 -4
- data/lib/spec_forge/cli/new.rb +78 -27
- data/lib/spec_forge/cli/run.rb +84 -52
- data/lib/spec_forge/cli/serve.rb +5 -0
- data/lib/spec_forge/cli.rb +6 -14
- data/lib/spec_forge/configuration.rb +209 -79
- data/lib/spec_forge/documentation/{loader → builder}/cache.rb +26 -23
- data/lib/spec_forge/documentation/builder/compiler.rb +373 -0
- data/lib/spec_forge/documentation/builder/extractor.rb +75 -0
- data/lib/spec_forge/documentation/builder.rb +77 -329
- data/lib/spec_forge/documentation/document/operation.rb +4 -4
- data/lib/spec_forge/documentation/document.rb +0 -6
- data/lib/spec_forge/documentation/generator.rb +88 -0
- data/lib/spec_forge/documentation/{generators/openapi → openapi/v3_0}/error_formatter.rb +2 -2
- data/lib/spec_forge/documentation/openapi/v3_0/example.rb +1 -1
- data/lib/spec_forge/documentation/openapi/v3_0/media_type.rb +1 -1
- data/lib/spec_forge/documentation/openapi/v3_0/operation.rb +21 -5
- data/lib/spec_forge/documentation/openapi/v3_0/response.rb +28 -6
- data/lib/spec_forge/documentation/openapi/v3_0/schema.rb +20 -2
- data/lib/spec_forge/documentation/openapi/v3_0/tag.rb +1 -1
- data/lib/spec_forge/documentation/openapi/v3_0.rb +116 -0
- data/lib/spec_forge/documentation/openapi.rb +40 -12
- data/lib/spec_forge/documentation.rb +1 -7
- data/lib/spec_forge/error.rb +215 -41
- data/lib/spec_forge/factory.rb +38 -18
- data/lib/spec_forge/forge/action.rb +41 -0
- data/lib/spec_forge/forge/actions/call.rb +33 -0
- data/lib/spec_forge/forge/actions/debug.rb +47 -0
- data/lib/spec_forge/forge/actions/expect.rb +44 -0
- data/lib/spec_forge/forge/actions/request.rb +65 -0
- data/lib/spec_forge/forge/actions/store.rb +31 -0
- data/lib/spec_forge/forge/callbacks.rb +80 -0
- data/lib/spec_forge/forge/context.rb +41 -0
- data/lib/spec_forge/forge/display.rb +503 -0
- data/lib/spec_forge/forge/hooks.rb +131 -0
- data/lib/spec_forge/forge/runner/array_io.rb +81 -0
- data/lib/spec_forge/forge/runner/content_validator.rb +92 -0
- data/lib/spec_forge/forge/runner/header_validator.rb +66 -0
- data/lib/spec_forge/forge/runner/reporter.rb +56 -0
- data/lib/spec_forge/forge/runner/schema_validator.rb +113 -0
- data/lib/spec_forge/forge/runner.rb +118 -0
- data/lib/spec_forge/forge/timer.rb +94 -0
- data/lib/spec_forge/forge/variables.rb +38 -0
- data/lib/spec_forge/forge.rb +207 -133
- data/lib/spec_forge/http/backend.rb +49 -146
- data/lib/spec_forge/http/client.rb +14 -17
- data/lib/spec_forge/http/request.rb +37 -84
- data/lib/spec_forge/http/verb.rb +4 -0
- data/lib/spec_forge/http.rb +0 -5
- data/lib/spec_forge/loader/filter.rb +85 -0
- data/lib/spec_forge/loader/step_processor.rb +282 -0
- data/lib/spec_forge/loader.rb +105 -220
- data/lib/spec_forge/normalizer/default.rb +1 -1
- data/lib/spec_forge/normalizer/structure.rb +140 -0
- data/lib/spec_forge/normalizer/transformers.rb +168 -0
- data/lib/spec_forge/normalizer/validators.rb +50 -8
- data/lib/spec_forge/normalizer.rb +76 -119
- data/lib/spec_forge/normalizers/callback.yml +38 -0
- data/lib/spec_forge/normalizers/configuration.yml +59 -9
- data/lib/spec_forge/normalizers/factory.yml +53 -2
- data/lib/spec_forge/normalizers/factory_reference.yml +63 -2
- data/lib/spec_forge/normalizers/json_schema.yml +79 -0
- data/lib/spec_forge/normalizers/step.yml +506 -0
- data/lib/spec_forge/step/call.rb +36 -0
- data/lib/spec_forge/step/expect.rb +110 -0
- data/lib/spec_forge/step/source.rb +22 -0
- data/lib/spec_forge/step.rb +129 -0
- data/lib/spec_forge/type.rb +115 -66
- data/lib/spec_forge/version.rb +1 -1
- data/lib/spec_forge.rb +44 -106
- data/lib/templates/forge_helper.rb.tt +43 -22
- data/lib/templates/new_blueprint.yml.tt +54 -0
- metadata +75 -44
- data/lib/spec_forge/attribute/global.rb +0 -96
- data/lib/spec_forge/attribute/store.rb +0 -65
- data/lib/spec_forge/backtrace_formatter.rb +0 -50
- data/lib/spec_forge/callbacks.rb +0 -88
- data/lib/spec_forge/context/callbacks.rb +0 -91
- data/lib/spec_forge/context/global.rb +0 -72
- data/lib/spec_forge/context/store.rb +0 -131
- data/lib/spec_forge/context/variables.rb +0 -91
- data/lib/spec_forge/context.rb +0 -36
- data/lib/spec_forge/core_ext/rspec.rb +0 -55
- data/lib/spec_forge/core_ext.rb +0 -5
- data/lib/spec_forge/documentation/generators/base.rb +0 -81
- data/lib/spec_forge/documentation/generators/openapi/base.rb +0 -100
- data/lib/spec_forge/documentation/generators/openapi/v3_0.rb +0 -65
- data/lib/spec_forge/documentation/generators/openapi.rb +0 -59
- data/lib/spec_forge/documentation/generators.rb +0 -17
- data/lib/spec_forge/documentation/loader.rb +0 -159
- data/lib/spec_forge/documentation/openapi/base.rb +0 -33
- data/lib/spec_forge/filter.rb +0 -86
- data/lib/spec_forge/normalizer/definition.rb +0 -248
- data/lib/spec_forge/normalizers/_shared.yml +0 -76
- data/lib/spec_forge/normalizers/constraint.yml +0 -8
- data/lib/spec_forge/normalizers/expectation.yml +0 -47
- data/lib/spec_forge/normalizers/global_context.yml +0 -28
- data/lib/spec_forge/normalizers/spec.yml +0 -50
- data/lib/spec_forge/runner/adapter.rb +0 -181
- data/lib/spec_forge/runner/callbacks.rb +0 -246
- data/lib/spec_forge/runner/debug_proxy.rb +0 -215
- data/lib/spec_forge/runner/listener.rb +0 -54
- data/lib/spec_forge/runner/metadata.rb +0 -58
- data/lib/spec_forge/runner/state.rb +0 -98
- data/lib/spec_forge/runner.rb +0 -75
- data/lib/spec_forge/spec/expectation/constraint.rb +0 -127
- data/lib/spec_forge/spec/expectation.rb +0 -68
- data/lib/spec_forge/spec.rb +0 -68
- data/lib/templates/new_spec.yml.tt +0 -43
data/lib/spec_forge/loader.rb
CHANGED
|
@@ -2,243 +2,128 @@
|
|
|
2
2
|
|
|
3
3
|
module SpecForge
|
|
4
4
|
#
|
|
5
|
-
#
|
|
5
|
+
# Loads and processes blueprint YAML files into executable Blueprint objects
|
|
6
6
|
#
|
|
7
|
-
# The Loader
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# @example Loading all specs
|
|
12
|
-
# specs = Loader.load_from_files
|
|
7
|
+
# The Loader handles the load-time phase of SpecForge, reading YAML files,
|
|
8
|
+
# parsing steps with line numbers, expanding includes, flattening hierarchies,
|
|
9
|
+
# and applying filters.
|
|
13
10
|
#
|
|
14
11
|
class Loader
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
raise Error::SpecLoadError.new(e, metadata[:relative_path])
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
specs =
|
|
32
|
-
specs.map do |spec|
|
|
33
|
-
Normalizer.normalize!(spec, using: :spec, label: "spec \"#{spec[:name]}\"")
|
|
34
|
-
rescue => e
|
|
35
|
-
raise Error::SpecLoadError.new(e, metadata[:relative_path], spec:)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
[global, metadata, specs]
|
|
39
|
-
end
|
|
40
|
-
end
|
|
12
|
+
#
|
|
13
|
+
# Loads blueprints from disk with optional filtering
|
|
14
|
+
#
|
|
15
|
+
# @param base_path [Pathname, String, nil] Base directory for glob loading (defaults to blueprints/)
|
|
16
|
+
# @param paths [Array<Pathname, String>] Specific file paths to load (no globbing)
|
|
17
|
+
# @param tags [Array<String>] Tags to include
|
|
18
|
+
# @param skip_tags [Array<String>] Tags to exclude
|
|
19
|
+
#
|
|
20
|
+
# @return [Array<Blueprint>, Hash] Loaded blueprint objects and any forge hooks
|
|
21
|
+
#
|
|
22
|
+
def self.load_blueprints(base_path: nil, paths: [], tags: [], skip_tags: [])
|
|
23
|
+
new(base_path:, paths:, filter: {tags:, skip_tags:}).load
|
|
24
|
+
end
|
|
41
25
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
parse_and_transform_specs(files)
|
|
57
|
-
end
|
|
26
|
+
#
|
|
27
|
+
# Creates a new Loader with the specified base path and filter options
|
|
28
|
+
#
|
|
29
|
+
# @param base_path [Pathname, String, nil] Base directory for glob loading (defaults to blueprints/)
|
|
30
|
+
# @param paths [Array<Pathname, String>] Specific file paths to load (no globbing)
|
|
31
|
+
# @param filter [Hash] Filter options for tags and skip_tags
|
|
32
|
+
#
|
|
33
|
+
# @return [Loader] A new loader instance
|
|
34
|
+
#
|
|
35
|
+
def initialize(base_path: nil, paths: [], filter: {})
|
|
36
|
+
@base_path = base_path.present? ? Pathname.new(base_path) : SpecForge.forge_path.join("blueprints")
|
|
37
|
+
@paths = Array.wrap(paths).map { |p| Pathname.new(p) }
|
|
38
|
+
@filter = filter
|
|
39
|
+
end
|
|
58
40
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
Dir[path.join("**/*.yml")].map do |file_path|
|
|
70
|
-
[file_path, File.read(file_path)]
|
|
71
|
-
end
|
|
72
|
-
end
|
|
41
|
+
#
|
|
42
|
+
# Loads and processes all blueprints, extracting any hook data at the same time
|
|
43
|
+
#
|
|
44
|
+
# @return [Array<Blueprint>, Hash] Loaded blueprint objects and any forge hooks
|
|
45
|
+
#
|
|
46
|
+
def load
|
|
47
|
+
blueprints, forge_hooks = read_blueprints
|
|
48
|
+
.index_by { |b| b[:name] }
|
|
49
|
+
.then { |blueprints| StepProcessor.new(blueprints).run }
|
|
73
50
|
|
|
74
|
-
|
|
75
|
-
# Parses YAML content and extracts line numbers for error reporting
|
|
76
|
-
#
|
|
77
|
-
# @param files [Array<Array<String, String>>] Array of [file_path, file_content] pairs
|
|
78
|
-
#
|
|
79
|
-
# @return [Array<Array>] Array of [global, metadata, specs] for each file
|
|
80
|
-
#
|
|
81
|
-
# @private
|
|
82
|
-
#
|
|
83
|
-
def parse_and_transform_specs(files)
|
|
84
|
-
base_path = SpecForge.forge_path.join("specs")
|
|
85
|
-
|
|
86
|
-
files.map do |file_path, content|
|
|
87
|
-
relative_path = Pathname.new(file_path).relative_path_from(base_path)
|
|
88
|
-
|
|
89
|
-
hash = YAML.safe_load(content, symbolize_names: true)
|
|
90
|
-
|
|
91
|
-
file_line_numbers = extract_line_numbers(content, hash)
|
|
92
|
-
|
|
93
|
-
# Currently, only holds onto global variables
|
|
94
|
-
global = hash.delete(:global) || {}
|
|
95
|
-
|
|
96
|
-
metadata = {
|
|
97
|
-
file_name: relative_path.basename(".yml").to_s,
|
|
98
|
-
relative_path: relative_path.to_s,
|
|
99
|
-
file_path:
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
specs =
|
|
103
|
-
hash.map do |spec_name, spec_hash|
|
|
104
|
-
line_number, *expectation_line_numbers = file_line_numbers[spec_name]
|
|
105
|
-
|
|
106
|
-
spec_hash[:id] = "spec_#{SpecForge.generate_id(spec_hash)}"
|
|
107
|
-
spec_hash[:name] = spec_name.to_s
|
|
108
|
-
spec_hash[:file_path] = metadata[:file_path]
|
|
109
|
-
spec_hash[:file_name] = metadata[:file_name]
|
|
110
|
-
spec_hash[:line_number] = line_number
|
|
111
|
-
|
|
112
|
-
# Check for expectations instead of defaulting. I want it to error
|
|
113
|
-
if (expectations = spec_hash[:expectations])
|
|
114
|
-
expectations.zip(expectation_line_numbers) do |expectation_hash, line_number|
|
|
115
|
-
expectation_hash[:id] = "expect_#{SpecForge.generate_id(expectation_hash)}"
|
|
116
|
-
expectation_hash[:name] = build_expectation_name(spec_hash, expectation_hash)
|
|
117
|
-
expectation_hash[:line_number] = line_number
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
spec_hash
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
[global, metadata, specs]
|
|
125
|
-
end
|
|
126
|
-
end
|
|
51
|
+
blueprints = Filter.new(blueprints).run(**@filter).map { |b| Blueprint.new(**b) }
|
|
127
52
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
#
|
|
131
|
-
# @param content [String] The raw file content
|
|
132
|
-
# @param input_hash [Hash] The parsed YAML structure
|
|
133
|
-
#
|
|
134
|
-
# @return [Hash] A mapping of spec names to line numbers
|
|
135
|
-
#
|
|
136
|
-
# @private
|
|
137
|
-
#
|
|
138
|
-
def extract_line_numbers(content, input_hash)
|
|
139
|
-
# I hate this code, lol, and it hates me.
|
|
140
|
-
# I've tried to make it better, I've tried to clean it up, but every time I break it.
|
|
141
|
-
# If you know how to make this better, please submit a PR and save me.
|
|
142
|
-
spec_names = input_hash.keys
|
|
143
|
-
keys = {}
|
|
144
|
-
|
|
145
|
-
current_spec_name = nil
|
|
146
|
-
expectations_line = nil
|
|
147
|
-
expectations_indent = nil
|
|
148
|
-
|
|
149
|
-
content.lines.each_with_index do |line, index|
|
|
150
|
-
line_number = index + 1
|
|
151
|
-
clean_line = line.rstrip
|
|
152
|
-
indentation = line[/^\s*/].size
|
|
153
|
-
|
|
154
|
-
# Skip blank lines
|
|
155
|
-
next if clean_line.empty?
|
|
156
|
-
|
|
157
|
-
# Reset on top-level elements
|
|
158
|
-
if indentation == 0
|
|
159
|
-
current_spec_name = nil
|
|
160
|
-
expectations_line = nil
|
|
161
|
-
expectations_indent = nil
|
|
162
|
-
|
|
163
|
-
# Check if this line starts a spec we're interested in
|
|
164
|
-
spec_names.each do |spec_name|
|
|
165
|
-
next unless clean_line.start_with?("#{spec_name}:")
|
|
166
|
-
|
|
167
|
-
current_spec_name = spec_name
|
|
168
|
-
keys[current_spec_name] = [line_number]
|
|
169
|
-
break
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
next
|
|
173
|
-
end
|
|
53
|
+
[blueprints, forge_hooks]
|
|
54
|
+
end
|
|
174
55
|
|
|
175
|
-
|
|
176
|
-
next unless current_spec_name
|
|
56
|
+
private
|
|
177
57
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
58
|
+
def read_blueprints
|
|
59
|
+
# Use specific paths if provided, otherwise glob from base_path
|
|
60
|
+
file_paths =
|
|
61
|
+
if @paths.present?
|
|
62
|
+
@paths
|
|
63
|
+
else
|
|
64
|
+
Dir.glob(@base_path.join("**", "*.{yml,yaml}")).map { |p| Pathname.new(p) }
|
|
65
|
+
end
|
|
184
66
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
67
|
+
file_paths.map do |file_path|
|
|
68
|
+
content = File.read(file_path)
|
|
69
|
+
|
|
70
|
+
# Determine the relative path for naming
|
|
71
|
+
relative_path =
|
|
72
|
+
if @paths.present?
|
|
73
|
+
# For specific paths, use the filename as the base
|
|
74
|
+
file_path.basename
|
|
75
|
+
else
|
|
76
|
+
# For glob loading, use path relative to base_path
|
|
77
|
+
file_path.relative_path_from(@base_path)
|
|
188
78
|
end
|
|
189
|
-
end
|
|
190
79
|
|
|
191
|
-
|
|
80
|
+
name = relative_path.to_s.delete_suffix(".yml").delete_suffix(".yaml")
|
|
81
|
+
steps = parse_steps(content)
|
|
82
|
+
|
|
83
|
+
{file_path: relative_path, name:, steps:}
|
|
192
84
|
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def parse_steps(content)
|
|
88
|
+
# Parse with Psych to make it easier to extract line numbers
|
|
89
|
+
yaml = Psych.parse(content)
|
|
193
90
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
# Removing the defaults and validators to avoid triggering extra logic
|
|
207
|
-
structure = Normalizer.structures[:spec][:structure].slice(:http_verb, :url)
|
|
208
|
-
.transform_values { |v| v.except(:default, :validator) }
|
|
209
|
-
|
|
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
|
-
)
|
|
216
|
-
|
|
217
|
-
request_data = normalized_spec.deep_merge(normalized_expectation)
|
|
218
|
-
|
|
219
|
-
url = request_data[:url]
|
|
220
|
-
http_verb = request_data[:http_verb].presence || "GET"
|
|
221
|
-
|
|
222
|
-
# Finally generate the name
|
|
223
|
-
generate_expectation_name(http_verb:, url:, name: expectation_hash[:name])
|
|
91
|
+
steps = yaml.to_ruby(symbolize_names: true)
|
|
92
|
+
inject_line_numbers(yaml.root, steps)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def inject_line_numbers(yaml_node, ruby_object)
|
|
96
|
+
case ruby_object
|
|
97
|
+
when Array
|
|
98
|
+
inject_line_numbers_into_array(yaml_node, ruby_object)
|
|
99
|
+
when Hash
|
|
100
|
+
inject_line_numbers_into_hash(yaml_node, ruby_object)
|
|
101
|
+
else
|
|
102
|
+
ruby_object
|
|
224
103
|
end
|
|
104
|
+
end
|
|
225
105
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
#
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
#
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
106
|
+
def inject_line_numbers_into_array(yaml_node, array)
|
|
107
|
+
yaml_node.children
|
|
108
|
+
.map
|
|
109
|
+
.with_index { |node, index| inject_line_numbers(node, array[index]) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def inject_line_numbers_into_hash(yaml_node, hash)
|
|
113
|
+
# Psych uses 0-indexed line numbers
|
|
114
|
+
hash[:line_number] = yaml_node.start_line + 1
|
|
115
|
+
|
|
116
|
+
# Walk through key-value pairs in the YAML tree
|
|
117
|
+
yaml_node.children.each_slice(2) do |key_node, value_node|
|
|
118
|
+
key = key_node.value.to_sym
|
|
119
|
+
|
|
120
|
+
# Only recursively add line numbers to substeps.
|
|
121
|
+
next unless key == :steps
|
|
122
|
+
|
|
123
|
+
hash[key] = inject_line_numbers(value_node, hash[key])
|
|
241
124
|
end
|
|
125
|
+
|
|
126
|
+
hash
|
|
242
127
|
end
|
|
243
128
|
end
|
|
244
129
|
end
|
|
@@ -15,7 +15,7 @@ module SpecForge
|
|
|
15
15
|
structure.each_with_object({}) do |(attribute_name, attribute), hash|
|
|
16
16
|
type = attribute[:type]
|
|
17
17
|
has_default = attribute.key?(:default)
|
|
18
|
-
required = attribute[:required]
|
|
18
|
+
required = attribute[:required] == true
|
|
19
19
|
|
|
20
20
|
next if !(include_optional || required || has_default)
|
|
21
21
|
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecForge
|
|
4
|
+
class Normalizer
|
|
5
|
+
#
|
|
6
|
+
# Represents a parsed structure definition for normalization
|
|
7
|
+
#
|
|
8
|
+
# Structure definitions specify the expected shape of input data,
|
|
9
|
+
# including types, defaults, aliases, and nested structures.
|
|
10
|
+
# They are loaded from YAML files and normalized themselves.
|
|
11
|
+
#
|
|
12
|
+
class Structure < Hash
|
|
13
|
+
# Meta-structure defining the format of structure definitions
|
|
14
|
+
#
|
|
15
|
+
# @return [Hash]
|
|
16
|
+
STRUCTURE = {
|
|
17
|
+
type: {
|
|
18
|
+
type: [String, Array, Class],
|
|
19
|
+
default: nil, # Important to default this to nil so other logic can handle it
|
|
20
|
+
required: true,
|
|
21
|
+
validator: :present?
|
|
22
|
+
},
|
|
23
|
+
default: {
|
|
24
|
+
type: [String, NilClass, Numeric, Array, Hash, TrueClass, FalseClass]
|
|
25
|
+
},
|
|
26
|
+
required: {
|
|
27
|
+
type: [TrueClass, FalseClass]
|
|
28
|
+
},
|
|
29
|
+
aliases: {
|
|
30
|
+
type: Array,
|
|
31
|
+
structure: {type: String}
|
|
32
|
+
},
|
|
33
|
+
structure: {
|
|
34
|
+
type: Hash
|
|
35
|
+
},
|
|
36
|
+
validator: {
|
|
37
|
+
type: String
|
|
38
|
+
},
|
|
39
|
+
transformer: {
|
|
40
|
+
type: String
|
|
41
|
+
},
|
|
42
|
+
description: {
|
|
43
|
+
type: String
|
|
44
|
+
},
|
|
45
|
+
examples: {
|
|
46
|
+
type: Array,
|
|
47
|
+
structure: {
|
|
48
|
+
type: [String, Integer, Float, Hash, Array, TrueClass, FalseClass]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
# @return [String] Human-readable label for this structure
|
|
54
|
+
attr_reader :label
|
|
55
|
+
|
|
56
|
+
#
|
|
57
|
+
# Creates a new Structure from the given input definition
|
|
58
|
+
#
|
|
59
|
+
# @param input [Hash] The raw structure definition from YAML
|
|
60
|
+
# @param label [String] Human-readable label for error messages
|
|
61
|
+
#
|
|
62
|
+
# @return [Structure] A new structure instance
|
|
63
|
+
#
|
|
64
|
+
def initialize(input, label: "")
|
|
65
|
+
@label = label
|
|
66
|
+
|
|
67
|
+
# Pull in the data
|
|
68
|
+
deep_merge!(input)
|
|
69
|
+
|
|
70
|
+
# And normalize
|
|
71
|
+
normalize
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def normalize
|
|
77
|
+
# Normalize the root level keys
|
|
78
|
+
transform_values!(with_key: true) do |attribute, name|
|
|
79
|
+
normalize_attribute(name, attribute)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def normalize_attribute(attribute_name, attribute)
|
|
86
|
+
case attribute
|
|
87
|
+
# Shorthands for single/multiple types
|
|
88
|
+
when String, Array
|
|
89
|
+
hash = {type: resolve_type(attribute)}
|
|
90
|
+
|
|
91
|
+
default = Normalizer.default(structure: STRUCTURE)
|
|
92
|
+
hash.merge!(default)
|
|
93
|
+
# Full syntax
|
|
94
|
+
when Hash
|
|
95
|
+
hash = Normalizer.raise_errors! do
|
|
96
|
+
Normalizer.new(
|
|
97
|
+
"#{attribute_name.in_quotes} in #{@label.in_quotes}",
|
|
98
|
+
attribute,
|
|
99
|
+
structure: STRUCTURE
|
|
100
|
+
).normalize
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
hash[:type] = resolve_type(attribute[:type])
|
|
104
|
+
|
|
105
|
+
if hash[:structure].present?
|
|
106
|
+
hash[:structure] = normalize_structure(attribute_name, hash) || {}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
hash
|
|
110
|
+
else
|
|
111
|
+
raise ArgumentError, "Attribute #{attribute_name.in_quotes}: Expected String, Array, or Hash. Got #{attribute.inspect}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def normalize_structure(name, hash)
|
|
116
|
+
if hash[:type] == Array
|
|
117
|
+
normalize_attribute(name, hash[:structure])
|
|
118
|
+
elsif hash[:type] == Hash
|
|
119
|
+
hash[:structure].transform_values(with_key: true) { |v, k| normalize_attribute(k, v) }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def resolve_type(type)
|
|
124
|
+
if type == "boolean"
|
|
125
|
+
[FalseClass, TrueClass]
|
|
126
|
+
elsif type == "any"
|
|
127
|
+
[Array, FalseClass, Hash, NilClass, Numeric, String, TrueClass]
|
|
128
|
+
elsif type.instance_of?(Array)
|
|
129
|
+
type.map { |t| resolve_type(t) }
|
|
130
|
+
elsif type.is_a?(String)
|
|
131
|
+
type.classify.constantize
|
|
132
|
+
else
|
|
133
|
+
type
|
|
134
|
+
end
|
|
135
|
+
rescue NameError => e
|
|
136
|
+
raise Error, "#{e}. #{type.inspect} is not a valid type found in #{@label.in_quotes}"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecForge
|
|
4
|
+
class Normalizer
|
|
5
|
+
#
|
|
6
|
+
# Provides transformation functions for normalizer structure definitions
|
|
7
|
+
#
|
|
8
|
+
# Transformers modify values during normalization, such as converting
|
|
9
|
+
# shorthand syntax into full structures or normalizing type definitions.
|
|
10
|
+
#
|
|
11
|
+
class Transformers
|
|
12
|
+
include Singleton
|
|
13
|
+
|
|
14
|
+
#
|
|
15
|
+
# Calls a transformer method with the given value
|
|
16
|
+
#
|
|
17
|
+
# @param method_name [Symbol, String] The transformer method to call
|
|
18
|
+
# @param value [Object] The value to transform
|
|
19
|
+
#
|
|
20
|
+
# @return [Object] The transformed value
|
|
21
|
+
#
|
|
22
|
+
def self.call(method_name, value)
|
|
23
|
+
instance.public_send(method_name, value)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
#
|
|
27
|
+
# Normalizes include values to an array of blueprint names
|
|
28
|
+
#
|
|
29
|
+
# @param value [String, Array<String>] Include value(s)
|
|
30
|
+
#
|
|
31
|
+
# @return [Array<String>] Normalized blueprint names without extensions
|
|
32
|
+
#
|
|
33
|
+
def normalize_includes(value)
|
|
34
|
+
Array(value).map! { |name| name.delete_suffix(".yml").delete_suffix(".yaml") }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
#
|
|
38
|
+
# Normalizes callback shorthand into full hash format
|
|
39
|
+
#
|
|
40
|
+
# Converts string callback names into the full hash structure with
|
|
41
|
+
# a :name key. Hashes pass through unchanged. Arrays are processed
|
|
42
|
+
# recursively to normalize each element.
|
|
43
|
+
#
|
|
44
|
+
# @param value [String, Hash, Array] Callback name, full definition, or array of callbacks
|
|
45
|
+
#
|
|
46
|
+
# @return [Array<Hash>] Normalized callback hash(es) with :name key
|
|
47
|
+
#
|
|
48
|
+
def normalize_callback(value)
|
|
49
|
+
return if value.blank?
|
|
50
|
+
|
|
51
|
+
case value
|
|
52
|
+
when Hash
|
|
53
|
+
[value]
|
|
54
|
+
when Array
|
|
55
|
+
value.map { |v| normalize_callback(v) }.flatten
|
|
56
|
+
else
|
|
57
|
+
[{name: value}]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
#
|
|
62
|
+
# Normalizes a shape definition into a structured schema format
|
|
63
|
+
#
|
|
64
|
+
# Converts shorthand shape syntax (arrays, hashes, type strings) into
|
|
65
|
+
# the full schema structure with :type, :pattern, and :structure keys.
|
|
66
|
+
#
|
|
67
|
+
# @param value [Array, Hash, String] The shape definition to normalize
|
|
68
|
+
#
|
|
69
|
+
# @return [Hash] Normalized schema structure
|
|
70
|
+
#
|
|
71
|
+
# @raise [ArgumentError] If value is nil
|
|
72
|
+
#
|
|
73
|
+
def normalize_shape(value)
|
|
74
|
+
raise ArgumentError, "Shape cannot be nil" if value.nil?
|
|
75
|
+
|
|
76
|
+
case value
|
|
77
|
+
when Array
|
|
78
|
+
shape = {type: [Array]}
|
|
79
|
+
|
|
80
|
+
if value.size == 1
|
|
81
|
+
shape[:pattern] = normalize_shape(value.first)
|
|
82
|
+
elsif value.size > 1
|
|
83
|
+
shape[:structure] = value.map { |i| normalize_shape(i) }
|
|
84
|
+
else
|
|
85
|
+
[]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
shape
|
|
89
|
+
when Hash
|
|
90
|
+
{
|
|
91
|
+
type: [Hash],
|
|
92
|
+
structure: value.transform_values { |v| normalize_shape(v) }
|
|
93
|
+
}
|
|
94
|
+
when String
|
|
95
|
+
result = Type.from_string(value)
|
|
96
|
+
{type: result[:types], optional: result[:optional]}
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
#
|
|
101
|
+
# Normalizes a schema definition by converting type strings to classes
|
|
102
|
+
#
|
|
103
|
+
# Recursively processes schema definitions, converting string type
|
|
104
|
+
# specifications into their Ruby class equivalents.
|
|
105
|
+
#
|
|
106
|
+
# @param value [Array, Hash, String] The schema definition to normalize
|
|
107
|
+
#
|
|
108
|
+
# @return [Hash, Array] Normalized schema with type classes
|
|
109
|
+
#
|
|
110
|
+
# @raise [ArgumentError] If value is nil
|
|
111
|
+
#
|
|
112
|
+
def normalize_schema(value)
|
|
113
|
+
raise ArgumentError, "Schema cannot be nil" if value.nil?
|
|
114
|
+
|
|
115
|
+
case value
|
|
116
|
+
when Array
|
|
117
|
+
value.each { |v| normalize_schema(v) }
|
|
118
|
+
when Hash
|
|
119
|
+
if (type = value[:type]) && type.is_a?(String)
|
|
120
|
+
result = Type.from_string(type)
|
|
121
|
+
|
|
122
|
+
value[:type] = result[:types]
|
|
123
|
+
value[:optional] = result[:optional] unless value.key?(:optional)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Handle explicit nullable: true (sugar for adding NilClass to type)
|
|
127
|
+
if value.delete(:nullable)
|
|
128
|
+
value[:type] ||= []
|
|
129
|
+
value[:type] << NilClass unless value[:type].include?(NilClass)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Default optional to false if not set
|
|
133
|
+
value[:optional] ||= false
|
|
134
|
+
|
|
135
|
+
if (structure = value[:structure])
|
|
136
|
+
value[:structure] =
|
|
137
|
+
case structure
|
|
138
|
+
when Array
|
|
139
|
+
structure.map { |v| normalize_schema(v) }
|
|
140
|
+
when Hash
|
|
141
|
+
structure.transform_values { |v| normalize_schema(v) }
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
if (pattern = value[:pattern])
|
|
146
|
+
value[:pattern] = normalize_schema(pattern)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
value
|
|
150
|
+
when String
|
|
151
|
+
result = Type.from_string(value)
|
|
152
|
+
{type: result[:types], optional: result[:optional]}
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
#
|
|
157
|
+
# Returns the absolute value of a number
|
|
158
|
+
#
|
|
159
|
+
# @param value [Numeric, nil] The value to convert
|
|
160
|
+
#
|
|
161
|
+
# @return [Numeric, nil] The absolute value, or nil if input is nil
|
|
162
|
+
#
|
|
163
|
+
def abs(value)
|
|
164
|
+
value&.abs
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|