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
@@ -0,0 +1,383 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpecForge
|
4
|
+
module Documentation
|
5
|
+
#
|
6
|
+
# Transforms extracted test data into a structured document
|
7
|
+
#
|
8
|
+
# This class processes raw endpoint data from tests into a hierarchical document
|
9
|
+
# structure suitable for rendering as API documentation.
|
10
|
+
#
|
11
|
+
# @example Creating a document from test data
|
12
|
+
# document = Builder.document_from_endpoints(endpoints)
|
13
|
+
#
|
14
|
+
class Builder
|
15
|
+
# Source: https://gist.github.com/johnelliott/cf77003f72f889abbc3f32785fa3df8d
|
16
|
+
UUID_REGEX = /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i
|
17
|
+
|
18
|
+
#
|
19
|
+
# Regular expression for matching floating point numbers in strings
|
20
|
+
#
|
21
|
+
# Matches decimal numbers with optional negative sign, used for type detection
|
22
|
+
# when analyzing API response data.
|
23
|
+
#
|
24
|
+
# @api private
|
25
|
+
#
|
26
|
+
INTEGER_REGEX = /^-?\d+$/
|
27
|
+
|
28
|
+
#
|
29
|
+
# Regular expression for matching integer numbers in strings
|
30
|
+
#
|
31
|
+
# Matches whole numbers with optional negative sign, used for type detection
|
32
|
+
# when analyzing API response data.
|
33
|
+
#
|
34
|
+
# @api private
|
35
|
+
#
|
36
|
+
FLOAT_REGEX = /^-?\d+\.\d+$/
|
37
|
+
|
38
|
+
#
|
39
|
+
# Creates a document from endpoint data
|
40
|
+
#
|
41
|
+
# @param endpoints [Array<Hash>] Array of endpoint data extracted from tests
|
42
|
+
#
|
43
|
+
# @return [Document] A structured documentation document
|
44
|
+
#
|
45
|
+
def self.document_from_endpoints(endpoints = [])
|
46
|
+
new(endpoints).export_as_document
|
47
|
+
end
|
48
|
+
|
49
|
+
#
|
50
|
+
# The processed endpoints organized by path and HTTP method
|
51
|
+
#
|
52
|
+
# Contains all endpoint data after grouping, sanitizing, merging,
|
53
|
+
# and flattening operations for document generation.
|
54
|
+
#
|
55
|
+
# @return [Hash] Processed endpoints ready for document creation
|
56
|
+
#
|
57
|
+
attr_reader :endpoints
|
58
|
+
|
59
|
+
#
|
60
|
+
# Initializes a new builder with endpoint data
|
61
|
+
#
|
62
|
+
# @param endpoints [Array<Hash>] Array of endpoint data extracted from tests
|
63
|
+
#
|
64
|
+
# @return [Builder] A new builder instance
|
65
|
+
#
|
66
|
+
def initialize(endpoints)
|
67
|
+
@endpoints = prepare_endpoints(endpoints)
|
68
|
+
end
|
69
|
+
|
70
|
+
#
|
71
|
+
# Prepares endpoint data for document creation
|
72
|
+
#
|
73
|
+
# Groups endpoints by path and HTTP method, sanitizes error responses,
|
74
|
+
# merges similar operations, and flattens the result.
|
75
|
+
#
|
76
|
+
# @param endpoints [Array<Hash>] Raw endpoint data from tests
|
77
|
+
#
|
78
|
+
# @return [Hash] Processed endpoints organized by path and method
|
79
|
+
#
|
80
|
+
def prepare_endpoints(endpoints)
|
81
|
+
# Step one, group the endpoints by their paths and verb
|
82
|
+
# { path: {get: [], post: []}, path_2: {get: []}, ... }
|
83
|
+
grouped = group_endpoints(endpoints)
|
84
|
+
|
85
|
+
grouped.each_value do |endpoint|
|
86
|
+
# Operations are those arrays
|
87
|
+
endpoint.transform_values! do |operations|
|
88
|
+
# Step two, clear data from any error (4xx, 5xx) operations
|
89
|
+
operations = sanitize_error_operations(operations)
|
90
|
+
|
91
|
+
# Step three, merge all of the operations into one single hash
|
92
|
+
operations = merge_operations(operations)
|
93
|
+
|
94
|
+
# Step four, flatten the operations into one
|
95
|
+
flatten_operations(operations)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
#
|
101
|
+
# Exports the processed endpoints as a document
|
102
|
+
#
|
103
|
+
# @return [Document] A document containing the processed endpoints
|
104
|
+
#
|
105
|
+
def export_as_document
|
106
|
+
Document.new(endpoints:)
|
107
|
+
end
|
108
|
+
|
109
|
+
private
|
110
|
+
|
111
|
+
def determine_type(value)
|
112
|
+
case value
|
113
|
+
when true, false
|
114
|
+
"boolean"
|
115
|
+
when Float
|
116
|
+
# According to the docs: A Float object represents a sometimes-inexact real number
|
117
|
+
# using the native architecture’s double-precision floating point representation.
|
118
|
+
# So a double it is!
|
119
|
+
"double"
|
120
|
+
when Integer
|
121
|
+
"integer"
|
122
|
+
when Array
|
123
|
+
"array"
|
124
|
+
when NilClass
|
125
|
+
"null"
|
126
|
+
when DateTime, Time
|
127
|
+
"datetime"
|
128
|
+
when Date
|
129
|
+
"date"
|
130
|
+
when String, Symbol
|
131
|
+
if value.match?(UUID_REGEX)
|
132
|
+
"uuid"
|
133
|
+
elsif value.match?(INTEGER_REGEX)
|
134
|
+
"integer"
|
135
|
+
elsif value.match?(FLOAT_REGEX)
|
136
|
+
"double"
|
137
|
+
elsif value == "true" || value == "false"
|
138
|
+
"boolean"
|
139
|
+
else
|
140
|
+
"string"
|
141
|
+
end
|
142
|
+
when URI
|
143
|
+
"uri"
|
144
|
+
when Numeric
|
145
|
+
"number"
|
146
|
+
else
|
147
|
+
"object"
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
#
|
152
|
+
# Groups endpoints by path and HTTP method
|
153
|
+
#
|
154
|
+
# @param endpoints [Array<Hash>] Array of endpoint data
|
155
|
+
#
|
156
|
+
# @return [Hash] Endpoints grouped by path and method
|
157
|
+
#
|
158
|
+
# @private
|
159
|
+
#
|
160
|
+
def group_endpoints(endpoints)
|
161
|
+
grouped = Hash.new_nested_hash(depth: 1)
|
162
|
+
|
163
|
+
# Convert the endpoints from a flat array of objects into a hash
|
164
|
+
endpoints.each do |input|
|
165
|
+
# "/users" => {}
|
166
|
+
endpoint_hash = grouped[input[:url]]
|
167
|
+
|
168
|
+
# "GET" => []
|
169
|
+
(endpoint_hash[input[:http_verb]] ||= []) << input
|
170
|
+
end
|
171
|
+
|
172
|
+
grouped
|
173
|
+
end
|
174
|
+
|
175
|
+
#
|
176
|
+
# Sanitizes operations that represent error responses
|
177
|
+
#
|
178
|
+
# Removes request details from operations with 4xx/5xx responses
|
179
|
+
# to prevent invalid data from appearing in documentation.
|
180
|
+
#
|
181
|
+
# @param operations [Array<Hash>] Array of operations
|
182
|
+
#
|
183
|
+
# @return [Array<Hash>] Sanitized operations
|
184
|
+
#
|
185
|
+
# @private
|
186
|
+
#
|
187
|
+
def sanitize_error_operations(operations)
|
188
|
+
operations.each do |operation|
|
189
|
+
next unless operation[:response_status] >= 400
|
190
|
+
|
191
|
+
# This keeps tests that handle errors from including their invalid attributes
|
192
|
+
# and such in the output.
|
193
|
+
operation[:request_query] = {}
|
194
|
+
operation[:request_headers] = {}
|
195
|
+
operation[:request_body] = {}
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
#
|
200
|
+
# Merges similar operations into a single operation
|
201
|
+
#
|
202
|
+
# @param operations [Array<Hash>] Array of operations
|
203
|
+
#
|
204
|
+
# @return [Array<Hash>] Merged operations
|
205
|
+
#
|
206
|
+
# @private
|
207
|
+
#
|
208
|
+
def merge_operations(operations)
|
209
|
+
operations.group_by { |o| o[:response_status] }
|
210
|
+
.transform_values { |o| o.to_merged_h }
|
211
|
+
.values
|
212
|
+
end
|
213
|
+
|
214
|
+
#
|
215
|
+
# Flattens multiple operations into a single operation structure
|
216
|
+
#
|
217
|
+
# @param operations [Array<Hash>] Array of operations
|
218
|
+
#
|
219
|
+
# @return [Hash] Flattened operation
|
220
|
+
#
|
221
|
+
# @private
|
222
|
+
#
|
223
|
+
def flatten_operations(operations)
|
224
|
+
id = operations.key_map(:spec_name).reject(&:blank?).first
|
225
|
+
|
226
|
+
description = operations.key_map(:expectation_name)
|
227
|
+
.reject(&:blank?)
|
228
|
+
.first
|
229
|
+
&.split(" - ")
|
230
|
+
&.second || ""
|
231
|
+
|
232
|
+
parameters = normalize_parameters(operations)
|
233
|
+
requests = normalize_requests(operations)
|
234
|
+
responses = normalize_responses(operations)
|
235
|
+
|
236
|
+
{
|
237
|
+
id:,
|
238
|
+
description:,
|
239
|
+
parameters:,
|
240
|
+
requests:,
|
241
|
+
responses:
|
242
|
+
}
|
243
|
+
end
|
244
|
+
|
245
|
+
#
|
246
|
+
# Normalizes request parameters from operations
|
247
|
+
#
|
248
|
+
# Extracts and categorizes parameters as path or query parameters
|
249
|
+
# and determines their data types.
|
250
|
+
#
|
251
|
+
# @param operations [Array<Hash>] Array of operations
|
252
|
+
#
|
253
|
+
# @return [Hash] Normalized parameters
|
254
|
+
#
|
255
|
+
# @private
|
256
|
+
#
|
257
|
+
def normalize_parameters(operations)
|
258
|
+
parameters = {}
|
259
|
+
|
260
|
+
operations.each do |operation|
|
261
|
+
# Store the URL so it can be determined if the param is in the path or not
|
262
|
+
url = operation[:url]
|
263
|
+
params = operation[:request_query].transform_values { |value| {value:, url:} }
|
264
|
+
|
265
|
+
parameters.merge!(params)
|
266
|
+
end
|
267
|
+
|
268
|
+
parameters.transform_values!(with_key: true) do |data, key|
|
269
|
+
key_in_path = data[:url].include?("{#{key}}")
|
270
|
+
|
271
|
+
{
|
272
|
+
location: key_in_path ? "path" : "query",
|
273
|
+
type: determine_type(data[:value])
|
274
|
+
}
|
275
|
+
end
|
276
|
+
end
|
277
|
+
|
278
|
+
#
|
279
|
+
# Normalizes request bodies from operations
|
280
|
+
#
|
281
|
+
# Extracts request bodies from successful operations and
|
282
|
+
# determines their data types.
|
283
|
+
#
|
284
|
+
# @param operations [Array<Hash>] Array of operations
|
285
|
+
#
|
286
|
+
# @return [Array<Hash>] Normalized request bodies
|
287
|
+
#
|
288
|
+
# @private
|
289
|
+
#
|
290
|
+
def normalize_requests(operations)
|
291
|
+
successful_operations = operations.select { |o| o[:response_status] < 400 }
|
292
|
+
return [] if successful_operations.blank?
|
293
|
+
|
294
|
+
successful_operations.filter_map.with_index do |operation, index|
|
295
|
+
content = operation[:request_body]
|
296
|
+
next if content.blank?
|
297
|
+
|
298
|
+
name = operation[:expectation_name].split(" - ").second
|
299
|
+
|
300
|
+
{
|
301
|
+
name: name || "Example #{index}",
|
302
|
+
content_type: operation[:content_type],
|
303
|
+
type: determine_type(content),
|
304
|
+
content:
|
305
|
+
}
|
306
|
+
end
|
307
|
+
end
|
308
|
+
|
309
|
+
#
|
310
|
+
# Normalizes responses from operations
|
311
|
+
#
|
312
|
+
# Extracts response details including status, headers, and body
|
313
|
+
# and determines their data types.
|
314
|
+
#
|
315
|
+
# @param operations [Array<Hash>] Array of operations
|
316
|
+
#
|
317
|
+
# @return [Array<Hash>] Normalized responses
|
318
|
+
#
|
319
|
+
# @private
|
320
|
+
#
|
321
|
+
def normalize_responses(operations)
|
322
|
+
operations.map do |operation|
|
323
|
+
{
|
324
|
+
content_type: operation[:content_type],
|
325
|
+
status: operation[:response_status],
|
326
|
+
headers: normalize_headers(operation[:response_headers]),
|
327
|
+
body: normalize_response_body(operation[:response_body])
|
328
|
+
}
|
329
|
+
end
|
330
|
+
end
|
331
|
+
|
332
|
+
#
|
333
|
+
# Normalizes response headers
|
334
|
+
#
|
335
|
+
# @param headers [Hash] Response headers
|
336
|
+
#
|
337
|
+
# @return [Hash] Normalized headers with types
|
338
|
+
#
|
339
|
+
# @private
|
340
|
+
#
|
341
|
+
def normalize_headers(headers)
|
342
|
+
headers.transform_values do |value|
|
343
|
+
{type: determine_type(value)}
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
#
|
348
|
+
# Normalizes response body structure
|
349
|
+
#
|
350
|
+
# @param body [Hash, Array, String] Response body
|
351
|
+
#
|
352
|
+
# @return [Hash] Normalized body structure with type information
|
353
|
+
#
|
354
|
+
# @private
|
355
|
+
#
|
356
|
+
def normalize_response_body(body)
|
357
|
+
proc = lambda do |value|
|
358
|
+
{type: determine_type(value)}
|
359
|
+
end
|
360
|
+
|
361
|
+
case body
|
362
|
+
when Hash
|
363
|
+
{
|
364
|
+
type: "object",
|
365
|
+
content: body.deep_transform_values(&proc)
|
366
|
+
}
|
367
|
+
when Array
|
368
|
+
{
|
369
|
+
type: "array",
|
370
|
+
content: body.map(&proc)
|
371
|
+
}
|
372
|
+
when String
|
373
|
+
{
|
374
|
+
type: "string",
|
375
|
+
content: body
|
376
|
+
}
|
377
|
+
else
|
378
|
+
raise "Unexpected body: #{body.inspect}"
|
379
|
+
end
|
380
|
+
end
|
381
|
+
end
|
382
|
+
end
|
383
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpecForge
|
4
|
+
module Documentation
|
5
|
+
class Document
|
6
|
+
#
|
7
|
+
# Represents an API operation (endpoint + HTTP method)
|
8
|
+
#
|
9
|
+
# An Operation contains all the information about a specific API endpoint
|
10
|
+
# with a specific HTTP method, including parameters, request bodies,
|
11
|
+
# and possible responses.
|
12
|
+
#
|
13
|
+
# @example Operation for creating a user
|
14
|
+
# operation = Operation.new(
|
15
|
+
# id: "create_user",
|
16
|
+
# description: "Creates a new user",
|
17
|
+
# parameters: {id: {name: "id", location: "path", type: "integer"}},
|
18
|
+
# requests: [{name: "example", content_type: "application/json", type: "object", content: {}}],
|
19
|
+
# responses: [{status: 201, content_type: "application/json", headers: {}, body: {}}]
|
20
|
+
# )
|
21
|
+
#
|
22
|
+
class Operation < Data.define(:id, :description, :parameters, :requests, :responses)
|
23
|
+
#
|
24
|
+
# Creates a new operation with normalized sub-components
|
25
|
+
#
|
26
|
+
# @param id [String] Unique identifier for the operation
|
27
|
+
# @param description [String] Human-readable description
|
28
|
+
# @param parameters [Hash] Parameters by name with their details
|
29
|
+
# @param requests [Array<Hash>] Request body examples
|
30
|
+
# @param responses [Array<Hash>] Possible responses
|
31
|
+
#
|
32
|
+
# @return [Operation] A new operation instance
|
33
|
+
#
|
34
|
+
def initialize(id:, description:, parameters:, requests:, responses:)
|
35
|
+
parameters = parameters.each_pair.map do |name, value|
|
36
|
+
[name, Parameter.new(name: name.to_s, **value)]
|
37
|
+
end.to_h
|
38
|
+
|
39
|
+
requests = requests.map { |r| RequestBody.new(**r) }
|
40
|
+
responses = responses.map { |r| Response.new(**r) }
|
41
|
+
|
42
|
+
super
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpecForge
|
4
|
+
module Documentation
|
5
|
+
class Document
|
6
|
+
#
|
7
|
+
# Represents a parameter for an API operation
|
8
|
+
#
|
9
|
+
# Parameters can appear in various locations (path, query, header)
|
10
|
+
# and have different types and validation rules.
|
11
|
+
#
|
12
|
+
# @example Path parameter
|
13
|
+
# Parameter.new(name: "id", location: "path", type: "integer")
|
14
|
+
#
|
15
|
+
# @example Query parameter
|
16
|
+
# Parameter.new(name: "limit", location: "query", type: "integer")
|
17
|
+
#
|
18
|
+
class Parameter < Data.define(:name, :location, :type)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpecForge
|
4
|
+
module Documentation
|
5
|
+
class Document
|
6
|
+
#
|
7
|
+
# Represents a request body example for an API operation
|
8
|
+
#
|
9
|
+
# Contains the content type, data structure, and example content
|
10
|
+
# for a request body.
|
11
|
+
#
|
12
|
+
# @example JSON request body
|
13
|
+
# RequestBody.new(
|
14
|
+
# name: "Create User",
|
15
|
+
# content_type: "application/json",
|
16
|
+
# type: "object",
|
17
|
+
# content: {name: "Example User", email: "user@example.com"}
|
18
|
+
# )
|
19
|
+
#
|
20
|
+
class RequestBody < Data.define(:name, :content_type, :type, :content)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpecForge
|
4
|
+
module Documentation
|
5
|
+
class Document
|
6
|
+
#
|
7
|
+
# Represents a possible response from an API operation
|
8
|
+
#
|
9
|
+
# Contains the status code, headers, and body content
|
10
|
+
# with content type information.
|
11
|
+
#
|
12
|
+
# @example Success response
|
13
|
+
# Response.new(
|
14
|
+
# content_type: "application/json",
|
15
|
+
# status: 200,
|
16
|
+
# headers: {"Cache-Control" => {type: "string"}},
|
17
|
+
# body: {type: "object", content: {id: {type: "integer"}}}
|
18
|
+
# )
|
19
|
+
#
|
20
|
+
class Response < Data.define(:content_type, :status, :headers, :body)
|
21
|
+
#
|
22
|
+
# Creates a new response with a normalized body
|
23
|
+
#
|
24
|
+
# @param content_type [String] The content type (e.g., "application/json")
|
25
|
+
# @param status [Integer] The HTTP status code
|
26
|
+
# @param headers [Hash] Response headers with their types
|
27
|
+
# @param body [Hash] Response body description
|
28
|
+
#
|
29
|
+
# @return [Response] A new response instance
|
30
|
+
#
|
31
|
+
def initialize(content_type:, status:, headers:, body:)
|
32
|
+
body = ResponseBody.new(**body) if body.present?
|
33
|
+
|
34
|
+
super
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpecForge
|
4
|
+
module Documentation
|
5
|
+
class Document
|
6
|
+
#
|
7
|
+
# Represents a response body structure
|
8
|
+
#
|
9
|
+
# Contains the type and content structure of a response body.
|
10
|
+
#
|
11
|
+
# @example Object response body
|
12
|
+
# ResponseBody.new(
|
13
|
+
# type: "object",
|
14
|
+
# content: {user: {type: "object", content: {id: {type: "integer"}}}}
|
15
|
+
# )
|
16
|
+
#
|
17
|
+
# @example Array response body
|
18
|
+
# ResponseBody.new(
|
19
|
+
# type: "array",
|
20
|
+
# content: [{type: "string"}]
|
21
|
+
# )
|
22
|
+
#
|
23
|
+
class ResponseBody < Data.define(:type, :content)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpecForge
|
4
|
+
module Documentation
|
5
|
+
#
|
6
|
+
# Represents the structured API documentation
|
7
|
+
#
|
8
|
+
# This class is the central data structure for API documentation,
|
9
|
+
# containing all endpoints organized by path and HTTP method.
|
10
|
+
# It serves as the bridge between extracted test data and generators.
|
11
|
+
#
|
12
|
+
# @example Creating a document
|
13
|
+
# document = Document.new(
|
14
|
+
# endpoints: {
|
15
|
+
# "/users" => {
|
16
|
+
# "get" => {id: "list_users", description: "List all users"...},
|
17
|
+
# "post" => {id: "create_user", description: "Create a user"...}
|
18
|
+
# }
|
19
|
+
# }
|
20
|
+
# )
|
21
|
+
#
|
22
|
+
class Document < Data.define(:endpoints)
|
23
|
+
#
|
24
|
+
# Creates a new document with normalized endpoints
|
25
|
+
#
|
26
|
+
# @param endpoints [Hash] A hash mapping paths to operations by HTTP method
|
27
|
+
#
|
28
|
+
# @return [Document] A new document instance
|
29
|
+
#
|
30
|
+
def initialize(endpoints: {})
|
31
|
+
endpoints = endpoints.transform_values do |operations|
|
32
|
+
operations.transform_keys(&:downcase)
|
33
|
+
.transform_values! { |op| Operation.new(**op) }
|
34
|
+
end
|
35
|
+
|
36
|
+
endpoints.deep_symbolize_keys!
|
37
|
+
|
38
|
+
super
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
require_relative "document/operation"
|
45
|
+
require_relative "document/parameter"
|
46
|
+
require_relative "document/request_body"
|
47
|
+
require_relative "document/response"
|
48
|
+
require_relative "document/response_body"
|
@@ -0,0 +1,81 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpecForge
|
4
|
+
module Documentation
|
5
|
+
module Generators
|
6
|
+
#
|
7
|
+
# Base class for all documentation generators
|
8
|
+
#
|
9
|
+
# Provides the common interface and shared functionality for generators
|
10
|
+
# that transform SpecForge documents into various output formats.
|
11
|
+
# Subclasses implement format-specific generation logic.
|
12
|
+
#
|
13
|
+
# @example Creating a custom generator
|
14
|
+
# class MyGenerator < Base
|
15
|
+
# def generate
|
16
|
+
# # Transform input document to custom format
|
17
|
+
# end
|
18
|
+
# end
|
19
|
+
#
|
20
|
+
class Base
|
21
|
+
#
|
22
|
+
# Generates documentation from test data with optional caching
|
23
|
+
#
|
24
|
+
# @param use_cache [Boolean] Whether to use cached test data if available
|
25
|
+
#
|
26
|
+
# @return [Object] The generated documentation in the target format
|
27
|
+
#
|
28
|
+
# @raise [RuntimeError] Must be implemented by subclasses
|
29
|
+
#
|
30
|
+
def self.generate(use_cache: false)
|
31
|
+
raise "not implemented"
|
32
|
+
end
|
33
|
+
|
34
|
+
#
|
35
|
+
# Validates the generated output according to format specifications
|
36
|
+
#
|
37
|
+
# @param input [Object] The generated documentation to validate
|
38
|
+
#
|
39
|
+
# @return [void]
|
40
|
+
#
|
41
|
+
# @raise [RuntimeError] Must be implemented by subclasses
|
42
|
+
#
|
43
|
+
def self.validate!(input)
|
44
|
+
raise "not implemented"
|
45
|
+
end
|
46
|
+
|
47
|
+
#
|
48
|
+
# The input document containing structured API data
|
49
|
+
#
|
50
|
+
# Contains all the endpoint information extracted from tests,
|
51
|
+
# organized and ready for transformation into the target format.
|
52
|
+
#
|
53
|
+
# @return [Document] The document to be processed by the generator
|
54
|
+
#
|
55
|
+
attr_reader :input
|
56
|
+
|
57
|
+
#
|
58
|
+
# Initializes a new generators
|
59
|
+
#
|
60
|
+
# @param input [Hash, Document] The document to generate
|
61
|
+
#
|
62
|
+
# @return [Base] A new generator instance
|
63
|
+
#
|
64
|
+
def initialize(input = {})
|
65
|
+
@input = input
|
66
|
+
end
|
67
|
+
|
68
|
+
#
|
69
|
+
# Generates the document into a specific format
|
70
|
+
#
|
71
|
+
# @raise [RuntimeError] Must be implemented by subclasses
|
72
|
+
#
|
73
|
+
# @return [Object] The generated document
|
74
|
+
#
|
75
|
+
def generate
|
76
|
+
raise "not implemented"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|