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.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +112 -2
  3. data/README.md +133 -8
  4. data/flake.lock +3 -3
  5. data/flake.nix +3 -3
  6. data/lib/spec_forge/attribute/factory.rb +1 -1
  7. data/lib/spec_forge/callbacks.rb +9 -0
  8. data/lib/spec_forge/cli/docs/generate.rb +72 -0
  9. data/lib/spec_forge/cli/docs.rb +92 -0
  10. data/lib/spec_forge/cli/init.rb +39 -7
  11. data/lib/spec_forge/cli/new.rb +13 -3
  12. data/lib/spec_forge/cli/run.rb +12 -4
  13. data/lib/spec_forge/cli/serve.rb +155 -0
  14. data/lib/spec_forge/cli.rb +14 -6
  15. data/lib/spec_forge/configuration.rb +2 -2
  16. data/lib/spec_forge/context/store.rb +23 -40
  17. data/lib/spec_forge/core_ext/array.rb +27 -0
  18. data/lib/spec_forge/documentation/builder.rb +383 -0
  19. data/lib/spec_forge/documentation/document/operation.rb +47 -0
  20. data/lib/spec_forge/documentation/document/parameter.rb +22 -0
  21. data/lib/spec_forge/documentation/document/request_body.rb +24 -0
  22. data/lib/spec_forge/documentation/document/response.rb +39 -0
  23. data/lib/spec_forge/documentation/document/response_body.rb +27 -0
  24. data/lib/spec_forge/documentation/document.rb +48 -0
  25. data/lib/spec_forge/documentation/generators/base.rb +81 -0
  26. data/lib/spec_forge/documentation/generators/openapi/base.rb +100 -0
  27. data/lib/spec_forge/documentation/generators/openapi/error_formatter.rb +149 -0
  28. data/lib/spec_forge/documentation/generators/openapi/v3_0.rb +65 -0
  29. data/lib/spec_forge/documentation/generators/openapi.rb +59 -0
  30. data/lib/spec_forge/documentation/generators.rb +17 -0
  31. data/lib/spec_forge/documentation/loader/cache.rb +138 -0
  32. data/lib/spec_forge/documentation/loader.rb +159 -0
  33. data/lib/spec_forge/documentation/openapi/base.rb +33 -0
  34. data/lib/spec_forge/documentation/openapi/v3_0/example.rb +44 -0
  35. data/lib/spec_forge/documentation/openapi/v3_0/media_type.rb +42 -0
  36. data/lib/spec_forge/documentation/openapi/v3_0/operation.rb +175 -0
  37. data/lib/spec_forge/documentation/openapi/v3_0/response.rb +65 -0
  38. data/lib/spec_forge/documentation/openapi/v3_0/schema.rb +80 -0
  39. data/lib/spec_forge/documentation/openapi/v3_0/tag.rb +71 -0
  40. data/lib/spec_forge/documentation/openapi.rb +23 -0
  41. data/lib/spec_forge/documentation.rb +27 -0
  42. data/lib/spec_forge/error.rb +17 -0
  43. data/lib/spec_forge/factory.rb +2 -2
  44. data/lib/spec_forge/filter.rb +3 -4
  45. data/lib/spec_forge/forge.rb +5 -4
  46. data/lib/spec_forge/http/backend.rb +2 -0
  47. data/lib/spec_forge/http/request.rb +14 -3
  48. data/lib/spec_forge/loader.rb +14 -24
  49. data/lib/spec_forge/normalizer/default.rb +51 -0
  50. data/lib/spec_forge/normalizer/definition.rb +248 -0
  51. data/lib/spec_forge/normalizer/validators.rb +99 -0
  52. data/lib/spec_forge/normalizer.rb +356 -199
  53. data/lib/spec_forge/normalizers/_shared.yml +74 -0
  54. data/lib/spec_forge/normalizers/configuration.yml +23 -0
  55. data/lib/spec_forge/normalizers/constraint.yml +8 -0
  56. data/lib/spec_forge/normalizers/expectation.yml +47 -0
  57. data/lib/spec_forge/normalizers/factory.yml +12 -0
  58. data/lib/spec_forge/normalizers/factory_reference.yml +15 -0
  59. data/lib/spec_forge/normalizers/global_context.yml +28 -0
  60. data/lib/spec_forge/normalizers/spec.yml +50 -0
  61. data/lib/spec_forge/runner/adapter.rb +183 -0
  62. data/lib/spec_forge/runner/debug_proxy.rb +3 -3
  63. data/lib/spec_forge/runner/state.rb +4 -5
  64. data/lib/spec_forge/runner.rb +40 -124
  65. data/lib/spec_forge/spec/expectation/constraint.rb +13 -5
  66. data/lib/spec_forge/spec/expectation.rb +7 -3
  67. data/lib/spec_forge/spec.rb +13 -58
  68. data/lib/spec_forge/version.rb +1 -1
  69. data/lib/spec_forge.rb +30 -23
  70. data/lib/templates/openapi.yml.tt +22 -0
  71. data/lib/templates/redoc.html.tt +28 -0
  72. data/lib/templates/swagger.html.tt +59 -0
  73. metadata +92 -14
  74. data/lib/spec_forge/normalizer/configuration.rb +0 -90
  75. data/lib/spec_forge/normalizer/constraint.rb +0 -60
  76. data/lib/spec_forge/normalizer/expectation.rb +0 -105
  77. data/lib/spec_forge/normalizer/factory.rb +0 -78
  78. data/lib/spec_forge/normalizer/factory_reference.rb +0 -85
  79. data/lib/spec_forge/normalizer/global_context.rb +0 -88
  80. data/lib/spec_forge/normalizer/spec.rb +0 -97
  81. /data/lib/templates/{forge_helper.tt → forge_helper.rb.tt} +0 -0
  82. /data/lib/templates/{new_factory.tt → new_factory.yml.tt} +0 -0
  83. /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