openapi-ruby 3.1.1 → 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: 953f7f56ecdec3d8ee0f1f1261e49328e53d0aab5041b43f47a6e1138a2e880f
4
- data.tar.gz: bec8a51340348604307f17791824d9327e7ceee7b58a8f35533ef7036c12156f
3
+ metadata.gz: 2e97365e4c2b566d59db1f4946a2b0e1f45f8ee9b938f2b5c049ac0ac9f56dcc
4
+ data.tar.gz: 431709a674d6a028fd8a14f47200761215a6e4576888009b3b2b637fd5a453b9
5
5
  SHA512:
6
- metadata.gz: c62b49da036fe1dbb27b3f7ce2937a044427ea89ded288bdab1c33dab6fc041e31c89ecb75fd2e2419731cd0f803428baed9ed5152cdf01d7cd77b42b004642e
7
- data.tar.gz: 5e2bbbcf02c286120207fc06a9f86c66b2618c9766a78b4b94c215641d0c2a5173aa4c45705e4fd2e6ed909271c63000a65182d159f22f3d716a4742dcbc6bae
6
+ metadata.gz: 0a670b21d1043c474b2eead1844c47258521df05b247d561a5578de4c8495a6519b575c7b3d2d4fe4fc54c22d5d524e45ee2fab492e99e3366c8eab5bc381ecf
7
+ data.tar.gz: e2981bfa965fef6c6db6767d8e36592b8166fa20f675265c9d0147265565250e845093ca4fb3dc3bd3a7d2a2b1c3a7c6cb9d52169a0142bc0be2a90727a50b77
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
@@ -230,6 +234,12 @@ rails generate openapi_ruby:component BearerAuth security_schemes
230
234
  require "openapi_ruby/rspec"
231
235
  ```
232
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
+
233
243
  ```ruby
234
244
  # spec/requests/users_spec.rb
235
245
  require "openapi_helper"
@@ -293,11 +303,71 @@ RSpec.describe "Users API", type: :openapi do
293
303
  end
294
304
  ```
295
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
+
296
364
  ### DSL Reference
297
365
 
298
366
  | Method | Level | Description |
299
367
  |--------|-------|-------------|
300
- | `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) |
301
371
  | `get/post/put/patch/delete(summary, &block)` | Path | Define an operation |
302
372
  | `tags(*tags)` | Operation | Tag the operation |
303
373
  | `operationId(id)` | Operation | Set operation ID |
@@ -311,7 +381,9 @@ end
311
381
  | `response(status, description, &block)` | Operation | Define expected response |
312
382
  | `schema(definition)` | Response | Response body schema |
313
383
  | `header(name, schema:, **opts)` | Response | Response header |
314
- | `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 |
315
387
 
316
388
  ## Testing with Minitest
317
389
 
@@ -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 || ""
@@ -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
@@ -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.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.1
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