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
@@ -2,88 +2,111 @@
|
|
2
2
|
|
3
3
|
module SpecForge
|
4
4
|
module HTTP
|
5
|
-
|
6
|
-
|
5
|
+
#
|
6
|
+
# The attributes used to build a Request
|
7
|
+
#
|
8
|
+
# @return [Array<Symbol>]
|
9
|
+
#
|
10
|
+
REQUEST_ATTRIBUTES = %i[
|
11
|
+
base_url
|
12
|
+
url
|
13
|
+
http_verb
|
14
|
+
content_type
|
15
|
+
headers
|
16
|
+
query
|
17
|
+
body
|
18
|
+
].freeze
|
7
19
|
|
20
|
+
#
|
21
|
+
# Represents an HTTP request configuration
|
22
|
+
#
|
23
|
+
# This data object contains all the necessary information to construct
|
24
|
+
# an HTTP request, including URL, method, headers, query params, and body.
|
25
|
+
#
|
26
|
+
# @example Creating a request
|
27
|
+
# request = HTTP::Request.new(
|
28
|
+
# base_url: "https://api.example.com",
|
29
|
+
# url: "/users",
|
30
|
+
# http_verb: "GET",
|
31
|
+
# headers: {"Content-Type" => "application/json"},
|
32
|
+
# query: {page: 1},
|
33
|
+
# body: {}
|
34
|
+
# )
|
35
|
+
#
|
36
|
+
class Request < Data.define(*REQUEST_ATTRIBUTES)
|
8
37
|
#
|
9
|
-
#
|
10
|
-
#
|
11
|
-
# @param [Hash] options The options to create the Request with
|
38
|
+
# Regex that attempts to match a valid header
|
12
39
|
#
|
13
|
-
# @
|
40
|
+
# @return [Regexp]
|
14
41
|
#
|
15
|
-
|
42
|
+
HEADER = /^[A-Z][A-Za-z0-9!-]*$/
|
43
|
+
|
16
44
|
#
|
17
|
-
#
|
45
|
+
# Creates a new Request with standardized headers and values
|
18
46
|
#
|
19
|
-
# @
|
47
|
+
# @param base_url [String, nil] The base URL for the request
|
48
|
+
# @param url [String, nil] The path portion of the URL
|
49
|
+
# @param http_verb [String, Symbol, nil] The HTTP method (GET, POST, etc.)
|
50
|
+
# @param headers [Hash, nil] HTTP headers for the request
|
51
|
+
# @param query [Hash, nil] Query parameters to include
|
52
|
+
# @param body [Hash, nil] Request body data
|
20
53
|
#
|
21
|
-
# @
|
54
|
+
# @return [Request] A new immutable request object
|
22
55
|
#
|
23
56
|
def initialize(**options)
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
57
|
+
base_url = options[:base_url] || ""
|
58
|
+
url = options[:url] || ""
|
59
|
+
http_verb = Verb.from(options[:http_verb].presence || "GET")
|
60
|
+
query = Attribute.from(options[:query] || {})
|
61
|
+
body = Attribute.from(options[:body] || {})
|
62
|
+
headers = normalize_headers(options[:headers] || {})
|
63
|
+
content_type = "application/json"
|
30
64
|
|
31
|
-
super(base_url:, url:,
|
32
|
-
end
|
33
|
-
|
34
|
-
def http_verb
|
35
|
-
http_method.name.downcase
|
65
|
+
super(base_url:, url:, http_verb:, content_type:, headers:, query:, body:)
|
36
66
|
end
|
37
67
|
|
68
|
+
#
|
69
|
+
# Returns a hash representation with all attributes fully resolved
|
70
|
+
#
|
71
|
+
# @return [Hash] The request data with all dynamic values resolved
|
72
|
+
#
|
38
73
|
def to_h
|
39
|
-
super.transform_values { |v| v.respond_to?(:
|
74
|
+
hash = super.transform_values { |v| v.respond_to?(:resolved) ? v.resolved : v }
|
75
|
+
hash[:http_verb] = hash[:http_verb].to_s
|
76
|
+
hash
|
40
77
|
end
|
41
78
|
|
42
79
|
private
|
43
80
|
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
def normalize_headers(options)
|
63
|
-
headers = options[:headers].transform_keys do |key|
|
64
|
-
key = key.to_s
|
81
|
+
#
|
82
|
+
# Normalizes HTTP header keys to standard format
|
83
|
+
#
|
84
|
+
# Converts snake_case and other formats to HTTP Header-Case format
|
85
|
+
# Examples:
|
86
|
+
# content_type -> Content-Type
|
87
|
+
# api_key -> Api-Key
|
88
|
+
#
|
89
|
+
# @param headers [Hash] The headers to normalize
|
90
|
+
#
|
91
|
+
# @return [Attribute::ResolvableHash] Normalized headers as attributes
|
92
|
+
#
|
93
|
+
# @private
|
94
|
+
#
|
95
|
+
def normalize_headers(headers)
|
96
|
+
headers =
|
97
|
+
headers.transform_keys do |key|
|
98
|
+
key = key.to_s
|
65
99
|
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
100
|
+
# If the key is already like a header, don't change it
|
101
|
+
if key.match?(HEADER)
|
102
|
+
key
|
103
|
+
else
|
104
|
+
# content_type => Content-Type
|
105
|
+
key.downcase.titleize.gsub(/\s+/, "-")
|
106
|
+
end
|
72
107
|
end
|
73
|
-
end
|
74
|
-
|
75
|
-
headers = Attribute.bind_variables(headers, options[:variables])
|
76
|
-
Attribute::ResolvableHash.new(headers)
|
77
|
-
end
|
78
|
-
|
79
|
-
def normalize_query(options)
|
80
|
-
query = Attribute.bind_variables(options[:query], options[:variables])
|
81
|
-
Attribute::ResolvableHash.new(query)
|
82
|
-
end
|
83
108
|
|
84
|
-
|
85
|
-
body = Attribute.bind_variables(options[:body], options[:variables])
|
86
|
-
Attribute::ResolvableHash.new(body)
|
109
|
+
Attribute.from(headers)
|
87
110
|
end
|
88
111
|
end
|
89
112
|
end
|
data/lib/spec_forge/http/verb.rb
CHANGED
@@ -2,33 +2,112 @@
|
|
2
2
|
|
3
3
|
module SpecForge
|
4
4
|
module HTTP
|
5
|
+
#
|
6
|
+
# Represents an HTTP verb (method)
|
7
|
+
#
|
8
|
+
# This class provides a type-safe way to work with HTTP methods,
|
9
|
+
# with predefined constants for common verbs like GET, POST, etc.
|
10
|
+
#
|
11
|
+
# @example Using predefined verbs
|
12
|
+
# HTTP::Verb::GET # => #<HTTP::Verb::Get @name="GET">
|
13
|
+
# HTTP::Verb::POST # => #<HTTP::Verb::Post @name="POST">
|
14
|
+
#
|
15
|
+
# @example Checking verb types
|
16
|
+
# verb = HTTP::Verb::POST
|
17
|
+
# verb.post? # => true
|
18
|
+
# verb.get? # => false
|
19
|
+
#
|
5
20
|
class Verb < Data.define(:name)
|
21
|
+
#
|
22
|
+
# Represents the HTTP DELETE method
|
23
|
+
#
|
24
|
+
# @return [Delete] A DELETE verb instance
|
25
|
+
#
|
6
26
|
class Delete < Verb
|
7
27
|
def initialize = super(name: "DELETE")
|
8
28
|
end
|
9
29
|
|
30
|
+
#
|
31
|
+
# Represents the HTTP GET method
|
32
|
+
#
|
33
|
+
# @return [Get] A GET verb instance
|
34
|
+
#
|
10
35
|
class Get < Verb
|
11
36
|
def initialize = super(name: "GET")
|
12
37
|
end
|
13
38
|
|
39
|
+
#
|
40
|
+
# Represents the HTTP PATCH method
|
41
|
+
#
|
42
|
+
# @return [Patch] A PATCH verb instance
|
43
|
+
#
|
14
44
|
class Patch < Verb
|
15
45
|
def initialize = super(name: "PATCH")
|
16
46
|
end
|
17
47
|
|
48
|
+
#
|
49
|
+
# Represents the HTTP POST method
|
50
|
+
#
|
51
|
+
# @return [Post] A POST verb instance
|
52
|
+
#
|
18
53
|
class Post < Verb
|
19
54
|
def initialize = super(name: "POST")
|
20
55
|
end
|
21
56
|
|
57
|
+
#
|
58
|
+
# Represents the HTTP PUT method
|
59
|
+
#
|
60
|
+
# @return [Put] A PUT verb instance
|
61
|
+
#
|
22
62
|
class Put < Verb
|
23
63
|
def initialize = super(name: "PUT")
|
24
64
|
end
|
25
65
|
|
66
|
+
#
|
67
|
+
# A predefined DELETE verb instance for HTTP method usage
|
68
|
+
#
|
69
|
+
# @return [Verb::Delete] A singleton instance representing the HTTP DELETE method
|
70
|
+
# @see Verb
|
71
|
+
#
|
26
72
|
DELETE = Delete.new
|
73
|
+
|
74
|
+
#
|
75
|
+
# A predefined GET verb instance for HTTP method usage
|
76
|
+
#
|
77
|
+
# @return [Verb::Get] A singleton instance representing the HTTP GET method
|
78
|
+
# @see Verb
|
79
|
+
#
|
27
80
|
GET = Get.new
|
81
|
+
|
82
|
+
#
|
83
|
+
# A predefined PATCH verb instance for HTTP method usage
|
84
|
+
#
|
85
|
+
# @return [Verb::Patch] A singleton instance representing the HTTP PATCH method
|
86
|
+
# @see Verb
|
87
|
+
#
|
28
88
|
PATCH = Patch.new
|
89
|
+
|
90
|
+
#
|
91
|
+
# A predefined POST verb instance for HTTP method usage
|
92
|
+
#
|
93
|
+
# @return [Verb::Post] A singleton instance representing the HTTP POST method
|
94
|
+
# @see Verb
|
95
|
+
#
|
29
96
|
POST = Post.new
|
97
|
+
|
98
|
+
#
|
99
|
+
# A predefined PUT verb instance for HTTP method usage
|
100
|
+
#
|
101
|
+
# @return [Verb::Put] A singleton instance representing the HTTP PUT method
|
102
|
+
# @see Verb
|
103
|
+
#
|
30
104
|
PUT = Put.new
|
31
105
|
|
106
|
+
#
|
107
|
+
# All HTTP verbs as a lookup hash
|
108
|
+
#
|
109
|
+
# @return [Hash<Symbol, Verb>]
|
110
|
+
#
|
32
111
|
VERBS = {
|
33
112
|
delete: DELETE,
|
34
113
|
get: GET,
|
data/lib/spec_forge/http.rb
CHANGED
@@ -1,5 +1,110 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
module SpecForge
|
4
|
+
#
|
5
|
+
# HTTP module providing request and response handling for API testing
|
6
|
+
#
|
7
|
+
# This module contains the HTTP client, request object, and other components
|
8
|
+
# needed to make API calls and validate responses against expectations.
|
9
|
+
#
|
10
|
+
module HTTP
|
11
|
+
#
|
12
|
+
# A mapping of HTTP status codes to their standard descriptions
|
13
|
+
#
|
14
|
+
# This constant provides a lookup table of common HTTP status codes with their
|
15
|
+
# official descriptions according to HTTP specifications. Used internally
|
16
|
+
# to generate human-readable test output.
|
17
|
+
#
|
18
|
+
# @example Looking up a status code description
|
19
|
+
# HTTP::STATUS_DESCRIPTIONS[200] # => "OK"
|
20
|
+
# HTTP::STATUS_DESCRIPTIONS[404] # => "Not Found"
|
21
|
+
#
|
22
|
+
STATUS_DESCRIPTIONS = {
|
23
|
+
# Success codes
|
24
|
+
200 => "OK",
|
25
|
+
201 => "Created",
|
26
|
+
202 => "Accepted",
|
27
|
+
204 => "No Content",
|
28
|
+
|
29
|
+
# Redirection
|
30
|
+
301 => "Moved Permanently",
|
31
|
+
302 => "Found",
|
32
|
+
304 => "Not Modified",
|
33
|
+
307 => "Temporary Redirect",
|
34
|
+
308 => "Permanent Redirect",
|
35
|
+
|
36
|
+
# Client errors
|
37
|
+
400 => "Bad Request",
|
38
|
+
401 => "Unauthorized",
|
39
|
+
403 => "Forbidden",
|
40
|
+
404 => "Not Found",
|
41
|
+
405 => "Method Not Allowed",
|
42
|
+
406 => "Not Acceptable",
|
43
|
+
407 => "Proxy Authentication Required",
|
44
|
+
409 => "Conflict",
|
45
|
+
410 => "Gone",
|
46
|
+
411 => "Length Required",
|
47
|
+
413 => "Payload Too Large",
|
48
|
+
414 => "URI Too Long",
|
49
|
+
415 => "Unsupported Media Type",
|
50
|
+
421 => "Misdirected Request",
|
51
|
+
422 => "Unprocessable Content",
|
52
|
+
423 => "Locked",
|
53
|
+
424 => "Failed Dependency",
|
54
|
+
428 => "Precondition Required",
|
55
|
+
429 => "Too Many Requests",
|
56
|
+
431 => "Request Header Fields Too Large",
|
57
|
+
|
58
|
+
# Server errors
|
59
|
+
500 => "Internal Server Error",
|
60
|
+
501 => "Not Implemented",
|
61
|
+
502 => "Bad Gateway",
|
62
|
+
503 => "Service Unavailable",
|
63
|
+
504 => "Gateway Timeout"
|
64
|
+
}
|
65
|
+
|
66
|
+
#
|
67
|
+
# Converts an HTTP status code to a human-readable description
|
68
|
+
#
|
69
|
+
# Takes a numeric status code and returns a formatted string containing both
|
70
|
+
# the code and its description. Uses predefined descriptions for common codes,
|
71
|
+
# with fallbacks to category-based descriptions for uncommon codes.
|
72
|
+
#
|
73
|
+
# @param code [Integer, String] The HTTP status code to convert
|
74
|
+
#
|
75
|
+
# @return [String] A formatted description string (e.g., "200 OK", "404 Not Found")
|
76
|
+
#
|
77
|
+
# @example Common status codes
|
78
|
+
# HTTP.status_code_to_description(200) # => "200 OK"
|
79
|
+
# HTTP.status_code_to_description(404) # => "404 Not Found"
|
80
|
+
#
|
81
|
+
# @example Fallback descriptions for uncommon codes
|
82
|
+
# HTTP.status_code_to_description(299) # => "299 Success"
|
83
|
+
# HTTP.status_code_to_description(499) # => "499 Client Error"
|
84
|
+
#
|
85
|
+
def self.status_code_to_description(code)
|
86
|
+
code = code.to_i
|
87
|
+
description = STATUS_DESCRIPTIONS[code]
|
88
|
+
return "#{code} #{description}" if description
|
89
|
+
|
90
|
+
# Fallbacks by range
|
91
|
+
if code >= 100 && code < 200
|
92
|
+
"#{code} Informational"
|
93
|
+
elsif code >= 200 && code < 300
|
94
|
+
"#{code} Success"
|
95
|
+
elsif code >= 300 && code < 400
|
96
|
+
"#{code} Redirection"
|
97
|
+
elsif code >= 400 && code < 500
|
98
|
+
"#{code} Client Error"
|
99
|
+
elsif code >= 500 && code < 600
|
100
|
+
"#{code} Server Error"
|
101
|
+
else
|
102
|
+
code.to_s
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
3
108
|
require_relative "http/backend"
|
4
109
|
require_relative "http/client"
|
5
110
|
require_relative "http/verb"
|
@@ -0,0 +1,244 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SpecForge
|
4
|
+
#
|
5
|
+
# Responsible for loading specs from YAML files and converting them to testable objects
|
6
|
+
#
|
7
|
+
# The Loader reads spec files, parses them as YAML, and transforms them into
|
8
|
+
# a structure that can be used to create Forge objects. It also extracts
|
9
|
+
# metadata like line numbers for error reporting.
|
10
|
+
#
|
11
|
+
# @example Loading all specs
|
12
|
+
# specs = Loader.load_from_files
|
13
|
+
#
|
14
|
+
class Loader
|
15
|
+
class << self
|
16
|
+
#
|
17
|
+
# Loads all spec YAML files and transforms them into normalized structures
|
18
|
+
#
|
19
|
+
# @return [Array<Array>] Array of [global, metadata, specs] for each loaded file
|
20
|
+
#
|
21
|
+
def load_from_files
|
22
|
+
# metadata is not normalized because its not user managed
|
23
|
+
load_specs_from_files.map do |global, metadata, specs|
|
24
|
+
global =
|
25
|
+
begin
|
26
|
+
Normalizer.normalize!(global, using: :global_context)
|
27
|
+
rescue => e
|
28
|
+
raise Error::SpecLoadError.new(e, metadata[:relative_path])
|
29
|
+
end
|
30
|
+
|
31
|
+
specs =
|
32
|
+
specs.map do |spec|
|
33
|
+
Normalizer.normalize!(spec, using: :spec, label: "spec \"#{spec[:name]}\"")
|
34
|
+
rescue => e
|
35
|
+
raise Error::SpecLoadError.new(e, metadata[:relative_path], spec:)
|
36
|
+
end
|
37
|
+
|
38
|
+
[global, metadata, specs]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
#
|
43
|
+
# Internal method that handles loading specs from files
|
44
|
+
#
|
45
|
+
# This method coordinates the entire spec loading process by:
|
46
|
+
# 1. Reading files from the specs directory
|
47
|
+
# 2. Parsing them as YAML
|
48
|
+
# 3. Transforming them into the proper structure
|
49
|
+
#
|
50
|
+
# @return [Array<Array>] Array of [global, metadata, specs] for each loaded file
|
51
|
+
#
|
52
|
+
# @private
|
53
|
+
#
|
54
|
+
def load_specs_from_files
|
55
|
+
files = read_from_files
|
56
|
+
parse_and_transform_specs(files)
|
57
|
+
end
|
58
|
+
|
59
|
+
#
|
60
|
+
# Reads spec files from the spec_forge/specs directory
|
61
|
+
#
|
62
|
+
# @return [Array<Array<String, String>>] Array of [file_path, file_content] pairs
|
63
|
+
#
|
64
|
+
# @private
|
65
|
+
#
|
66
|
+
def read_from_files
|
67
|
+
path = SpecForge.forge_path.join("specs")
|
68
|
+
|
69
|
+
Dir[path.join("**/*.yml")].map do |file_path|
|
70
|
+
[file_path, File.read(file_path)]
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
#
|
75
|
+
# Parses YAML content and extracts line numbers for error reporting
|
76
|
+
#
|
77
|
+
# @param files [Array<Array<String, String>>] Array of [file_path, file_content] pairs
|
78
|
+
#
|
79
|
+
# @return [Array<Array>] Array of [global, metadata, specs] for each file
|
80
|
+
#
|
81
|
+
# @private
|
82
|
+
#
|
83
|
+
def parse_and_transform_specs(files)
|
84
|
+
base_path = SpecForge.forge_path.join("specs")
|
85
|
+
|
86
|
+
files.map do |file_path, content|
|
87
|
+
relative_path = Pathname.new(file_path).relative_path_from(base_path)
|
88
|
+
|
89
|
+
hash = YAML.safe_load(content, symbolize_names: true)
|
90
|
+
|
91
|
+
file_line_numbers = extract_line_numbers(content, hash)
|
92
|
+
|
93
|
+
# Currently, only holds onto global variables
|
94
|
+
global = hash.delete(:global) || {}
|
95
|
+
|
96
|
+
metadata = {
|
97
|
+
file_name: relative_path.basename(".yml").to_s,
|
98
|
+
relative_path: relative_path.to_s,
|
99
|
+
file_path:
|
100
|
+
}
|
101
|
+
|
102
|
+
specs =
|
103
|
+
hash.map do |spec_name, spec_hash|
|
104
|
+
line_number, *expectation_line_numbers = file_line_numbers[spec_name]
|
105
|
+
|
106
|
+
spec_hash[:id] = "spec_#{SpecForge.generate_id(spec_hash)}"
|
107
|
+
spec_hash[:name] = spec_name.to_s
|
108
|
+
spec_hash[:file_path] = metadata[:file_path]
|
109
|
+
spec_hash[:file_name] = metadata[:file_name]
|
110
|
+
spec_hash[:line_number] = line_number
|
111
|
+
|
112
|
+
# Check for expectations instead of defaulting. I want it to error
|
113
|
+
if (expectations = spec_hash[:expectations])
|
114
|
+
expectations.zip(expectation_line_numbers) do |expectation_hash, line_number|
|
115
|
+
expectation_hash[:id] = "expect_#{SpecForge.generate_id(expectation_hash)}"
|
116
|
+
expectation_hash[:name] = build_expectation_name(spec_hash, expectation_hash)
|
117
|
+
expectation_hash[:line_number] = line_number
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
spec_hash
|
122
|
+
end
|
123
|
+
|
124
|
+
[global, metadata, specs]
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
#
|
129
|
+
# Extracts line numbers from each YAML section for error reporting
|
130
|
+
#
|
131
|
+
# @param content [String] The raw file content
|
132
|
+
# @param input_hash [Hash] The parsed YAML structure
|
133
|
+
#
|
134
|
+
# @return [Hash] A mapping of spec names to line numbers
|
135
|
+
#
|
136
|
+
# @private
|
137
|
+
#
|
138
|
+
def extract_line_numbers(content, input_hash)
|
139
|
+
# I hate this code, lol, and it hates me.
|
140
|
+
# I've tried to make it better, I've tried to clean it up, but every time I break it.
|
141
|
+
# If you know how to make this better, please submit a PR and save me.
|
142
|
+
spec_names = input_hash.keys
|
143
|
+
keys = {}
|
144
|
+
|
145
|
+
current_spec_name = nil
|
146
|
+
expectations_line = nil
|
147
|
+
expectations_indent = nil
|
148
|
+
|
149
|
+
content.lines.each_with_index do |line, index|
|
150
|
+
line_number = index + 1
|
151
|
+
clean_line = line.rstrip
|
152
|
+
indentation = line[/^\s*/].size
|
153
|
+
|
154
|
+
# Skip blank lines
|
155
|
+
next if clean_line.empty?
|
156
|
+
|
157
|
+
# Reset on top-level elements
|
158
|
+
if indentation == 0
|
159
|
+
current_spec_name = nil
|
160
|
+
expectations_line = nil
|
161
|
+
expectations_indent = nil
|
162
|
+
|
163
|
+
# Check if this line starts a spec we're interested in
|
164
|
+
spec_names.each do |spec_name|
|
165
|
+
next unless clean_line.start_with?("#{spec_name}:")
|
166
|
+
|
167
|
+
current_spec_name = spec_name
|
168
|
+
keys[current_spec_name] = [line_number]
|
169
|
+
break
|
170
|
+
end
|
171
|
+
|
172
|
+
next
|
173
|
+
end
|
174
|
+
|
175
|
+
# Skip if we're not in a relevant spec
|
176
|
+
next unless current_spec_name
|
177
|
+
|
178
|
+
# Found expectations section
|
179
|
+
if clean_line.match?(/^[^#]\s*expectations:/i)
|
180
|
+
expectations_line = line_number
|
181
|
+
expectations_indent = indentation
|
182
|
+
next
|
183
|
+
end
|
184
|
+
|
185
|
+
# Found an expectation item
|
186
|
+
if expectations_line && clean_line.start_with?("#{" " * expectations_indent}- ")
|
187
|
+
keys[current_spec_name] << line_number
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
keys
|
192
|
+
end
|
193
|
+
|
194
|
+
#
|
195
|
+
# Builds a name for an expectation based on HTTP verb, URL, and optional name
|
196
|
+
#
|
197
|
+
# @param spec_hash [Hash] The spec configuration
|
198
|
+
# @param expectation_hash [Hash] The expectation configuration
|
199
|
+
#
|
200
|
+
# @return [String] A formatted expectation name (e.g., "GET /users - Find User")
|
201
|
+
#
|
202
|
+
# @private
|
203
|
+
#
|
204
|
+
def build_expectation_name(spec_hash, expectation_hash)
|
205
|
+
# Create a structure for http_verb and url
|
206
|
+
# Removing the defaults and validators to avoid triggering extra logic
|
207
|
+
structure = Normalizer.structures[:spec][:structure].slice(:http_verb, :url)
|
208
|
+
.transform_values { |v| v.except(:default, :validator) }
|
209
|
+
|
210
|
+
# Ignore any errors. These will be validated later
|
211
|
+
normalized_spec, _ = Normalizer.normalize(spec_hash, using: structure, label: "n/a")
|
212
|
+
normalized_expectation, _ = Normalizer.normalize(
|
213
|
+
expectation_hash,
|
214
|
+
using: structure, label: "n/a"
|
215
|
+
)
|
216
|
+
|
217
|
+
request_data = normalized_spec.deep_merge(normalized_expectation)
|
218
|
+
|
219
|
+
url = request_data[:url]
|
220
|
+
http_verb = request_data[:http_verb].presence || "GET"
|
221
|
+
|
222
|
+
# Finally generate the name
|
223
|
+
generate_expectation_name(http_verb:, url:, name: expectation_hash[:name])
|
224
|
+
end
|
225
|
+
|
226
|
+
#
|
227
|
+
# Generates an expectation name from its components
|
228
|
+
#
|
229
|
+
# @param http_verb [String] The HTTP verb (GET, POST, etc.)
|
230
|
+
# @param url [String] The URL path
|
231
|
+
# @param name [String, nil] Optional descriptive name
|
232
|
+
#
|
233
|
+
# @return [String] A formatted expectation name
|
234
|
+
#
|
235
|
+
# @private
|
236
|
+
#
|
237
|
+
def generate_expectation_name(http_verb:, url:, name: nil)
|
238
|
+
base = "#{http_verb.upcase} #{url}" # GET /users
|
239
|
+
base += " - #{name}" if name.present? # GET /users - Returns 404 because y not?
|
240
|
+
base
|
241
|
+
end
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|