spec_forge 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.standard.yml +3 -3
- data/CHANGELOG.md +217 -2
- data/README.md +162 -25
- data/flake.lock +3 -3
- data/flake.nix +11 -5
- data/lib/spec_forge/attribute/chainable.rb +208 -20
- data/lib/spec_forge/attribute/factory.rb +92 -15
- data/lib/spec_forge/attribute/faker.rb +62 -13
- data/lib/spec_forge/attribute/global.rb +96 -0
- data/lib/spec_forge/attribute/literal.rb +15 -2
- data/lib/spec_forge/attribute/matcher.rb +186 -11
- data/lib/spec_forge/attribute/parameterized.rb +45 -12
- data/lib/spec_forge/attribute/regex.rb +55 -5
- data/lib/spec_forge/attribute/resolvable.rb +48 -5
- data/lib/spec_forge/attribute/resolvable_array.rb +62 -4
- data/lib/spec_forge/attribute/resolvable_hash.rb +62 -4
- data/lib/spec_forge/attribute/store.rb +65 -0
- data/lib/spec_forge/attribute/transform.rb +33 -5
- data/lib/spec_forge/attribute/variable.rb +37 -6
- data/lib/spec_forge/attribute.rb +166 -66
- data/lib/spec_forge/backtrace_formatter.rb +26 -3
- data/lib/spec_forge/callbacks.rb +88 -0
- data/lib/spec_forge/cli/actions.rb +27 -0
- data/lib/spec_forge/cli/command.rb +78 -24
- data/lib/spec_forge/cli/docs/generate.rb +72 -0
- data/lib/spec_forge/cli/docs.rb +92 -0
- data/lib/spec_forge/cli/init.rb +51 -9
- data/lib/spec_forge/cli/new.rb +67 -6
- data/lib/spec_forge/cli/run.rb +32 -4
- data/lib/spec_forge/cli/serve.rb +155 -0
- data/lib/spec_forge/cli.rb +26 -7
- data/lib/spec_forge/configuration.rb +96 -24
- data/lib/spec_forge/context/callbacks.rb +91 -0
- data/lib/spec_forge/context/global.rb +72 -0
- data/lib/spec_forge/context/store.rb +131 -0
- data/lib/spec_forge/context/variables.rb +91 -0
- data/lib/spec_forge/context.rb +36 -0
- data/lib/spec_forge/core_ext/array.rb +27 -0
- data/lib/spec_forge/core_ext/rspec.rb +22 -4
- data/lib/spec_forge/documentation/builder.rb +383 -0
- data/lib/spec_forge/documentation/document/operation.rb +47 -0
- data/lib/spec_forge/documentation/document/parameter.rb +22 -0
- data/lib/spec_forge/documentation/document/request_body.rb +24 -0
- data/lib/spec_forge/documentation/document/response.rb +39 -0
- data/lib/spec_forge/documentation/document/response_body.rb +27 -0
- data/lib/spec_forge/documentation/document.rb +48 -0
- data/lib/spec_forge/documentation/generators/base.rb +81 -0
- data/lib/spec_forge/documentation/generators/openapi/base.rb +100 -0
- data/lib/spec_forge/documentation/generators/openapi/error_formatter.rb +149 -0
- data/lib/spec_forge/documentation/generators/openapi/v3_0.rb +65 -0
- data/lib/spec_forge/documentation/generators/openapi.rb +59 -0
- data/lib/spec_forge/documentation/generators.rb +17 -0
- data/lib/spec_forge/documentation/loader/cache.rb +138 -0
- data/lib/spec_forge/documentation/loader.rb +159 -0
- data/lib/spec_forge/documentation/openapi/base.rb +33 -0
- data/lib/spec_forge/documentation/openapi/v3_0/example.rb +44 -0
- data/lib/spec_forge/documentation/openapi/v3_0/media_type.rb +42 -0
- data/lib/spec_forge/documentation/openapi/v3_0/operation.rb +175 -0
- data/lib/spec_forge/documentation/openapi/v3_0/response.rb +65 -0
- data/lib/spec_forge/documentation/openapi/v3_0/schema.rb +80 -0
- data/lib/spec_forge/documentation/openapi/v3_0/tag.rb +71 -0
- data/lib/spec_forge/documentation/openapi.rb +23 -0
- data/lib/spec_forge/documentation.rb +27 -0
- data/lib/spec_forge/error.rb +284 -113
- data/lib/spec_forge/factory.rb +35 -16
- data/lib/spec_forge/filter.rb +86 -0
- data/lib/spec_forge/forge.rb +171 -0
- data/lib/spec_forge/http/backend.rb +101 -29
- data/lib/spec_forge/http/client.rb +23 -13
- data/lib/spec_forge/http/request.rb +85 -62
- data/lib/spec_forge/http/verb.rb +79 -0
- data/lib/spec_forge/http.rb +105 -0
- data/lib/spec_forge/loader.rb +244 -0
- data/lib/spec_forge/matchers.rb +130 -0
- data/lib/spec_forge/normalizer/default.rb +51 -0
- data/lib/spec_forge/normalizer/definition.rb +248 -0
- data/lib/spec_forge/normalizer/validators.rb +99 -0
- data/lib/spec_forge/normalizer.rb +486 -115
- data/lib/spec_forge/normalizers/_shared.yml +74 -0
- data/lib/spec_forge/normalizers/configuration.yml +23 -0
- data/lib/spec_forge/normalizers/constraint.yml +8 -0
- data/lib/spec_forge/normalizers/expectation.yml +47 -0
- data/lib/spec_forge/normalizers/factory.yml +12 -0
- data/lib/spec_forge/normalizers/factory_reference.yml +15 -0
- data/lib/spec_forge/normalizers/global_context.yml +28 -0
- data/lib/spec_forge/normalizers/spec.yml +50 -0
- data/lib/spec_forge/runner/adapter.rb +183 -0
- data/lib/spec_forge/runner/callbacks.rb +246 -0
- data/lib/spec_forge/runner/debug_proxy.rb +213 -0
- data/lib/spec_forge/runner/listener.rb +54 -0
- data/lib/spec_forge/runner/metadata.rb +58 -0
- data/lib/spec_forge/runner/state.rb +98 -0
- data/lib/spec_forge/runner.rb +50 -125
- data/lib/spec_forge/spec/expectation/constraint.rb +100 -21
- data/lib/spec_forge/spec/expectation.rb +47 -51
- data/lib/spec_forge/spec.rb +50 -108
- data/lib/spec_forge/type.rb +36 -4
- data/lib/spec_forge/version.rb +4 -1
- data/lib/spec_forge.rb +168 -76
- data/lib/templates/openapi.yml.tt +22 -0
- data/lib/templates/redoc.html.tt +28 -0
- data/lib/templates/swagger.html.tt +59 -0
- metadata +109 -16
- data/lib/spec_forge/normalizer/configuration.rb +0 -77
- data/lib/spec_forge/normalizer/constraint.rb +0 -47
- data/lib/spec_forge/normalizer/expectation.rb +0 -86
- data/lib/spec_forge/normalizer/factory.rb +0 -65
- data/lib/spec_forge/normalizer/factory_reference.rb +0 -71
- data/lib/spec_forge/normalizer/spec.rb +0 -74
- data/spec_forge/factories/user.yml +0 -4
- data/spec_forge/forge_helper.rb +0 -48
- data/spec_forge/specs/users.yml +0 -65
- /data/lib/templates/{forge_helper.tt → forge_helper.rb.tt} +0 -0
- /data/lib/templates/{new_factory.tt → new_factory.yml.tt} +0 -0
- /data/lib/templates/{new_spec.tt → new_spec.yml.tt} +0 -0
@@ -0,0 +1,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
|
@@ -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"
|