openapi_first 1.4.3 → 2.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -1
  3. data/README.md +105 -28
  4. data/lib/openapi_first/body_parser.rb +8 -11
  5. data/lib/openapi_first/builder.rb +81 -0
  6. data/lib/openapi_first/configuration.rb +25 -4
  7. data/lib/openapi_first/definition.rb +44 -100
  8. data/lib/openapi_first/error_response.rb +2 -2
  9. data/lib/openapi_first/error_responses/default.rb +73 -0
  10. data/lib/openapi_first/error_responses/jsonapi.rb +59 -0
  11. data/lib/openapi_first/errors.rb +26 -4
  12. data/lib/openapi_first/failure.rb +29 -26
  13. data/lib/openapi_first/json_refs.rb +1 -3
  14. data/lib/openapi_first/middlewares/request_validation.rb +2 -2
  15. data/lib/openapi_first/middlewares/response_validation.rb +4 -3
  16. data/lib/openapi_first/request.rb +92 -0
  17. data/lib/openapi_first/request_parser.rb +35 -0
  18. data/lib/openapi_first/request_validator.rb +25 -0
  19. data/lib/openapi_first/response.rb +57 -0
  20. data/lib/openapi_first/response_parser.rb +49 -0
  21. data/lib/openapi_first/response_validator.rb +27 -0
  22. data/lib/openapi_first/router/find_content.rb +17 -0
  23. data/lib/openapi_first/router/find_response.rb +45 -0
  24. data/lib/openapi_first/{definition → router}/path_template.rb +9 -1
  25. data/lib/openapi_first/router.rb +100 -0
  26. data/lib/openapi_first/schema/validation_error.rb +16 -10
  27. data/lib/openapi_first/schema/validation_result.rb +8 -6
  28. data/lib/openapi_first/schema.rb +4 -8
  29. data/lib/openapi_first/test/methods.rb +21 -0
  30. data/lib/openapi_first/test.rb +27 -0
  31. data/lib/openapi_first/validated_request.rb +81 -0
  32. data/lib/openapi_first/validated_response.rb +33 -0
  33. data/lib/openapi_first/validators/request_body.rb +39 -0
  34. data/lib/openapi_first/validators/request_parameters.rb +61 -0
  35. data/lib/openapi_first/validators/response_body.rb +30 -0
  36. data/lib/openapi_first/validators/response_headers.rb +25 -0
  37. data/lib/openapi_first/version.rb +1 -1
  38. data/lib/openapi_first.rb +40 -21
  39. metadata +25 -20
  40. data/lib/openapi_first/definition/operation.rb +0 -197
  41. data/lib/openapi_first/definition/path_item.rb +0 -40
  42. data/lib/openapi_first/definition/request_body.rb +0 -46
  43. data/lib/openapi_first/definition/response.rb +0 -32
  44. data/lib/openapi_first/definition/responses.rb +0 -87
  45. data/lib/openapi_first/plugins/default/error_response.rb +0 -74
  46. data/lib/openapi_first/plugins/default.rb +0 -11
  47. data/lib/openapi_first/plugins/jsonapi/error_response.rb +0 -60
  48. data/lib/openapi_first/plugins/jsonapi.rb +0 -11
  49. data/lib/openapi_first/plugins.rb +0 -25
  50. data/lib/openapi_first/request_validation/request_body_validator.rb +0 -41
  51. data/lib/openapi_first/request_validation/validator.rb +0 -82
  52. data/lib/openapi_first/response_validation/validator.rb +0 -98
  53. data/lib/openapi_first/runtime_request.rb +0 -166
  54. data/lib/openapi_first/runtime_response.rb +0 -124
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 65d3a7d13fef3df69dbbbe5c42fae60a977f56dd044ce712a9826b1489c1b001
4
- data.tar.gz: 54c3fd940b948a6c72f001e02a011882f60dbb06c7e7ea0b1660657c949f3d15
3
+ metadata.gz: 87fceda33e397fa75a8268144e5cbacfb802333a5ef1c315676d14458312b01b
4
+ data.tar.gz: a734ee73e2dce89fea8d2fc93c713b0505923b2b2098b560a2b44ef3e5753c84
5
5
  SHA512:
6
- metadata.gz: 0cd9e2433e14958f762282293ea2f2dff45ec984d406386eb9cb43d5277ba96eb0b7a31f6a8747a2ba5747a1374be72bc86116470fbe266b8c05a5f72daee12e
7
- data.tar.gz: c2e7aab524164d310570fab575f9ff5c0b891f1501f90b0d6fe6f8265e028cd543fd22c64fb99a4227b488f340da4947f69b0988f8e3c9315df2c17bb7089762
6
+ metadata.gz: 63d51c543d3d689ced06170e1e53f6d0a20056af2643e1f1fccfbf966b14352c55ad9c0dfd92478ceb7e5aaac0b6f7c7563e17d9e69ab2f924f6a9d4846c45ff
7
+ data.tar.gz: 8649d5737439a2414802e17ed562a0efdf294f29e7515b0e248e5736cd59235eba876baf52aad8e5d602ff90ba5afc8902ed1609d902dfe03c65d4136afc1042
data/CHANGELOG.md CHANGED
@@ -2,6 +2,53 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 2.0.2
6
+
7
+ - Fix setting custom error response (thanks @gobijan)
8
+
9
+ ## 2.0.1 (Janked)
10
+
11
+ ## 2.0.0
12
+
13
+ ### New Features
14
+ - Test Assertions! 📋 You can now use `assert_api_conform` for contract testing in your rack-test / Rails integration tests. See Readme for details.
15
+
16
+ - New option for `Middlewares::ResponseValidation`: `:raise_error` (default: true). If set to `false`, the middleware will not aise an error if the response is invalid. 🤫
17
+
18
+ - Hooks 🪝🪝 (see Readme for details). You can use these to collect metrics, write error logs etc.:
19
+ - `after_request_validation`
20
+ - `after_response_validation`
21
+ - `after_request_body_property_validation`
22
+ - `after_request_parameter_property_validation`
23
+
24
+ - Exceptions such as `OpenapiFirst::ResponseInvalidError` not respond to `#request` to get information about the validated request 💁🏻
25
+
26
+ - Performance improvements 🚴🏻‍♀️
27
+
28
+ - Validation failures returned by `ValidatedRequest#error` always returns a `#message`. So you can call `my_validated_request.error.message if validated_request.invalid?` and always get a human-readable error message. 😴
29
+
30
+ ### Breaking Changes
31
+
32
+ #### Manual validation
33
+ - `Definition#request.validate` was removed. Please use `Definition#validate_request` instead.
34
+ - `Definition#validate_request` returns a `ValidatedRequest` which delgates all methods to the original (rack) request, except for `#valid?` `#parsed_body`. `#parsed_query`, `#operation` etc. See Readme for details.
35
+ - The `Operation` class was removed. `ValidatedRequest#operation` now returns the OpenAPI 3 operation object as a plain Hash. So you can still call `ValidatedRequest#operation['x-foo']`. You can call `ValidatedRequest#operation_id` if you just need the _operationId_.
36
+ -
37
+
38
+ #### Inspecting OpenAPI files
39
+
40
+ - `Definition#operations` has been removed. Please use `Definition#routes`, which returns a list of routes. Routes have a `#path`, `#request_method`, `#requests` and `#responses`.
41
+ A route has one path and one request method, but can have multiple requests (one for each supported content-type) and responses (statuses + content-type).
42
+
43
+ - Several internal changes to make the code more maintainable, more performant , support hooks and prepare for OpenAPI 4. If you have monkey-patched OpenapiFirst, you might need to adjust your code. Please contact me if you need help.
44
+
45
+ ### Deprecations
46
+
47
+ #### Custom error responses
48
+
49
+ - `ValidationError#error`, `#instance_location` and `#schema_location` have been deprecated. Use `ValidationError#message`, `#data_pointer` and `#schema_pointer` instead.
50
+ - `Failure#error_type` has been deprecated. Use `#type` instead
51
+
5
52
  ## 1.4.3
6
53
 
7
54
  - Allow using json_schemer 2...3
@@ -27,7 +74,7 @@ Some redundant methods to validate or inspect requests/responses will be removed
27
74
 
28
75
  ## 1.3.6
29
76
 
30
- - Fix Rack 2 / Rails 6 compatibility ([#246](https://github.com/ahx/openapi_first/issues/246)
77
+ - Fixed Rack 2 / Rails 6 compatibility ([#246](https://github.com/ahx/openapi_first/issues/246)
31
78
 
32
79
  ## 1.3.5
33
80
 
@@ -37,6 +84,7 @@ Some redundant methods to validate or inspect requests/responses will be removed
37
84
 
38
85
  - Fixed handling "binary" format in optional multipart file uploads
39
86
  - Cache the resolved OAD. This especially makes things run faster in tests.
87
+ - Internally used `Operation#query_parameters`, `Operation#path_parameters` etc. now only returns parameters that are defined on the operation level not on the PathItem. Use `PathItem#query_parameters` to get those.
40
88
 
41
89
  ## 1.3.3 (yanked)
42
90
 
data/README.md CHANGED
@@ -9,11 +9,13 @@ OpenapiFirst helps to implement HTTP APIs based on an [OpenAPI](https://www.open
9
9
  - [Rack Middlewares](#rack-middlewares)
10
10
  - [Request validation](#request-validation)
11
11
  - [Response validation](#response-validation)
12
+ - [Test assertions](#test-assertions)
12
13
  - [Manual use](#manual-use)
13
14
  - [Validate request](#validate-request)
14
15
  - [Validate response](#validate-response)
15
- - [Configuration](#configuration)
16
16
  - [Framework integration](#framework-integration)
17
+ - [Configuration](#configuration)
18
+ - [Hooks](#hooks)
17
19
  - [Alternatives](#alternatives)
18
20
  - [Development](#development)
19
21
  - [Benchmarks](#benchmarks)
@@ -23,13 +25,9 @@ OpenapiFirst helps to implement HTTP APIs based on an [OpenAPI](https://www.open
23
25
 
24
26
  ## Rack Middlewares
25
27
 
26
- All middlewares add a _request_ object to the current Rack env at `env[OpenapiFirst::REQUEST]`), which is in an instance of `OpenapiFirst::RuntimeRequest` that responds to `.params`, `.parsed_body` etc.
27
-
28
- This gives you access to the converted request parameters and body exaclty as described in your API description instead of relying on Rack alone to parse the request. This only includes query parameters that are defined in the API description. It supports every [`style` and `explode` value as described](https://spec.openapis.org/oas/latest.html#style-examples) in the OpenAPI 3.0 and 3.1 specs.
29
-
30
28
  ### Request validation
31
29
 
32
- The request validation middleware returns a 4xx if the request is invalid or not defined in the API description.
30
+ The request validation middleware returns a 4xx if the request is invalid or not defined in the API description. It adds a request object to the current Rack environment at `env[OpenapiFirst::REQUEST]` with the request parameters parsed exaclty as described in your API description plus access to meta information from your API description. See _[Manual use](#manual-use)_ for more details about that object.
33
31
 
34
32
  ```ruby
35
33
  use OpenapiFirst::Middlewares::RequestValidation, spec: 'openapi.yaml'
@@ -123,6 +121,7 @@ use OpenapiFirst::Middlewares::RequestValidation, spec: 'openapi.yaml, error_res
123
121
  #### Custom error responses
124
122
 
125
123
  You can build your own custom error response with `error_response: MyCustomClass` that implements `OpenapiFirst::ErrorResponse`.
124
+ 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`.
126
125
 
127
126
  #### readOnly / writeOnly properties
128
127
 
@@ -132,7 +131,7 @@ Response validation fails if response body includes a property with `writeOnly:
132
131
 
133
132
  ### Response validation
134
133
 
135
- This middleware is especially useful when testing. It _always_ raises an error if the response is not valid.
134
+ This middleware is especially useful when testing. It raises an error by default if the response is not valid.
136
135
 
137
136
  ```ruby
138
137
  use OpenapiFirst::Middlewares::ResponseValidation, spec: 'openapi.yaml' if ENV['RACK_ENV'] == 'test'
@@ -143,6 +142,37 @@ use OpenapiFirst::Middlewares::ResponseValidation, spec: 'openapi.yaml' if ENV['
143
142
  | Name | Possible values | Description |
144
143
  | :------ | --------------- | ---------------------------------------------------------------- |
145
144
  | `spec:` | | The path to the spec file or spec loaded via `OpenapiFirst.load` |
145
+ | `raise_error:` | `true` (default), `false` | If set to true the middleware raises `OpenapiFirst::ResponseInvalidError` or `OpenapiFirst::ResonseNotFoundError` if the response does not match the API description. |
146
+
147
+ ## Test assertions
148
+
149
+ openapi_first ships with a simple but powerful Test module to run request and response validation in your tests without using the middlewares. This is designed to be used with rack-test or Ruby on Rails integration tests or request specs.
150
+
151
+ Here is how to set it up for Rails integration tests:
152
+
153
+ ```ruby
154
+ # test_helper.rb
155
+ require 'openapi_first/test'
156
+ OpenapiFirst::Test.register('openapi/v1.openapi.yaml')
157
+ ```
158
+
159
+ Inside your test:
160
+ ```ruby
161
+ # test/integration/trips_api_test.rb
162
+ require 'test_helper'
163
+
164
+ class TripsApiTest < ActionDispatch::IntegrationTest
165
+ include OpenapiFirst::Test::Methods
166
+
167
+ test 'GET /trips' do
168
+ get '/trips',
169
+ params: { origin: 'efdbb9d1-02c2-4bc3-afb7-6788d8782b1e', destination: 'b2e783e1-c824-4d63-b37a-d8d698862f1d',
170
+ date: '2024-07-02T09:00:00Z' }
171
+
172
+ assert_api_conform(status: 200)
173
+ end
174
+ end
175
+ ```
146
176
 
147
177
  ## Manual use
148
178
 
@@ -159,23 +189,28 @@ definition = OpenapiFirst.load('openapi.yaml')
159
189
  ```ruby
160
190
  # Find and validate request
161
191
  rack_request = Rack::Request.new(env)
162
- request = definition.validate_request(rack_request)
192
+ validated_request = definition.validate_request(rack_request)
163
193
  # Or raise an exception if validation fails:
164
- request = definition.validate_request(rack_request, raise_error: true) # Raises OpenapiFirst::RequestInvalidError or OpenapiFirst::NotFoundError if request is invalid
194
+ definition.validate_request(rack_request, raise_error: true) # Raises OpenapiFirst::RequestInvalidError or OpenapiFirst::NotFoundError if request is invalid
165
195
 
166
196
  # Inspect the request and access parsed parameters
167
- request.known? # Is the request defined in the API description?
168
- request.valid? # => true / false
169
- request.error # => Failure object if request is invalid
170
- request.body # alias: parsed_body
171
- request.path_parameters # => { "pet_id" => 42 }
172
- request.query # alias: query_parameters
173
- request.params # Merged path and query parameters
174
- request.headers
175
- request.cookies
176
- request.content_type
177
- request.request_method # => "get"
178
- request.path # => "/pets/42"
197
+ validated_request.known? # Is the request defined in the API description?
198
+ validated_request.valid? # => true / false
199
+ validated_request.invalid? # => true / false
200
+ validated_request.error # => Failure object if request is invalid
201
+ validated_request.parsed_params # Merged parsed path, query parameters and request body
202
+ validated_request.parsed_body
203
+ validated_request.parsed_path_parameters # => { "pet_id" => 42 }
204
+ validated_request.parsed_headers
205
+ validated_request.parsed_cookies
206
+ validated_request.parsed_query
207
+
208
+ # Access the Openapi 3 Operation Object Hash
209
+ validated_request.operation['x-foo']
210
+ validated_request.operation['operationId']
211
+ # or the whole request definition
212
+ validated_request.request_definition.path # => "/pets/{petId}"
213
+ validated_request.request_definition.operation_id # => "showPetById"
179
214
  ```
180
215
 
181
216
  ### Validate response
@@ -183,21 +218,19 @@ request.path # => "/pets/42"
183
218
  ```ruby
184
219
  # Find and validate the response
185
220
  rack_response = Rack::Response[*app.call(env)]
186
- response = definition.validate_response(rack_request, rack_response)
221
+ validated_response = definition.validate_response(rack_request, rack_response)
187
222
 
188
223
  # Raise an exception if validation fails:
189
- response = definition.validate_response(rack_request,rack_response, raise_error: true) # Raises OpenapiFirst::ResponseInvalidError or OpenapiFirst::ResponseNotFoundError
190
- # Or you can also call a method on the request object mentioned above
191
- request.validate_response(rack_response)
224
+ definition.validate_response(rack_request,rack_response, raise_error: true) # Raises OpenapiFirst::ResponseInvalidError or OpenapiFirst::ResponseNotFoundError
192
225
 
193
226
  # Inspect the response and access parsed parameters and
194
227
  response.known? # Is the response defined in the API description?
195
228
  response.valid? # => true / false
229
+ response.invalid? # => true / false
196
230
  response.error # => Failure object if response is invalid
197
- response.body
198
- request.headers
199
231
  response.status # => 200
200
- response.content_type
232
+ response.parsed_body
233
+ response.parsed_headers
201
234
  ```
202
235
 
203
236
  OpenapiFirst uses [`multi_json`](https://rubygems.org/gems/multi_json).
@@ -215,6 +248,50 @@ OpenapiFirst.configure do |config|
215
248
  end
216
249
  ```
217
250
 
251
+ or configure per instance:
252
+
253
+ ```ruby
254
+ OpenapiFirst.load('openapi.yaml') do |config|
255
+ config.request_validation_error_response = :jsonapi
256
+ end
257
+ ```
258
+
259
+ ## Hooks
260
+
261
+ You can integrate your code at certain points during request/response validation via hooks.
262
+
263
+ Available hooks:
264
+
265
+ - `after_request_validation`
266
+ - `after_response_validation`
267
+ - `after_request_parameter_property_validation`
268
+ - `after_request_body_property_validation`
269
+
270
+ Setup per per instance:
271
+
272
+ ```ruby
273
+ OpenapiFirst.load('openapi.yaml') do |config|
274
+ config.after_request_validation do |validated_request|
275
+ validated_request.valid? # => true / false
276
+ end
277
+ config.after_response_validation do |validated_response, request|
278
+ if validated_response.invalid?
279
+ warn "#{request.request_method} #{request.path}: #{validated_response.error.message}"
280
+ end
281
+ end
282
+ end
283
+ ```
284
+
285
+ Setup globally:
286
+
287
+ ```ruby
288
+ OpenapiFirst.configure do |config|
289
+ config.after_request_parameter_property_validation do |data, property, property_schema|
290
+ data[property] = Date.iso8601(data[property]) if propert_schema['format'] == 'date'
291
+ end
292
+ end
293
+ ```
294
+
218
295
  ## Framework integration
219
296
 
220
297
  Using rack middlewares is supported in probably all Ruby web frameworks.
@@ -5,30 +5,27 @@ require 'multi_json'
5
5
  module OpenapiFirst
6
6
  # @!visibility private
7
7
  class BodyParser
8
- def self.const_missing(const_name)
9
- super unless const_name == :ParsingError
10
- warn 'DEPRECATION WARNING: OpenapiFirst::BodyParser::ParsingError is deprecated. ' \
11
- 'Use OpenapiFirst::ParseError instead.'
12
- OpenapiFirst::ParseError
8
+ def initialize(content_type)
9
+ @is_json = :json if /json/i.match?(content_type)
13
10
  end
14
11
 
15
- def parse(request, content_type)
12
+ def parse(request)
16
13
  body = read_body(request)
17
- return if body.empty?
14
+ return if body.nil? || body.empty?
18
15
 
19
- return MultiJson.load(body) if content_type =~ (/json/i) && (content_type =~ /json/i)
16
+ return MultiJson.load(body) if @is_json
20
17
  return request.POST if request.form_data?
21
18
 
22
19
  body
23
20
  rescue MultiJson::ParseError
24
- raise ParseError, 'Failed to parse body as JSON'
21
+ Failure.fail!(:invalid_body, message: 'Failed to parse request body as JSON')
25
22
  end
26
23
 
27
24
  private
28
25
 
29
26
  def read_body(request)
30
- body = request.body.read
31
- request.body.rewind
27
+ body = request.body&.read
28
+ request.body.rewind if request.body.respond_to?(:rewind)
32
29
  body
33
30
  end
34
31
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ # Builds parts of a Definition
5
+ # This knows how to read a resolved OpenAPI document and build {Request} and {Response} objects.
6
+ class Builder
7
+ REQUEST_METHODS = %w[get head post put patch delete trace options].freeze
8
+
9
+ # Builds a router from a resolved OpenAPI document.
10
+ # @param resolved [Hash] The resolved OpenAPI document.
11
+ # @param config [OpenapiFirst::Configuration] The configuration object.
12
+ def self.build_router(resolved, config)
13
+ openapi_version = (resolved['openapi'] || resolved['swagger'])[0..2]
14
+ new(resolved, config, openapi_version).router
15
+ end
16
+
17
+ def initialize(resolved, config, openapi_version)
18
+ @resolved = resolved
19
+ @config = config
20
+ @openapi_version = openapi_version
21
+ end
22
+
23
+ attr_reader :resolved, :openapi_version, :config
24
+
25
+ def router # rubocop:disable Metrics/MethodLength
26
+ router = OpenapiFirst::Router.new
27
+ resolved['paths'].each do |path, path_item_object|
28
+ path_item_object.slice(*REQUEST_METHODS).keys.map do |request_method|
29
+ operation_object = path_item_object[request_method]
30
+ build_requests(path, request_method, operation_object, path_item_object).each do |request|
31
+ router.add_request(
32
+ request,
33
+ request_method:,
34
+ path:,
35
+ content_type: request.content_type
36
+ )
37
+ end
38
+ build_responses(operation_object).each do |response|
39
+ router.add_response(
40
+ response,
41
+ request_method:,
42
+ path:,
43
+ status: response.status,
44
+ response_content_type: response.content_type
45
+ )
46
+ end
47
+ end
48
+ end
49
+ router
50
+ end
51
+
52
+ def build_requests(path, request_method, operation_object, path_item_object)
53
+ hooks = config.hooks
54
+ path_item_parameters = path_item_object['parameters']
55
+ parameters = operation_object['parameters'].to_a.chain(path_item_parameters.to_a)
56
+ required_body = operation_object.dig('requestBody', 'required') == true
57
+ result = operation_object.dig('requestBody', 'content')&.map do |content_type, content|
58
+ Request.new(path:, request_method:, operation_object:, parameters:, content_type:,
59
+ content_schema: content['schema'], required_body:, hooks:, openapi_version:)
60
+ end || []
61
+ return result if required_body
62
+
63
+ result << Request.new(
64
+ path:, request_method:, operation_object:,
65
+ parameters:, content_type: nil, content_schema: nil,
66
+ required_body:, hooks:, openapi_version:
67
+ )
68
+ end
69
+
70
+ def build_responses(operation_object)
71
+ Array(operation_object['responses']).flat_map do |status, response_object|
72
+ headers = response_object['headers']
73
+ response_object['content']&.map do |content_type, content_object|
74
+ content_schema = content_object['schema']
75
+ Response.new(status:, headers:, content_type:, content_schema:, openapi_version:)
76
+ end || Response.new(status:, headers:, content_type: nil,
77
+ content_schema: nil, openapi_version:)
78
+ end
79
+ end
80
+ end
81
+ end
@@ -3,17 +3,38 @@
3
3
  module OpenapiFirst
4
4
  # Global configuration. Currently only used for the request validation middleware.
5
5
  class Configuration
6
+ HOOKS = %i[
7
+ after_request_validation
8
+ after_response_validation
9
+ after_request_parameter_property_validation
10
+ after_request_body_property_validation
11
+ ].freeze
12
+
6
13
  def initialize
7
- @request_validation_error_response = OpenapiFirst.find_plugin(:default)::ErrorResponse
14
+ @request_validation_error_response = OpenapiFirst.find_error_response(:default)
8
15
  @request_validation_raise_error = false
16
+ @response_validation_raise_error = true
17
+ @hooks = (HOOKS.map { [_1, []] }).to_h
9
18
  end
10
19
 
11
- attr_reader :request_validation_error_response
12
- attr_accessor :request_validation_raise_error
20
+ attr_reader :request_validation_error_response, :hooks
21
+ attr_accessor :request_validation_raise_error, :response_validation_raise_error
22
+
23
+ def clone
24
+ copy = super
25
+ copy.instance_variable_set(:@hooks, @hooks&.transform_values(&:clone))
26
+ copy
27
+ end
28
+
29
+ HOOKS.each do |hook|
30
+ define_method(hook) do |&block|
31
+ hooks[hook] << block
32
+ end
33
+ end
13
34
 
14
35
  def request_validation_error_response=(mod)
15
36
  @request_validation_error_response = if mod.is_a?(Symbol)
16
- OpenapiFirst.find_plugin(:default)::ErrorResponse
37
+ OpenapiFirst.find_error_response(mod)
17
38
  else
18
39
  mod
19
40
  end
@@ -1,125 +1,69 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'definition/path_item'
4
- require_relative 'runtime_request'
5
- require_relative 'request_validation/validator'
6
- require_relative 'response_validation/validator'
3
+ require_relative 'failure'
4
+ require_relative 'router'
5
+ require_relative 'request'
6
+ require_relative 'response'
7
+ require_relative 'builder'
7
8
 
8
9
  module OpenapiFirst
9
10
  # Represents an OpenAPI API Description document
10
11
  # This is returned by OpenapiFirst.load.
11
12
  class Definition
12
- attr_reader :filepath, :paths, :openapi_version
13
+ attr_reader :filepath, :config, :paths, :router
13
14
 
14
15
  # @param resolved [Hash] The resolved OpenAPI document.
15
16
  # @param filepath [String] The file path of the OpenAPI document.
16
17
  def initialize(resolved, filepath = nil)
17
18
  @filepath = filepath
18
- @paths = resolved['paths']
19
- @openapi_version = detect_version(resolved)
19
+ @config = OpenapiFirst.configuration.clone
20
+ yield @config if block_given?
21
+ @config.freeze
22
+ @router = Builder.build_router(resolved, @config)
23
+ @paths = resolved['paths'].keys # TODO: Move into builder as well
24
+ end
25
+
26
+ def routes
27
+ @router.routes
20
28
  end
21
29
 
22
30
  # Validates the request against the API description.
23
- # @param rack_request [Rack::Request] The Rack request object.
24
- # @param raise_error [Boolean] Whether to raise an error if validation fails.
25
- # @return [RuntimeRequest] The validated request object.
26
- def validate_request(rack_request, raise_error: false)
27
- runtime_request = request(rack_request)
28
- validator = RequestValidation::Validator.new(runtime_request.operation)
29
- validation_error = validator.validate(runtime_request)
30
- validation_error.raise! if validation_error && raise_error
31
- runtime_request.error = validation_error
32
- runtime_request
31
+ # @param [Rack::Request] rack_request The Rack request object.
32
+ # @param [Boolean] raise_error Whether to raise an error if validation fails.
33
+ # @return [ValidatedRequest] The validated request object.
34
+ def validate_request(request, raise_error: false)
35
+ route = @router.match(request.request_method, request.path, content_type: request.content_type)
36
+ validated = if route.error
37
+ ValidatedRequest.new(request, error: route.error)
38
+ else
39
+ route.request_definition.validate(request, route_params: route.params)
40
+ end
41
+ @config.hooks[:after_request_validation].each { |hook| hook.call(validated, self) }
42
+ raise validated.error.exception(validated) if validated.error && raise_error
43
+
44
+ validated
33
45
  end
34
46
 
35
47
  # Validates the response against the API description.
36
48
  # @param rack_request [Rack::Request] The Rack request object.
37
49
  # @param rack_response [Rack::Response] The Rack response object.
38
50
  # @param raise_error [Boolean] Whether to raise an error if validation fails.
39
- # @return [RuntimeResponse] The validated response object.
51
+ # @return [ValidatedResponse] The validated response object.
40
52
  def validate_response(rack_request, rack_response, raise_error: false)
41
- runtime_response = response(rack_request, rack_response)
42
- validator = ResponseValidation::Validator.new(runtime_response.operation)
43
- validation_error = validator.validate(runtime_response)
44
- validation_error.raise! if validation_error && raise_error
45
- runtime_response.error = validation_error
46
- runtime_response
47
- end
48
-
49
- # Builds a RuntimeRequest object based on the Rack request.
50
- # @param rack_request [Rack::Request] The Rack request object.
51
- # @return [RuntimeRequest] The RuntimeRequest object.
52
- def request(rack_request)
53
- path_item, path_params = find_path_item_and_params(rack_request.path)
54
- operation = path_item&.operation(rack_request.request_method.downcase)
55
- RuntimeRequest.new(
56
- request: rack_request,
57
- path_item:,
58
- operation:,
59
- path_params:
60
- )
61
- end
62
-
63
- # Builds a RuntimeResponse object based on the Rack request and response.
64
- # @param rack_request [Rack::Request] The Rack request object.
65
- # @param rack_response [Rack::Response] The Rack response object.
66
- # @return [RuntimeResponse] The RuntimeResponse object.
67
- def response(rack_request, rack_response)
68
- runtime_request = request(rack_request)
69
- RuntimeResponse.new(runtime_request.operation, rack_response)
70
- end
71
-
72
- # Gets all the operations defined in the API description.
73
- # @return [Array<Operation>] An array of Operation objects.
74
- def operations
75
- @operations ||= path_items.flat_map(&:operations)
76
- end
77
-
78
- # Gets the PathItem object for the specified path.
79
- # @param pathname [String] The path template string.
80
- # @return [PathItem] The PathItem object.
81
- # Example:
82
- # definition.path('/pets/{id}')
83
- def path(pathname)
84
- return unless paths.key?(pathname)
85
-
86
- PathItem.new(pathname, paths[pathname], openapi_version:)
87
- end
88
-
89
- private
90
-
91
- # Gets all the PathItem objects defined in the API description.
92
- # @return [Array] An array of PathItem objects.
93
- def path_items
94
- @path_items ||= paths.flat_map do |path, path_item_object|
95
- PathItem.new(path, path_item_object, openapi_version:)
96
- end
97
- end
98
-
99
- def find_path_item_and_params(request_path)
100
- if paths.key?(request_path)
101
- return [
102
- PathItem.new(request_path, paths[request_path], openapi_version:),
103
- {}
104
- ]
105
- end
106
- search_for_path_item(request_path)
107
- end
108
-
109
- def search_for_path_item(request_path)
110
- path_items.find do |path_item|
111
- path_params = path_item.match(request_path)
112
- next unless path_params
113
-
114
- return [
115
- path_item,
116
- path_params
117
- ]
118
- end
119
- end
120
-
121
- def detect_version(resolved)
122
- (resolved['openapi'] || resolved['swagger'])[0..2]
53
+ route = @router.match(rack_request.request_method, rack_request.path, content_type: rack_request.content_type)
54
+ return if route.error # Skip response validation for unknown requests
55
+
56
+ response_match = route.match_response(status: rack_response.status, content_type: rack_response.content_type)
57
+ error = response_match.error
58
+ validated = if error
59
+ ValidatedResponse.new(rack_response, error:)
60
+ else
61
+ response_match.response.validate(rack_response)
62
+ end
63
+ @config.hooks[:after_response_validation]&.each { |hook| hook.call(validated, rack_request, self) }
64
+ raise validated.error.exception(validated) if raise_error && validated.invalid?
65
+
66
+ validated
123
67
  end
124
68
  end
125
69
  end
@@ -12,12 +12,12 @@ module OpenapiFirst
12
12
 
13
13
  # The response body
14
14
  def body
15
- raise NotImplementedError
15
+ raise "#{self.class} must implement the method #{__method__}"
16
16
  end
17
17
 
18
18
  # The response content-type
19
19
  def content_type
20
- raise NotImplementedError
20
+ raise "#{self.class} must implement the method #{__method__}"
21
21
  end
22
22
 
23
23
  STATUS = {