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.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/.standard.yml +3 -3
  3. data/CHANGELOG.md +217 -2
  4. data/README.md +162 -25
  5. data/flake.lock +3 -3
  6. data/flake.nix +11 -5
  7. data/lib/spec_forge/attribute/chainable.rb +208 -20
  8. data/lib/spec_forge/attribute/factory.rb +92 -15
  9. data/lib/spec_forge/attribute/faker.rb +62 -13
  10. data/lib/spec_forge/attribute/global.rb +96 -0
  11. data/lib/spec_forge/attribute/literal.rb +15 -2
  12. data/lib/spec_forge/attribute/matcher.rb +186 -11
  13. data/lib/spec_forge/attribute/parameterized.rb +45 -12
  14. data/lib/spec_forge/attribute/regex.rb +55 -5
  15. data/lib/spec_forge/attribute/resolvable.rb +48 -5
  16. data/lib/spec_forge/attribute/resolvable_array.rb +62 -4
  17. data/lib/spec_forge/attribute/resolvable_hash.rb +62 -4
  18. data/lib/spec_forge/attribute/store.rb +65 -0
  19. data/lib/spec_forge/attribute/transform.rb +33 -5
  20. data/lib/spec_forge/attribute/variable.rb +37 -6
  21. data/lib/spec_forge/attribute.rb +166 -66
  22. data/lib/spec_forge/backtrace_formatter.rb +26 -3
  23. data/lib/spec_forge/callbacks.rb +88 -0
  24. data/lib/spec_forge/cli/actions.rb +27 -0
  25. data/lib/spec_forge/cli/command.rb +78 -24
  26. data/lib/spec_forge/cli/docs/generate.rb +72 -0
  27. data/lib/spec_forge/cli/docs.rb +92 -0
  28. data/lib/spec_forge/cli/init.rb +51 -9
  29. data/lib/spec_forge/cli/new.rb +67 -6
  30. data/lib/spec_forge/cli/run.rb +32 -4
  31. data/lib/spec_forge/cli/serve.rb +155 -0
  32. data/lib/spec_forge/cli.rb +26 -7
  33. data/lib/spec_forge/configuration.rb +96 -24
  34. data/lib/spec_forge/context/callbacks.rb +91 -0
  35. data/lib/spec_forge/context/global.rb +72 -0
  36. data/lib/spec_forge/context/store.rb +131 -0
  37. data/lib/spec_forge/context/variables.rb +91 -0
  38. data/lib/spec_forge/context.rb +36 -0
  39. data/lib/spec_forge/core_ext/array.rb +27 -0
  40. data/lib/spec_forge/core_ext/rspec.rb +22 -4
  41. data/lib/spec_forge/documentation/builder.rb +383 -0
  42. data/lib/spec_forge/documentation/document/operation.rb +47 -0
  43. data/lib/spec_forge/documentation/document/parameter.rb +22 -0
  44. data/lib/spec_forge/documentation/document/request_body.rb +24 -0
  45. data/lib/spec_forge/documentation/document/response.rb +39 -0
  46. data/lib/spec_forge/documentation/document/response_body.rb +27 -0
  47. data/lib/spec_forge/documentation/document.rb +48 -0
  48. data/lib/spec_forge/documentation/generators/base.rb +81 -0
  49. data/lib/spec_forge/documentation/generators/openapi/base.rb +100 -0
  50. data/lib/spec_forge/documentation/generators/openapi/error_formatter.rb +149 -0
  51. data/lib/spec_forge/documentation/generators/openapi/v3_0.rb +65 -0
  52. data/lib/spec_forge/documentation/generators/openapi.rb +59 -0
  53. data/lib/spec_forge/documentation/generators.rb +17 -0
  54. data/lib/spec_forge/documentation/loader/cache.rb +138 -0
  55. data/lib/spec_forge/documentation/loader.rb +159 -0
  56. data/lib/spec_forge/documentation/openapi/base.rb +33 -0
  57. data/lib/spec_forge/documentation/openapi/v3_0/example.rb +44 -0
  58. data/lib/spec_forge/documentation/openapi/v3_0/media_type.rb +42 -0
  59. data/lib/spec_forge/documentation/openapi/v3_0/operation.rb +175 -0
  60. data/lib/spec_forge/documentation/openapi/v3_0/response.rb +65 -0
  61. data/lib/spec_forge/documentation/openapi/v3_0/schema.rb +80 -0
  62. data/lib/spec_forge/documentation/openapi/v3_0/tag.rb +71 -0
  63. data/lib/spec_forge/documentation/openapi.rb +23 -0
  64. data/lib/spec_forge/documentation.rb +27 -0
  65. data/lib/spec_forge/error.rb +284 -113
  66. data/lib/spec_forge/factory.rb +35 -16
  67. data/lib/spec_forge/filter.rb +86 -0
  68. data/lib/spec_forge/forge.rb +171 -0
  69. data/lib/spec_forge/http/backend.rb +101 -29
  70. data/lib/spec_forge/http/client.rb +23 -13
  71. data/lib/spec_forge/http/request.rb +85 -62
  72. data/lib/spec_forge/http/verb.rb +79 -0
  73. data/lib/spec_forge/http.rb +105 -0
  74. data/lib/spec_forge/loader.rb +244 -0
  75. data/lib/spec_forge/matchers.rb +130 -0
  76. data/lib/spec_forge/normalizer/default.rb +51 -0
  77. data/lib/spec_forge/normalizer/definition.rb +248 -0
  78. data/lib/spec_forge/normalizer/validators.rb +99 -0
  79. data/lib/spec_forge/normalizer.rb +486 -115
  80. data/lib/spec_forge/normalizers/_shared.yml +74 -0
  81. data/lib/spec_forge/normalizers/configuration.yml +23 -0
  82. data/lib/spec_forge/normalizers/constraint.yml +8 -0
  83. data/lib/spec_forge/normalizers/expectation.yml +47 -0
  84. data/lib/spec_forge/normalizers/factory.yml +12 -0
  85. data/lib/spec_forge/normalizers/factory_reference.yml +15 -0
  86. data/lib/spec_forge/normalizers/global_context.yml +28 -0
  87. data/lib/spec_forge/normalizers/spec.yml +50 -0
  88. data/lib/spec_forge/runner/adapter.rb +183 -0
  89. data/lib/spec_forge/runner/callbacks.rb +246 -0
  90. data/lib/spec_forge/runner/debug_proxy.rb +213 -0
  91. data/lib/spec_forge/runner/listener.rb +54 -0
  92. data/lib/spec_forge/runner/metadata.rb +58 -0
  93. data/lib/spec_forge/runner/state.rb +98 -0
  94. data/lib/spec_forge/runner.rb +50 -125
  95. data/lib/spec_forge/spec/expectation/constraint.rb +100 -21
  96. data/lib/spec_forge/spec/expectation.rb +47 -51
  97. data/lib/spec_forge/spec.rb +50 -108
  98. data/lib/spec_forge/type.rb +36 -4
  99. data/lib/spec_forge/version.rb +4 -1
  100. data/lib/spec_forge.rb +168 -76
  101. data/lib/templates/openapi.yml.tt +22 -0
  102. data/lib/templates/redoc.html.tt +28 -0
  103. data/lib/templates/swagger.html.tt +59 -0
  104. metadata +109 -16
  105. data/lib/spec_forge/normalizer/configuration.rb +0 -77
  106. data/lib/spec_forge/normalizer/constraint.rb +0 -47
  107. data/lib/spec_forge/normalizer/expectation.rb +0 -86
  108. data/lib/spec_forge/normalizer/factory.rb +0 -65
  109. data/lib/spec_forge/normalizer/factory_reference.rb +0 -71
  110. data/lib/spec_forge/normalizer/spec.rb +0 -74
  111. data/spec_forge/factories/user.yml +0 -4
  112. data/spec_forge/forge_helper.rb +0 -48
  113. data/spec_forge/specs/users.yml +0 -65
  114. /data/lib/templates/{forge_helper.tt → forge_helper.rb.tt} +0 -0
  115. /data/lib/templates/{new_factory.tt → new_factory.yml.tt} +0 -0
  116. /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
- class Request < Data.define(:base_url, :url, :http_method, :headers, :query, :body)
6
- HEADER = /^[A-Z][A-Za-z0-9!-]*$/
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
- # Initializes a new Request instance with the given options
10
- #
11
- # @param [Hash] options The options to create the Request with
38
+ # Regex that attempts to match a valid header
12
39
  #
13
- # @option options [String] :url The request URL
40
+ # @return [Regexp]
14
41
  #
15
- # @option options [String, Verb] :http_method The HTTP method to use
42
+ HEADER = /^[A-Z][A-Za-z0-9!-]*$/
43
+
16
44
  #
17
- # @option options [Hash] :headers Any headers
45
+ # Creates a new Request with standardized headers and values
18
46
  #
19
- # @option options [Hash] :query The query parameters for the request (defaults to {})
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
- # @option options [Hash] :body The request body (defaults to {})
54
+ # @return [Request] A new immutable request object
22
55
  #
23
56
  def initialize(**options)
24
- url = extract_url(options)
25
- base_url = extract_base_url(options)
26
- http_method = normalize_http_method(options)
27
- headers = normalize_headers(options)
28
- query = normalize_query(options)
29
- body = normalize_body(options)
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:, http_method:, headers:, query:, body:)
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?(:resolve) ? v.resolve : v }
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
- def extract_base_url(options)
45
- options[:base_url].resolve
46
- end
47
-
48
- def extract_url(options)
49
- options[:url].resolve
50
- end
51
-
52
- def normalize_http_method(options)
53
- method = options[:http_method].resolve.presence || "GET"
54
-
55
- if method.is_a?(String)
56
- Verb.from(method)
57
- else
58
- method
59
- end
60
- end
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
- # If the key is already like a header, don't change it
67
- if key.match?(HEADER)
68
- key
69
- else
70
- # content_type => Content-Type
71
- key.downcase.titleize.gsub(/\s+/, "-")
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
- def normalize_body(options)
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
@@ -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,
@@ -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