openapi-ruby 3.1.1 → 3.3.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 +105 -14
- data/lib/openapi_ruby/adapters/minitest.rb +32 -0
- data/lib/openapi_ruby/adapters/rspec.rb +162 -0
- data/lib/openapi_ruby/components/base.rb +10 -0
- data/lib/openapi_ruby/configuration.rb +5 -0
- data/lib/openapi_ruby/dsl/context.rb +6 -0
- data/lib/openapi_ruby/dsl/operation_context.rb +6 -0
- data/lib/openapi_ruby/dsl/response_context.rb +6 -0
- 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: 2596878f73873970637c965935c1f4689cc744f3496467d734bd9a2c816904ee
|
|
4
|
+
data.tar.gz: 2c7f2d7b4497175d0c558f2179888529ae1397d724cba0088248049ef5fda518
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d54c13cb1de806e0e5f21ed1e17d6287a525b00be8654d2c77e554a6bc6762dc206002797874fa6882872754b2bc6438dbfa590e0f3c03f0211383d1fa2490ff
|
|
7
|
+
data.tar.gz: fb01ab7bb2b8f51d7fb9ef0432a261717d5cf1dc90a7c2c17168f3039456d0b5af99c74eb1853d9ebbf1df80dc8410bbaffa5c53af4f3799960a00c4a128d287
|
data/README.md
CHANGED
|
@@ -68,6 +68,10 @@ OpenapiRuby.configure do |config|
|
|
|
68
68
|
config.request_validation = :disabled # :enabled, :disabled, :warn_only
|
|
69
69
|
config.response_validation = :disabled
|
|
70
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
|
+
|
|
71
75
|
# Optional Swagger UI (disabled by default)
|
|
72
76
|
config.ui_enabled = false
|
|
73
77
|
end
|
|
@@ -189,6 +193,31 @@ class Schemas::AdminUser
|
|
|
189
193
|
end
|
|
190
194
|
```
|
|
191
195
|
|
|
196
|
+
### Class References
|
|
197
|
+
|
|
198
|
+
Instead of writing `$ref` strings manually, you can pass component classes directly anywhere a `$ref` is expected. This gives you typo protection (via `NameError`), IDE navigation, and less boilerplate:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
# Instead of:
|
|
202
|
+
schema "$ref" => "#/components/schemas/User"
|
|
203
|
+
schema type: :array, items: { "$ref" => "#/components/schemas/User" }
|
|
204
|
+
|
|
205
|
+
# You can write:
|
|
206
|
+
schema Schemas::User
|
|
207
|
+
schema type: :array, items: Schemas::User
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
This works in `schema`, `request_body`, and anywhere nested inside hash/array definitions. Non-component classes raise `ArgumentError`.
|
|
211
|
+
|
|
212
|
+
You can also use the explicit `.to_ref` method:
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
Schemas::User.to_ref
|
|
216
|
+
# => { "$ref" => "#/components/schemas/User" }
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Both class refs and string `$ref` hashes are fully supported — use whichever you prefer.
|
|
220
|
+
|
|
192
221
|
### Strong Params
|
|
193
222
|
|
|
194
223
|
Schema components can derive Rails strong params permit lists:
|
|
@@ -230,6 +259,12 @@ rails generate openapi_ruby:component BearerAuth security_schemes
|
|
|
230
259
|
require "openapi_ruby/rspec"
|
|
231
260
|
```
|
|
232
261
|
|
|
262
|
+
RSpec supports two DSL styles. Both generate the same OpenAPI spec and validate responses (and requests) against it.
|
|
263
|
+
|
|
264
|
+
### Style 1: `path` / `run_test!`
|
|
265
|
+
|
|
266
|
+
Schema definition and test execution are interleaved. Each `response` block uses `let` values and `run_test!` to send the request inline:
|
|
267
|
+
|
|
233
268
|
```ruby
|
|
234
269
|
# spec/requests/users_spec.rb
|
|
235
270
|
require "openapi_helper"
|
|
@@ -242,7 +277,7 @@ RSpec.describe "Users API", type: :openapi do
|
|
|
242
277
|
produces "application/json"
|
|
243
278
|
|
|
244
279
|
response 200, "returns all users" do
|
|
245
|
-
schema type: :array, items:
|
|
280
|
+
schema type: :array, items: Schemas::User
|
|
246
281
|
|
|
247
282
|
run_test! do
|
|
248
283
|
expect(JSON.parse(response.body).length).to be > 0
|
|
@@ -255,19 +290,17 @@ RSpec.describe "Users API", type: :openapi do
|
|
|
255
290
|
consumes "application/json"
|
|
256
291
|
|
|
257
292
|
request_body required: true, content: {
|
|
258
|
-
"application/json" => {
|
|
259
|
-
schema: { "$ref" => "#/components/schemas/UserInput" }
|
|
260
|
-
}
|
|
293
|
+
"application/json" => { schema: Schemas::UserInput }
|
|
261
294
|
}
|
|
262
295
|
|
|
263
296
|
response 201, "user created" do
|
|
264
|
-
schema
|
|
297
|
+
schema Schemas::User
|
|
265
298
|
let(:request_body) { { name: "Jane", email: "jane@example.com" } }
|
|
266
299
|
run_test!
|
|
267
300
|
end
|
|
268
301
|
|
|
269
302
|
response 422, "validation errors" do
|
|
270
|
-
schema
|
|
303
|
+
schema Schemas::ValidationErrors
|
|
271
304
|
let(:request_body) { { name: "" } }
|
|
272
305
|
run_test!
|
|
273
306
|
end
|
|
@@ -279,7 +312,7 @@ RSpec.describe "Users API", type: :openapi do
|
|
|
279
312
|
|
|
280
313
|
get "Get a user" do
|
|
281
314
|
response 200, "user found" do
|
|
282
|
-
schema
|
|
315
|
+
schema Schemas::User
|
|
283
316
|
let(:id) { User.create!(name: "Jane", email: "jane@example.com").id }
|
|
284
317
|
run_test!
|
|
285
318
|
end
|
|
@@ -293,11 +326,69 @@ RSpec.describe "Users API", type: :openapi do
|
|
|
293
326
|
end
|
|
294
327
|
```
|
|
295
328
|
|
|
329
|
+
### Style 2: `api_path` / `assert_api_response`
|
|
330
|
+
|
|
331
|
+
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:
|
|
332
|
+
|
|
333
|
+
```ruby
|
|
334
|
+
require "openapi_helper"
|
|
335
|
+
|
|
336
|
+
RSpec.describe "Users API", type: :openapi do
|
|
337
|
+
openapi_schema :public_api
|
|
338
|
+
|
|
339
|
+
api_path "/api/v1/users" do
|
|
340
|
+
get "List users" do
|
|
341
|
+
tags "Users"
|
|
342
|
+
produces "application/json"
|
|
343
|
+
|
|
344
|
+
response 200, "returns all users" do
|
|
345
|
+
schema type: :array, items: Schemas::User
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
post "Create a user" do
|
|
350
|
+
consumes "application/json"
|
|
351
|
+
|
|
352
|
+
request_body required: true, content: {
|
|
353
|
+
"application/json" => { schema: Schemas::UserInput }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
response 201, "user created" do
|
|
357
|
+
schema Schemas::User
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
response 422, "validation errors" do
|
|
361
|
+
schema Schemas::ValidationErrors
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Normal RSpec examples
|
|
367
|
+
it "returns all users" do
|
|
368
|
+
User.create!(name: "Jane", email: "jane@example.com")
|
|
369
|
+
|
|
370
|
+
assert_api_response :get, 200 do
|
|
371
|
+
expect(parsed_body.length).to eq(1)
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
it "creates a user" do
|
|
376
|
+
assert_api_response :post, 201, body: { name: "Jane", email: "jane@example.com" } do
|
|
377
|
+
expect(parsed_body["name"]).to eq("Jane")
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
`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.
|
|
384
|
+
|
|
296
385
|
### DSL Reference
|
|
297
386
|
|
|
298
387
|
| Method | Level | Description |
|
|
299
388
|
|--------|-------|-------------|
|
|
300
|
-
| `path(template, &block)` | Top | Define an API path |
|
|
389
|
+
| `path(template, &block)` | Top | Define an API path (style 1) |
|
|
390
|
+
| `api_path(template, &block)` | Top | Define an API path (style 2) |
|
|
391
|
+
| `openapi_schema(name)` | Top | Set the schema name (style 2) |
|
|
301
392
|
| `get/post/put/patch/delete(summary, &block)` | Path | Define an operation |
|
|
302
393
|
| `tags(*tags)` | Operation | Tag the operation |
|
|
303
394
|
| `operationId(id)` | Operation | Set operation ID |
|
|
@@ -311,7 +402,9 @@ end
|
|
|
311
402
|
| `response(status, description, &block)` | Operation | Define expected response |
|
|
312
403
|
| `schema(definition)` | Response | Response body schema |
|
|
313
404
|
| `header(name, schema:, **opts)` | Response | Response header |
|
|
314
|
-
| `run_test!(&block)` | Response | Execute request and validate |
|
|
405
|
+
| `run_test!(&block)` | Response | Execute request and validate (style 1) |
|
|
406
|
+
| `assert_api_response(method, status, **opts, &block)` | Example | Execute request and validate (style 2) |
|
|
407
|
+
| `parsed_body` | Example | Parsed JSON response body |
|
|
315
408
|
|
|
316
409
|
## Testing with Minitest
|
|
317
410
|
|
|
@@ -335,7 +428,7 @@ class UsersApiTest < ActionDispatch::IntegrationTest
|
|
|
335
428
|
produces "application/json"
|
|
336
429
|
|
|
337
430
|
response 200, "returns all users" do
|
|
338
|
-
schema type: :array, items:
|
|
431
|
+
schema type: :array, items: Schemas::User
|
|
339
432
|
end
|
|
340
433
|
end
|
|
341
434
|
|
|
@@ -343,13 +436,11 @@ class UsersApiTest < ActionDispatch::IntegrationTest
|
|
|
343
436
|
consumes "application/json"
|
|
344
437
|
|
|
345
438
|
request_body required: true, content: {
|
|
346
|
-
"application/json" => {
|
|
347
|
-
schema: { "$ref" => "#/components/schemas/UserInput" }
|
|
348
|
-
}
|
|
439
|
+
"application/json" => { schema: Schemas::UserInput }
|
|
349
440
|
}
|
|
350
441
|
|
|
351
442
|
response 201, "user created" do
|
|
352
|
-
schema
|
|
443
|
+
schema Schemas::User
|
|
353
444
|
end
|
|
354
445
|
end
|
|
355
446
|
end
|
|
@@ -81,6 +81,21 @@ 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
|
|
@@ -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)
|
|
@@ -164,6 +274,35 @@ module OpenapiRuby
|
|
|
164
274
|
request_args = {headers: headers}
|
|
165
275
|
end
|
|
166
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?
|
|
304
|
+
end
|
|
305
|
+
|
|
167
306
|
send(method.to_sym, path, **request_args)
|
|
168
307
|
end
|
|
169
308
|
|
|
@@ -195,6 +334,29 @@ module OpenapiRuby
|
|
|
195
334
|
|
|
196
335
|
private
|
|
197
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
|
+
|
|
198
360
|
def resolve_path(metadata)
|
|
199
361
|
path_ctx = find_in_metadata(metadata, :openapi_path_context)
|
|
200
362
|
template = path_ctx&.path_template || ""
|
|
@@ -85,6 +85,10 @@ module OpenapiRuby
|
|
|
85
85
|
end
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
+
def to_ref
|
|
89
|
+
OpenapiRuby::Core::RefResolver.ref_object(_component_type, component_name)
|
|
90
|
+
end
|
|
91
|
+
|
|
88
92
|
def to_openapi
|
|
89
93
|
definition = _schema_definition.deep_dup
|
|
90
94
|
|
|
@@ -147,6 +151,12 @@ module OpenapiRuby
|
|
|
147
151
|
|
|
148
152
|
def deep_stringify(value)
|
|
149
153
|
case value
|
|
154
|
+
when Class
|
|
155
|
+
if value < OpenapiRuby::Components::Base
|
|
156
|
+
value.to_ref
|
|
157
|
+
else
|
|
158
|
+
raise ArgumentError, "#{value} is not an OpenapiRuby component"
|
|
159
|
+
end
|
|
150
160
|
when Hash
|
|
151
161
|
value.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify(v) }
|
|
152
162
|
when Array
|
|
@@ -18,6 +18,10 @@ module OpenapiRuby
|
|
|
18
18
|
# Middleware (runtime validation)
|
|
19
19
|
attr_accessor :request_validation, :response_validation, :coerce_params
|
|
20
20
|
|
|
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
|
|
24
|
+
|
|
21
25
|
# OpenAPI meta-schema validation of generated specs and middleware-loaded
|
|
22
26
|
# documents. One of :disabled, :enabled (raise on errors), :warn_only
|
|
23
27
|
# (default, log warnings). Boolean values are accepted for backwards
|
|
@@ -46,6 +50,7 @@ module OpenapiRuby
|
|
|
46
50
|
@request_validation = :disabled
|
|
47
51
|
@response_validation = :disabled
|
|
48
52
|
@coerce_params = true
|
|
53
|
+
@test_request_validation = true
|
|
49
54
|
@schema_output_dir = "swagger"
|
|
50
55
|
@schema_output_format = :yaml
|
|
51
56
|
@ui_enabled = false
|
|
@@ -45,6 +45,12 @@ module OpenapiRuby
|
|
|
45
45
|
|
|
46
46
|
def deep_stringify(value)
|
|
47
47
|
case value
|
|
48
|
+
when Class
|
|
49
|
+
if value < OpenapiRuby::Components::Base
|
|
50
|
+
value.to_ref
|
|
51
|
+
else
|
|
52
|
+
raise ArgumentError, "#{value} is not an OpenapiRuby component"
|
|
53
|
+
end
|
|
48
54
|
when Hash
|
|
49
55
|
value.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify(v) }
|
|
50
56
|
when Array
|
|
@@ -128,6 +128,12 @@ module OpenapiRuby
|
|
|
128
128
|
|
|
129
129
|
def deep_stringify(value)
|
|
130
130
|
case value
|
|
131
|
+
when Class
|
|
132
|
+
if value < OpenapiRuby::Components::Base
|
|
133
|
+
value.to_ref
|
|
134
|
+
else
|
|
135
|
+
raise ArgumentError, "#{value} is not an OpenapiRuby component"
|
|
136
|
+
end
|
|
131
137
|
when Hash
|
|
132
138
|
value.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify(v) }
|
|
133
139
|
when Array
|
|
@@ -72,6 +72,12 @@ module OpenapiRuby
|
|
|
72
72
|
|
|
73
73
|
def deep_stringify(value)
|
|
74
74
|
case value
|
|
75
|
+
when Class
|
|
76
|
+
if value < OpenapiRuby::Components::Base
|
|
77
|
+
value.to_ref
|
|
78
|
+
else
|
|
79
|
+
raise ArgumentError, "#{value} is not an OpenapiRuby component"
|
|
80
|
+
end
|
|
75
81
|
when Hash
|
|
76
82
|
value.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify(v) }
|
|
77
83
|
when Array
|
|
@@ -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.3.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
|