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 +4 -4
- data/lib/openapi_ruby/adapters/minitest.rb +97 -32
- data/lib/openapi_ruby/adapters/rspec.rb +184 -184
- data/lib/openapi_ruby/components/loader.rb +49 -14
- data/lib/openapi_ruby/components/registry.rb +12 -1
- data/lib/openapi_ruby/core/document_builder.rb +3 -3
- data/lib/openapi_ruby/version.rb +1 -1
- data/lib/tasks/openapi_ruby.rake +44 -3
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b425b7d5cf7a5dc4768214f1aa26d5d8786cf3ceb6cf113ce05cf52412be7249
|
|
4
|
+
data.tar.gz: 7ff62541a6000e03418958afa9255b58ea7b254449a2b1ff9a159dd06c9696ff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
112
|
-
|
|
70
|
+
def request_body_example(**kwargs)
|
|
71
|
+
metadata[:openapi_operation]&.request_body_example(**kwargs)
|
|
113
72
|
end
|
|
114
|
-
end
|
|
115
73
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
85
|
+
metadata[:openapi_response]&.schema(definition)
|
|
124
86
|
end
|
|
125
87
|
|
|
126
88
|
def header(name, attributes = {})
|
|
127
|
-
|
|
89
|
+
metadata[:openapi_response]&.header(name, attributes)
|
|
128
90
|
end
|
|
129
91
|
|
|
130
|
-
def
|
|
131
|
-
|
|
132
|
-
end
|
|
92
|
+
def run_test!(description = nil, &block)
|
|
93
|
+
response_ctx = metadata[:openapi_response]
|
|
133
94
|
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
|
215
|
-
|
|
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 =
|
|
185
|
+
path_ctx = find_in_metadata(metadata, :openapi_path_context)
|
|
225
186
|
template = path_ctx&.path_template || ""
|
|
226
|
-
find_operation(metadata)
|
|
227
187
|
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
232
|
-
val || "{#{name}}"
|
|
193
|
+
resolve_let(name.to_sym) || "{#{name}}"
|
|
233
194
|
end
|
|
234
195
|
end
|
|
235
196
|
|
|
236
|
-
def
|
|
197
|
+
def find_in_metadata(metadata, key)
|
|
237
198
|
meta = metadata
|
|
238
199
|
while meta
|
|
239
|
-
return meta[
|
|
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
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
|
52
|
+
# Add SchemaValidationError response component
|
|
53
53
|
@components["responses"] ||= {}
|
|
54
|
-
@components["responses"]["
|
|
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/
|
|
69
|
+
operation["responses"]["400"] ||= {"$ref" => "#/components/responses/SchemaValidationError"}
|
|
70
70
|
end
|
|
71
71
|
end
|
|
72
72
|
end
|
data/lib/openapi_ruby/version.rb
CHANGED
data/lib/tasks/openapi_ruby.rake
CHANGED
|
@@ -1,9 +1,50 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
namespace :openapi_ruby do
|
|
4
|
-
desc "Generate OpenAPI
|
|
4
|
+
desc "Generate OpenAPI schema files from spec definitions and components"
|
|
5
5
|
task generate: :environment do
|
|
6
|
-
|
|
7
|
-
|
|
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
|