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,100 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpecForge
|
4
|
+
module Documentation
|
5
|
+
module Generators
|
6
|
+
module OpenAPI
|
7
|
+
#
|
8
|
+
# Base class for OpenAPI generators
|
9
|
+
#
|
10
|
+
# Provides common functionality for OpenAPI generators of different versions.
|
11
|
+
#
|
12
|
+
class Base < Generators::Base
|
13
|
+
#
|
14
|
+
# Converts the generator's version to a semantic version object
|
15
|
+
#
|
16
|
+
# @return [SemVersion] The semantic version
|
17
|
+
#
|
18
|
+
def self.to_sem_version
|
19
|
+
SemVersion.new(CURRENT_VERSION)
|
20
|
+
end
|
21
|
+
|
22
|
+
#
|
23
|
+
# Generates OpenAPI documentation from test data with optional caching
|
24
|
+
#
|
25
|
+
# Loads endpoint data from tests (either fresh or cached), creates a document,
|
26
|
+
# and generates the OpenAPI specification using the appropriate version generator.
|
27
|
+
#
|
28
|
+
# @param use_cache [Boolean] Whether to use cached test data if available
|
29
|
+
#
|
30
|
+
# @return [Hash] The generated OpenAPI specification
|
31
|
+
#
|
32
|
+
def self.generate(use_cache: false)
|
33
|
+
document = Documentation::Loader.load_document(use_cache:)
|
34
|
+
new(document).generate
|
35
|
+
end
|
36
|
+
|
37
|
+
#
|
38
|
+
# Validates an OpenAPI specification against the standard
|
39
|
+
#
|
40
|
+
# Uses the openapi3_parser gem to validate the generated specification
|
41
|
+
# and provides detailed error reporting if validation fails.
|
42
|
+
#
|
43
|
+
# @param output [Hash] The OpenAPI specification to validate
|
44
|
+
#
|
45
|
+
# @return [void]
|
46
|
+
#
|
47
|
+
# @raise [Error::InvalidOASDocument] If the specification is invalid
|
48
|
+
#
|
49
|
+
def self.validate!(output)
|
50
|
+
document = Openapi3Parser.load(output)
|
51
|
+
if document.valid?
|
52
|
+
puts "✅ No validation errors found!"
|
53
|
+
return
|
54
|
+
end
|
55
|
+
|
56
|
+
puts ErrorFormatter.format(document.errors.errors)
|
57
|
+
raise Error::InvalidOASDocument
|
58
|
+
end
|
59
|
+
|
60
|
+
protected
|
61
|
+
|
62
|
+
#
|
63
|
+
# Loads OpenAPI configuration from YAML
|
64
|
+
#
|
65
|
+
# @return [Hash] The normalized OpenAPI configuration
|
66
|
+
#
|
67
|
+
# @api private
|
68
|
+
#
|
69
|
+
def config
|
70
|
+
@config ||= begin
|
71
|
+
file_extension_glob = "*.{yml,yaml}"
|
72
|
+
base_path = SpecForge.openapi_path.join("config")
|
73
|
+
|
74
|
+
root_paths = base_path.join(file_extension_glob)
|
75
|
+
path_paths = base_path.join("paths", "**", file_extension_glob)
|
76
|
+
component_paths = base_path.join("components", "**", file_extension_glob)
|
77
|
+
|
78
|
+
config = load_yml_from_paths(root_paths).to_merged_h
|
79
|
+
paths_config = load_yml_from_paths(path_paths).to_merged_h
|
80
|
+
component_config = load_yml_from_paths(component_paths).to_merged_h
|
81
|
+
|
82
|
+
(config["paths"] ||= {}).deep_merge!(paths_config)
|
83
|
+
(config["components"] ||= {}).deep_merge!(component_config)
|
84
|
+
|
85
|
+
config
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
def load_yml_from_paths(paths)
|
92
|
+
Dir[paths].map do |path|
|
93
|
+
YAML.safe_load_file(path)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,149 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpecForge
|
4
|
+
module Documentation
|
5
|
+
module Generators
|
6
|
+
module OpenAPI
|
7
|
+
#
|
8
|
+
# Formats OpenAPI validation errors into human-readable messages
|
9
|
+
#
|
10
|
+
# Takes validation errors from OpenAPI parsers and transforms them into
|
11
|
+
# structured, easy-to-understand error messages with context information
|
12
|
+
# and suggestions for resolution.
|
13
|
+
#
|
14
|
+
# @example Formatting validation errors
|
15
|
+
# errors = openapi_parser.errors
|
16
|
+
# formatted = ErrorFormatter.format(errors)
|
17
|
+
# puts formatted
|
18
|
+
#
|
19
|
+
class ErrorFormatter
|
20
|
+
#
|
21
|
+
# Regular expression for matching path-related validation errors
|
22
|
+
#
|
23
|
+
# Captures path, HTTP method, and response code from OpenAPI error contexts
|
24
|
+
# to provide meaningful location information in error messages.
|
25
|
+
#
|
26
|
+
# @api private
|
27
|
+
#
|
28
|
+
PATHS_REGEX = %r{#/paths/(.+?)/(get|post|put|patch|delete|head|options)/responses/(.+)}i
|
29
|
+
|
30
|
+
#
|
31
|
+
# Regular expression for matching schema-related validation errors
|
32
|
+
#
|
33
|
+
# Captures schema name and field path from OpenAPI error contexts
|
34
|
+
# to identify specific schema validation failures.
|
35
|
+
#
|
36
|
+
# @api private
|
37
|
+
#
|
38
|
+
SCHEMA_REGEX = %r{#/components/schemas/(.+?)/(.+)}i
|
39
|
+
|
40
|
+
#
|
41
|
+
# Formats an array of validation errors into a readable string
|
42
|
+
#
|
43
|
+
# @param errors [Array] Array of validation error objects
|
44
|
+
#
|
45
|
+
# @return [String, nil] Formatted error message or nil if no errors
|
46
|
+
#
|
47
|
+
def self.format(errors)
|
48
|
+
new(errors).format
|
49
|
+
end
|
50
|
+
|
51
|
+
#
|
52
|
+
# Creates a new error formatter
|
53
|
+
#
|
54
|
+
# @param errors [Array] Array of validation error objects to format
|
55
|
+
#
|
56
|
+
# @return [ErrorFormatter] A new formatter instance
|
57
|
+
#
|
58
|
+
def initialize(errors)
|
59
|
+
@errors = errors
|
60
|
+
end
|
61
|
+
|
62
|
+
#
|
63
|
+
# Formats the errors into a structured, readable message
|
64
|
+
#
|
65
|
+
# Groups errors by type (unexpected vs validation), formats each error
|
66
|
+
# with context and location information, and returns a comprehensive
|
67
|
+
# error report with resolution guidance.
|
68
|
+
#
|
69
|
+
# @return [String, nil] Formatted error message or nil if no errors
|
70
|
+
#
|
71
|
+
def format
|
72
|
+
return if @errors.blank?
|
73
|
+
|
74
|
+
unexpected_errors, errors = @errors.partition { |e| e.message.include?("Unexpected") }
|
75
|
+
|
76
|
+
unexpected_errors = format_errors(unexpected_errors)
|
77
|
+
errors = format_errors(errors, start_index: unexpected_errors.size)
|
78
|
+
|
79
|
+
if unexpected_errors.size > 0
|
80
|
+
unexpected_message = <<~STRING
|
81
|
+
|
82
|
+
Field errors (resolve these first):
|
83
|
+
|
84
|
+
#{unexpected_errors.join("\n\n")}
|
85
|
+
|
86
|
+
-------
|
87
|
+
|
88
|
+
Other validation errors:
|
89
|
+
STRING
|
90
|
+
end
|
91
|
+
|
92
|
+
<<~STRING
|
93
|
+
========================================
|
94
|
+
🚨 Validation Errors
|
95
|
+
========================================
|
96
|
+
#{unexpected_message}
|
97
|
+
#{errors.join("\n\n")}
|
98
|
+
|
99
|
+
Total errors: #{errors.size}
|
100
|
+
STRING
|
101
|
+
end
|
102
|
+
|
103
|
+
private
|
104
|
+
|
105
|
+
def format_errors(errors, start_index: 0)
|
106
|
+
errors.map.with_index do |error, index|
|
107
|
+
format_single_error(error, start_index + index + 1)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def format_single_error(error, number)
|
112
|
+
context_path = simplify_context_path(error.context.to_s)
|
113
|
+
error_message =
|
114
|
+
<<~STRING
|
115
|
+
Error ##{number}:
|
116
|
+
Message: #{error.message}
|
117
|
+
Location: #{context_path}
|
118
|
+
STRING
|
119
|
+
|
120
|
+
error_message += " Type: #{error.for_type}" if error.for_type
|
121
|
+
error_message
|
122
|
+
end
|
123
|
+
|
124
|
+
def simplify_context_path(context_path)
|
125
|
+
# Clean up the basic encoding mess first
|
126
|
+
path = context_path.gsub(/.*source_location: /, "")
|
127
|
+
.gsub("%7B", "{")
|
128
|
+
.gsub("%7D", "}")
|
129
|
+
.gsub("~1", "/")
|
130
|
+
.gsub("~0", "~")
|
131
|
+
.gsub("%24", "$")
|
132
|
+
|
133
|
+
# Now try to make it human-readable
|
134
|
+
if (match = path.match(PATHS_REGEX))
|
135
|
+
full_path, method, rest = match.captures
|
136
|
+
|
137
|
+
"#{method.upcase} #{full_path} → responses/#{rest}"
|
138
|
+
elsif (match = path.match(SCHEMA_REGEX))
|
139
|
+
schema, rest = match.captures
|
140
|
+
"Schemas → #{schema} → #{rest}"
|
141
|
+
else
|
142
|
+
path
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpecForge
|
4
|
+
module Documentation
|
5
|
+
module Generators
|
6
|
+
module OpenAPI
|
7
|
+
# https://spec.openapis.org/oas/v3.0.4.html
|
8
|
+
class V3_0 < Base # standard:disable Naming/ClassAndModuleCamelCase
|
9
|
+
#
|
10
|
+
# Current OpenAPI 3.0 version supported by this generator
|
11
|
+
#
|
12
|
+
# @api private
|
13
|
+
#
|
14
|
+
CURRENT_VERSION = "3.0.4"
|
15
|
+
|
16
|
+
#
|
17
|
+
# Alias for OpenAPI V3.0 classes for cleaner code
|
18
|
+
#
|
19
|
+
# @api private
|
20
|
+
#
|
21
|
+
OAS = Documentation::OpenAPI::V3_0
|
22
|
+
|
23
|
+
#
|
24
|
+
# Generates an OpenAPI 3.0 specification from the input document
|
25
|
+
#
|
26
|
+
# Creates a complete OpenAPI specification by combining the document's
|
27
|
+
# endpoint data with configuration files and ensuring compliance with
|
28
|
+
# OpenAPI 3.0.4 standards.
|
29
|
+
#
|
30
|
+
# @return [Hash] Complete OpenAPI 3.0 specification
|
31
|
+
#
|
32
|
+
def generate
|
33
|
+
output = {
|
34
|
+
openapi: CURRENT_VERSION,
|
35
|
+
paths:
|
36
|
+
}
|
37
|
+
|
38
|
+
output.deep_stringify_keys!
|
39
|
+
output.deep_merge!(config)
|
40
|
+
|
41
|
+
output
|
42
|
+
end
|
43
|
+
|
44
|
+
#
|
45
|
+
# Transforms document endpoints into OpenAPI paths structure
|
46
|
+
#
|
47
|
+
# Converts the internal endpoint representation into the OpenAPI paths
|
48
|
+
# format, with each path containing operations organized by HTTP method.
|
49
|
+
#
|
50
|
+
# @return [Hash] OpenAPI paths object with operations
|
51
|
+
#
|
52
|
+
def paths
|
53
|
+
paths = input.endpoints.deep_dup
|
54
|
+
|
55
|
+
paths.each do |path, operations|
|
56
|
+
operations.transform_values! do |document|
|
57
|
+
OAS::Operation.new(document).to_h
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "openapi/base"
|
4
|
+
require_relative "openapi/error_formatter"
|
5
|
+
require_relative "openapi/v3_0"
|
6
|
+
|
7
|
+
module SpecForge
|
8
|
+
module Documentation
|
9
|
+
module Generators
|
10
|
+
#
|
11
|
+
# Namespace for OpenAPI generators
|
12
|
+
#
|
13
|
+
# Contains version-specific OpenAPI generators and helper methods
|
14
|
+
# for selecting the appropriate generator.
|
15
|
+
#
|
16
|
+
module OpenAPI
|
17
|
+
#
|
18
|
+
# Current OpenAPI version used as default
|
19
|
+
#
|
20
|
+
# Points to the latest supported OpenAPI version for new specifications.
|
21
|
+
#
|
22
|
+
# @api private
|
23
|
+
#
|
24
|
+
CURRENT_VERSION = V3_0::CURRENT_VERSION
|
25
|
+
|
26
|
+
#
|
27
|
+
# Mapping of OpenAPI versions to their generator classes
|
28
|
+
#
|
29
|
+
# Used for version selection when generating OpenAPI documentation.
|
30
|
+
# Keys are SemVersion objects, values are generator classes.
|
31
|
+
#
|
32
|
+
# @api private
|
33
|
+
#
|
34
|
+
VERSIONS = {
|
35
|
+
V3_0.to_sem_version => V3_0
|
36
|
+
}.freeze
|
37
|
+
|
38
|
+
#
|
39
|
+
# Selects an OpenAPI generator by version
|
40
|
+
#
|
41
|
+
# @param version [String] OpenAPI version (e.g., "3.0")
|
42
|
+
#
|
43
|
+
# @return [Class] The appropriate generator class
|
44
|
+
# @raise [ArgumentError] If the version is not supported
|
45
|
+
#
|
46
|
+
def self.[](version)
|
47
|
+
version = SemVersion.from_loose_version(version)
|
48
|
+
generator = VERSIONS.value_where { |k, _v| k.satisfies?("~> #{version}") }
|
49
|
+
|
50
|
+
if generator.nil?
|
51
|
+
raise ArgumentError, "Invalid OpenAPI version provided: #{version.to_s.in_quotes}"
|
52
|
+
end
|
53
|
+
|
54
|
+
generator
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpecForge
|
4
|
+
module Documentation
|
5
|
+
#
|
6
|
+
# Documentation rendering functionality
|
7
|
+
#
|
8
|
+
# Contains generator classes for transforming SpecForge documents
|
9
|
+
# into various output formats like OpenAPI specifications.
|
10
|
+
#
|
11
|
+
module Generators
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
require_relative "generators/base"
|
17
|
+
require_relative "generators/openapi"
|
@@ -0,0 +1,138 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpecForge
|
4
|
+
module Documentation
|
5
|
+
class Loader
|
6
|
+
#
|
7
|
+
# Manages caching of test execution data for documentation generation
|
8
|
+
#
|
9
|
+
# Provides caching of endpoint data extracted from tests,
|
10
|
+
# checking file modification times to determine cache validity and
|
11
|
+
# avoiding unnecessary test re-execution when specs haven't changed.
|
12
|
+
#
|
13
|
+
# @example Using the cache
|
14
|
+
# cache = Cache.new
|
15
|
+
# if cache.valid?
|
16
|
+
# endpoints = cache.read
|
17
|
+
# else
|
18
|
+
# endpoints = run_tests_and_extract_data
|
19
|
+
# cache.create(endpoints)
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
class Cache
|
23
|
+
#
|
24
|
+
# Creates a new cache manager
|
25
|
+
#
|
26
|
+
# Sets up file paths for endpoint and spec caches in the OpenAPI
|
27
|
+
# generated directory structure.
|
28
|
+
#
|
29
|
+
# @return [Cache] A new cache instance
|
30
|
+
#
|
31
|
+
def initialize
|
32
|
+
@endpoint_cache = SpecForge.openapi_path.join("generated", ".cache", "endpoints.yml")
|
33
|
+
@spec_cache = SpecForge.openapi_path.join("generated", ".cache", "specs.yml")
|
34
|
+
end
|
35
|
+
|
36
|
+
#
|
37
|
+
# Checks if the cache is valid and can be used
|
38
|
+
#
|
39
|
+
# Determines cache validity by checking if endpoint cache exists
|
40
|
+
# and whether any spec files have been modified since the cache
|
41
|
+
# was created.
|
42
|
+
#
|
43
|
+
# @return [Boolean] true if cache is valid and can be used
|
44
|
+
#
|
45
|
+
def valid?
|
46
|
+
endpoint_cache? && !specs_updated?
|
47
|
+
end
|
48
|
+
|
49
|
+
#
|
50
|
+
# Creates a cache entry with endpoint data and spec file metadata
|
51
|
+
#
|
52
|
+
# Writes both the endpoint data and current spec file modification times
|
53
|
+
# to enable cache invalidation when specs change.
|
54
|
+
#
|
55
|
+
# @param endpoints [Array<Hash>] Endpoint data to cache
|
56
|
+
#
|
57
|
+
# @return [void]
|
58
|
+
#
|
59
|
+
def create(endpoints)
|
60
|
+
write_spec_cache
|
61
|
+
write(endpoints)
|
62
|
+
end
|
63
|
+
|
64
|
+
#
|
65
|
+
# Writes endpoint data to the cache file
|
66
|
+
#
|
67
|
+
# @param endpoints [Array<Hash>] Endpoint data to write
|
68
|
+
#
|
69
|
+
# @return [void]
|
70
|
+
#
|
71
|
+
def write(endpoints)
|
72
|
+
write_to_file(endpoints, @endpoint_cache)
|
73
|
+
end
|
74
|
+
|
75
|
+
#
|
76
|
+
# Reads cached endpoint data from disk
|
77
|
+
#
|
78
|
+
# @return [Array<Hash>] Previously cached endpoint data
|
79
|
+
#
|
80
|
+
# @raise [StandardError] If cache file is missing or corrupted
|
81
|
+
#
|
82
|
+
def read
|
83
|
+
read_from_file(@endpoint_cache)
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
def write_to_file(data, path)
|
89
|
+
File.write(path, data.to_yaml(stringify_names: true))
|
90
|
+
end
|
91
|
+
|
92
|
+
def read_from_file(path)
|
93
|
+
YAML.safe_load_file(path, symbolize_names: true, permitted_classes: [Symbol, Time])
|
94
|
+
end
|
95
|
+
|
96
|
+
def specs_updated?
|
97
|
+
return true if !File.exist?(@spec_cache)
|
98
|
+
|
99
|
+
cache = read_from_file(@spec_cache)
|
100
|
+
new_cache = generate_spec_cache
|
101
|
+
|
102
|
+
different?(cache, new_cache)
|
103
|
+
end
|
104
|
+
|
105
|
+
def endpoint_cache?
|
106
|
+
File.exist?(@endpoint_cache)
|
107
|
+
end
|
108
|
+
|
109
|
+
def generate_spec_cache
|
110
|
+
paths = SpecForge.forge_path.join("specs", "**", "*.{yml,yaml}")
|
111
|
+
|
112
|
+
Dir[paths].each_with_object({}) do |path, hash|
|
113
|
+
hash[path.to_sym] = File.mtime(path)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def write_spec_cache
|
118
|
+
data = generate_spec_cache
|
119
|
+
write_to_file(data, @spec_cache)
|
120
|
+
end
|
121
|
+
|
122
|
+
def different?(cache_left, cache_right)
|
123
|
+
# The number of files changed
|
124
|
+
return true if cache_left.size != cache_right.size
|
125
|
+
|
126
|
+
default_time = Time.now
|
127
|
+
|
128
|
+
# Check if any of the files have changed since last time
|
129
|
+
cache_left.any? do |path, time_left|
|
130
|
+
time_right = cache_right[path] || default_time
|
131
|
+
|
132
|
+
time_left != time_right
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
@@ -0,0 +1,159 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "loader/cache"
|
4
|
+
|
5
|
+
module SpecForge
|
6
|
+
module Documentation
|
7
|
+
#
|
8
|
+
# Extracts API documentation data from SpecForge tests
|
9
|
+
#
|
10
|
+
# This class runs all tests and captures successful test contexts
|
11
|
+
# to extract endpoint information, including request/response data.
|
12
|
+
#
|
13
|
+
# @example Extracting documentation data
|
14
|
+
# endpoints = Loader.extract_from_tests
|
15
|
+
#
|
16
|
+
class Loader
|
17
|
+
#
|
18
|
+
# Loads a documentation document with optional caching
|
19
|
+
#
|
20
|
+
# Extracts endpoint data from SpecForge tests, either from cache (if valid
|
21
|
+
# and requested) or by running fresh tests. Returns a structured document
|
22
|
+
# ready for generator consumption.
|
23
|
+
#
|
24
|
+
# @param use_cache [Boolean] Whether to use cached data if available
|
25
|
+
#
|
26
|
+
# @return [Document] Structured document containing endpoint data
|
27
|
+
#
|
28
|
+
def self.load_document(use_cache: false)
|
29
|
+
cache = Cache.new
|
30
|
+
|
31
|
+
endpoints =
|
32
|
+
if use_cache && cache.valid?
|
33
|
+
puts "Loading from cache..."
|
34
|
+
cache.read
|
35
|
+
else
|
36
|
+
puts "Cache invalid - Regenerating..." if use_cache
|
37
|
+
|
38
|
+
endpoints = extract_from_tests
|
39
|
+
cache.create(endpoints)
|
40
|
+
|
41
|
+
endpoints
|
42
|
+
end
|
43
|
+
|
44
|
+
Builder.document_from_endpoints(endpoints)
|
45
|
+
end
|
46
|
+
|
47
|
+
#
|
48
|
+
# Runs tests and extracts endpoint data
|
49
|
+
#
|
50
|
+
# @return [Array<Hash>] Extracted endpoint data from successful tests
|
51
|
+
#
|
52
|
+
def self.extract_from_tests
|
53
|
+
new
|
54
|
+
.run_tests
|
55
|
+
.extract_and_normalize_data
|
56
|
+
end
|
57
|
+
|
58
|
+
#
|
59
|
+
# Initializes a new loader
|
60
|
+
#
|
61
|
+
# Sets up a unique callback and prepares storage for successful test results
|
62
|
+
#
|
63
|
+
# @return [Loader] A new loader instance
|
64
|
+
#
|
65
|
+
def initialize
|
66
|
+
@callback_name = "__sf_docs_#{SpecForge.generate_id(self)}"
|
67
|
+
@successes = []
|
68
|
+
end
|
69
|
+
|
70
|
+
#
|
71
|
+
# Runs all tests and captures successful test results
|
72
|
+
#
|
73
|
+
# Registers a callback to capture test context and runs all tests
|
74
|
+
#
|
75
|
+
# @return [self] Returns self for method chaining
|
76
|
+
#
|
77
|
+
def run_tests
|
78
|
+
@successes.clear
|
79
|
+
|
80
|
+
Callbacks.register(@callback_name) do |context|
|
81
|
+
next if context.expectation.documentation == false || context.spec.documentation == false
|
82
|
+
|
83
|
+
@successes << context if context.example.execution_result.status == :passed
|
84
|
+
end
|
85
|
+
|
86
|
+
forges = prepare_forges
|
87
|
+
|
88
|
+
Runner.run(forges, exit_on_finish: false, exit_on_failure: true)
|
89
|
+
|
90
|
+
self
|
91
|
+
ensure
|
92
|
+
Callbacks.deregister(@callback_name)
|
93
|
+
end
|
94
|
+
|
95
|
+
#
|
96
|
+
# Prepares forge objects for test execution
|
97
|
+
#
|
98
|
+
# Adds the documentation callback to each forge
|
99
|
+
#
|
100
|
+
# @return [Array<Forge>] Array of prepared forge objects
|
101
|
+
#
|
102
|
+
def prepare_forges
|
103
|
+
forges = Runner.prepare
|
104
|
+
|
105
|
+
forges.each do |forge|
|
106
|
+
forge.global[:callbacks] << {after_each: @callback_name}
|
107
|
+
end
|
108
|
+
|
109
|
+
forges
|
110
|
+
end
|
111
|
+
|
112
|
+
#
|
113
|
+
# Extracts and normalizes endpoint data from test results
|
114
|
+
#
|
115
|
+
# @return [Array<Hash>] Normalized endpoint data
|
116
|
+
#
|
117
|
+
def extract_and_normalize_data
|
118
|
+
@successes.map { |d| extract_endpoint(d) }
|
119
|
+
end
|
120
|
+
|
121
|
+
private
|
122
|
+
|
123
|
+
def extract_endpoint(context)
|
124
|
+
request_hash = context.request.to_h
|
125
|
+
response_hash = context.response.to_hash
|
126
|
+
|
127
|
+
# Only pull the headers that the user explicitly checked for.
|
128
|
+
# This keeps the extra unrelated headers from being included
|
129
|
+
response_headers = context.expectation
|
130
|
+
.constraints
|
131
|
+
.headers
|
132
|
+
.keys
|
133
|
+
.map { |h| h.to_s.downcase }
|
134
|
+
|
135
|
+
response_headers = response_hash[:response_headers].slice(*response_headers)
|
136
|
+
|
137
|
+
{
|
138
|
+
# Metadata
|
139
|
+
spec_name: context.spec.name,
|
140
|
+
expectation_name: context.expectation.name,
|
141
|
+
|
142
|
+
# Request data
|
143
|
+
base_url: request_hash[:base_url],
|
144
|
+
url: request_hash[:url],
|
145
|
+
http_verb: request_hash[:http_verb],
|
146
|
+
content_type: request_hash[:content_type],
|
147
|
+
request_body: request_hash[:body],
|
148
|
+
request_headers: request_hash[:headers],
|
149
|
+
request_query: request_hash[:query],
|
150
|
+
|
151
|
+
# Response data
|
152
|
+
response_status: response_hash[:status],
|
153
|
+
response_body: response_hash[:body],
|
154
|
+
response_headers:
|
155
|
+
}
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|