openapi_first 3.4.3 → 4.0.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -0
  3. data/README.md +95 -42
  4. data/lib/openapi_first/builder.rb +85 -50
  5. data/lib/openapi_first/child_configuration.rb +0 -2
  6. data/lib/openapi_first/configuration.rb +0 -23
  7. data/lib/openapi_first/definition.rb +35 -2
  8. data/lib/openapi_first/failure.rb +5 -1
  9. data/lib/openapi_first/middlewares/request_validation.rb +1 -1
  10. data/lib/openapi_first/middlewares/response_validation.rb +1 -1
  11. data/lib/openapi_first/parameter/converter/array_converter.rb +42 -0
  12. data/lib/openapi_first/parameter/converter/object_converter.rb +60 -0
  13. data/lib/openapi_first/parameter/converter.rb +69 -0
  14. data/lib/openapi_first/parameter/unpackers.rb +132 -0
  15. data/lib/openapi_first/parameter.rb +70 -0
  16. data/lib/openapi_first/parameter_content_parsers.rb +55 -0
  17. data/lib/openapi_first/parameters_parser.rb +23 -0
  18. data/lib/openapi_first/query_string_parser.rb +93 -0
  19. data/lib/openapi_first/ref_resolver.rb +56 -3
  20. data/lib/openapi_first/request.rb +11 -10
  21. data/lib/openapi_first/request_body_parsers.rb +11 -7
  22. data/lib/openapi_first/request_headers.rb +27 -0
  23. data/lib/openapi_first/request_validator.rb +4 -1
  24. data/lib/openapi_first/response_header.rb +9 -0
  25. data/lib/openapi_first/response_parser.rb +3 -10
  26. data/lib/openapi_first/router.rb +27 -12
  27. data/lib/openapi_first/schema/hash.rb +0 -1
  28. data/lib/openapi_first/sinatra.rb +217 -0
  29. data/lib/openapi_first/test/configuration.rb +0 -34
  30. data/lib/openapi_first/test/coverage/html_reporter/context.rb +24 -17
  31. data/lib/openapi_first/test/coverage/html_reporter.css +214 -67
  32. data/lib/openapi_first/test/coverage/html_reporter.html.erb +39 -11
  33. data/lib/openapi_first/test/coverage/html_reporter.rb +11 -1
  34. data/lib/openapi_first/test/coverage/plan.rb +30 -10
  35. data/lib/openapi_first/test/coverage/request_task.rb +7 -2
  36. data/lib/openapi_first/test/coverage/response_task.rb +6 -1
  37. data/lib/openapi_first/test/coverage/route_task.rb +23 -1
  38. data/lib/openapi_first/test/coverage/skipped_summary.rb +22 -0
  39. data/lib/openapi_first/test/coverage/terminal_reporter.rb +23 -11
  40. data/lib/openapi_first/test/coverage.rb +9 -3
  41. data/lib/openapi_first/test.rb +69 -11
  42. data/lib/openapi_first/validators/multipart_request_body.rb +57 -0
  43. data/lib/openapi_first/validators/request_body.rb +20 -7
  44. data/lib/openapi_first/validators/request_parameters.rb +5 -4
  45. data/lib/openapi_first/version.rb +1 -1
  46. metadata +15 -23
  47. data/lib/openapi_first/header.rb +0 -9
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aa35ee9098e786a73c8d0de102d691cb0f5cec972e60e9a8d96d439f63eef872
4
- data.tar.gz: b6f8461c433753a49563055b4fd3156cbbacf0c89d5f7e2f2a7f1366c2c3b32c
3
+ metadata.gz: 47dffdd527985dd188f08ea57ff1104f3e51203746175cf4a964c757af8ebd6e
4
+ data.tar.gz: c67d1e706d644caadd9397bfdb2eef046317e2c4ac86bfd8e261b60ad7136bf7
5
5
  SHA512:
6
- metadata.gz: 2146d351211ebc10237dd8f6d30381d15d99ac15bf0cfa05a9095b6f493738b48c4e406b0d359fcc6f11c9910702b0e3ad4003b6513d32cd2390c81a356fd21b
7
- data.tar.gz: 2235965c286c2d182a77b491422c18a25010f69c0e91e87e50731334aa5515fdbe3cd06d03a4a509c3f07b07f1405a928b50a6b037ed022fc0dd3b60e8bf1be9
6
+ metadata.gz: e5e16ce4c6c4756517e9bff345eb7182937b239be36a038444680f61b169790aef3c5d10c3c1d8b2be0e3332b72784a3722ba3ea99de0e7712f4c1e48284c0ec
7
+ data.tar.gz: fa631d3327bbcf640645d2b762d00ac8baa4bf5c87276c9c00cc280e58d1985a419a64ccd4b8521f411f9d5f47442ae9efc31f0bf959b5a8d4f24fb8205de7a7
data/CHANGELOG.md CHANGED
@@ -2,6 +2,72 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 4.0.0
6
+
7
+ This release has no stricter or less strict request validation. It changes mostly internal stuff and adds a Sinatra integration. It's a major version, but it should be safe to upgrade.
8
+
9
+ #### Breaking changes
10
+ - Uploaded files are no longer read during request validation. Before, the whole content of every `multipart/form-data` part that was sent as a file was read into memory, which allowed a single large upload to any documented multipart route to exhaust the memory of the server process. Such a field is now passed through as Rack parsed it (`{ filename:, type:, name:, tempfile:, head: }`), which is the same shape that Sinatra and Hanami hand to your application. Use `parsed_body['file'][:tempfile]` to read or stream the file.
11
+ - The content of these fields is not validated anymore, so `minLength`, `maxLength` or `pattern` on a field that was sent as a file are ignored.
12
+ - An `after_request_body_property_validation` hook sees an empty String instead of the file.
13
+ - Fields that were not sent as a file, and fields with a JSON `contentType` in the `encoding` map, are read and validated as before.
14
+ - The `openapi_parameters` gem was merged into openapi_first and is not a dependency anymore. Parameter parsing now lives in openapi_first itself. If you registered a parser for parameters that use a `content` field, use `OpenapiFirst::ParameterContentParsers.register` instead of `OpenapiParameters::ContentParsers.register`.
15
+ - Changed: `OpenapiFirst::ResponseHeader` (returned by `Response#headers`, renamed from `OpenapiFirst::Header`) exposes `parameter`, an `OpenapiFirst::Parameter`, instead of `node`.
16
+ - Changed: `OpenapiFirst::Request#parameters` returns the parameters that are defined for a request as `OpenapiFirst::Parameter` objects, which expose `name`, `location`, `schema`, `required?`, `deprecated?`, `style`, `explode?` and `media_type`.
17
+
18
+ #### Removed deprecations
19
+ - Removed: `OpenapiFirst::Configuration#request_validation_raise_error` and `#response_validation_raise_error` (both reader and writer), deprecated since 3.0.0. Pass `raise_error:` to middlewares instead.
20
+ - Removed: `OpenapiFirst::Test::Configuration#coverage_formatter`, `#coverage_formatter=`, `#coverage_formatter_options` and `#coverage_formatter_options=`, deprecated since 3.4.0. Use `#coverage_reporter` / `#coverage_reporter_options` instead.
21
+ - Removed: `OpenapiFirst::Test::Coverage::TerminalFormatter`, deprecated since 3.4.0. Use `OpenapiFirst::Test::Coverage::TerminalReporter` instead.
22
+ - Removed: The `formatter:` keyword of `OpenapiFirst::Test.report_coverage`, deprecated since 3.4.0. Use `reporter:` instead.
23
+ - Removed: `OpenapiFirst::Test::Coverage::TerminalReporter#format`, deprecated since 3.4.0. Use `#report` instead.
24
+
25
+ #### Added
26
+ - Added: API coverage now reports skipped requests and responses.
27
+ - Added: OpenAPI 3.2 documents are accepted, but not fully supported yet. They are handled using the OpenAPI 3.1 rules, so features introduced in 3.2 may be ignored. Loading such a document prints a warning. Operations defined under `additionalOperations` are routed. See [#469](https://github.com/ahx/openapi_first/issues/469).
28
+ - Added: Show all covered endpoints in HTML coverage reporter and filter covered/uncovered endpoints.
29
+ - Added: Sinatra integration (OpenapiFirst::Sinatra)
30
+ A Sinatra extension to define routes by referencing OpenAPI operations:
31
+
32
+ ```ruby
33
+ require 'openapi_first/sinatra'
34
+
35
+ class PetsApi < Sinatra::Base
36
+ register OpenapiFirst::Sinatra
37
+ openapi 'openapi.yaml'
38
+
39
+ operation :index_pets do |params|
40
+ json index_pets(params[:filter])
41
+ end
42
+
43
+ operation :create_pet do
44
+ pet = create_pet(parsed_body)
45
+ headers['Location'] = operation_url(:show_pet, petId: pet.id)
46
+ status :created
47
+ json pet
48
+ end
49
+
50
+ operation :show_pet do |params|
51
+ json show_pet(params[:petId])
52
+ end
53
+ end
54
+ ```
55
+
56
+ The HTTP method and path for each route come from the operationId.
57
+ Request validation is called automatically for these operations.
58
+
59
+ #### Fixed
60
+ - Fixed: Validating against a schema from a referenced file raised `ArgumentError` in OpenAPI 3.0 documents when a top-level key of that file collides with a JSON Schema keyword, such as `$ref: 'parameters.yaml#/id'`. The containing file is no longer parsed as a schema itself, so such keys work like any other now. See [#348](https://github.com/ahx/openapi_first/issues/348).
61
+ - Fixed: `$ref`s nested inside the schema of a parameter or a response header are resolved now, so these values are unpacked and converted as described. Before, only a `$ref` at the top level of the schema was resolved. See #450.
62
+ - Fixed: The JSON schema of a parameter that uses a `content` field with a `$ref`'d schema is resolved now.
63
+ - Fixed: Loading a document no longer raises `NoMethodError` when a parameter has neither `schema` nor `content`.
64
+ - Fixed: Repeated values for a query parameter that describes an object or uses `content` (`?filter=a&filter=b`) raised a `NoMethodError` or `TypeError`. The values are validated against the schema now, which returns an `:invalid_query` failure.
65
+ - Fixed: A parameter with `style: matrix` raised a `NoMethodError` if its value did not contain the parameter name, or contained it more than once. Such values are parsed like their `explode` counterpart now.
66
+ - Fixed: A parameter with `style: matrix`, or a path parameter that describes an object, raised an `ArgumentError` if its value had an invalid `%`-encoding. Such values are validated against the schema now.
67
+ - Fixed: A path, header or cookie parameter that uses a `content` field with a value that could not be parsed as that media type (e.g. `007` as `application/json`) was converted using the parameter's schema type anyway, which could make an invalid value pass schema validation (e.g. as the integer `7`). Such values are left as they are now, so schema validation rejects them as before.
68
+ - Fixed: Reduced memory retained by a loaded `Definition`. Response headers with a schema no longer keep the whole raw document node alive, and a couple of build-time-only hashes were replaced with more compact structures.
69
+
70
+
5
71
  ## 3.4.3
6
72
 
7
73
  Fixed: Loading a document no longer raises `NoMethodError: undefined method 'schema' for nil` when a Media Type Object has no `schema` (e.g. it only declares an `example`). `schema` is optional in a Media Type Object; such media types now impose no body-schema constraint.
data/README.md CHANGED
@@ -49,7 +49,7 @@ end
49
49
 
50
50
  ## Configuration
51
51
 
52
- You should register OADs globally so you don't have to load the file multiple times or to refernce them by Symbol (like :v1 in this example).
52
+ You should register OADs globally so you don't have to load the file multiple times.
53
53
  ```ruby
54
54
  OpenapiFirst.configure do |config|
55
55
  config.register('openapi/openapi.yaml') # :default
@@ -57,7 +57,7 @@ OpenapiFirst.configure do |config|
57
57
  end
58
58
  ```
59
59
 
60
- You can configure default options globally:
60
+ You can configure defaults globally:
61
61
 
62
62
  ```ruby
63
63
  OpenapiFirst.configure do |config|
@@ -119,7 +119,7 @@ content-type: "application/problem+json"
119
119
  openapi_first offers a [JSON:API](https://jsonapi.org/) error response by passing `error_response: :jsonapi`:
120
120
 
121
121
  ```ruby
122
- use OpenapiFirst::Middlewares::RequestValidation, 'openapi.yaml', error_response: :jsonapi
122
+ use OpenapiFirst::Middlewares::RequestValidation, error_response: :jsonapi
123
123
  ```
124
124
 
125
125
  <details>
@@ -166,9 +166,28 @@ use OpenapiFirst::Middlewares::RequestValidation, 'openapi.yaml', error_response
166
166
  You can build your own custom error response with `error_response: MyCustomClass` that implements `OpenapiFirst::ErrorResponse`.
167
167
  You can define custom error responses globally by including / implementing `OpenapiFirst::ErrorResponse` and register it via `OpenapiFirst.register_error_response(my_name, MyCustomErrorResponse)` and set `error_response: my_name`.
168
168
 
169
+ #### Multipart file uploads
170
+
171
+ Uploaded files are not read during request validation. A `multipart/form-data` field that was sent as a file is passed through as Rack parsed it – the same shape that Sinatra and Hanami hand to your application:
172
+
173
+ ```ruby
174
+ file = validated_request.parsed_body['file']
175
+ file[:filename] # => "cat.jpg"
176
+ file[:type] # => "image/jpeg"
177
+ file[:tempfile] # => #<Tempfile …> Read or stream this in your application.
178
+ ```
179
+
180
+ The tempfile is only usable while the request is being handled, because Rack removes it afterwards.
181
+
182
+ This means the _content_ of these fields is not validated, so `minLength`, `maxLength` or `pattern` on a field that was sent as a file are ignored. Fields that were not sent as a file are read and validated as usual, and a field with `contentType: application/json` in the `encoding` map is still parsed as JSON.
183
+
169
184
  ### Response validation
170
185
 
186
+ > [!WARNING]
187
+ > You should use [Contract Testing](#contract-testing) instead of this middleware.
188
+
171
189
  This middleware raises an error by default if the response is not valid.
190
+
172
191
  This can be useful in a test or staging environment, especially if you are adopting OpenAPI for an existing implementation.
173
192
 
174
193
  ```ruby
@@ -178,7 +197,7 @@ use OpenapiFirst::Middlewares::ResponseValidation if ENV['RACK_ENV'] == 'test'
178
197
  use OpenapiFirst::Middlewares::ResponseValidation, raise_error: false
179
198
  ```
180
199
 
181
- If you are adopting OpenAPI you can use these options together with [hooks](#hooks) to get notified about requests/responses that do match your API description.
200
+ If you are adopting OpenAPI you can use these options together with [hooks](#hooks) to get notified about requests/responses that do not match your API description.
182
201
 
183
202
  ## Contract Testing
184
203
 
@@ -192,36 +211,17 @@ Here is how to set it up:
192
211
  require 'openapi_first'
193
212
  OpenapiFirst::Test.setup
194
213
  ```
195
- 2. Observe your application. You can do this in multiple ways:
196
- - Add an `app` method to your tests (which is called by rack-test) that wraps your application with silent request / response validation.
197
- ```ruby
198
- module RequestSpecHelpers
199
- def app
200
- OpenapiFirst::Test.app(MyApp)
201
- end
202
- end
203
-
204
- RSpec.configure do |config|
205
- config.include RequestSpecHelpers, type: :request
206
- end
207
- ```
208
-
209
- Or do this by creating a Module and including it to add an "app" method.
210
-
211
- ```ruby
212
- RSpec.configure do |config|
213
- config.include OpenapiFirst::Test::Methods[MyApp], type: :request
214
- end
215
- ```
216
- 3. Run your tests. The Coverage feature will tell you about missing or invalid requests/responses:
217
- ```
218
- ✓ GET /stations
219
- ✓ 200(application/json)
220
- ❌ 200(application/xml) – No responses tracked!
221
- ❌ 400(application/problem+json) – No responses tracked!
222
- ```
223
-
224
- Now add tests for all those "❌" to make them "✓" and you're green!
214
+ 2. Observe your application.
215
+ If you are using plain rack-test (sinatra, roda, hanami, etc.), this will add an `app` method that wraps your application with silent request / response validation:
216
+
217
+ ```ruby
218
+ RSpec.configure do |config|
219
+ config.include OpenapiFirst::Test::Methods[MyApp], type: :request
220
+ end
221
+ ```
222
+
223
+ There are other ways to monitor your app, see `OpenapiFirst::Test.observe` (rails) or `OpenapiFirst::Test.app`.
224
+ 3. Run your tests. The Coverage feature will produce a HTML report at `coverage/openapi_coverage.html` with missing or invalid requests/responses.
225
225
 
226
226
  > [!NOTE]
227
227
  > Check out [faraday-openapi](https://codeberg.org/ahx/faraday-openapi) to have your API _client_ validate request/responses against an OAD, which is useful to validate HTTP mocks during testing.
@@ -239,12 +239,11 @@ OpenapiFirst::Test.setup do |test|
239
239
 
240
240
  test.ignore_response_error do |validated_response, rack_request|
241
241
  # Ignore invalid response bodies on certain paths
242
- validated_request.path.start_with?('/api/legacy/stuff') && validated_request.error.type == :invalid_body
242
+ rack_request.path.start_with?('/api/legacy/stuff') && validated_response.error.type == :invalid_body
243
243
  end
244
244
  end
245
245
  ```
246
246
 
247
-
248
247
  OpenapiFirst::Test raises an error when a response status is not defined except for 404 and 500. You can change this:
249
248
 
250
249
  ```ruby
@@ -276,7 +275,7 @@ Skip coverage for a request and all responses alltogether of a route with `skip_
276
275
  ```ruby
277
276
  OpenapiFirst::Test.setup do |test|
278
277
  test.skip_coverage do |path, request_method|
279
- path == '/bookings/{bookingId}' && requests_method == 'DELETE'
278
+ path == '/bookings/{bookingId}' && request_method == 'DELETE'
280
279
  end
281
280
  end
282
281
  ```
@@ -351,6 +350,18 @@ validated_request.operation['operationId'] => "getStuff"
351
350
  validated_request.request_definition.path # => "/pets/{petId}"
352
351
  validated_request.request_definition.operation_id # => "showPetById"
353
352
 
353
+ # Inspect the parameters that are defined for this request, in the order path, query, header, cookie.
354
+ # Parameters that openapi_first ignores (Content-Type, Accept, Authorization) are not included.
355
+ parameter = validated_request.request_definition.parameters.first
356
+ parameter.name # => "petId"
357
+ parameter.location # => "path" ("path", "query", "header" or "cookie")
358
+ parameter.schema # => { "type" => "integer" } (the JSON Schema, with $refs resolved)
359
+ parameter.required? # => true
360
+ parameter.deprecated? # => false
361
+ parameter.style # => "simple"
362
+ parameter.explode? # => false
363
+ parameter.media_type # => "application/json" if the parameter uses `content`, otherwise nil
364
+
354
365
  # Or you can raise an exception if validation fails:
355
366
  definition.validate_request(rack_request, raise_error: true) # Raises OpenapiFirst::RequestInvalidError or OpenapiFirst::NotFoundError if request is invalid
356
367
  ```
@@ -360,7 +371,7 @@ definition.validate_request(rack_request, raise_error: true) # Raises OpenapiFir
360
371
  ```ruby
361
372
  validated_response = definition.validate_response(rack_request, rack_response)
362
373
 
363
- # Inspect the response and access parsed parameters and
374
+ # Inspect the response and access parsed parameters
364
375
  validated_response.valid?
365
376
  validated_response.invalid?
366
377
  validated_response.error # => Failure object or nil
@@ -369,7 +380,7 @@ validated_response.parsed_body
369
380
  validated_response.parsed_headers
370
381
 
371
382
  # Or you can raise an exception if validation fails:
372
- definition.validate_response(rack_request,rack_response, raise_error: true) # Raises OpenapiFirst::ResponseInvalidError or OpenapiFirst::ResponseNotFoundError
383
+ definition.validate_response(rack_request, rack_response, raise_error: true) # Raises OpenapiFirst::ResponseInvalidError or OpenapiFirst::ResponseNotFoundError
373
384
  ```
374
385
 
375
386
  ## Hooks
@@ -416,12 +427,54 @@ Using rack middlewares is supported in probably all Ruby web frameworks.
416
427
 
417
428
  The contract testing feature is designed to be used via rack-test, which should be compatible all Ruby web frameworks as well.
418
429
 
419
- That aside, closer integration with specific frameworks like Sinatra, Hanami, Roda or others would be great. If you have ideas, pain points or PRs, please don't hesitate to [share](https://github.com/ahx/openapi_first/discussions).
430
+ That aside, closer integration with specific frameworks like Hanami, Roda or others would be great. If you have ideas, pain points or PRs, please don't hesitate to [share](https://github.com/ahx/openapi_first/discussions).
431
+
432
+ ### Sinatra
433
+
434
+ `OpenapiFirst::Sinatra` is a Sinatra extension to define routes by referencing operations in your OpenAPI description. The URL and HTTP method for each route are read from the OAD by `operationId`, so they are never repeated in your code – the API description stays the single source of truth for your routes.
435
+
436
+ ```ruby
437
+ require 'sinatra/base'
438
+ require 'openapi_first/sinatra'
439
+
440
+ class PetsApi < Sinatra::Base
441
+ register OpenapiFirst::Sinatra
442
+ openapi 'openapi.yaml'
443
+
444
+ operation :index_pets do |params|
445
+ json index_pets(params[:filter])
446
+ end
447
+
448
+ operation :create_pet do
449
+ json create_pet(parsed_body[:data])
450
+ end
451
+ end
452
+ ```
453
+
454
+ In a **classic** (top-level) app the extension registers itself, so requiring the file is enough:
455
+
456
+ ```ruby
457
+ require 'sinatra'
458
+ require 'openapi_first/sinatra'
459
+
460
+ openapi 'openapi.yaml'
461
+
462
+ operation :create_pet do
463
+ json create_pet(parsed_body)
464
+ end
465
+ ```
466
+
467
+ Each `operation` route validates its request against the description before the block runs, so contract violations return `400`/`415` and the block is not reached. Validation reuses Sinatra's own routing (the operation's path and method are known when the route is defined), so openapi_first does not run its own router – there is no request-validation middleware.
468
+
469
+ Because routing is left to Sinatra, requests to paths without an `operation` block fall through to Sinatra's normal handling (a `404` by default), and you can add plain Sinatra routes (health checks, assets, …) alongside `operation` blocks. Likewise, an undocumented method on a documented path returns Sinatra's `404`.
470
+
471
+ Use a String for operationIds that are not valid Ruby symbols, e.g. `operation 'pets.list'`.
472
+
473
+ See [`sinatra.rb`](lib/openapi_first/sinatra.rb) for a more details.
420
474
 
421
475
  ## Alternatives
422
476
 
423
477
  This gem was inspired by [committee](https://github.com/interagent/committee) (Ruby) and [Connexion](https://github.com/spec-first/connexion) (Python).
424
- Here is a [feature comparison between openapi_first and committee](https://gist.github.com/ahx/1538c31f0652f459861713b5259e366a).
425
478
 
426
479
  ## Frequently Asked Questions
427
480
 
@@ -441,7 +494,7 @@ Here your OpenAPI schema defines endpoints starting with `/resource` but your ac
441
494
 
442
495
  ```ruby
443
496
  oad = OpenapiFirst.load('openapi.yaml') do |config|
444
- config.path = ->(req) { request.path.delete_prefix('/api') }
497
+ config.path = ->(req) { req.path.delete_prefix('/api') }
445
498
  end
446
499
  use OpenapiFirst::Middlewares::RequestValidation, oad
447
500
  ```
@@ -4,7 +4,10 @@ require 'json_schemer'
4
4
 
5
5
  require_relative 'failure'
6
6
  require_relative 'router'
7
- require_relative 'header'
7
+ require_relative 'response_header'
8
+ require_relative 'parameter'
9
+ require_relative 'parameters_parser'
10
+ require_relative 'query_string_parser'
8
11
  require_relative 'request'
9
12
  require_relative 'response'
10
13
  require_relative 'schema/hash'
@@ -52,7 +55,11 @@ module OpenapiFirst
52
55
  version = document['openapi']
53
56
  case version
54
57
  when /\A3\.1\.\d+\z/
55
- document.fetch('jsonSchemaDialect') { JSONSchemer::OpenAPI31::BASE_URI.to_s }
58
+ openapi31_meta_schema(document)
59
+ when /\A3\.2\.\d+\z/
60
+ warn "OpenAPI 3.2 is not fully supported. #{filepath || 'This API description'} is handled " \
61
+ 'using the OpenAPI 3.1 rules, so features introduced in 3.2 may be ignored.'
62
+ openapi31_meta_schema(document)
56
63
  when /\A3\.0\.\d+\z/
57
64
  JSONSchemer::OpenAPI30::BASE_URI.to_s
58
65
  else
@@ -60,60 +67,86 @@ module OpenapiFirst
60
67
  end
61
68
  end
62
69
 
63
- def router # rubocop:disable Metrics/MethodLength
70
+ def openapi31_meta_schema(document)
71
+ document.fetch('jsonSchemaDialect') { JSONSchemer::OpenAPI31::BASE_URI.to_s }
72
+ end
73
+
74
+ def router
64
75
  router = OpenapiFirst::Router.new
65
76
  @contents.fetch('paths').each do |path, path_item_object|
66
77
  path_parameters = path_item_object['parameters'] || []
67
78
  path_item_object.resolved.keys.intersection(REQUEST_METHODS).map do |request_method|
68
79
  operation_object = path_item_object[request_method]
69
- operation_parameters = operation_object['parameters'] || []
70
- parameters = parse_parameters(operation_parameters.chain(path_parameters))
71
-
72
- build_requests(path:, request_method:, operation_object:,
73
- parameters:).each do |request|
74
- router.add_request(
75
- request,
76
- request_method:,
77
- path:,
78
- content_type: request.content_type,
79
- allow_empty_content: request.allow_empty_content?
80
- )
81
- build_responses(request:, responses: operation_object['responses']).each do |response|
82
- router.add_response(
83
- response,
84
- request_method:,
85
- path:,
86
- status: response.status,
87
- response_content_type: response.content_type
88
- )
89
- end
90
- end
80
+ register_operation(router, path:, request_method:, operation_object:, path_parameters:)
81
+ end
82
+
83
+ path_item_object['additionalOperations']&.each do |request_method, operation_object|
84
+ register_operation(router, path:, request_method: request_method.downcase,
85
+ operation_object:, path_parameters:)
91
86
  end
92
87
  end
93
88
  router
94
89
  end
95
90
 
91
+ def register_operation(router, path:, request_method:, operation_object:, path_parameters:)
92
+ operation_parameters = operation_object['parameters'] || []
93
+ parameters = parse_parameters(operation_parameters.chain(path_parameters))
94
+
95
+ build_requests(path:, request_method:, operation_object:,
96
+ parameters:).each do |request|
97
+ router.add_request(
98
+ request,
99
+ request_method:,
100
+ path:,
101
+ content_type: request.content_type,
102
+ allow_empty_content: request.allow_empty_content?
103
+ )
104
+ register_responses(router, request:, path:, request_method:,
105
+ responses: operation_object['responses'])
106
+ end
107
+ end
108
+
109
+ def register_responses(router, request:, path:, request_method:, responses:)
110
+ build_responses(request:, responses:).each do |response|
111
+ router.add_response(
112
+ response,
113
+ request_method:,
114
+ path:,
115
+ status: response.status,
116
+ response_content_type: response.content_type
117
+ )
118
+ end
119
+ end
120
+
96
121
  def parse_parameters(parameters)
97
- grouped_parameters = group_parameters(parameters)
122
+ grouped = group_parameters(parameters)
123
+ path = build_parameters(grouped[:path])
124
+ query = build_parameters(grouped[:query])
125
+ header = build_parameters(grouped[:header])
126
+ cookie = build_parameters(grouped[:cookie])
98
127
  ParsedParameters.new(
99
- query: resolve_parameters(grouped_parameters[:query]),
100
- path: resolve_parameters(grouped_parameters[:path]),
101
- cookie: resolve_parameters(grouped_parameters[:cookie]),
102
- header: resolve_parameters(grouped_parameters[:header]),
103
- query_schema: build_parameter_schema(grouped_parameters[:query]),
104
- path_schema: build_parameter_schema(grouped_parameters[:path]),
105
- cookie_schema: build_parameter_schema(grouped_parameters[:cookie]),
106
- header_schema: build_parameter_schema(grouped_parameters[:header])
128
+ all: [*path, *query, *header, *cookie].freeze,
129
+ path_parser: grouped[:path] && ParametersParser.new(path),
130
+ query_parser: QueryStringParser.new(query),
131
+ header_parser: grouped[:header] && ParametersParser.new(header),
132
+ cookie_parser: grouped[:cookie] && ParametersParser.new(cookie),
133
+ path_schema: build_parameter_schema(grouped[:path]),
134
+ query_schema: build_parameter_schema(grouped[:query]),
135
+ header_schema: build_parameter_schema(grouped[:header]),
136
+ cookie_schema: build_parameter_schema(grouped[:cookie])
107
137
  )
108
138
  end
109
139
 
110
- def resolve_parameters(parameters)
111
- parameters&.map do |parameter|
112
- result = parameter.resolved
113
- _media_type, media_type_object = parameter['content']&.first
114
- result['schema'] = (media_type_object || parameter)['schema'].resolved
115
- result
116
- end.to_a
140
+ def build_parameters(parameters)
141
+ parameters.to_a.map do |parameter|
142
+ Parameter.new(parameter.resolved, schema: parameter_schema_node(parameter)&.dereferenced)
143
+ end
144
+ end
145
+
146
+ # The schema of a parameter is either defined directly or inside a content media type object
147
+ def parameter_schema_node(parameter)
148
+ _media_type, media_type_object = parameter['content']&.first
149
+ (media_type_object || parameter)['schema']
117
150
  end
118
151
 
119
152
  def build_parameter_schema(parameters)
@@ -121,7 +154,7 @@ module OpenapiFirst
121
154
 
122
155
  required = []
123
156
  schemas = parameters.each_with_object({}) do |parameter, result|
124
- schema = parameter['schema'].schema(configuration: schemer_configuration)
157
+ schema = parameter_schema_node(parameter)&.schema(configuration: schemer_configuration)
125
158
  name = parameter['name']&.value
126
159
  required << name if parameter['required']&.value
127
160
  result[name] = schema if schema
@@ -193,16 +226,18 @@ module OpenapiFirst
193
226
 
194
227
  result = []
195
228
  headers_object.each do |name, header|
196
- next if header['schema'].nil?
229
+ schema_node = header['schema']
230
+ next if schema_node.nil?
197
231
  next if IGNORED_HEADER_PARAMETERS.include?(name)
198
232
 
199
- header = Header.new(
233
+ required = header['required']&.value == true
234
+ result << ResponseHeader.new(
200
235
  name:,
201
- schema: header['schema'].schema(configuration: schemer_configuration),
202
- required?: header['required']&.value == true,
203
- node: header
236
+ schema: schema_node.schema(configuration: schemer_configuration),
237
+ required?: required,
238
+ parameter: Parameter.new({ 'name' => name, 'in' => 'header', 'required' => required },
239
+ schema: schema_node.dereferenced)
204
240
  )
205
- result << header
206
241
  end
207
242
  result
208
243
  end
@@ -216,8 +251,8 @@ module OpenapiFirst
216
251
  result
217
252
  end
218
253
 
219
- ParsedParameters = Data.define(:path, :query, :header, :cookie, :path_schema, :query_schema, :header_schema,
220
- :cookie_schema)
254
+ ParsedParameters = Data.define(:all, :path_parser, :query_parser, :header_parser, :cookie_parser,
255
+ :path_schema, :query_schema, :header_schema, :cookie_schema)
221
256
  private_constant :ParsedParameters
222
257
  end
223
258
  end
@@ -7,8 +7,6 @@ module OpenapiFirst
7
7
  super()
8
8
  @parent = parent
9
9
  @request_validation_error_response = parent.request_validation_error_response
10
- @request_validation_raise_error = parent.request_validation_raise_error
11
- @response_validation_raise_error = parent.response_validation_raise_error
12
10
  @path = parent.path
13
11
  end
14
12
 
@@ -14,8 +14,6 @@ module OpenapiFirst
14
14
 
15
15
  def initialize
16
16
  @request_validation_error_response = OpenapiFirst.find_error_response(:default)
17
- @request_validation_raise_error = false
18
- @response_validation_raise_error = true
19
17
  @hooks = HOOKS.to_h { [_1, Set.new] }
20
18
  @path = nil
21
19
  end
@@ -27,11 +25,6 @@ module OpenapiFirst
27
25
  attr_reader :hooks, :request_validation_error_response
28
26
  attr_accessor :path
29
27
 
30
- # @deprecated
31
- attr_reader :request_validation_raise_error
32
- # @deprecated
33
- attr_reader :response_validation_raise_error
34
-
35
28
  # Return a child configuration that still receives updates of global hooks.
36
29
  def child
37
30
  ChildConfiguration.new(parent: self)
@@ -42,22 +35,6 @@ module OpenapiFirst
42
35
  raise NoMethodError, 'OpenapiFirst::Configuration#clone was removed. You want to call #child instead'
43
36
  end
44
37
 
45
- # @deprecated Pass `raise_error:` to OpenapiFirst::Middlewares::RequestValidation directly
46
- def request_validation_raise_error=(value)
47
- message = 'Setting OpenapiFirst::Configuration#request_validation_raise_error will be removed. ' \
48
- 'Please pass `raise_error:` to `OpenapiFirst::Middlewares::RequestValidation directly`'
49
- warn message, category: :deprecated
50
- @request_validation_raise_error = value
51
- end
52
-
53
- # @deprecated Pass `raise_error:` to OpenapiFirst::Middlewares::ResponseValidation directly
54
- def response_validation_raise_error=(value)
55
- message = 'Setting OpenapiFirst::Configuration#request_validation_raise_error will be removed. ' \
56
- 'Please pass `raise_error:` to `OpenapiFirst::Middlewares::ResponseValidation directly`'
57
- warn message
58
- @response_validation_raise_error = value
59
- end
60
-
61
38
  HOOKS.each do |hook|
62
39
  define_method(hook) do |&block|
63
40
  return hooks[hook] if block.nil?
@@ -71,15 +71,40 @@ module OpenapiFirst
71
71
  "#<#{self.class.name} @key='#{key}'>"
72
72
  end
73
73
 
74
+ # Resolves the path for the named operation, filling in any `{param}` placeholders.
75
+ # @param operation_id [String, Symbol] An operationId present in this API description.
76
+ # @param params [Hash] Path-parameter values keyed by name (String or Symbol).
77
+ # @return [String] The resolved path (e.g. "/pets/42").
78
+ # @raise [ArgumentError] if the operationId is not found or a required path parameter is missing.
79
+ def path_for(params = {}, operation_id:)
80
+ request_def = routes.lazy.flat_map(&:requests).find { |r| r.operation_id == operation_id.to_s }
81
+ raise ArgumentError, "Operation #{operation_id.inspect} is not defined in #{key}." unless request_def
82
+
83
+ request_def.path.gsub(/\{([^}]+)\}/) do
84
+ name = Regexp.last_match(1)
85
+ params.fetch(name.to_sym) do
86
+ params.fetch(name) do
87
+ raise ArgumentError, "Missing path parameter #{name.inspect} for operation #{operation_id.inspect}."
88
+ end
89
+ end
90
+ end
91
+ end
92
+
74
93
  # Validates the request against the API description.
75
94
  # @param [Rack::Request] request The Rack request object.
76
95
  # @param [Boolean] raise_error Whether to raise an error if validation fails.
96
+ # @param [String,nil] path_template The OpenAPI path template (e.g. "/pets/{petId}") of the
97
+ # already-matched route. Pass this when your own router has matched the route, to skip
98
+ # openapi_first's path matching. Used by framework integrations like Sinatra.
99
+ # @param [Hash,nil] path_params The path parameters extracted by your own router
100
+ # (e.g. { "petId" => "42" }), keyed by parameter name. Pass this together with +path_template+
101
+ # to skip openapi_first's path-parameter extraction; otherwise they are extracted from the path.
77
102
  # @yield [ValidatedRequest] Optional block called after successful validation.
78
103
  # The block runs inside the same catch(FAILURE) as the after_request_validation hooks,
79
104
  # so it may call OpenapiFirst::Failure.fail! to short-circuit and produce an error.
80
105
  # @return [ValidatedRequest] The validated request object.
81
- def validate_request(request, raise_error: false, &after_block)
82
- route = @router.match(request.request_method, resolve_path(request), content_type: request.content_type)
106
+ def validate_request(request, raise_error: false, path_template: nil, path_params: nil, &after_block)
107
+ route = match_route(request, path_template, params: path_params)
83
108
  validated = if route.error
84
109
  ValidatedRequest.new(request, error: route.error)
85
110
  else
@@ -117,6 +142,14 @@ module OpenapiFirst
117
142
 
118
143
  private
119
144
 
145
+ def match_route(request, path_template, params:)
146
+ request_method = request.request_method
147
+ content_type = request.content_type
148
+ return @router.match_route(request_method, path_template, params:, content_type:) if path_template
149
+
150
+ @router.match(request_method, resolve_path(request), content_type:)
151
+ end
152
+
120
153
  def call_before_request_validation_hooks(request, request_definition)
121
154
  return if @config.before_request_validation.none?
122
155
 
@@ -3,7 +3,7 @@
3
3
  module OpenapiFirst
4
4
  # A failure object returned when validation or parsing of a request or response has failed.
5
5
  # This returned in ValidatedRequest#error and ValidatedResponse#error.
6
- class Failure < Data.define(:type, :message, :errors) # rubocop:disable Style/DataInheritance
6
+ class Failure < Data.define(:type, :message, :errors)
7
7
  TYPES = {
8
8
  not_found: [NotFoundError, 'Not found.'],
9
9
  method_not_allowed: [RequestInvalidError, 'Request method is not defined.'],
@@ -49,6 +49,10 @@ module OpenapiFirst
49
49
  alias original_message message
50
50
  private :original_message
51
51
 
52
+ def inspect
53
+ "#<OpenapiFirst::Failure:#{object_id} type: #{type}, message: #{message}>"
54
+ end
55
+
52
56
  # A generic error message
53
57
  def message
54
58
  original_message || exception_message
@@ -36,7 +36,7 @@ module OpenapiFirst
36
36
  options = spec
37
37
  spec = options[:spec]
38
38
  end
39
- @raise = options.fetch(:raise_error, OpenapiFirst.configuration.request_validation_raise_error)
39
+ @raise = options.fetch(:raise_error, false)
40
40
  @error_response_class = error_response_option(options[:error_response])
41
41
 
42
42
  spec ||= :default