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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e392a1235f97dc71aaf0ec1790ee0bd64c4bcbae412063af363af91e95cb608d
4
- data.tar.gz: 90f056e8bc51aa18d6de20a38ac706c079955567066e034d9408a8bd75b2f966
3
+ metadata.gz: 2e97365e4c2b566d59db1f4946a2b0e1f45f8ee9b938f2b5c049ac0ac9f56dcc
4
+ data.tar.gz: 431709a674d6a028fd8a14f47200761215a6e4576888009b3b2b637fd5a453b9
5
5
  SHA512:
6
- metadata.gz: bde611e9f9b188969e3bbc8a6f1062efe7738ebc15e7eef40079406cccd0eab35051c767db9abfbcba0eea1e91ec39a265f7654468f8478efef8462e63b67f42
7
- data.tar.gz: c02da938cd7edd9053fc89730916dbc31b87829fc945c36248269e6a77a569cf77ea07c14938550517a16909615ee926fc05b1b54bf848a3997ac7cfa1bf4c47
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 OpenapiRuby.configuration.validate_responses_in_tests && response_ctx.schema_definition
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 = {params: params, headers: headers}
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
- attr_accessor :camelize_keys, :key_transform, :response_validation, :strict_query_params,
13
- :coerce_params, :error_handler, :schema_output_format, :validate_responses_in_tests, :ui_path, :ui_config, :coverage_report_path
14
- attr_accessor :strict_reference_validation
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 / Generation
22
- attr_accessor :schema_output_dir
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
- # UI (optional)
25
- attr_accessor :ui_enabled
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
- # Coverage
28
- attr_accessor :coverage_enabled
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
- @error_handler = nil
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 = true
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) if OpenapiRuby.configuration.strict_reference_validation
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
- warn "[openapi_ruby] Generated schema '#{@schema_name}' has validation errors:\n#{error_messages.join("\n")}"
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: true)
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 unless @strict_reference_validation
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
- raise OpenapiRuby::ConfigurationError,
52
- "OpenAPI document validation failed:\n#{error_messages.join("\n")}"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiRuby
4
- VERSION = "3.1.0"
4
+ VERSION = "3.2.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.0
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