openapi-ruby 2.3.1 → 2.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0192eda7d50aeced42bcb97c08b150e147bf4da29beabd5f50d507093b89d7f1'
4
- data.tar.gz: 21e7c12d78f74e7340ede68b5b1cfef72c5564e6e24a1caf9d63c9d819aececf
3
+ metadata.gz: b425b7d5cf7a5dc4768214f1aa26d5d8786cf3ceb6cf113ce05cf52412be7249
4
+ data.tar.gz: 7ff62541a6000e03418958afa9255b58ea7b254449a2b1ff9a159dd06c9696ff
5
5
  SHA512:
6
- metadata.gz: a1656c5d6fc9c042c4a2fd1fc9406378430589c87c8a545a05b3eb493c9ebed1cb4238e7ed5053f05c23e8c22bcd0fffd7932fe42aebde48f299eb0ab570edef
7
- data.tar.gz: 363f1dea3ab2c8b40bba3c3969117db68017a0cb29d6b53a1b16249585703ed6dd7bd57fc8e78b247a61c1668b737e4929f9ef19f14a19dcf9951336e3710b3f
6
+ metadata.gz: d86968a1f1cdb51da0018e7679174f4fa7b527d5486c11f0bbbfbf7f01484054aafe1cc61021db1af0eb9fad5009281cb6e0d23684d7beb4d9b5b11201311467
7
+ data.tar.gz: 1321040fcdbad45bcca7e7ae2f908252ef998aca9cdd78535a72e7a15e5c2abbd334818e49f3b7663301b7f96c995904acb98d17fcffdfcafc8362bfbfcb968f
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "openapi_ruby"
4
+ require "cgi"
5
+ require "uri"
4
6
 
5
7
  module OpenapiRuby
6
8
  module Adapters
@@ -36,45 +38,64 @@ module OpenapiRuby
36
38
  response_ctx = operation.responses[expected_status.to_s]
37
39
  raise OpenapiRuby::Error, "No response #{expected_status} defined for #{method.upcase}" unless response_ctx
38
40
 
39
- # Build the request path
40
- path = expand_path(context.path_template, params.merge(path_params))
41
+ # Build the request path with base path from schema server URL
42
+ base_path = resolve_base_path(context.schema_name)
43
+ path = "#{base_path}#{expand_path(context.path_template, params.merge(path_params))}"
41
44
 
42
- # Execute the request
43
- request_params = body || params.reject { |k, _| path_param_names(context).include?(k.to_s) }
44
- request_headers = headers.dup
45
+ # Resolve security scheme parameters
46
+ security_params = resolve_security_params(operation, context.schema_name)
47
+ security_params.each do |param|
48
+ val = params[param[:name].to_sym] || params[param[:name]]
49
+ next if val.nil?
45
50
 
46
- if body
47
- content_type = request_headers["Content-Type"] || context.operations[method.to_s]&.instance_variable_get(:@consumes_list)&.first
51
+ case param[:in].to_s
52
+ when "header" then headers[param[:name]] = val
53
+ when "query" then params[param[:name]] = val
54
+ when "cookie" then headers["Cookie"] = "#{param[:name]}=#{val}"
55
+ end
56
+ end
48
57
 
49
- if content_type&.include?("form-data") || content_type&.include?("x-www-form-urlencoded")
50
- request_params = body
51
- request_headers["Content-Type"] ||= content_type
58
+ # Default Accept header for API requests
59
+ headers["Accept"] ||= "application/json"
60
+
61
+ # Build query params (exclude path params)
62
+ query_params = params.reject { |k, _| path_param_names(context).include?(k.to_s) }
63
+
64
+ # Execute the request
65
+ if body
66
+ content_type = operation.request_body_definition&.dig("content")&.keys&.first || "application/json"
67
+ request_args = if content_type.include?("form-data") || content_type.include?("x-www-form-urlencoded")
68
+ {params: body, headers: headers}
52
69
  else
53
- request_params = body.is_a?(String) ? body : body.to_json
54
- request_headers["Content-Type"] ||= content_type || "application/json"
70
+ {
71
+ params: body.is_a?(String) ? body : body.to_json,
72
+ headers: headers.merge("Content-Type" => content_type)
73
+ }
74
+ end
75
+ # Append query params to path when body is present
76
+ if query_params.any?
77
+ query_string = query_params.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join("&")
78
+ path = "#{path}?#{query_string}"
55
79
  end
80
+ else
81
+ request_args = {params: query_params, headers: headers}
56
82
  end
57
83
 
58
- send_args = {params: request_params}
59
- send_args[:headers] = request_headers if request_headers.any?
60
-
61
- send(method, path, **send_args)
84
+ send(method, path, **request_args)
62
85
 
63
86
  # Validate response
64
- if OpenapiRuby.configuration.validate_responses_in_tests
65
- assert_equal expected_status, response.status,
66
- "Expected status #{expected_status}, got #{response.status}"
67
-
68
- if response_ctx.schema_definition
69
- validator = Testing::ResponseValidator.new
70
- body_data = parse_response_body
71
- errors = validator.validate(
72
- response_body: body_data,
73
- status_code: response.status,
74
- response_context: response_ctx
75
- )
76
- assert errors.empty?, "Response validation failed:\n#{errors.join("\n")}"
77
- end
87
+ assert_equal expected_status, response.status,
88
+ "Expected status #{expected_status}, got #{response.status}\nResponse body: #{response.body}"
89
+
90
+ if OpenapiRuby.configuration.validate_responses_in_tests && response_ctx.schema_definition
91
+ validator = Testing::ResponseValidator.new
92
+ body_data = parse_response_body
93
+ errors = validator.validate(
94
+ response_body: body_data,
95
+ status_code: response.status,
96
+ response_context: response_ctx
97
+ )
98
+ assert errors.empty?, "Response validation failed:\n#{errors.join("\n")}"
78
99
  end
79
100
 
80
101
  # Execute additional assertions
@@ -94,10 +115,8 @@ module OpenapiRuby
94
115
  next false unless ctx.operations.key?(method.to_s)
95
116
 
96
117
  if has_path_params
97
- # Pick the context that has path parameter placeholders
98
118
  ctx.path_template.include?("{")
99
119
  else
100
- # Pick the context without path parameter placeholders
101
120
  !ctx.path_template.include?("{")
102
121
  end
103
122
  end
@@ -115,6 +134,52 @@ module OpenapiRuby
115
134
  context.path_parameters.map { |p| p["name"] }
116
135
  end
117
136
 
137
+ def resolve_base_path(schema_name)
138
+ return "" unless schema_name
139
+
140
+ config = OpenapiRuby.configuration
141
+ schema_config = config.schemas[schema_name.to_sym] || config.schemas[schema_name.to_s]
142
+ return "" unless schema_config
143
+
144
+ server_url = schema_config.dig(:servers, 0, :url) || schema_config.dig("servers", 0, "url")
145
+ return "" unless server_url
146
+
147
+ URI.parse(server_url).path.chomp("/")
148
+ rescue URI::InvalidURIError
149
+ ""
150
+ end
151
+
152
+ def resolve_security_params(operation, schema_name)
153
+ security_list = operation.instance_variable_get(:@security_list)
154
+ return [] unless security_list
155
+
156
+ config = OpenapiRuby.configuration
157
+ schema_config = config.schemas[schema_name.to_sym] || config.schemas[schema_name.to_s]
158
+ return [] unless schema_config
159
+
160
+ security_schemes = schema_config.dig(:components, :securitySchemes) ||
161
+ schema_config.dig("components", "securitySchemes") || {}
162
+
163
+ if security_schemes.empty?
164
+ loader = Components::Loader.new(scope: schema_name.to_s.tr("/", "_").to_sym)
165
+ security_schemes = loader.security_schemes
166
+ end
167
+
168
+ scheme_names = security_list.flat_map { |s| s.is_a?(Hash) ? s.keys.map(&:to_s) : [] }
169
+
170
+ scheme_names.filter_map do |name|
171
+ scheme = security_schemes[name] || security_schemes[name.to_sym]
172
+ next unless scheme
173
+
174
+ type = scheme[:type] || scheme["type"]
175
+ if type.to_s == "apiKey"
176
+ {name: (scheme[:name] || scheme["name"]).to_s, in: (scheme[:in] || scheme["in"]).to_s}
177
+ else
178
+ {name: "Authorization", in: "header"}
179
+ end
180
+ end.uniq { |p| [p[:name], p[:in]] }
181
+ end
182
+
118
183
  def parse_response_body
119
184
  return nil if response.body.empty?
120
185
 
@@ -1,256 +1,257 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "openapi_ruby"
4
+ require "cgi"
5
+ require "uri"
4
6
 
5
7
  module OpenapiRuby
6
8
  module Adapters
7
9
  module RSpec
10
+ # Class-level DSL methods extended onto :openapi example groups.
11
+ # All methods are inherited by nested describe/context/it_behaves_like blocks.
12
+ # Data is stored in RSpec metadata which propagates to child groups.
8
13
  module ExampleGroupHelpers
9
14
  def path(template, &block)
10
15
  schema_name = metadata[:openapi_schema_name]
11
16
  context = DSL::Context.new(template, schema_name: schema_name)
12
17
 
13
18
  describe template do
14
- # Store context reference on the example group metadata
15
19
  metadata[:openapi_path_context] = context
16
-
17
- # Evaluate the block which defines operations via get/post/etc.
18
- # We need a proxy that captures DSL calls and maps them to RSpec describe blocks
19
- proxy = PathProxy.new(self, context)
20
- proxy.instance_eval(&block) if block
21
-
22
- # Register the context for spec generation
20
+ instance_eval(&block) if block
23
21
  DSL::MetadataStore.register(context)
24
22
  end
25
23
  end
26
- end
27
-
28
- class PathProxy
29
- def initialize(example_group, context)
30
- @example_group = example_group
31
- @context = context
32
- end
33
-
34
- def parameter(attributes = {})
35
- @context.parameter(attributes)
36
- end
37
24
 
38
25
  DSL::Context::HTTP_METHODS.each do |method|
39
26
  define_method(method) do |summary = nil, &block|
27
+ path_ctx = metadata[:openapi_path_context]
40
28
  op_context = DSL::OperationContext.new(method, summary)
41
- # Copy path-level parameters
42
- @context.path_parameters.each { |p| op_context.parameter(p) }
43
- @context.operations[method.to_s] = op_context
44
-
45
- @example_group.describe "#{method.to_s.upcase} #{@context.path_template}" do
46
- # Evaluate operation-level DSL
47
- op_proxy = OperationProxy.new(self, op_context, @context)
48
- op_proxy.instance_eval(&block) if block
29
+ path_ctx.path_parameters.each { |p| op_context.parameter(p) }
30
+ path_ctx.operations[method.to_s] = op_context
31
+
32
+ describe "#{method.to_s.upcase} #{path_ctx.path_template}" do
33
+ metadata[:openapi_operation] = op_context
34
+ instance_eval(&block) if block
49
35
  end
50
36
  end
51
37
  end
52
38
 
53
- private
54
-
55
- # Forward missing methods to the example group for non-DSL calls
56
- def method_missing(name, ...)
57
- if @example_group.respond_to?(name)
58
- @example_group.send(name, ...)
59
- else
60
- super
39
+ def parameter(attributes = {})
40
+ if metadata[:openapi_operation]
41
+ metadata[:openapi_operation].parameter(attributes)
42
+ elsif metadata[:openapi_path_context]
43
+ metadata[:openapi_path_context].parameter(attributes)
61
44
  end
62
45
  end
63
46
 
64
- def respond_to_missing?(name, include_private = false)
65
- @example_group.respond_to?(name, include_private) || super
47
+ %i[tags operationId deprecated security].each do |attr_name|
48
+ define_method(attr_name) do |value|
49
+ metadata[:openapi_operation]&.send(attr_name, value)
50
+ end
66
51
  end
67
- end
68
52
 
69
- class OperationProxy
70
- def initialize(example_group, operation_context, path_context)
71
- @example_group = example_group
72
- @operation = operation_context
73
- @path_context = path_context
53
+ def description(value = nil)
54
+ return super() if value.nil?
55
+ metadata[:openapi_operation]&.description(value)
74
56
  end
75
57
 
76
- %i[tags operationId description deprecated consumes produces security
77
- parameter request_body request_body_example].each do |method|
78
- define_method(method) do |*args, **kwargs, &block|
79
- if kwargs.empty?
80
- @operation.send(method, *args, &block)
81
- else
82
- @operation.send(method, *args, **kwargs, &block)
83
- end
84
- end
58
+ def consumes(*content_types)
59
+ metadata[:openapi_operation]&.consumes(*content_types)
85
60
  end
86
61
 
87
- def response(status_code, description, hidden: false, &block)
88
- response_ctx = @operation.response(status_code, description, hidden: hidden)
89
- operation = @operation
90
-
91
- @example_group.context "response #{status_code} #{description}" do
92
- metadata[:openapi_operation] = operation
93
- metadata[:openapi_response] = response_ctx
94
-
95
- # Evaluate response-level DSL
96
- resp_proxy = ResponseProxy.new(self, response_ctx)
97
- resp_proxy.instance_eval(&block) if block
98
- end
62
+ def produces(*content_types)
63
+ metadata[:openapi_operation]&.produces(*content_types)
99
64
  end
100
65
 
101
- private
102
-
103
- def method_missing(name, ...)
104
- if @example_group.respond_to?(name)
105
- @example_group.send(name, ...)
106
- else
107
- super
108
- end
66
+ def request_body(attributes = {})
67
+ metadata[:openapi_operation]&.request_body(attributes)
109
68
  end
110
69
 
111
- def respond_to_missing?(name, include_private = false)
112
- @example_group.respond_to?(name, include_private) || super
70
+ def request_body_example(**kwargs)
71
+ metadata[:openapi_operation]&.request_body_example(**kwargs)
113
72
  end
114
- end
115
73
 
116
- class ResponseProxy
117
- def initialize(example_group, response_context)
118
- @example_group = example_group
119
- @response = response_context
74
+ def response(status_code, description, hidden: false, &block)
75
+ operation = metadata[:openapi_operation]
76
+ response_ctx = operation.response(status_code, description, hidden: hidden)
77
+
78
+ context "response #{status_code} #{description}" do
79
+ metadata[:openapi_response] = response_ctx
80
+ instance_eval(&block) if block
81
+ end
120
82
  end
121
83
 
122
84
  def schema(definition)
123
- @response.schema(definition)
85
+ metadata[:openapi_response]&.schema(definition)
124
86
  end
125
87
 
126
88
  def header(name, attributes = {})
127
- @response.header(name, attributes)
89
+ metadata[:openapi_response]&.header(name, attributes)
128
90
  end
129
91
 
130
- def example(content_type, **)
131
- @response.example(content_type, **)
132
- end
92
+ def run_test!(description = nil, &block)
93
+ response_ctx = metadata[:openapi_response]
133
94
 
134
- def produces(*content_types)
135
- @response.produces(*content_types)
95
+ before do |example|
96
+ submit_openapi_request(example.metadata)
97
+ end
98
+
99
+ it(description || "returns #{response_ctx.status_code}") do |example|
100
+ assert_openapi_response(example.metadata)
101
+ instance_eval(&block) if block
102
+ end
136
103
  end
104
+ end
137
105
 
138
- def run_test!(description = nil, &block)
139
- response_ctx = @response
140
- @example_group.it(description || "returns #{response_ctx.status_code}") do |example|
141
- example_metadata = example.metadata
142
-
143
- # Resolve path parameters from let variables
144
- path = resolve_path(example_metadata)
145
- operation = find_operation(example_metadata)
146
-
147
- # Build params and headers from let variables
148
- params = resolve_let(:request_params) || {}
149
- headers = resolve_let(:request_headers) || {}
150
- body = resolve_let(:request_body)
151
-
152
- # Merge individual parameter let values
153
- operation&.parameters&.each do |param|
154
- name = param["name"]
155
- val = resolve_let(name.to_sym)
156
- next unless val
157
-
158
- case param["in"]
159
- when "query"
160
- params[name] = val
161
- when "header"
162
- headers[name] = val
163
- when "path"
164
- # Already handled in resolve_path
165
- end
166
- end
106
+ # Instance-level helper methods mixed into RSpec examples
107
+ module ExampleHelpers
108
+ # submit_openapi_request is public so specs can call it directly
109
+ # (e.g., for rate limiting tests that need multiple requests)
110
+ def submit_openapi_request(metadata)
111
+ path = resolve_path(metadata)
112
+ operation = find_in_metadata(metadata, :openapi_operation)
113
+
114
+ params = resolve_let(:request_params) || {}
115
+ headers = resolve_let(:request_headers) || {}
116
+ body = resolve_let(:request_body)
117
+
118
+ # Merge individual parameter let values
119
+ operation&.parameters&.each do |param|
120
+ name = param["name"]
121
+ val = resolve_let(name.to_sym)
122
+ next if val.nil?
167
123
 
168
- # Execute the request
169
- method = operation&.verb || example_metadata[:openapi_operation]&.verb || "get"
170
- send_args = {params: body || params}
171
- send_args[:headers] = headers if headers.any?
172
-
173
- if body
174
- content_type = headers["Content-Type"] || operation&.instance_variable_get(:@consumes_list)&.first
175
-
176
- if content_type&.include?("form-data") || content_type&.include?("x-www-form-urlencoded")
177
- send_args[:params] = body
178
- send_args[:headers] = (headers || {}).merge("Content-Type" => content_type)
179
- else
180
- send_args[:params] = body.is_a?(String) ? body : body.to_json
181
- send_args[:headers] = (headers || {}).merge("Content-Type" => content_type || "application/json")
182
- end
124
+ case param["in"]
125
+ when "query" then params[name] = val
126
+ when "header" then headers[name] = val
183
127
  end
128
+ end
184
129
 
185
- send(method.to_sym, path, **send_args)
186
-
187
- # Validate response
188
- if OpenapiRuby.configuration.validate_responses_in_tests && response_ctx.schema_definition
189
- validator = Testing::ResponseValidator.new
190
- errors = validator.validate(
191
- response_body: parsed_response_body,
192
- status_code: response.status,
193
- response_context: response_ctx
194
- )
195
- expect(errors).to be_empty, "Response validation failed:\n#{errors.join("\n")}"
196
- else
197
- expect(response.status).to eq(response_ctx.status_code.to_i)
198
- end
130
+ # Resolve security scheme parameters from let variables
131
+ resolve_security_params(operation, metadata).each do |param|
132
+ val = resolve_let(param[:name].to_sym)
133
+ next unless val
199
134
 
200
- # Execute additional assertions
201
- instance_eval(&block) if block
135
+ case param[:in].to_s
136
+ when "header" then headers[param[:name]] = val
137
+ when "query" then params[param[:name]] = val
138
+ when "cookie" then headers["Cookie"] = "#{param[:name]}=#{val}"
139
+ end
202
140
  end
203
- end
204
141
 
205
- # Forward let/before/after/subject to RSpec
206
- def method_missing(name, ...)
207
- if @example_group.respond_to?(name)
208
- @example_group.send(name, ...)
142
+ method = operation&.verb || "get"
143
+ # Accept header: use let(:Accept) if defined, otherwise default to JSON
144
+ accept = resolve_let(:Accept)
145
+ headers["Accept"] = accept || "application/json"
146
+
147
+ if body
148
+ content_type = operation&.request_body_definition&.dig("content")&.keys&.first || "application/json"
149
+ request_args = if content_type.include?("form-data") || content_type.include?("x-www-form-urlencoded")
150
+ {params: body, headers: headers}
151
+ else
152
+ {
153
+ params: body.is_a?(String) ? body : body.to_json,
154
+ headers: headers.merge("Content-Type" => content_type)
155
+ }
156
+ end
157
+ # Append query params to path when body is present
158
+ if params.any?
159
+ query_string = params.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join("&")
160
+ path = "#{path}?#{query_string}"
161
+ end
209
162
  else
210
- super
163
+ request_args = {params: params, headers: headers}
211
164
  end
165
+
166
+ send(method.to_sym, path, **request_args)
212
167
  end
213
168
 
214
- def respond_to_missing?(name, include_private = false)
215
- @example_group.respond_to?(name, include_private) || super
169
+ def assert_openapi_response(metadata)
170
+ response_ctx = find_in_metadata(metadata, :openapi_response)
171
+
172
+ expected_status = response_ctx.status_code.to_i
173
+ actual_status = response.status
174
+
175
+ unless actual_status == expected_status
176
+ raise "Response validation failed:\n" \
177
+ "Expected status #{expected_status}, got #{actual_status}\n" \
178
+ "Response body: #{response.body}"
179
+ end
216
180
  end
217
- end
218
181
 
219
- # Helper methods mixed into RSpec examples
220
- module ExampleHelpers
221
182
  private
222
183
 
223
184
  def resolve_path(metadata)
224
- path_ctx = find_path_context(metadata)
185
+ path_ctx = find_in_metadata(metadata, :openapi_path_context)
225
186
  template = path_ctx&.path_template || ""
226
- find_operation(metadata)
227
187
 
228
- # Substitute {param} placeholders with let values
229
- template.gsub(/\{(\w+)\}/) do
188
+ base_path = resolve_base_path(path_ctx&.schema_name)
189
+ full_path = "#{base_path}#{template}"
190
+
191
+ full_path.gsub(/\{(\w+)\}/) do
230
192
  name = ::Regexp.last_match(1)
231
- val = resolve_let(name.to_sym)
232
- val || "{#{name}}"
193
+ resolve_let(name.to_sym) || "{#{name}}"
233
194
  end
234
195
  end
235
196
 
236
- def find_path_context(metadata)
197
+ def find_in_metadata(metadata, key)
237
198
  meta = metadata
238
199
  while meta
239
- return meta[:openapi_path_context] if meta[:openapi_path_context]
240
-
200
+ return meta[key] if meta[key]
241
201
  meta = meta[:parent_example_group]
242
202
  end
243
203
  nil
244
204
  end
245
205
 
246
- def find_operation(metadata)
247
- meta = metadata
248
- while meta
249
- return meta[:openapi_operation] if meta[:openapi_operation]
206
+ def resolve_base_path(schema_name)
207
+ return "" unless schema_name
250
208
 
251
- meta = meta[:parent_example_group]
209
+ config = OpenapiRuby.configuration
210
+ schema_config = config.schemas[schema_name.to_sym] || config.schemas[schema_name.to_s]
211
+ return "" unless schema_config
212
+
213
+ server_url = schema_config.dig(:servers, 0, :url) || schema_config.dig("servers", 0, "url")
214
+ return "" unless server_url
215
+
216
+ URI.parse(server_url).path.chomp("/")
217
+ rescue URI::InvalidURIError
218
+ ""
219
+ end
220
+
221
+ def resolve_security_params(operation, metadata)
222
+ security_list = operation&.instance_variable_get(:@security_list)
223
+ return [] unless security_list
224
+
225
+ schema_name = find_in_metadata(metadata, :openapi_schema_name)
226
+ return [] unless schema_name
227
+
228
+ config = OpenapiRuby.configuration
229
+ schema_config = config.schemas[schema_name.to_sym] || config.schemas[schema_name.to_s]
230
+ return [] unless schema_config
231
+
232
+ security_schemes = schema_config.dig(:components, :securitySchemes) ||
233
+ schema_config.dig("components", "securitySchemes") || {}
234
+
235
+ # Also check registered components
236
+ if security_schemes.empty?
237
+ loader = Components::Loader.new(scope: schema_name.to_s.tr("/", "_").to_sym)
238
+ security_schemes = loader.security_schemes
252
239
  end
253
- nil
240
+
241
+ scheme_names = security_list.flat_map { |s| s.is_a?(Hash) ? s.keys.map(&:to_s) : [] }
242
+
243
+ scheme_names.filter_map do |name|
244
+ scheme = security_schemes[name] || security_schemes[name.to_sym]
245
+ next unless scheme
246
+
247
+ type = scheme[:type] || scheme["type"]
248
+ if type.to_s == "apiKey"
249
+ {name: (scheme[:name] || scheme["name"]).to_s, in: (scheme[:in] || scheme["in"]).to_s}
250
+ else
251
+ # OAuth2, http bearer, etc. → Authorization header
252
+ {name: "Authorization", in: "header"}
253
+ end
254
+ end.uniq { |p| [p[:name], p[:in]] }
254
255
  end
255
256
 
256
257
  def resolve_let(name)
@@ -261,21 +262,10 @@ module OpenapiRuby
261
262
 
262
263
  def parsed_response_body
263
264
  return nil if response.body.empty?
264
-
265
265
  JSON.parse(response.body)
266
266
  rescue JSON::ParserError
267
267
  response.body
268
268
  end
269
-
270
- def operation_context_from_parent
271
- meta = self.class.metadata
272
- while meta
273
- return meta[:openapi_operation] if meta[:openapi_operation]
274
-
275
- meta = meta[:parent_example_group]
276
- end
277
- nil
278
- end
279
269
  end
280
270
 
281
271
  def self.install!
@@ -283,7 +273,6 @@ module OpenapiRuby
283
273
  config.extend ExampleGroupHelpers, type: :openapi
284
274
  config.include ExampleHelpers, type: :openapi
285
275
 
286
- # Make type: :openapi behave like request specs (includes integration test methods)
287
276
  if defined?(::RSpec::Rails)
288
277
  config.include ::RSpec::Rails::RequestExampleGroup, type: :openapi
289
278
  end
@@ -293,6 +282,17 @@ module OpenapiRuby
293
282
  rescue => e
294
283
  warn "[openapi_ruby] Schema generation failed: #{e.message}"
295
284
  end
285
+
286
+ # RSpec's --dry-run mode does not fire after(:suite) hooks.
287
+ # Describe/context blocks are still evaluated (registering DSL contexts),
288
+ # but the hook that writes schemas never runs. Use at_exit as a fallback.
289
+ if config.dry_run?
290
+ at_exit do
291
+ OpenapiRuby::Generator::SchemaWriter.generate_all!
292
+ rescue => e
293
+ warn "[openapi_ruby] Schema generation failed: #{e.message}"
294
+ end
295
+ end
296
296
  end
297
297
  end
298
298
  end
@@ -110,28 +110,63 @@ module OpenapiRuby
110
110
  end
111
111
 
112
112
  def load_with_scope_inference(all_files, scope_paths)
113
+ # Build a map of file path → inferred scope before loading.
114
+ file_scope_map = {}
113
115
  all_files.each do |entry|
114
- inferred_scope = infer_scope(entry[:relative], scope_paths)
116
+ scope = infer_scope(entry[:relative], scope_paths)
117
+ file_scope_map[entry[:file]] = scope if scope
118
+ end
115
119
 
120
+ # Load files, tracking which classes each file registers.
121
+ # We track both newly loaded AND already-loaded classes via before/after diffs.
122
+ class_to_file = {}
123
+ all_files.each do |entry|
116
124
  registered_before = Registry.instance.all_registered_classes.dup
117
125
  require entry[:file]
118
- registered_after = Registry.instance.all_registered_classes
119
-
120
- new_classes = registered_after - registered_before
121
- new_classes.each do |klass|
122
- next if klass._component_scopes_explicitly_set
123
-
124
- if inferred_scope == :shared
125
- klass._component_scopes = []
126
- elsif inferred_scope
127
- Registry.instance.unregister(klass)
128
- klass._component_scopes = [inferred_scope]
129
- Registry.instance.register(klass)
130
- end
126
+ new_classes = Registry.instance.all_registered_classes - registered_before
127
+ new_classes.each { |klass| class_to_file[klass] = entry[:file] }
128
+ end
129
+
130
+ # For components that were already autoloaded by Rails (require returned false,
131
+ # so they didn't appear in the before/after diff), try to match them to files
132
+ # by their class name → file path convention.
133
+ Registry.instance.all_registered_classes.each do |klass|
134
+ next if class_to_file.key?(klass)
135
+
136
+ source_file = find_source_file_for(klass)
137
+ class_to_file[klass] = source_file if source_file
138
+ end
139
+
140
+ # Assign scopes to all registered components based on their source file.
141
+ class_to_file.each do |klass, file|
142
+ next if klass._component_scopes_explicitly_set
143
+
144
+ inferred_scope = file_scope_map[file]
145
+ next unless inferred_scope
146
+
147
+ if inferred_scope == :shared
148
+ klass._component_scopes = []
149
+ else
150
+ Registry.instance.unregister(klass)
151
+ klass._component_scopes = [inferred_scope]
152
+ Registry.instance.register(klass)
131
153
  end
132
154
  end
133
155
  end
134
156
 
157
+ def find_source_file_for(klass)
158
+ return nil unless klass.name
159
+
160
+ # Try the conventional path based on the class name (e.g., Internal::V1::Schemas::User → internal/v1/schemas/user.rb)
161
+ relative = klass.name.underscore + ".rb"
162
+ @paths.each do |base_path|
163
+ expanded = File.expand_path(base_path)
164
+ candidate = File.join(expanded, relative)
165
+ return candidate if File.exist?(candidate)
166
+ end
167
+ nil
168
+ end
169
+
135
170
  def infer_scope(relative_path, scope_paths)
136
171
  scope_paths.sort_by { |prefix, _| -prefix.length }.each do |prefix, scope|
137
172
  return scope&.to_sym if relative_path.start_with?("#{prefix}/")
@@ -94,7 +94,18 @@ module OpenapiRuby
94
94
  result[type_key] = {}
95
95
  components.each_value do |klass|
96
96
  next if klass._schema_hidden
97
- next if scope && !klass._component_scopes.empty? && !klass._component_scopes.include?(scope)
97
+ # When filtering by scope:
98
+ # - Components with matching scope: included
99
+ # - Components explicitly marked as shared (empty scopes + explicitly_set): included
100
+ # - Components with non-matching scope: excluded
101
+ # - Components with no scope assigned (empty scopes + NOT explicitly_set): excluded
102
+ if scope
103
+ if klass._component_scopes.empty?
104
+ next unless klass._component_scopes_explicitly_set
105
+ else
106
+ next unless klass._component_scopes.include?(scope)
107
+ end
108
+ end
98
109
 
99
110
  result[type_key][klass.component_name] = klass.to_openapi
100
111
  end
@@ -49,9 +49,9 @@ module OpenapiRuby
49
49
  private
50
50
 
51
51
  def inject_validation_error_responses!
52
- # Add ValidationError response component
52
+ # Add SchemaValidationError response component
53
53
  @components["responses"] ||= {}
54
- @components["responses"]["ValidationError"] ||= {
54
+ @components["responses"]["SchemaValidationError"] ||= {
55
55
  "description" => "Request validation failed",
56
56
  "content" => {
57
57
  "application/json" => {
@@ -66,7 +66,7 @@ module OpenapiRuby
66
66
  next unless operation.is_a?(Hash) && operation.key?("responses")
67
67
  next if key == "parameters"
68
68
 
69
- operation["responses"]["400"] ||= {"$ref" => "#/components/responses/ValidationError"}
69
+ operation["responses"]["400"] ||= {"$ref" => "#/components/responses/SchemaValidationError"}
70
70
  end
71
71
  end
72
72
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiRuby
4
- VERSION = "2.3.1"
4
+ VERSION = "2.5.0"
5
5
  end
@@ -1,9 +1,50 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  namespace :openapi_ruby do
4
- desc "Generate OpenAPI spec files from test definitions and components"
4
+ desc "Generate OpenAPI schema files from spec definitions and components"
5
5
  task generate: :environment do
6
- require "openapi_ruby"
7
- OpenapiRuby::Generator::SchemaWriter.generate_all!
6
+ framework = ENV.fetch("FRAMEWORK", detect_test_framework).to_s
7
+
8
+ case framework
9
+ when "rspec"
10
+ generate_with_rspec
11
+ when "minitest"
12
+ generate_with_minitest
13
+ else
14
+ abort "Unknown test framework '#{framework}'. Set FRAMEWORK=rspec or FRAMEWORK=minitest."
15
+ end
16
+ end
17
+ end
18
+
19
+ def detect_test_framework
20
+ if File.exist?("spec/spec_helper.rb") || File.exist?("spec/rails_helper.rb")
21
+ "rspec"
22
+ elsif File.exist?("test/test_helper.rb")
23
+ "minitest"
24
+ else
25
+ abort "Could not detect test framework. Set FRAMEWORK=rspec or FRAMEWORK=minitest."
8
26
  end
9
27
  end
28
+
29
+ def generate_with_rspec
30
+ pattern = ENV.fetch("PATTERN", "spec/**/*_spec.rb")
31
+ command = "bundle exec rspec --pattern '#{pattern}' --dry-run --order defined"
32
+ puts "Generating OpenAPI schemas (RSpec)..."
33
+ system(command) || abort("Schema generation failed")
34
+ end
35
+
36
+ def generate_with_minitest
37
+ pattern = ENV.fetch("PATTERN", "test/**/*_test.rb")
38
+ puts "Generating OpenAPI schemas (Minitest)..."
39
+
40
+ # Load Rails environment and minitest adapter
41
+ require "openapi_ruby/minitest"
42
+
43
+ # Load all test files to trigger api_path registrations.
44
+ # Minitest's api_path registers DSL contexts at class load time,
45
+ # so simply requiring the files is enough.
46
+ Dir.glob(pattern).each { |f| require File.expand_path(f) }
47
+
48
+ # Generate schemas from the registered contexts
49
+ OpenapiRuby::Generator::SchemaWriter.generate_all!
50
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openapi-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.1
4
+ version: 2.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Morten Hartvig