openapi-ruby 3.1.0 → 3.2.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/README.md +74 -3
- data/lib/generators/openapi_ruby/install/templates/initializer.rb.tt +0 -3
- data/lib/openapi_ruby/adapters/minitest.rb +33 -1
- data/lib/openapi_ruby/adapters/rspec.rb +205 -6
- data/lib/openapi_ruby/configuration.rb +27 -17
- data/lib/openapi_ruby/generator/schema_writer.rb +7 -2
- data/lib/openapi_ruby/middleware/schema_resolver.rb +8 -4
- data/lib/openapi_ruby/testing/request_validator.rb +153 -0
- data/lib/openapi_ruby/version.rb +1 -1
- data/lib/openapi_ruby.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2e97365e4c2b566d59db1f4946a2b0e1f45f8ee9b938f2b5c049ac0ac9f56dcc
|
|
4
|
+
data.tar.gz: 431709a674d6a028fd8a14f47200761215a6e4576888009b3b2b637fd5a453b9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0a670b21d1043c474b2eead1844c47258521df05b247d561a5578de4c8495a6519b575c7b3d2d4fe4fc54c22d5d524e45ee2fab492e99e3366c8eab5bc381ecf
|
|
7
|
+
data.tar.gz: e2981bfa965fef6c6db6767d8e36592b8166fa20f675265c9d0147265565250e845093ca4fb3dc3bd3a7d2a2b1c3a7c6cb9d52169a0142bc0be2a90727a50b77
|
data/README.md
CHANGED
|
@@ -63,12 +63,15 @@ OpenapiRuby.configure do |config|
|
|
|
63
63
|
config.camelize_keys = true
|
|
64
64
|
config.schema_output_dir = "swagger"
|
|
65
65
|
config.schema_output_format = :yaml
|
|
66
|
-
config.validate_responses_in_tests = true
|
|
67
66
|
|
|
68
67
|
# Runtime middleware (disabled by default)
|
|
69
68
|
config.request_validation = :disabled # :enabled, :disabled, :warn_only
|
|
70
69
|
config.response_validation = :disabled
|
|
71
70
|
|
|
71
|
+
# Test DSL: validate requests against declared operations before sending.
|
|
72
|
+
# Enabled by default; set to false to disable.
|
|
73
|
+
config.test_request_validation = true
|
|
74
|
+
|
|
72
75
|
# Optional Swagger UI (disabled by default)
|
|
73
76
|
config.ui_enabled = false
|
|
74
77
|
end
|
|
@@ -231,6 +234,12 @@ rails generate openapi_ruby:component BearerAuth security_schemes
|
|
|
231
234
|
require "openapi_ruby/rspec"
|
|
232
235
|
```
|
|
233
236
|
|
|
237
|
+
RSpec supports two DSL styles. Both generate the same OpenAPI spec and validate responses (and requests) against it.
|
|
238
|
+
|
|
239
|
+
### Style 1: `path` / `run_test!`
|
|
240
|
+
|
|
241
|
+
Schema definition and test execution are interleaved. Each `response` block uses `let` values and `run_test!` to send the request inline:
|
|
242
|
+
|
|
234
243
|
```ruby
|
|
235
244
|
# spec/requests/users_spec.rb
|
|
236
245
|
require "openapi_helper"
|
|
@@ -294,11 +303,71 @@ RSpec.describe "Users API", type: :openapi do
|
|
|
294
303
|
end
|
|
295
304
|
```
|
|
296
305
|
|
|
306
|
+
### Style 2: `api_path` / `assert_api_response`
|
|
307
|
+
|
|
308
|
+
Schema definition at the top, normal RSpec examples underneath. Mirrors the Minitest DSL and is useful when you want basic schema validation separated from detailed edge-case tests:
|
|
309
|
+
|
|
310
|
+
```ruby
|
|
311
|
+
require "openapi_helper"
|
|
312
|
+
|
|
313
|
+
RSpec.describe "Users API", type: :openapi do
|
|
314
|
+
openapi_schema :public_api
|
|
315
|
+
|
|
316
|
+
api_path "/api/v1/users" do
|
|
317
|
+
get "List users" do
|
|
318
|
+
tags "Users"
|
|
319
|
+
produces "application/json"
|
|
320
|
+
|
|
321
|
+
response 200, "returns all users" do
|
|
322
|
+
schema type: :array, items: { "$ref" => "#/components/schemas/User" }
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
post "Create a user" do
|
|
327
|
+
consumes "application/json"
|
|
328
|
+
|
|
329
|
+
request_body required: true, content: {
|
|
330
|
+
"application/json" => {
|
|
331
|
+
schema: { "$ref" => "#/components/schemas/UserInput" }
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
response 201, "user created" do
|
|
336
|
+
schema "$ref" => "#/components/schemas/User"
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
response 422, "validation errors" do
|
|
340
|
+
schema "$ref" => "#/components/schemas/ValidationErrors"
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Normal RSpec examples
|
|
346
|
+
it "returns all users" do
|
|
347
|
+
User.create!(name: "Jane", email: "jane@example.com")
|
|
348
|
+
|
|
349
|
+
assert_api_response :get, 200 do
|
|
350
|
+
expect(parsed_body.length).to eq(1)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
it "creates a user" do
|
|
355
|
+
assert_api_response :post, 201, body: { name: "Jane", email: "jane@example.com" } do
|
|
356
|
+
expect(parsed_body["name"]).to eq("Jane")
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
`assert_api_response` accepts `params:`, `headers:`, `body:`, and `path_params:` keyword arguments. It validates the response status and body schema automatically, then yields to the block for additional expectations.
|
|
363
|
+
|
|
297
364
|
### DSL Reference
|
|
298
365
|
|
|
299
366
|
| Method | Level | Description |
|
|
300
367
|
|--------|-------|-------------|
|
|
301
|
-
| `path(template, &block)` | Top | Define an API path |
|
|
368
|
+
| `path(template, &block)` | Top | Define an API path (style 1) |
|
|
369
|
+
| `api_path(template, &block)` | Top | Define an API path (style 2) |
|
|
370
|
+
| `openapi_schema(name)` | Top | Set the schema name (style 2) |
|
|
302
371
|
| `get/post/put/patch/delete(summary, &block)` | Path | Define an operation |
|
|
303
372
|
| `tags(*tags)` | Operation | Tag the operation |
|
|
304
373
|
| `operationId(id)` | Operation | Set operation ID |
|
|
@@ -312,7 +381,9 @@ end
|
|
|
312
381
|
| `response(status, description, &block)` | Operation | Define expected response |
|
|
313
382
|
| `schema(definition)` | Response | Response body schema |
|
|
314
383
|
| `header(name, schema:, **opts)` | Response | Response header |
|
|
315
|
-
| `run_test!(&block)` | Response | Execute request and validate |
|
|
384
|
+
| `run_test!(&block)` | Response | Execute request and validate (style 1) |
|
|
385
|
+
| `assert_api_response(method, status, **opts, &block)` | Example | Execute request and validate (style 2) |
|
|
386
|
+
| `parsed_body` | Example | Parsed JSON response body |
|
|
316
387
|
|
|
317
388
|
## Testing with Minitest
|
|
318
389
|
|
|
@@ -20,9 +20,6 @@ OpenapiRuby.configure do |config|
|
|
|
20
20
|
config.schema_output_dir = "swagger"
|
|
21
21
|
config.schema_output_format = :yaml
|
|
22
22
|
|
|
23
|
-
# Validate response bodies in tests against defined schemas
|
|
24
|
-
config.validate_responses_in_tests = true
|
|
25
|
-
|
|
26
23
|
# Runtime request/response validation middleware
|
|
27
24
|
# Options: :disabled, :enabled, :warn_only
|
|
28
25
|
config.request_validation = :disabled
|
|
@@ -81,13 +81,28 @@ module OpenapiRuby
|
|
|
81
81
|
request_args = {params: query_params, headers: headers}
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
+
# Validate the request against the declared operation (skip for error responses,
|
|
85
|
+
# since those tests intentionally send invalid data)
|
|
86
|
+
if OpenapiRuby.configuration.test_request_validation && expected_status < 400
|
|
87
|
+
document_hash = build_validation_document(context.schema_name)
|
|
88
|
+
req_errors = Testing::RequestValidator.new(document_hash).validate(
|
|
89
|
+
operation: operation,
|
|
90
|
+
path_context: context,
|
|
91
|
+
params: params,
|
|
92
|
+
headers: headers,
|
|
93
|
+
body: body,
|
|
94
|
+
path_params: path_params
|
|
95
|
+
)
|
|
96
|
+
assert req_errors.empty?, "Request validation failed:\n#{req_errors.join("\n")}"
|
|
97
|
+
end
|
|
98
|
+
|
|
84
99
|
send(method, path, **request_args)
|
|
85
100
|
|
|
86
101
|
# Validate response
|
|
87
102
|
assert_equal expected_status, response.status,
|
|
88
103
|
"Expected status #{expected_status}, got #{response.status}\nResponse body: #{response.body}"
|
|
89
104
|
|
|
90
|
-
if
|
|
105
|
+
if response_ctx.schema_definition
|
|
91
106
|
validator = Testing::ResponseValidator.new
|
|
92
107
|
body_data = parse_response_body
|
|
93
108
|
errors = validator.validate(
|
|
@@ -181,6 +196,23 @@ module OpenapiRuby
|
|
|
181
196
|
end.uniq { |p| [p[:name], p[:in]] }
|
|
182
197
|
end
|
|
183
198
|
|
|
199
|
+
def build_validation_document(schema_name)
|
|
200
|
+
return nil unless schema_name
|
|
201
|
+
|
|
202
|
+
config = OpenapiRuby.configuration
|
|
203
|
+
schema_config = config.schemas[schema_name.to_sym] || config.schemas[schema_name.to_s]
|
|
204
|
+
return nil unless schema_config
|
|
205
|
+
|
|
206
|
+
builder = OpenapiRuby::Core::DocumentBuilder.new(schema_config)
|
|
207
|
+
OpenapiRuby::DSL::MetadataStore.contexts_for(schema_name).each do |ctx|
|
|
208
|
+
builder.add_path(ctx.path_template, ctx.to_openapi)
|
|
209
|
+
end
|
|
210
|
+
scope = schema_config[:component_scope]
|
|
211
|
+
loader = OpenapiRuby::Components::Loader.new(scope: scope)
|
|
212
|
+
builder.merge_components(loader.to_openapi_hash)
|
|
213
|
+
builder.build.data
|
|
214
|
+
end
|
|
215
|
+
|
|
184
216
|
def parse_response_body
|
|
185
217
|
return nil if response.body.empty?
|
|
186
218
|
|
|
@@ -11,6 +11,22 @@ module OpenapiRuby
|
|
|
11
11
|
# All methods are inherited by nested describe/context/it_behaves_like blocks.
|
|
12
12
|
# Data is stored in RSpec metadata which propagates to child groups.
|
|
13
13
|
module ExampleGroupHelpers
|
|
14
|
+
def openapi_schema(name)
|
|
15
|
+
metadata[:openapi_schema_name] = name.to_sym
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Minitest-style DSL: define the schema at the top of the spec file,
|
|
19
|
+
# then write normal RSpec examples underneath using assert_api_response.
|
|
20
|
+
def api_path(template, &block)
|
|
21
|
+
schema_name = metadata[:openapi_schema_name]
|
|
22
|
+
context = DSL::Context.new(template, schema_name: schema_name)
|
|
23
|
+
context.instance_eval(&block) if block
|
|
24
|
+
metadata[:openapi_api_contexts] ||= []
|
|
25
|
+
metadata[:openapi_api_contexts] << context
|
|
26
|
+
DSL::MetadataStore.register(context)
|
|
27
|
+
context
|
|
28
|
+
end
|
|
29
|
+
|
|
14
30
|
def path(template, &block)
|
|
15
31
|
schema_name = metadata[:openapi_schema_name]
|
|
16
32
|
context = DSL::Context.new(template, schema_name: schema_name)
|
|
@@ -104,6 +120,100 @@ module OpenapiRuby
|
|
|
104
120
|
|
|
105
121
|
# Instance-level helper methods mixed into RSpec examples
|
|
106
122
|
module ExampleHelpers
|
|
123
|
+
# Minitest-style assertion: looks up the api_path context, makes the
|
|
124
|
+
# request, validates the response status + body, then yields to the
|
|
125
|
+
# block for additional expectations.
|
|
126
|
+
def assert_api_response(method, expected_status, params: {}, headers: {}, body: nil, path_params: {}, &block)
|
|
127
|
+
meta = ::RSpec.current_example.metadata
|
|
128
|
+
context = find_api_context_for(meta, method, path_params)
|
|
129
|
+
raise OpenapiRuby::Error, "No api_path defined for #{method.upcase} in this example group" unless context
|
|
130
|
+
|
|
131
|
+
operation = context.operations[method.to_s]
|
|
132
|
+
raise OpenapiRuby::Error, "No #{method.upcase} operation defined" unless operation
|
|
133
|
+
|
|
134
|
+
response_ctx = operation.responses[expected_status.to_s]
|
|
135
|
+
raise OpenapiRuby::Error, "No response #{expected_status} defined for #{method.upcase}" unless response_ctx
|
|
136
|
+
|
|
137
|
+
base_path = resolve_base_path(context.schema_name)
|
|
138
|
+
path = "#{base_path}#{expand_path(context.path_template, params.merge(path_params))}"
|
|
139
|
+
|
|
140
|
+
# Resolve security scheme parameters
|
|
141
|
+
resolve_security_params(operation, meta).each do |param|
|
|
142
|
+
val = params[param[:name].to_sym] || params[param[:name]]
|
|
143
|
+
next if val.nil?
|
|
144
|
+
|
|
145
|
+
case param[:in].to_s
|
|
146
|
+
when "header" then headers[param[:name]] = val
|
|
147
|
+
when "query" then params[param[:name]] = val
|
|
148
|
+
when "cookie" then headers["Cookie"] = "#{param[:name]}=#{val}"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
headers["Accept"] ||= "application/json"
|
|
153
|
+
|
|
154
|
+
path_param_names = context.path_parameters.map { |p| p["name"] }
|
|
155
|
+
query_params = params.reject { |k, _| path_param_names.include?(k.to_s) }
|
|
156
|
+
|
|
157
|
+
if body
|
|
158
|
+
content_type = operation.request_body_definition&.dig("content")&.keys&.first || "application/json"
|
|
159
|
+
request_args = if content_type.include?("form-data") || content_type.include?("x-www-form-urlencoded")
|
|
160
|
+
{params: body, headers: headers}
|
|
161
|
+
else
|
|
162
|
+
{
|
|
163
|
+
params: body.is_a?(String) ? body : body.to_json,
|
|
164
|
+
headers: headers.merge("Content-Type" => content_type)
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
else
|
|
168
|
+
request_args = {headers: headers}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
if query_params.any?
|
|
172
|
+
path = "#{path}?#{Rack::Utils.build_nested_query(query_params)}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Validate the request against the declared operation (skip for error responses,
|
|
176
|
+
# since those tests intentionally send invalid data)
|
|
177
|
+
if OpenapiRuby.configuration.test_request_validation && expected_status < 400
|
|
178
|
+
document_hash = OpenapiRuby::Adapters::RSpec.validation_document_for(context.schema_name)
|
|
179
|
+
req_errors = Testing::RequestValidator.new(document_hash).validate(
|
|
180
|
+
operation: operation,
|
|
181
|
+
path_context: context,
|
|
182
|
+
params: params,
|
|
183
|
+
headers: headers,
|
|
184
|
+
body: body,
|
|
185
|
+
path_params: path_params
|
|
186
|
+
)
|
|
187
|
+
raise "Request validation failed:\n#{req_errors.join("\n")}" unless req_errors.empty?
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
send(method, path, **request_args)
|
|
191
|
+
|
|
192
|
+
unless response.status == expected_status
|
|
193
|
+
raise "Expected status #{expected_status}, got #{response.status}\nResponse body: #{response.body}"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
if response_ctx.schema_definition
|
|
197
|
+
validator = Testing::ResponseValidator.new(
|
|
198
|
+
OpenapiRuby::Adapters::RSpec.validation_document_for(context.schema_name)
|
|
199
|
+
)
|
|
200
|
+
errors = validator.validate(
|
|
201
|
+
response_body: parsed_response_body,
|
|
202
|
+
status_code: response.status,
|
|
203
|
+
response_context: response_ctx
|
|
204
|
+
)
|
|
205
|
+
unless errors.empty?
|
|
206
|
+
raise "Response body validation failed:\n#{errors.join("\n")}\nResponse body: #{response.body}"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
instance_eval(&block) if block
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def parsed_body
|
|
214
|
+
parsed_response_body
|
|
215
|
+
end
|
|
216
|
+
|
|
107
217
|
# submit_openapi_request is public so specs can call it directly
|
|
108
218
|
# (e.g., for rate limiting tests that need multiple requests)
|
|
109
219
|
def submit_openapi_request(metadata)
|
|
@@ -144,6 +254,12 @@ module OpenapiRuby
|
|
|
144
254
|
accept = resolve_let(:Accept)
|
|
145
255
|
headers["Accept"] = accept || "application/json"
|
|
146
256
|
|
|
257
|
+
# Always append query params to the URL so the middleware sees them
|
|
258
|
+
# (Rails sends params as request body for non-GET methods).
|
|
259
|
+
if params.any?
|
|
260
|
+
path = "#{path}?#{Rack::Utils.build_nested_query(params)}"
|
|
261
|
+
end
|
|
262
|
+
|
|
147
263
|
if body
|
|
148
264
|
content_type = operation&.request_body_definition&.dig("content")&.keys&.first || "application/json"
|
|
149
265
|
request_args = if content_type.include?("form-data") || content_type.include?("x-www-form-urlencoded")
|
|
@@ -154,13 +270,37 @@ module OpenapiRuby
|
|
|
154
270
|
headers: headers.merge("Content-Type" => content_type)
|
|
155
271
|
}
|
|
156
272
|
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
|
|
162
273
|
else
|
|
163
|
-
request_args = {
|
|
274
|
+
request_args = {headers: headers}
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Validate the request against the declared operation (skip for error responses,
|
|
278
|
+
# since those tests intentionally send invalid data)
|
|
279
|
+
response_ctx = find_in_metadata(metadata, :openapi_response)
|
|
280
|
+
expected_status = response_ctx&.status_code.to_i
|
|
281
|
+
if OpenapiRuby.configuration.test_request_validation && operation && expected_status < 400
|
|
282
|
+
path_ctx = find_in_metadata(metadata, :openapi_path_context)
|
|
283
|
+
schema_name = find_in_metadata(metadata, :openapi_schema_name)
|
|
284
|
+
|
|
285
|
+
# Collect resolved path param values for validation
|
|
286
|
+
path_param_values = {}
|
|
287
|
+
(path_ctx&.path_parameters || []).each do |param|
|
|
288
|
+
name = param["name"]
|
|
289
|
+
next unless name
|
|
290
|
+
val = resolve_let(name.to_sym)
|
|
291
|
+
path_param_values[name] = val if val
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
document_hash = OpenapiRuby::Adapters::RSpec.validation_document_for(schema_name)
|
|
295
|
+
req_errors = Testing::RequestValidator.new(document_hash).validate(
|
|
296
|
+
operation: operation,
|
|
297
|
+
path_context: path_ctx,
|
|
298
|
+
params: params,
|
|
299
|
+
headers: headers,
|
|
300
|
+
body: body,
|
|
301
|
+
path_params: path_param_values
|
|
302
|
+
)
|
|
303
|
+
raise "Request validation failed:\n#{req_errors.join("\n")}" unless req_errors.empty?
|
|
164
304
|
end
|
|
165
305
|
|
|
166
306
|
send(method.to_sym, path, **request_args)
|
|
@@ -177,10 +317,46 @@ module OpenapiRuby
|
|
|
177
317
|
"Expected status #{expected_status}, got #{actual_status}\n" \
|
|
178
318
|
"Response body: #{response.body}"
|
|
179
319
|
end
|
|
320
|
+
|
|
321
|
+
if response_ctx.schema_definition
|
|
322
|
+
schema_name = find_in_metadata(metadata, :openapi_schema_name)
|
|
323
|
+
validator = Testing::ResponseValidator.new(OpenapiRuby::Adapters::RSpec.validation_document_for(schema_name))
|
|
324
|
+
errors = validator.validate(
|
|
325
|
+
response_body: parsed_response_body,
|
|
326
|
+
status_code: response.status,
|
|
327
|
+
response_context: response_ctx
|
|
328
|
+
)
|
|
329
|
+
unless errors.empty?
|
|
330
|
+
raise "Response body validation failed:\n#{errors.join("\n")}\nResponse body: #{response.body}"
|
|
331
|
+
end
|
|
332
|
+
end
|
|
180
333
|
end
|
|
181
334
|
|
|
182
335
|
private
|
|
183
336
|
|
|
337
|
+
def find_api_context_for(metadata, method, path_params)
|
|
338
|
+
contexts = find_in_metadata(metadata, :openapi_api_contexts) || []
|
|
339
|
+
has_path_params = path_params.any?
|
|
340
|
+
|
|
341
|
+
contexts.find do |ctx|
|
|
342
|
+
next false unless ctx.operations.key?(method.to_s)
|
|
343
|
+
|
|
344
|
+
if has_path_params
|
|
345
|
+
ctx.path_template.include?("{")
|
|
346
|
+
else
|
|
347
|
+
!ctx.path_template.include?("{")
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def expand_path(template, params)
|
|
353
|
+
template.gsub(/\{(\w+)\}/) do
|
|
354
|
+
name = ::Regexp.last_match(1)
|
|
355
|
+
value = params[name.to_sym] || params[name.to_s]
|
|
356
|
+
value || "{#{name}}"
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
184
360
|
def resolve_path(metadata)
|
|
185
361
|
path_ctx = find_in_metadata(metadata, :openapi_path_context)
|
|
186
362
|
template = path_ctx&.path_template || ""
|
|
@@ -269,6 +445,29 @@ module OpenapiRuby
|
|
|
269
445
|
end
|
|
270
446
|
end
|
|
271
447
|
|
|
448
|
+
# Build the OpenAPI document hash for a given schema name and cache it.
|
|
449
|
+
# Used by response body validation so $ref schemas can be resolved.
|
|
450
|
+
def self.validation_document_for(schema_name)
|
|
451
|
+
return nil unless schema_name
|
|
452
|
+
|
|
453
|
+
key = schema_name.to_sym
|
|
454
|
+
@validation_documents ||= {}
|
|
455
|
+
@validation_documents[key] ||= begin
|
|
456
|
+
config = OpenapiRuby.configuration
|
|
457
|
+
schema_config = config.schemas[key] || config.schemas[schema_name.to_s]
|
|
458
|
+
return nil unless schema_config
|
|
459
|
+
|
|
460
|
+
builder = OpenapiRuby::Core::DocumentBuilder.new(schema_config)
|
|
461
|
+
OpenapiRuby::DSL::MetadataStore.contexts_for(schema_name).each do |context|
|
|
462
|
+
builder.add_path(context.path_template, context.to_openapi)
|
|
463
|
+
end
|
|
464
|
+
scope = schema_config[:component_scope]
|
|
465
|
+
loader = OpenapiRuby::Components::Loader.new(scope: scope)
|
|
466
|
+
builder.merge_components(loader.to_openapi_hash)
|
|
467
|
+
builder.build.data
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
272
471
|
def self.install!
|
|
273
472
|
::RSpec.configure do |config|
|
|
274
473
|
config.extend ExampleGroupHelpers, type: :openapi
|
|
@@ -9,46 +9,56 @@ module OpenapiRuby
|
|
|
9
9
|
# Components
|
|
10
10
|
attr_accessor :component_paths
|
|
11
11
|
attr_accessor :component_scope_paths
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
attr_accessor :
|
|
12
|
+
|
|
13
|
+
# Output / formatting
|
|
14
|
+
attr_accessor :camelize_keys, :schema_output_format, :schema_output_dir
|
|
15
15
|
attr_accessor :auto_validation_error_response
|
|
16
16
|
attr_accessor :validation_error_schema
|
|
17
17
|
|
|
18
18
|
# Middleware (runtime validation)
|
|
19
|
-
attr_accessor :request_validation
|
|
19
|
+
attr_accessor :request_validation, :response_validation, :coerce_params
|
|
20
20
|
|
|
21
|
-
# Test
|
|
22
|
-
|
|
21
|
+
# Test DSL: validate that requests match the declared operation before sending.
|
|
22
|
+
# Enabled by default; set to false to disable.
|
|
23
|
+
attr_accessor :test_request_validation
|
|
23
24
|
|
|
24
|
-
#
|
|
25
|
-
|
|
25
|
+
# OpenAPI meta-schema validation of generated specs and middleware-loaded
|
|
26
|
+
# documents. One of :disabled, :enabled (raise on errors), :warn_only
|
|
27
|
+
# (default, log warnings). Boolean values are accepted for backwards
|
|
28
|
+
# compatibility: `true` → :warn_only, `false` → :disabled.
|
|
29
|
+
attr_reader :strict_reference_validation
|
|
30
|
+
|
|
31
|
+
def strict_reference_validation=(value)
|
|
32
|
+
@strict_reference_validation = case value
|
|
33
|
+
when true, :warn_only then :warn_only
|
|
34
|
+
when false, :disabled then :disabled
|
|
35
|
+
when :enabled then :enabled
|
|
36
|
+
else
|
|
37
|
+
raise ConfigurationError,
|
|
38
|
+
"strict_reference_validation must be :disabled, :enabled, :warn_only, or a boolean"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
26
41
|
|
|
27
|
-
#
|
|
28
|
-
attr_accessor :
|
|
42
|
+
# UI (optional)
|
|
43
|
+
attr_accessor :ui_enabled, :ui_path, :ui_config
|
|
29
44
|
|
|
30
45
|
def initialize
|
|
31
46
|
@schemas = {}
|
|
32
47
|
@component_paths = ["app/api_components"]
|
|
33
48
|
@component_scope_paths = {}
|
|
34
49
|
@camelize_keys = true
|
|
35
|
-
@key_transform = nil
|
|
36
50
|
@request_validation = :disabled
|
|
37
51
|
@response_validation = :disabled
|
|
38
|
-
@strict_query_params = false
|
|
39
52
|
@coerce_params = true
|
|
40
|
-
@
|
|
53
|
+
@test_request_validation = true
|
|
41
54
|
@schema_output_dir = "swagger"
|
|
42
55
|
@schema_output_format = :yaml
|
|
43
|
-
@validate_responses_in_tests = true
|
|
44
56
|
@ui_enabled = false
|
|
45
57
|
@ui_path = "/api-docs"
|
|
46
58
|
@ui_config = {}
|
|
47
|
-
@strict_reference_validation =
|
|
59
|
+
@strict_reference_validation = :warn_only
|
|
48
60
|
@auto_validation_error_response = true
|
|
49
61
|
@validation_error_schema = nil
|
|
50
|
-
@coverage_enabled = false
|
|
51
|
-
@coverage_report_path = "tmp/openapi_coverage.json"
|
|
52
62
|
end
|
|
53
63
|
|
|
54
64
|
def validate!
|
|
@@ -23,7 +23,7 @@ module OpenapiRuby
|
|
|
23
23
|
|
|
24
24
|
def write!
|
|
25
25
|
document = build_document
|
|
26
|
-
validate_document!(document)
|
|
26
|
+
validate_document!(document) unless OpenapiRuby.configuration.strict_reference_validation == :disabled
|
|
27
27
|
output_path = File.join(output_dir, filename)
|
|
28
28
|
FileUtils.mkdir_p(output_dir)
|
|
29
29
|
File.write(output_path, format_output(document))
|
|
@@ -63,7 +63,12 @@ module OpenapiRuby
|
|
|
63
63
|
return if errors.empty?
|
|
64
64
|
|
|
65
65
|
error_messages = errors.first(10).map { |e| e["error"] || e.to_s }
|
|
66
|
-
|
|
66
|
+
message = "[openapi_ruby] Generated schema '#{@schema_name}' has validation errors:\n#{error_messages.join("\n")}"
|
|
67
|
+
if OpenapiRuby.configuration.strict_reference_validation == :enabled
|
|
68
|
+
raise OpenapiRuby::ConfigurationError, message
|
|
69
|
+
else
|
|
70
|
+
warn message
|
|
71
|
+
end
|
|
67
72
|
end
|
|
68
73
|
|
|
69
74
|
def format_output(document)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module OpenapiRuby
|
|
4
4
|
module Middleware
|
|
5
5
|
class SchemaResolver
|
|
6
|
-
def initialize(spec_path: nil, document: nil, strict_reference_validation:
|
|
6
|
+
def initialize(spec_path: nil, document: nil, strict_reference_validation: :warn_only)
|
|
7
7
|
@spec_path = spec_path
|
|
8
8
|
@document = document
|
|
9
9
|
@strict_reference_validation = strict_reference_validation
|
|
@@ -41,15 +41,19 @@ module OpenapiRuby
|
|
|
41
41
|
private
|
|
42
42
|
|
|
43
43
|
def validate_document!(doc)
|
|
44
|
-
return
|
|
44
|
+
return if @strict_reference_validation == :disabled
|
|
45
45
|
|
|
46
46
|
schemer = JSONSchemer.openapi(doc)
|
|
47
47
|
errors = schemer.validate.to_a
|
|
48
48
|
return if errors.empty?
|
|
49
49
|
|
|
50
50
|
error_messages = errors.first(5).map { |e| e["error"] || e.to_s }
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
message = "OpenAPI document validation failed:\n#{error_messages.join("\n")}"
|
|
52
|
+
if @strict_reference_validation == :enabled
|
|
53
|
+
raise OpenapiRuby::ConfigurationError, message
|
|
54
|
+
else
|
|
55
|
+
warn message
|
|
56
|
+
end
|
|
53
57
|
end
|
|
54
58
|
|
|
55
59
|
def load_document
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiRuby
|
|
4
|
+
module Testing
|
|
5
|
+
class RequestValidator
|
|
6
|
+
def initialize(document_hash = nil)
|
|
7
|
+
@document_hash = document_hash
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def validate(operation:, path_context: nil, params: {}, headers: {}, body: nil, path_params: {})
|
|
11
|
+
errors = []
|
|
12
|
+
|
|
13
|
+
# Collect all parameters (path-level + operation-level)
|
|
14
|
+
all_parameters = (path_context&.path_parameters || []) + (operation.parameters || [])
|
|
15
|
+
|
|
16
|
+
# Validate each declared parameter
|
|
17
|
+
all_parameters.each do |param|
|
|
18
|
+
name = param["name"]
|
|
19
|
+
next unless name
|
|
20
|
+
|
|
21
|
+
value = extract_param_value(param, params, headers, path_params)
|
|
22
|
+
|
|
23
|
+
if value.nil?
|
|
24
|
+
errors << "Missing required #{param["in"]} parameter: #{name}" if param["required"]
|
|
25
|
+
next
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
if param["schema"]
|
|
29
|
+
param_errors = validate_value(value, param["schema"], "#{param["in"]} parameter '#{name}'")
|
|
30
|
+
errors.concat(param_errors)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Validate request body
|
|
35
|
+
errors.concat(validate_request_body(operation, body))
|
|
36
|
+
|
|
37
|
+
errors
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def extract_param_value(param, params, headers, path_params)
|
|
43
|
+
name = param["name"]
|
|
44
|
+
|
|
45
|
+
case param["in"]
|
|
46
|
+
when "query"
|
|
47
|
+
params[name.to_sym] || params[name.to_s]
|
|
48
|
+
when "path"
|
|
49
|
+
path_params[name.to_sym] || path_params[name.to_s]
|
|
50
|
+
when "header"
|
|
51
|
+
headers[name] || headers[name.downcase]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate_request_body(operation, body)
|
|
56
|
+
errors = []
|
|
57
|
+
rb_spec = operation.request_body_definition
|
|
58
|
+
return errors unless rb_spec
|
|
59
|
+
|
|
60
|
+
if rb_spec["required"] && body.nil?
|
|
61
|
+
errors << "Request body is required"
|
|
62
|
+
return errors
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
return errors unless body && rb_spec["content"]
|
|
66
|
+
|
|
67
|
+
media_type = rb_spec["content"].keys.first
|
|
68
|
+
schema = rb_spec.dig("content", media_type, "schema")
|
|
69
|
+
return errors unless schema
|
|
70
|
+
|
|
71
|
+
validate_against_schema(body, schema, "request body", errors)
|
|
72
|
+
errors
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def validate_value(value, schema, context)
|
|
76
|
+
coerced = coerce_for_validation(value, schema)
|
|
77
|
+
schema_validator = resolve_schema(schema)
|
|
78
|
+
return [] unless schema_validator
|
|
79
|
+
|
|
80
|
+
schema_validator.validate(coerced).map do |err|
|
|
81
|
+
"Invalid #{context}: #{format_error(err)}"
|
|
82
|
+
end
|
|
83
|
+
rescue => e
|
|
84
|
+
["Invalid #{context}: #{e.message}"]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def validate_against_schema(data, schema, context, errors)
|
|
88
|
+
schema_validator = resolve_schema(schema)
|
|
89
|
+
return unless schema_validator
|
|
90
|
+
|
|
91
|
+
schema_validator.validate(data).each do |err|
|
|
92
|
+
pointer = err["data_pointer"] || ""
|
|
93
|
+
msg = format_error(err)
|
|
94
|
+
location = pointer.empty? ? context : "#{context} at #{pointer}"
|
|
95
|
+
errors << "Invalid #{location}: #{msg}"
|
|
96
|
+
end
|
|
97
|
+
rescue => e
|
|
98
|
+
errors << "Invalid #{context}: #{e.message}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def resolve_schema(schema)
|
|
102
|
+
if schema.is_a?(Hash) && schema["$ref"] && @document_hash
|
|
103
|
+
JSONSchemer.openapi(@document_hash).ref(schema["$ref"])
|
|
104
|
+
elsif contains_ref?(schema)
|
|
105
|
+
nil # Cannot validate $ref without document
|
|
106
|
+
else
|
|
107
|
+
JSONSchemer.schema(schema)
|
|
108
|
+
end
|
|
109
|
+
rescue
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def coerce_for_validation(value, schema)
|
|
114
|
+
return value unless value.is_a?(String)
|
|
115
|
+
|
|
116
|
+
type = schema.is_a?(Hash) ? schema["type"] : nil
|
|
117
|
+
case type
|
|
118
|
+
when "integer" then Integer(value)
|
|
119
|
+
when "number" then Float(value)
|
|
120
|
+
when "boolean"
|
|
121
|
+
case value.downcase
|
|
122
|
+
when "true", "1" then true
|
|
123
|
+
when "false", "0" then false
|
|
124
|
+
else value
|
|
125
|
+
end
|
|
126
|
+
else value
|
|
127
|
+
end
|
|
128
|
+
rescue ArgumentError, TypeError
|
|
129
|
+
value
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def contains_ref?(value)
|
|
133
|
+
case value
|
|
134
|
+
when Hash
|
|
135
|
+
return true if value.key?("$ref")
|
|
136
|
+
value.values.any? { |v| contains_ref?(v) }
|
|
137
|
+
when Array
|
|
138
|
+
value.any? { |v| contains_ref?(v) }
|
|
139
|
+
else
|
|
140
|
+
false
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def format_error(error)
|
|
145
|
+
if error.is_a?(Hash)
|
|
146
|
+
error["error"] || error["type"] || "validation failed"
|
|
147
|
+
else
|
|
148
|
+
error.to_s
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
data/lib/openapi_ruby/version.rb
CHANGED
data/lib/openapi_ruby.rb
CHANGED
|
@@ -43,6 +43,7 @@ require_relative "openapi_ruby/dsl/context"
|
|
|
43
43
|
require_relative "openapi_ruby/dsl/metadata_store"
|
|
44
44
|
require_relative "openapi_ruby/testing/request_builder"
|
|
45
45
|
require_relative "openapi_ruby/testing/response_validator"
|
|
46
|
+
require_relative "openapi_ruby/testing/request_validator"
|
|
46
47
|
require_relative "openapi_ruby/testing/assertions"
|
|
47
48
|
require_relative "openapi_ruby/testing/coverage"
|
|
48
49
|
require_relative "openapi_ruby/generator/schema_writer"
|
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: 3.
|
|
4
|
+
version: 3.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Morten Hartvig
|
|
@@ -117,6 +117,7 @@ files:
|
|
|
117
117
|
- lib/openapi_ruby/testing/assertions.rb
|
|
118
118
|
- lib/openapi_ruby/testing/coverage.rb
|
|
119
119
|
- lib/openapi_ruby/testing/request_builder.rb
|
|
120
|
+
- lib/openapi_ruby/testing/request_validator.rb
|
|
120
121
|
- lib/openapi_ruby/testing/response_validator.rb
|
|
121
122
|
- lib/openapi_ruby/version.rb
|
|
122
123
|
- lib/tasks/openapi_ruby.rake
|