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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 953f7f56ecdec3d8ee0f1f1261e49328e53d0aab5041b43f47a6e1138a2e880f
4
- data.tar.gz: bec8a51340348604307f17791824d9327e7ceee7b58a8f35533ef7036c12156f
3
+ metadata.gz: 2596878f73873970637c965935c1f4689cc744f3496467d734bd9a2c816904ee
4
+ data.tar.gz: 2c7f2d7b4497175d0c558f2179888529ae1397d724cba0088248049ef5fda518
5
5
  SHA512:
6
- metadata.gz: c62b49da036fe1dbb27b3f7ce2937a044427ea89ded288bdab1c33dab6fc041e31c89ecb75fd2e2419731cd0f803428baed9ed5152cdf01d7cd77b42b004642e
7
- data.tar.gz: 5e2bbbcf02c286120207fc06a9f86c66b2618c9766a78b4b94c215641d0c2a5173aa4c45705e4fd2e6ed909271c63000a65182d159f22f3d716a4742dcbc6bae
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: { "$ref" => "#/components/schemas/User" }
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 "$ref" => "#/components/schemas/User"
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 "$ref" => "#/components/schemas/ValidationErrors"
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 "$ref" => "#/components/schemas/User"
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: { "$ref" => "#/components/schemas/User" }
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 "$ref" => "#/components/schemas/User"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiRuby
4
- VERSION = "3.1.1"
4
+ VERSION = "3.3.0"
5
5
  end
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.1.1
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