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.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -1
  3. data/README.md +124 -202
  4. data/bin/spec_forge +1 -1
  5. data/flake.lock +76 -4
  6. data/flake.nix +5 -4
  7. data/lib/spec_forge/attribute/chainable.rb +6 -6
  8. data/lib/spec_forge/attribute/environment.rb +45 -0
  9. data/lib/spec_forge/attribute/factory.rb +26 -17
  10. data/lib/spec_forge/attribute/faker.rb +6 -1
  11. data/lib/spec_forge/attribute/generate.rb +114 -0
  12. data/lib/spec_forge/attribute/literal.rb +1 -14
  13. data/lib/spec_forge/attribute/matcher.rb +6 -2
  14. data/lib/spec_forge/attribute/parameterized.rb +20 -22
  15. data/lib/spec_forge/attribute/resolvable_array.rb +16 -16
  16. data/lib/spec_forge/attribute/resolvable_hash.rb +17 -16
  17. data/lib/spec_forge/attribute/resolvable_struct.rb +67 -0
  18. data/lib/spec_forge/attribute/template.rb +118 -0
  19. data/lib/spec_forge/attribute/transform.rb +14 -19
  20. data/lib/spec_forge/attribute/variable.rb +31 -31
  21. data/lib/spec_forge/attribute.rb +54 -100
  22. data/lib/spec_forge/blueprint.rb +27 -0
  23. data/lib/spec_forge/cli/docs/generate.rb +28 -8
  24. data/lib/spec_forge/cli/docs.rb +5 -2
  25. data/lib/spec_forge/cli/init.rb +4 -4
  26. data/lib/spec_forge/cli/new.rb +78 -27
  27. data/lib/spec_forge/cli/run.rb +84 -52
  28. data/lib/spec_forge/cli/serve.rb +5 -0
  29. data/lib/spec_forge/cli.rb +6 -14
  30. data/lib/spec_forge/configuration.rb +209 -79
  31. data/lib/spec_forge/documentation/{loader → builder}/cache.rb +26 -23
  32. data/lib/spec_forge/documentation/builder/compiler.rb +373 -0
  33. data/lib/spec_forge/documentation/builder/extractor.rb +75 -0
  34. data/lib/spec_forge/documentation/builder.rb +77 -329
  35. data/lib/spec_forge/documentation/document/operation.rb +4 -4
  36. data/lib/spec_forge/documentation/document.rb +0 -6
  37. data/lib/spec_forge/documentation/generator.rb +88 -0
  38. data/lib/spec_forge/documentation/{generators/openapi → openapi/v3_0}/error_formatter.rb +2 -2
  39. data/lib/spec_forge/documentation/openapi/v3_0/example.rb +1 -1
  40. data/lib/spec_forge/documentation/openapi/v3_0/media_type.rb +1 -1
  41. data/lib/spec_forge/documentation/openapi/v3_0/operation.rb +21 -5
  42. data/lib/spec_forge/documentation/openapi/v3_0/response.rb +28 -6
  43. data/lib/spec_forge/documentation/openapi/v3_0/schema.rb +20 -2
  44. data/lib/spec_forge/documentation/openapi/v3_0/tag.rb +1 -1
  45. data/lib/spec_forge/documentation/openapi/v3_0.rb +116 -0
  46. data/lib/spec_forge/documentation/openapi.rb +40 -12
  47. data/lib/spec_forge/documentation.rb +1 -7
  48. data/lib/spec_forge/error.rb +215 -41
  49. data/lib/spec_forge/factory.rb +38 -18
  50. data/lib/spec_forge/forge/action.rb +41 -0
  51. data/lib/spec_forge/forge/actions/call.rb +33 -0
  52. data/lib/spec_forge/forge/actions/debug.rb +47 -0
  53. data/lib/spec_forge/forge/actions/expect.rb +44 -0
  54. data/lib/spec_forge/forge/actions/request.rb +65 -0
  55. data/lib/spec_forge/forge/actions/store.rb +31 -0
  56. data/lib/spec_forge/forge/callbacks.rb +80 -0
  57. data/lib/spec_forge/forge/context.rb +41 -0
  58. data/lib/spec_forge/forge/display.rb +503 -0
  59. data/lib/spec_forge/forge/hooks.rb +131 -0
  60. data/lib/spec_forge/forge/runner/array_io.rb +81 -0
  61. data/lib/spec_forge/forge/runner/content_validator.rb +92 -0
  62. data/lib/spec_forge/forge/runner/header_validator.rb +66 -0
  63. data/lib/spec_forge/forge/runner/reporter.rb +56 -0
  64. data/lib/spec_forge/forge/runner/schema_validator.rb +113 -0
  65. data/lib/spec_forge/forge/runner.rb +118 -0
  66. data/lib/spec_forge/forge/timer.rb +94 -0
  67. data/lib/spec_forge/forge/variables.rb +38 -0
  68. data/lib/spec_forge/forge.rb +207 -133
  69. data/lib/spec_forge/http/backend.rb +49 -146
  70. data/lib/spec_forge/http/client.rb +14 -17
  71. data/lib/spec_forge/http/request.rb +37 -84
  72. data/lib/spec_forge/http/verb.rb +4 -0
  73. data/lib/spec_forge/http.rb +0 -5
  74. data/lib/spec_forge/loader/filter.rb +85 -0
  75. data/lib/spec_forge/loader/step_processor.rb +282 -0
  76. data/lib/spec_forge/loader.rb +105 -220
  77. data/lib/spec_forge/normalizer/default.rb +1 -1
  78. data/lib/spec_forge/normalizer/structure.rb +140 -0
  79. data/lib/spec_forge/normalizer/transformers.rb +168 -0
  80. data/lib/spec_forge/normalizer/validators.rb +50 -8
  81. data/lib/spec_forge/normalizer.rb +76 -119
  82. data/lib/spec_forge/normalizers/callback.yml +38 -0
  83. data/lib/spec_forge/normalizers/configuration.yml +59 -9
  84. data/lib/spec_forge/normalizers/factory.yml +53 -2
  85. data/lib/spec_forge/normalizers/factory_reference.yml +63 -2
  86. data/lib/spec_forge/normalizers/json_schema.yml +79 -0
  87. data/lib/spec_forge/normalizers/step.yml +506 -0
  88. data/lib/spec_forge/step/call.rb +36 -0
  89. data/lib/spec_forge/step/expect.rb +110 -0
  90. data/lib/spec_forge/step/source.rb +22 -0
  91. data/lib/spec_forge/step.rb +129 -0
  92. data/lib/spec_forge/type.rb +115 -66
  93. data/lib/spec_forge/version.rb +1 -1
  94. data/lib/spec_forge.rb +44 -106
  95. data/lib/templates/forge_helper.rb.tt +43 -22
  96. data/lib/templates/new_blueprint.yml.tt +54 -0
  97. metadata +75 -44
  98. data/lib/spec_forge/attribute/global.rb +0 -96
  99. data/lib/spec_forge/attribute/store.rb +0 -65
  100. data/lib/spec_forge/backtrace_formatter.rb +0 -50
  101. data/lib/spec_forge/callbacks.rb +0 -88
  102. data/lib/spec_forge/context/callbacks.rb +0 -91
  103. data/lib/spec_forge/context/global.rb +0 -72
  104. data/lib/spec_forge/context/store.rb +0 -131
  105. data/lib/spec_forge/context/variables.rb +0 -91
  106. data/lib/spec_forge/context.rb +0 -36
  107. data/lib/spec_forge/core_ext/rspec.rb +0 -55
  108. data/lib/spec_forge/core_ext.rb +0 -5
  109. data/lib/spec_forge/documentation/generators/base.rb +0 -81
  110. data/lib/spec_forge/documentation/generators/openapi/base.rb +0 -100
  111. data/lib/spec_forge/documentation/generators/openapi/v3_0.rb +0 -65
  112. data/lib/spec_forge/documentation/generators/openapi.rb +0 -59
  113. data/lib/spec_forge/documentation/generators.rb +0 -17
  114. data/lib/spec_forge/documentation/loader.rb +0 -159
  115. data/lib/spec_forge/documentation/openapi/base.rb +0 -33
  116. data/lib/spec_forge/filter.rb +0 -86
  117. data/lib/spec_forge/normalizer/definition.rb +0 -248
  118. data/lib/spec_forge/normalizers/_shared.yml +0 -76
  119. data/lib/spec_forge/normalizers/constraint.yml +0 -8
  120. data/lib/spec_forge/normalizers/expectation.yml +0 -47
  121. data/lib/spec_forge/normalizers/global_context.yml +0 -28
  122. data/lib/spec_forge/normalizers/spec.yml +0 -50
  123. data/lib/spec_forge/runner/adapter.rb +0 -181
  124. data/lib/spec_forge/runner/callbacks.rb +0 -246
  125. data/lib/spec_forge/runner/debug_proxy.rb +0 -215
  126. data/lib/spec_forge/runner/listener.rb +0 -54
  127. data/lib/spec_forge/runner/metadata.rb +0 -58
  128. data/lib/spec_forge/runner/state.rb +0 -98
  129. data/lib/spec_forge/runner.rb +0 -75
  130. data/lib/spec_forge/spec/expectation/constraint.rb +0 -127
  131. data/lib/spec_forge/spec/expectation.rb +0 -68
  132. data/lib/spec_forge/spec.rb +0 -68
  133. data/lib/templates/new_spec.yml.tt +0 -43
@@ -0,0 +1,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ module Documentation
5
+ class Builder
6
+ #
7
+ # Compiles raw endpoint data into structured documentation format
8
+ #
9
+ # The Compiler transforms flat endpoint data extracted from test runs
10
+ # into a hierarchical structure organized by URL path and HTTP method.
11
+ # It handles:
12
+ # - Grouping endpoints by path and HTTP verb
13
+ # - Sanitizing error responses to exclude invalid request data
14
+ # - Merging multiple operations with the same status code
15
+ # - Normalizing parameters, request bodies, and responses
16
+ # - Type detection for all values
17
+ #
18
+ # @example Compiling endpoints
19
+ # compiler = Compiler.new(endpoints)
20
+ # compiled = compiler.compile
21
+ # # => { "/users" => { "GET" => { id: "...", responses: [...] } } }
22
+ #
23
+ class Compiler
24
+ #
25
+ # Regular expression for matching UUID v4 strings
26
+ #
27
+ # @see https://gist.github.com/johnelliott/cf77003f72f889abbc3f32785fa3df8d
28
+ #
29
+ # @api private
30
+ #
31
+ 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
32
+
33
+ #
34
+ # Regular expression for matching integer numbers in strings
35
+ #
36
+ # Matches whole numbers with optional negative sign, used for type detection
37
+ # when analyzing API response data.
38
+ #
39
+ # @api private
40
+ #
41
+ INTEGER_REGEX = /^-?\d+$/
42
+
43
+ #
44
+ # Regular expression for matching floating point numbers in strings
45
+ #
46
+ # Matches decimal numbers with optional negative sign, used for type detection
47
+ # when analyzing API response data.
48
+ #
49
+ # @api private
50
+ #
51
+ FLOAT_REGEX = /^-?\d+\.\d+$/
52
+
53
+ #
54
+ # Creates a new Compiler instance
55
+ #
56
+ # @param endpoints [Array<Hash>] Raw endpoint data from the Extractor
57
+ #
58
+ # @return [Compiler] A new compiler instance
59
+ #
60
+ def initialize(endpoints)
61
+ @endpoints = endpoints
62
+ end
63
+
64
+ #
65
+ # Compiles endpoints into a structured documentation format
66
+ #
67
+ # Processes all endpoints through grouping, sanitization, merging,
68
+ # and normalization steps to produce a hash structure suitable
69
+ # for documentation generation.
70
+ #
71
+ # @return [Hash] Compiled endpoints organized by path and HTTP method.
72
+ # Each operation contains :id, :description, :parameters, :requests,
73
+ # and :responses keys.
74
+ #
75
+ def compile
76
+ # Step one, group the endpoints by their paths and verb
77
+ # { path: {get: [], post: []}, path_2: {get: []}, ... }
78
+ grouped = group_endpoints(@endpoints)
79
+
80
+ grouped.each_value do |endpoint|
81
+ # Operations are those arrays
82
+ endpoint.transform_values! do |operations|
83
+ # Step two, clear data from any error (4xx, 5xx) operations
84
+ operations = sanitize_error_operations(operations)
85
+
86
+ # Step three, merge all of the operations into one single hash
87
+ operations = merge_operations(operations)
88
+
89
+ # Step four, flatten the operations into one
90
+ flatten_operations(operations)
91
+ end
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ def determine_type(value)
98
+ case value
99
+ when true, false
100
+ "boolean"
101
+ when Float
102
+ # According to the docs: A Float object represents a sometimes-inexact real number
103
+ # using the native architecture’s double-precision floating point representation.
104
+ # So a double it is!
105
+ "double"
106
+ when Integer
107
+ "integer"
108
+ when Array
109
+ "array"
110
+ when NilClass
111
+ "null"
112
+ when DateTime, Time
113
+ "datetime"
114
+ when Date
115
+ "date"
116
+ when String, Symbol
117
+ if value.match?(UUID_REGEX)
118
+ "uuid"
119
+ elsif value.match?(INTEGER_REGEX)
120
+ "integer"
121
+ elsif value.match?(FLOAT_REGEX)
122
+ "double"
123
+ elsif value == "true" || value == "false"
124
+ "boolean"
125
+ else
126
+ "string"
127
+ end
128
+ when URI
129
+ "uri"
130
+ when Numeric
131
+ "number"
132
+ else
133
+ "object"
134
+ end
135
+ end
136
+
137
+ #
138
+ # Groups endpoints by path and HTTP method
139
+ #
140
+ # @param endpoints [Array<Hash>] Array of endpoint data
141
+ #
142
+ # @return [Hash] Endpoints grouped by path and method
143
+ #
144
+ # @private
145
+ #
146
+ def group_endpoints(endpoints)
147
+ grouped = Hash.new { |hash, key| hash[key] = {} }
148
+
149
+ # Convert the endpoints from a flat array of objects into a hash
150
+ endpoints.each do |input|
151
+ # "/users" => {}
152
+ endpoint_hash = grouped[input[:url]]
153
+
154
+ # "GET" => []
155
+ (endpoint_hash[input[:http_verb]] ||= []) << input
156
+ end
157
+
158
+ grouped
159
+ end
160
+
161
+ #
162
+ # Sanitizes operations that represent error responses
163
+ #
164
+ # Removes request details from operations with 4xx/5xx responses
165
+ # to prevent invalid data from appearing in documentation.
166
+ #
167
+ # @param operations [Array<Hash>] Array of operations
168
+ #
169
+ # @return [Array<Hash>] Sanitized operations
170
+ #
171
+ # @private
172
+ #
173
+ def sanitize_error_operations(operations)
174
+ operations.each do |operation|
175
+ next unless operation[:response_status] >= 400
176
+
177
+ # This keeps tests that handle errors from including their invalid attributes
178
+ # and such in the output.
179
+ operation[:request_query] = {}
180
+ operation[:request_headers] = {}
181
+ operation[:request_body] = {}
182
+ end
183
+ end
184
+
185
+ #
186
+ # Merges similar operations into a single operation
187
+ #
188
+ # @param operations [Array<Hash>] Array of operations
189
+ #
190
+ # @return [Array<Hash>] Merged operations
191
+ #
192
+ # @private
193
+ #
194
+ def merge_operations(operations)
195
+ operations.group_by { |o| o[:response_status] }
196
+ .transform_values { |o| o.to_merged_h }
197
+ .values
198
+ end
199
+
200
+ #
201
+ # Flattens multiple operations into a single operation structure
202
+ #
203
+ # @param operations [Array<Hash>] Array of operations
204
+ #
205
+ # @return [Hash] Flattened operation
206
+ #
207
+ # @private
208
+ #
209
+ def flatten_operations(operations)
210
+ # Get HTTP method and path from first operation (all operations in this array have same path/method)
211
+ first_op = operations.first
212
+ http_method = first_op[:http_verb]
213
+ path = first_op[:url]
214
+
215
+ # Create a sanitized ID for operationId (e.g., "getApiV10GuildsId")
216
+ id = "#{http_method}_#{path}".gsub(/[^a-zA-Z0-9]/, "_").to_camelcase(:lower)
217
+
218
+ # Use "METHOD /path" format for summary (e.g., "GET /api/v10/guilds/{id}")
219
+ summary = "#{http_method} #{path}"
220
+
221
+ parameters = normalize_parameters(operations)
222
+ requests = normalize_requests(operations)
223
+ responses = normalize_responses(operations)
224
+
225
+ {
226
+ id:,
227
+ summary:,
228
+ parameters:,
229
+ requests:,
230
+ responses:
231
+ }
232
+ end
233
+
234
+ #
235
+ # Normalizes request parameters from operations
236
+ #
237
+ # Extracts and categorizes parameters as path or query parameters
238
+ # and determines their data types.
239
+ #
240
+ # @param operations [Array<Hash>] Array of operations
241
+ #
242
+ # @return [Hash] Normalized parameters
243
+ #
244
+ # @private
245
+ #
246
+ def normalize_parameters(operations)
247
+ parameters = {}
248
+
249
+ operations.each do |operation|
250
+ # Store the URL so it can be determined if the param is in the path or not
251
+ url = operation[:url]
252
+ params = operation[:request_query].transform_values { |value| {value:, url:} }
253
+
254
+ parameters.merge!(params)
255
+ end
256
+
257
+ parameters.transform_values!(with_key: true) do |data, key|
258
+ key_in_path = data[:url].include?("{#{key}}")
259
+
260
+ {
261
+ location: key_in_path ? "path" : "query",
262
+ type: determine_type(data[:value])
263
+ }
264
+ end
265
+ end
266
+
267
+ #
268
+ # Normalizes request bodies from operations
269
+ #
270
+ # Extracts request bodies from successful operations and
271
+ # determines their data types.
272
+ #
273
+ # @param operations [Array<Hash>] Array of operations
274
+ #
275
+ # @return [Array<Hash>] Normalized request bodies
276
+ #
277
+ # @private
278
+ #
279
+ def normalize_requests(operations)
280
+ successful_operations = operations.select { |o| o[:response_status] < 400 }
281
+ return [] if successful_operations.blank?
282
+
283
+ successful_operations.filter_map.with_index do |operation, index|
284
+ content = operation[:request_body]
285
+ next if content.blank?
286
+
287
+ name = "name_#{SecureRandom.uuid}"
288
+
289
+ {
290
+ name: name || "Example #{index}",
291
+ content_type: operation[:content_type],
292
+ type: determine_type(content),
293
+ content:
294
+ }
295
+ end
296
+ end
297
+
298
+ #
299
+ # Normalizes responses from operations
300
+ #
301
+ # Extracts response details including status, headers, and body
302
+ # and determines their data types.
303
+ #
304
+ # @param operations [Array<Hash>] Array of operations
305
+ #
306
+ # @return [Array<Hash>] Normalized responses
307
+ #
308
+ # @private
309
+ #
310
+ def normalize_responses(operations)
311
+ operations.map do |operation|
312
+ {
313
+ content_type: operation[:content_type],
314
+ status: operation[:response_status],
315
+ headers: normalize_headers(operation[:response_headers]),
316
+ body: normalize_response_body(operation[:response_body])
317
+ }
318
+ end
319
+ end
320
+
321
+ #
322
+ # Normalizes response headers
323
+ #
324
+ # @param headers [Hash] Response headers
325
+ #
326
+ # @return [Hash] Normalized headers with types
327
+ #
328
+ # @private
329
+ #
330
+ def normalize_headers(headers)
331
+ headers.transform_values do |value|
332
+ {type: determine_type(value)}
333
+ end
334
+ end
335
+
336
+ #
337
+ # Normalizes response body structure
338
+ #
339
+ # @param body [Hash, Array, String] Response body
340
+ #
341
+ # @return [Hash] Normalized body structure with type information
342
+ #
343
+ # @private
344
+ #
345
+ def normalize_response_body(body)
346
+ proc = lambda do |value|
347
+ {type: determine_type(value)}
348
+ end
349
+
350
+ case body
351
+ when Hash
352
+ {
353
+ type: "object",
354
+ content: body.deep_transform_values(&proc)
355
+ }
356
+ when Array
357
+ {
358
+ type: "array",
359
+ content: body.map(&proc)
360
+ }
361
+ when String
362
+ {
363
+ type: "string",
364
+ content: body
365
+ }
366
+ else
367
+ raise "Unexpected body: #{body.inspect}"
368
+ end
369
+ end
370
+ end
371
+ end
372
+ end
373
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ module Documentation
5
+ class Builder
6
+ #
7
+ # Extracts endpoint data from a test execution context
8
+ #
9
+ # The Extractor takes a Forge::Context from a completed test step
10
+ # and extracts all relevant request and response data needed for
11
+ # API documentation generation.
12
+ #
13
+ # @example Extracting endpoint data
14
+ # extractor = Extractor.new(context)
15
+ # endpoint = extractor.extract_endpoint
16
+ # # => { url: "/users", http_verb: "GET", response_status: 200, ... }
17
+ #
18
+ class Extractor
19
+ #
20
+ # Creates a new Extractor instance
21
+ #
22
+ # @param context [Forge::Context] The execution context from a test step
23
+ # containing request/response variables
24
+ #
25
+ # @return [Extractor] A new extractor instance
26
+ #
27
+ def initialize(context)
28
+ @context = context
29
+ @step = context.step
30
+ @variables = context.variables
31
+ end
32
+
33
+ #
34
+ # Extracts endpoint data from the context
35
+ #
36
+ # Pulls request and response information from the context variables
37
+ # and organizes it into a hash structure for documentation.
38
+ #
39
+ # @return [Hash] Endpoint data containing:
40
+ # - :base_url [String] The API base URL
41
+ # - :url [String] The endpoint path
42
+ # - :http_verb [String] The HTTP method (GET, POST, etc.)
43
+ # - :content_type [String, nil] The request content type
44
+ # - :request_body [Hash] The request body
45
+ # - :request_headers [Hash] Request headers (excluding content-type)
46
+ # - :request_query [Hash] Query parameters
47
+ # - :response_status [Integer] HTTP response status code
48
+ # - :response_body [Hash, Array, String] The response body
49
+ # - :response_headers [Hash] Response headers
50
+ #
51
+ def extract_endpoint
52
+ request = @variables[:request]
53
+ response = @variables[:response]
54
+ headers = request[:headers]
55
+
56
+ {
57
+ # Request data
58
+ base_url: request[:base_url],
59
+ url: request[:url],
60
+ http_verb: request[:http_verb],
61
+ content_type: headers["content-type"],
62
+ request_body: request[:body],
63
+ request_headers: headers.except("content-type"),
64
+ request_query: request[:query],
65
+
66
+ # Response data
67
+ response_status: response[:status],
68
+ response_body: response[:body],
69
+ response_headers: response[:headers]
70
+ }
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end