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
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Context
5
+ #
6
+ # Manages storage of API responses for use in subsequent tests
7
+ #
8
+ # This class provides a mechanism to store HTTP requests and responses
9
+ # during test execution, allowing values to be referenced in later tests
10
+ # through the `store.id.body.attribute` syntax.
11
+ #
12
+ # @example Storing and retrieving a response in specs
13
+ # # In one expectation:
14
+ # store_as: user_creation
15
+ #
16
+ # # In a later test:
17
+ # query:
18
+ # id: store.user_creation.body.id
19
+ #
20
+ class Store
21
+ #
22
+ # Represents a stored entry containing arbitrary data from test execution
23
+ #
24
+ # Entries are created during test execution to store custom data that can be
25
+ # accessed in subsequent tests. Unlike the original rigid Data structure, this
26
+ # OpenStruct-based approach allows storing any key-value pairs, making it perfect
27
+ # for complex test scenarios that need custom configuration, metadata, or
28
+ # computed values.
29
+ #
30
+ # @example Storing custom configuration data
31
+ # SpecForge.context.store.set(
32
+ # "app_config",
33
+ # api_version: "v2.1",
34
+ # feature_flags: { advanced_search: true }
35
+ # )
36
+ #
37
+ # @example Accessing stored data in tests
38
+ # headers:
39
+ # X-API-Version: store.app_config.api_version
40
+ # query:
41
+ # search_enabled: store.app_config.feature_flags.advanced_search
42
+ #
43
+ class Entry < OpenStruct
44
+ #
45
+ # Creates a new store entry
46
+ #
47
+ # @param scope [Symbol] Scope of this entry, either :file or :spec
48
+ #
49
+ # @return [Entry] A new entry instance
50
+ #
51
+ def initialize(scope: :file, **)
52
+ super
53
+ end
54
+
55
+ #
56
+ # Returns all available methods that can be called
57
+ #
58
+ # @return [Array] The method names
59
+ #
60
+ def available_methods
61
+ @table.keys
62
+ end
63
+ end
64
+
65
+ #
66
+ # Creates a new empty store
67
+ #
68
+ # @return [Store] A new store instance
69
+ #
70
+ def initialize
71
+ @inner = {}
72
+ end
73
+
74
+ #
75
+ # Retrieves a stored entry by ID
76
+ #
77
+ # @param id [String, Symbol] The identifier for the stored entry
78
+ #
79
+ # @return [Entry, nil] The stored entry or nil if not found
80
+ #
81
+ def [](id)
82
+ @inner[id]
83
+ end
84
+
85
+ #
86
+ # Returns the number of entries in the store
87
+ #
88
+ # @return [Integer] The count of stored entries
89
+ #
90
+ def size
91
+ @inner.size
92
+ end
93
+
94
+ #
95
+ # Stores an entry with the specified ID
96
+ #
97
+ # @param id [String, Symbol] The identifier to store the entry under
98
+ #
99
+ # @return [self]
100
+ #
101
+ def set(id, **)
102
+ @inner[id] = Entry.new(**)
103
+
104
+ self
105
+ end
106
+
107
+ #
108
+ # Removes all entries from the store
109
+ #
110
+ def clear
111
+ @inner.clear
112
+ end
113
+
114
+ #
115
+ # Removes all spec entries from the store
116
+ #
117
+ def clear_specs
118
+ @inner.delete_if { |_k, v| v.scope == :spec }
119
+ end
120
+
121
+ #
122
+ # Returns a hash representation of store
123
+ #
124
+ # @return [Hash]
125
+ #
126
+ def to_h
127
+ @inner.transform_values(&:to_h).deep_stringify_keys
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Context
5
+ #
6
+ # Manages variable resolution across different expectations in SpecForge tests.
7
+ #
8
+ # The Variables class handles two layers of variable definitions:
9
+ # - Base variables: The core set of variables defined at the spec level
10
+ # - Overlay variables: Additional variables defined at the expectation level
11
+ # that can override base variables with the same name.
12
+ #
13
+ # @example Basic usage
14
+ # variables = Variables.new(
15
+ # base: {user_id: 123, name: "Test User"},
16
+ # overlay: {
17
+ # "expectation_1": {name: "Override User"}
18
+ # }
19
+ # )
20
+ #
21
+ # variables[:user_id] #=> 123
22
+ # variables[:name] #=> "Test User"
23
+ #
24
+ # variables.use_overlay("expectation_1")
25
+ # variables[:name] #=> "Override User"
26
+ # variables[:user_id] #=> 123 (unchanged)
27
+ #
28
+ class Variables < Hash
29
+ attr_reader :base, :overlay
30
+
31
+ #
32
+ # Creates a new Variables container with base and overlay definitions
33
+ #
34
+ # @param base [Hash] The base set of variables (typically defined at spec level)
35
+ # @param overlay [Hash<String, Hash>] A hash of overlay variable sets keyed by ID
36
+ #
37
+ # @return [Variables]
38
+ #
39
+ def initialize(base: {}, overlay: {})
40
+ set(base:, overlay:)
41
+ end
42
+
43
+ #
44
+ # Sets the base and overlay variable hashes
45
+ #
46
+ # @param base [Hash] The new base variable hash
47
+ # @param overlay [Hash<String, Hash>] The new overlay variable hashes
48
+ #
49
+ # @return [self]
50
+ #
51
+ def set(base:, overlay: {})
52
+ @base = Attribute.from(base)
53
+ @overlay = overlay
54
+
55
+ resolve_into_self(@base)
56
+ self
57
+ end
58
+
59
+ #
60
+ # Applies a specific overlay to the base variables
61
+ # If the overlay doesn't exist or is empty, no changes are made.
62
+ #
63
+ # @param id [String] The ID of the overlay to apply
64
+ #
65
+ # @return [nil]
66
+ #
67
+ def use_overlay(id)
68
+ active = @base
69
+
70
+ if (overlay = @overlay[id]) && overlay.present?
71
+ active = active.deep_merge(overlay)
72
+ end
73
+
74
+ resolve_into_self(active)
75
+ self
76
+ end
77
+
78
+ private
79
+
80
+ def resolve_into_self(hash)
81
+ # Start fresh
82
+ clear
83
+
84
+ # Load the resolved values into self
85
+ hash.each do |key, value|
86
+ self[key] = Attribute.from(value).resolved
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ #
5
+ # Core data structure that maintains context during test execution
6
+ #
7
+ # Context stores and provides access to global variables, test variables, and
8
+ # shared state across specs.
9
+ # It acts as a central repository for test data during execution.
10
+ #
11
+ # @example Accessing the current context
12
+ # SpecForge.context.variables[:user_id] #=> 123
13
+ #
14
+ class Context < Data.define(:global, :store, :variables)
15
+ #
16
+ # Creates a new context with default values
17
+ #
18
+ # @param global [Hash] Global variables shared across all specs
19
+ # @param variables [Hash] Test variables specific to the current context
20
+ #
21
+ # @return [Context] A new context instance
22
+ #
23
+ def initialize(global: {}, variables: {})
24
+ super(
25
+ global: Global.new(**global),
26
+ store: Store.new,
27
+ variables: Variables.new(**variables)
28
+ )
29
+ end
30
+ end
31
+ end
32
+
33
+ require_relative "context/callbacks"
34
+ require_relative "context/global"
35
+ require_relative "context/store"
36
+ require_relative "context/variables"
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Extensions to Ruby's Array class for SpecForge functionality
5
+ #
6
+ # Adds utility methods used throughout SpecForge for array manipulation
7
+ # and data processing.
8
+ #
9
+ class Array
10
+ #
11
+ # Merges an array of hashes into a single hash
12
+ #
13
+ # Performs a deep merge on each hash in the array, combining them
14
+ # into a single hash with all keys and values.
15
+ #
16
+ # @return [Hash] A hash containing the merged contents of all hashes in the array
17
+ #
18
+ # @example Merging an array of hashes
19
+ # [{a: 1}, {b: 2}, {a: 3}].to_merged_h
20
+ # # => {a: 3, b: 2}
21
+ #
22
+ def to_merged_h
23
+ each_with_object({}) do |hash, output|
24
+ output.deep_merge!(hash)
25
+ end
26
+ end
27
+ end
@@ -2,19 +2,37 @@
2
2
 
3
3
  return if defined?(SPEC_FORGE_INTERNAL_TESTING)
4
4
 
5
+ #
6
+ # RSpec's core testing framework module
7
+ # Provides the fundamental structure and functionality for RSpec tests
8
+ #
5
9
  module RSpec
10
+ #
11
+ # Core implementation details and extensions for RSpec
12
+ # Contains the fundamental building blocks of the RSpec testing framework
13
+ #
6
14
  module Core
15
+ #
16
+ # Handles notifications and reporting for RSpec test runs
17
+ # Manages how test results and metadata are processed and communicated
18
+ #
7
19
  module Notifications
8
20
  #
9
- # I did attempt to do this without monkey patching
10
- # Getting around the `rspec` word was making it difficult
21
+ # A monkey patch of an internal RSpec class to allow SpecForge to replace parts of
22
+ # RSpec's reporting output in order to provide useful feedback to the user.
23
+ # This replaces "rspec" in commands with "spec_forge", removes any line numbers, and
24
+ # ensures that failures properly report the YAML file that it occurred in.
11
25
  #
12
26
  class SummaryNotification
27
+ #
28
+ # Create an alias to RSpec original colorized_rerun_commands so it can be called at a
29
+ # later point.
30
+ #
31
+ alias_method :og_colorized_rerun_commands, :colorized_rerun_commands
32
+
13
33
  # Customizes RSpec's failure output to:
14
34
  # 1. Use 'spec_forge' instead of 'rspec' for rerun commands
15
35
  # 2. Remove line numbers since SpecForge uses dynamic spec generation
16
- alias_method :og_colorized_rerun_commands, :colorized_rerun_commands
17
-
18
36
  def colorized_rerun_commands(colorizer)
19
37
  # Updating these at this point fixes the re-run for some failures - it depends
20
38
  failed_examples.each do |example|
@@ -0,0 +1,383 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ module Documentation
5
+ #
6
+ # Transforms extracted test data into a structured document
7
+ #
8
+ # This class processes raw endpoint data from tests into a hierarchical document
9
+ # structure suitable for rendering as API documentation.
10
+ #
11
+ # @example Creating a document from test data
12
+ # document = Builder.document_from_endpoints(endpoints)
13
+ #
14
+ class Builder
15
+ # Source: https://gist.github.com/johnelliott/cf77003f72f889abbc3f32785fa3df8d
16
+ UUID_REGEX = /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i
17
+
18
+ #
19
+ # Regular expression for matching floating point numbers in strings
20
+ #
21
+ # Matches decimal numbers with optional negative sign, used for type detection
22
+ # when analyzing API response data.
23
+ #
24
+ # @api private
25
+ #
26
+ INTEGER_REGEX = /^-?\d+$/
27
+
28
+ #
29
+ # Regular expression for matching integer numbers in strings
30
+ #
31
+ # Matches whole numbers with optional negative sign, used for type detection
32
+ # when analyzing API response data.
33
+ #
34
+ # @api private
35
+ #
36
+ FLOAT_REGEX = /^-?\d+\.\d+$/
37
+
38
+ #
39
+ # Creates a document from endpoint data
40
+ #
41
+ # @param endpoints [Array<Hash>] Array of endpoint data extracted from tests
42
+ #
43
+ # @return [Document] A structured documentation document
44
+ #
45
+ def self.document_from_endpoints(endpoints = [])
46
+ new(endpoints).export_as_document
47
+ end
48
+
49
+ #
50
+ # The processed endpoints organized by path and HTTP method
51
+ #
52
+ # Contains all endpoint data after grouping, sanitizing, merging,
53
+ # and flattening operations for document generation.
54
+ #
55
+ # @return [Hash] Processed endpoints ready for document creation
56
+ #
57
+ attr_reader :endpoints
58
+
59
+ #
60
+ # Initializes a new builder with endpoint data
61
+ #
62
+ # @param endpoints [Array<Hash>] Array of endpoint data extracted from tests
63
+ #
64
+ # @return [Builder] A new builder instance
65
+ #
66
+ def initialize(endpoints)
67
+ @endpoints = prepare_endpoints(endpoints)
68
+ end
69
+
70
+ #
71
+ # Prepares endpoint data for document creation
72
+ #
73
+ # Groups endpoints by path and HTTP method, sanitizes error responses,
74
+ # merges similar operations, and flattens the result.
75
+ #
76
+ # @param endpoints [Array<Hash>] Raw endpoint data from tests
77
+ #
78
+ # @return [Hash] Processed endpoints organized by path and method
79
+ #
80
+ def prepare_endpoints(endpoints)
81
+ # Step one, group the endpoints by their paths and verb
82
+ # { path: {get: [], post: []}, path_2: {get: []}, ... }
83
+ grouped = group_endpoints(endpoints)
84
+
85
+ grouped.each_value do |endpoint|
86
+ # Operations are those arrays
87
+ endpoint.transform_values! do |operations|
88
+ # Step two, clear data from any error (4xx, 5xx) operations
89
+ operations = sanitize_error_operations(operations)
90
+
91
+ # Step three, merge all of the operations into one single hash
92
+ operations = merge_operations(operations)
93
+
94
+ # Step four, flatten the operations into one
95
+ flatten_operations(operations)
96
+ end
97
+ end
98
+ end
99
+
100
+ #
101
+ # Exports the processed endpoints as a document
102
+ #
103
+ # @return [Document] A document containing the processed endpoints
104
+ #
105
+ def export_as_document
106
+ Document.new(endpoints:)
107
+ end
108
+
109
+ private
110
+
111
+ def determine_type(value)
112
+ case value
113
+ when true, false
114
+ "boolean"
115
+ when Float
116
+ # According to the docs: A Float object represents a sometimes-inexact real number
117
+ # using the native architecture’s double-precision floating point representation.
118
+ # So a double it is!
119
+ "double"
120
+ when Integer
121
+ "integer"
122
+ when Array
123
+ "array"
124
+ when NilClass
125
+ "null"
126
+ when DateTime, Time
127
+ "datetime"
128
+ when Date
129
+ "date"
130
+ when String, Symbol
131
+ if value.match?(UUID_REGEX)
132
+ "uuid"
133
+ elsif value.match?(INTEGER_REGEX)
134
+ "integer"
135
+ elsif value.match?(FLOAT_REGEX)
136
+ "double"
137
+ elsif value == "true" || value == "false"
138
+ "boolean"
139
+ else
140
+ "string"
141
+ end
142
+ when URI
143
+ "uri"
144
+ when Numeric
145
+ "number"
146
+ else
147
+ "object"
148
+ end
149
+ end
150
+
151
+ #
152
+ # Groups endpoints by path and HTTP method
153
+ #
154
+ # @param endpoints [Array<Hash>] Array of endpoint data
155
+ #
156
+ # @return [Hash] Endpoints grouped by path and method
157
+ #
158
+ # @private
159
+ #
160
+ def group_endpoints(endpoints)
161
+ grouped = Hash.new_nested_hash(depth: 1)
162
+
163
+ # Convert the endpoints from a flat array of objects into a hash
164
+ endpoints.each do |input|
165
+ # "/users" => {}
166
+ endpoint_hash = grouped[input[:url]]
167
+
168
+ # "GET" => []
169
+ (endpoint_hash[input[:http_verb]] ||= []) << input
170
+ end
171
+
172
+ grouped
173
+ end
174
+
175
+ #
176
+ # Sanitizes operations that represent error responses
177
+ #
178
+ # Removes request details from operations with 4xx/5xx responses
179
+ # to prevent invalid data from appearing in documentation.
180
+ #
181
+ # @param operations [Array<Hash>] Array of operations
182
+ #
183
+ # @return [Array<Hash>] Sanitized operations
184
+ #
185
+ # @private
186
+ #
187
+ def sanitize_error_operations(operations)
188
+ operations.each do |operation|
189
+ next unless operation[:response_status] >= 400
190
+
191
+ # This keeps tests that handle errors from including their invalid attributes
192
+ # and such in the output.
193
+ operation[:request_query] = {}
194
+ operation[:request_headers] = {}
195
+ operation[:request_body] = {}
196
+ end
197
+ end
198
+
199
+ #
200
+ # Merges similar operations into a single operation
201
+ #
202
+ # @param operations [Array<Hash>] Array of operations
203
+ #
204
+ # @return [Array<Hash>] Merged operations
205
+ #
206
+ # @private
207
+ #
208
+ def merge_operations(operations)
209
+ operations.group_by { |o| o[:response_status] }
210
+ .transform_values { |o| o.to_merged_h }
211
+ .values
212
+ end
213
+
214
+ #
215
+ # Flattens multiple operations into a single operation structure
216
+ #
217
+ # @param operations [Array<Hash>] Array of operations
218
+ #
219
+ # @return [Hash] Flattened operation
220
+ #
221
+ # @private
222
+ #
223
+ def flatten_operations(operations)
224
+ id = operations.key_map(:spec_name).reject(&:blank?).first
225
+
226
+ description = operations.key_map(:expectation_name)
227
+ .reject(&:blank?)
228
+ .first
229
+ &.split(" - ")
230
+ &.second || ""
231
+
232
+ parameters = normalize_parameters(operations)
233
+ requests = normalize_requests(operations)
234
+ responses = normalize_responses(operations)
235
+
236
+ {
237
+ id:,
238
+ description:,
239
+ parameters:,
240
+ requests:,
241
+ responses:
242
+ }
243
+ end
244
+
245
+ #
246
+ # Normalizes request parameters from operations
247
+ #
248
+ # Extracts and categorizes parameters as path or query parameters
249
+ # and determines their data types.
250
+ #
251
+ # @param operations [Array<Hash>] Array of operations
252
+ #
253
+ # @return [Hash] Normalized parameters
254
+ #
255
+ # @private
256
+ #
257
+ def normalize_parameters(operations)
258
+ parameters = {}
259
+
260
+ operations.each do |operation|
261
+ # Store the URL so it can be determined if the param is in the path or not
262
+ url = operation[:url]
263
+ params = operation[:request_query].transform_values { |value| {value:, url:} }
264
+
265
+ parameters.merge!(params)
266
+ end
267
+
268
+ parameters.transform_values!(with_key: true) do |data, key|
269
+ key_in_path = data[:url].include?("{#{key}}")
270
+
271
+ {
272
+ location: key_in_path ? "path" : "query",
273
+ type: determine_type(data[:value])
274
+ }
275
+ end
276
+ end
277
+
278
+ #
279
+ # Normalizes request bodies from operations
280
+ #
281
+ # Extracts request bodies from successful operations and
282
+ # determines their data types.
283
+ #
284
+ # @param operations [Array<Hash>] Array of operations
285
+ #
286
+ # @return [Array<Hash>] Normalized request bodies
287
+ #
288
+ # @private
289
+ #
290
+ def normalize_requests(operations)
291
+ successful_operations = operations.select { |o| o[:response_status] < 400 }
292
+ return [] if successful_operations.blank?
293
+
294
+ successful_operations.filter_map.with_index do |operation, index|
295
+ content = operation[:request_body]
296
+ next if content.blank?
297
+
298
+ name = operation[:expectation_name].split(" - ").second
299
+
300
+ {
301
+ name: name || "Example #{index}",
302
+ content_type: operation[:content_type],
303
+ type: determine_type(content),
304
+ content:
305
+ }
306
+ end
307
+ end
308
+
309
+ #
310
+ # Normalizes responses from operations
311
+ #
312
+ # Extracts response details including status, headers, and body
313
+ # and determines their data types.
314
+ #
315
+ # @param operations [Array<Hash>] Array of operations
316
+ #
317
+ # @return [Array<Hash>] Normalized responses
318
+ #
319
+ # @private
320
+ #
321
+ def normalize_responses(operations)
322
+ operations.map do |operation|
323
+ {
324
+ content_type: operation[:content_type],
325
+ status: operation[:response_status],
326
+ headers: normalize_headers(operation[:response_headers]),
327
+ body: normalize_response_body(operation[:response_body])
328
+ }
329
+ end
330
+ end
331
+
332
+ #
333
+ # Normalizes response headers
334
+ #
335
+ # @param headers [Hash] Response headers
336
+ #
337
+ # @return [Hash] Normalized headers with types
338
+ #
339
+ # @private
340
+ #
341
+ def normalize_headers(headers)
342
+ headers.transform_values do |value|
343
+ {type: determine_type(value)}
344
+ end
345
+ end
346
+
347
+ #
348
+ # Normalizes response body structure
349
+ #
350
+ # @param body [Hash, Array, String] Response body
351
+ #
352
+ # @return [Hash] Normalized body structure with type information
353
+ #
354
+ # @private
355
+ #
356
+ def normalize_response_body(body)
357
+ proc = lambda do |value|
358
+ {type: determine_type(value)}
359
+ end
360
+
361
+ case body
362
+ when Hash
363
+ {
364
+ type: "object",
365
+ content: body.deep_transform_values(&proc)
366
+ }
367
+ when Array
368
+ {
369
+ type: "array",
370
+ content: body.map(&proc)
371
+ }
372
+ when String
373
+ {
374
+ type: "string",
375
+ content: body
376
+ }
377
+ else
378
+ raise "Unexpected body: #{body.inspect}"
379
+ end
380
+ end
381
+ end
382
+ end
383
+ end