openapi_first 2.2.3 → 2.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 543c0103c059d292d91db5a177e4a2fec307473b0438b2b851d96f2818920a9c
4
- data.tar.gz: b98142de184e58931bc544aa867cf4700605164fbfe2b426e074732dd37f293e
3
+ metadata.gz: 851414a9f2a8d64df210a610ed9577ad6608c85173a06221b96c3d390dcca1e5
4
+ data.tar.gz: 815e970727a8e683740b622768ccd031a138e4ef4ca38c8aebcd73bf2a7f4230
5
5
  SHA512:
6
- metadata.gz: 19cec612f7336c745bbccff2a518f65ce9785d9b4f4b237adb3b4bf6b5198eaa2e52c6eefce79eec748a627e99a6d58c41a648f95e66065b9bb9c01ed188fd58
7
- data.tar.gz: 7350acd1b0a7e4b7ca34c9c0ad2e7e18096b344c2bec42af12c140271eb55765ea73d9fbd39808d919c6a6d061b4759a0323c647bd5b7a42f104efa72d0638f8
6
+ metadata.gz: 8696eaabb5455c0e009233a3f8099198aa222cb1a442ef53314e5ed756373f41eff3747f69c0036cb43c708a746b21289866b9d7cfeace5649a68a686ad302ef
7
+ data.tar.gz: 9bb2db9c0443eb75299c755a79a0e9c6d696bc27a5683bc8fe4994e3e7e965e336afe855bbe41666346697668ae312697f5b0e307ec89a13c1c9ecf6c4087134
data/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 2.3.0
6
+
7
+ ### New feature
8
+ - Add OpenapiFirst::Test::Coverage to track request/response coverage for your API descriptions. (https://github.com/ahx/openapi_first/pull/327)
9
+
10
+ ## 2.2.4
11
+
12
+ - Fix request validation file uploads in multipart/form-data requests with nested fields (https://github.com/ahx/openapi_first/issues/324)
13
+ - Add more error details to validation result (https://github.com/ahx/openapi_first/pull/322)
14
+
5
15
  ## 2.2.3
6
16
 
7
17
  - Respect global JSONSchemer configuration (https://github.com/ahx/openapi_first/pull/318)
data/README.md CHANGED
@@ -1,21 +1,20 @@
1
1
  # openapi_first
2
2
 
3
- OpenapiFirst helps to implement HTTP APIs based on an [OpenAPI](https://www.openapis.org/) API description. It supports OpenAPI 3.0 and 3.1. It offers request and response validation and it ensures that your implementation follows exactly the API description.
3
+ openapi_first is a Ruby gem for request / response validation and contract-testing against an [OpenAPI](https://www.openapis.org/) 3.0 or 3.1 API description. It makes an APIFirst workflow easy and reliable.
4
4
 
5
- [![Tests](https://github.com/ahx/openapi_first/actions/workflows/ruby.yml/badge.svg)](https://github.com/ahx/openapi_first/actions/workflows/ruby.yml)
6
- [![CodeQL](https://github.com/ahx/openapi_first/actions/workflows/codeql.yml/badge.svg)](https://github.com/ahx/openapi_first/blob/codeql/.github/workflows/codeql.yml)
5
+ You can use openapi_first on production for [request validation](#request-validation) and in your tests to avoid API drift with it's request/response validation and coverage features.
7
6
 
8
7
  ## Contents
9
8
 
10
9
  <!-- TOC -->
11
10
 
12
- - [Manual use](#manual-use)
13
- - [Validate request](#validate-request)
14
- - [Validate response](#validate-response)
15
11
  - [Rack Middlewares](#rack-middlewares)
16
12
  - [Request validation](#request-validation)
17
13
  - [Response validation](#response-validation)
18
- - [Test assertions](#test-assertions)
14
+ - [Contract testing](#contract-testing)
15
+ - [Coverage](#coverage)
16
+ - [Test assertions](#test-assertions)
17
+ - [Manual use](#manual-use)
19
18
  - [Framework integration](#framework-integration)
20
19
  - [Configuration](#configuration)
21
20
  - [Hooks](#hooks)
@@ -26,59 +25,6 @@ OpenapiFirst helps to implement HTTP APIs based on an [OpenAPI](https://www.open
26
25
 
27
26
  <!-- /TOC -->
28
27
 
29
- ## Manual use
30
-
31
- Load the API description:
32
-
33
- ```ruby
34
- require 'openapi_first'
35
-
36
- definition = OpenapiFirst.load('openapi.yaml')
37
- ```
38
-
39
- ### Validate request
40
-
41
- ```ruby
42
- validated_request = definition.validate_request(rack_request)
43
-
44
- # Inspect the request and access parsed parameters
45
- validated_request.valid?
46
- validated_request.invalid?
47
- validated_request.error # => Failure object or nil
48
- validated_request.parsed_body # => The parsed request body (Hash)
49
- validated_request.parsed_query # A Hash of query parameters that are defined in the API description, parsed exactly as described.
50
- validated_request.parsed_path_parameters
51
- validated_request.parsed_headers
52
- validated_request.parsed_cookies
53
- validated_request.parsed_params # Merged parsed path, query parameters and request body
54
- # Access the Openapi 3 Operation Object Hash
55
- validated_request.operation['x-foo']
56
- validated_request.operation['operationId'] => "getStuff"
57
- # or the whole request definition
58
- validated_request.request_definition.path # => "/pets/{petId}"
59
- validated_request.request_definition.operation_id # => "showPetById"
60
-
61
- # Or you can raise an exception if validation fails:
62
- definition.validate_request(rack_request, raise_error: true) # Raises OpenapiFirst::RequestInvalidError or OpenapiFirst::NotFoundError if request is invalid
63
- ```
64
-
65
- ### Validate response
66
-
67
- ```ruby
68
- validated_response = definition.validate_response(rack_request, rack_response)
69
-
70
- # Inspect the response and access parsed parameters and
71
- validated_response.valid?
72
- validated_response.invalid?
73
- validated_response.error # => Failure object or nil
74
- validated_response.status # => 200
75
- validated_response.parsed_body
76
- validated_response.parsed_headers
77
-
78
- # Or you can raise an exception if validation fails:
79
- definition.validate_response(rack_request,rack_response, raise_error: true) # Raises OpenapiFirst::ResponseInvalidError or OpenapiFirst::ResponseNotFoundError
80
- ```
81
-
82
28
  ## Rack Middlewares
83
29
 
84
30
  ### Request validation
@@ -87,15 +33,10 @@ The request validation middleware returns a 4xx if the request is invalid or not
87
33
 
88
34
  ```ruby
89
35
  use OpenapiFirst::Middlewares::RequestValidation, spec: 'openapi.yaml'
90
- ```
91
36
 
92
- #### Options
93
-
94
- | Name | Possible values | Description |
95
- | :---------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
96
- | `spec:` | | The path to the spec file or spec loaded via `OpenapiFirst.load` |
97
- | `raise_error:` | `false` (default), `true` | If set to true the middleware raises `OpenapiFirst::RequestInvalidError` or `OpenapiFirst::NotFoundError` instead of returning 4xx. |
98
- | `error_response:` | `:default` (default), `:jsonapi`, Your implementation of `ErrorResponse` or `false` to disable responding |
37
+ # Pass `raise_error: true` to raise an error if request is invalid:
38
+ use OpenapiFirst::Middlewares::RequestValidation, raise_error: true, spec: 'openapi.yaml'
39
+ ```
99
40
 
100
41
  #### Error responses
101
42
 
@@ -129,7 +70,7 @@ content-type: "application/problem+json"
129
70
  }
130
71
  ```
131
72
 
132
- openapi_first offers a [JSON:API](https://jsonapi.org/) error response as well:
73
+ openapi_first offers a [JSON:API](https://jsonapi.org/) error response by passing `error_response: :jsonapi`:
133
74
 
134
75
  ```ruby
135
76
  use OpenapiFirst::Middlewares::RequestValidation, spec: 'openapi.yaml, error_response: :jsonapi'
@@ -179,37 +120,58 @@ use OpenapiFirst::Middlewares::RequestValidation, spec: 'openapi.yaml, error_res
179
120
  You can build your own custom error response with `error_response: MyCustomClass` that implements `OpenapiFirst::ErrorResponse`.
180
121
  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`.
181
122
 
182
- #### readOnly / writeOnly properties
183
-
184
- Request validation fails if request includes a property with `readOnly: true`.
185
-
186
- Response validation fails if response body includes a property with `writeOnly: true`.
187
-
188
123
  ### Response validation
189
124
 
190
- This middleware is especially useful when testing. It raises an error by default if the response is not valid.
125
+ This middleware raises an error by default if the response is not valid.
126
+ This can be useful in a test or staging environment, especially if you are adopting OpenAPI for an existing implementation.
191
127
 
192
128
  ```ruby
193
129
  use OpenapiFirst::Middlewares::ResponseValidation, spec: 'openapi.yaml' if ENV['RACK_ENV'] == 'test'
130
+
131
+ # Pass `raise_error: false` to not raise an error:
132
+ use OpenapiFirst::Middlewares::ResponseValidation, raise_error: true, spec: 'openapi.yaml'
194
133
  ```
195
134
 
196
- #### Options
135
+ ## Contract Testing
197
136
 
198
- | Name | Possible values | Description |
199
- | :------ | --------------- | ---------------------------------------------------------------- |
200
- | `spec:` | | The path to the spec file or spec loaded via `OpenapiFirst.load` |
201
- | `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. |
137
+ ### Coverage
202
138
 
203
- ## Test assertions
139
+ > [!NOTE]
140
+ > This is a brand new feature. ✨ Your feedback is very welcome.
204
141
 
205
- 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.
142
+ This feature tracks all requests/resposes that are validated via openapi_first and get return an overal coverage value. If all of your described requests/responses have been validated successfully at least once, your coverage is 100%.
143
+ By checking your validation coverage you can avoid API drift where your API description describes requests/responses differently than your implemention works.
206
144
 
207
- Here is how to set it up for Rails integration tests:
145
+ Here is how to set it up for RSpec in your `spec/spec_helper.rb`:
208
146
 
209
- ```ruby
210
- # test_helper.rb
211
- OpenapiFirst::Test.register('openapi/v1.openapi.yaml')
212
- ```
147
+ 1. Register all OpenAPI documents to track coverage for and start tracking. This should go at the top of you test helper file before loading application code.
148
+ ```ruby
149
+ require 'openapi_first'
150
+ OpenapiFirst::Test.setup do |test|
151
+ test.register('openapi/openapi.yaml')
152
+ end
153
+ ```
154
+ 2. Wrap your app with silent request / response validation. This validates all requets/responses you do during your test run. (✷1)
155
+ ```ruby
156
+ config.before type: :request do
157
+ def app
158
+ OpenapiFirst::Test::(App)
159
+ end
160
+ end
161
+ ```
162
+ 3. Check coverage after your test suite has finished
163
+ ```ruby
164
+ # Prints a coverage report to the terminal
165
+ config.after(:suite) { OpenapiFirst::Test.report_coverage }
166
+ ```
167
+
168
+ (✷1): Instead of using `OpenapiFirstTest.app` to wrap your application, you can use the middlewares or [test assertion method](#test-assertions), but you would have to do that for all requests/responses defined in your API description to make coverage work.
169
+
170
+ ### Test assertions
171
+
172
+ openapi_first ships with a simple but powerful Test method 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.
173
+
174
+ Here is how to set it up for Rails integration tests:
213
175
 
214
176
  Inside your test:
215
177
  ```ruby
@@ -225,10 +187,64 @@ class TripsApiTest < ActionDispatch::IntegrationTest
225
187
  date: '2024-07-02T09:00:00Z' }
226
188
 
227
189
  assert_api_conform(status: 200)
190
+ # assert_api_conform(status: 200, api: :v1) # Or this if you have multiple API descriptions
228
191
  end
229
192
  end
230
193
  ```
231
194
 
195
+ ## Manual use
196
+
197
+ Load the API description:
198
+
199
+ ```ruby
200
+ require 'openapi_first'
201
+
202
+ definition = OpenapiFirst.load('openapi.yaml')
203
+ ```
204
+
205
+ ### Validate request
206
+
207
+ ```ruby
208
+ validated_request = definition.validate_request(rack_request)
209
+
210
+ # Inspect the request and access parsed parameters
211
+ validated_request.valid?
212
+ validated_request.invalid?
213
+ validated_request.error # => Failure object or nil
214
+ validated_request.parsed_body # => The parsed request body (Hash)
215
+ validated_request.parsed_query # A Hash of query parameters that are defined in the API description, parsed exactly as described.
216
+ validated_request.parsed_path_parameters
217
+ validated_request.parsed_headers
218
+ validated_request.parsed_cookies
219
+ validated_request.parsed_params # Merged parsed path, query parameters and request body
220
+ # Access the Openapi 3 Operation Object Hash
221
+ validated_request.operation['x-foo']
222
+ validated_request.operation['operationId'] => "getStuff"
223
+ # or the whole request definition
224
+ validated_request.request_definition.path # => "/pets/{petId}"
225
+ validated_request.request_definition.operation_id # => "showPetById"
226
+
227
+ # Or you can raise an exception if validation fails:
228
+ definition.validate_request(rack_request, raise_error: true) # Raises OpenapiFirst::RequestInvalidError or OpenapiFirst::NotFoundError if request is invalid
229
+ ```
230
+
231
+ ### Validate response
232
+
233
+ ```ruby
234
+ validated_response = definition.validate_response(rack_request, rack_response)
235
+
236
+ # Inspect the response and access parsed parameters and
237
+ validated_response.valid?
238
+ validated_response.invalid?
239
+ validated_response.error # => Failure object or nil
240
+ validated_response.status # => 200
241
+ validated_response.parsed_body
242
+ validated_response.parsed_headers
243
+
244
+ # Or you can raise an exception if validation fails:
245
+ definition.validate_response(rack_request,rack_response, raise_error: true) # Raises OpenapiFirst::ResponseInvalidError or OpenapiFirst::ResponseNotFoundError
246
+ ```
247
+
232
248
  ## Configuration
233
249
 
234
250
  You can configure default options globally:
@@ -291,16 +307,6 @@ end
291
307
  Using rack middlewares is supported in probably all Ruby web frameworks.
292
308
  If you are using Ruby on Rails for example, you can add the request validation middleware globally in `config/application.rb` or inside specific controllers.
293
309
 
294
- When running integration tests (or request specs when using rspec), it makes sense to add the response validation middleware to `config/environments/test.rb`:
295
-
296
- ```ruby
297
- config.middleware.use OpenapiFirst::Middlewares::ResponseValidation,
298
- spec: 'api/openapi.yaml'
299
- ```
300
-
301
- That way you don't have to call specific test assertions to make sure your API matches the OpenAPI document.
302
- There is no need to run response validation on production if your test coverage is decent.
303
-
304
310
  ## Alternatives
305
311
 
306
312
  This gem was inspired by [committe](https://github.com/interagent/committee) (Ruby) and [Connexion](https://github.com/spec-first/connexion) (Python).
@@ -328,6 +334,6 @@ bundle exec ruby benchmarks.rb
328
334
 
329
335
  ### Contributing
330
336
 
331
- If you have a question or an idea or found a bug don't hesitate to [create an issue](https://github.com/ahx/openapi_first/issues) or [start a discussion](https://github.com/ahx/openapi_first/discussions).
337
+ If you have a question or an idea or found a bug, don't hesitate to create an issue [on Github](https://github.com/ahx/openapi_first) or [Codeberg](https://codeberg.org/ahx/openapi_first) or say hi on [Mastodon (ruby.social)](https://ruby.social/@ahx).
332
338
 
333
339
  Pull requests are very welcome as well, of course. Feel free to create a "draft" pull request early on, even if your change is still work in progress. 🤗
@@ -22,7 +22,7 @@ module OpenapiFirst
22
22
  @schemer_configuration.insert_property_defaults = true
23
23
 
24
24
  @config = config
25
- @contents = RefResolver.for(contents, dir: filepath && File.dirname(filepath))
25
+ @contents = RefResolver.for(contents, filepath:)
26
26
  end
27
27
 
28
28
  attr_reader :config
@@ -58,17 +58,18 @@ module OpenapiFirst
58
58
  request,
59
59
  request_method:,
60
60
  path:,
61
- content_type: request.content_type
62
- )
63
- end
64
- build_responses(responses: operation_object['responses']).each do |response|
65
- router.add_response(
66
- response,
67
- request_method:,
68
- path:,
69
- status: response.status,
70
- response_content_type: response.content_type
61
+ content_type: request.content_type,
62
+ allow_empty_content: request.allow_empty_content?
71
63
  )
64
+ build_responses(request:, responses: operation_object['responses']).each do |response|
65
+ router.add_response(
66
+ response,
67
+ request_method:,
68
+ path:,
69
+ status: response.status,
70
+ response_content_type: response.content_type
71
+ )
72
+ end
72
73
  end
73
74
  end
74
75
  end
@@ -106,28 +107,37 @@ module OpenapiFirst
106
107
  end
107
108
 
108
109
  def build_requests(path:, request_method:, operation_object:, parameters:)
110
+ content_objects = operation_object.dig('requestBody', 'content')
111
+ if content_objects.nil?
112
+ return [
113
+ request_without_body(path:, request_method:, parameters:, operation_object:)
114
+ ]
115
+ end
109
116
  required_body = operation_object['requestBody']&.resolved&.fetch('required', false) == true
110
- result = operation_object.dig('requestBody', 'content')&.map do |content_type, content_object|
117
+ content_objects.map do |content_type, content_object|
111
118
  content_schema = content_object['schema'].schema(
112
119
  configuration: schemer_configuration,
113
120
  after_property_validation: config.hooks[:after_request_body_property_validation]
114
121
  )
115
- Request.new(path:, request_method:,
122
+ Request.new(path:, request_method:, parameters:,
116
123
  operation_object: operation_object.resolved,
117
- parameters:, content_type:,
124
+ content_type:,
118
125
  content_schema:,
119
- required_body:)
120
- end || []
121
- return result if required_body
122
-
123
- result << Request.new(
124
- path:, request_method:, operation_object: operation_object.resolved,
125
- parameters:, content_type: nil, content_schema: nil,
126
- required_body:
127
- )
126
+ required_body:,
127
+ key: [path, request_method, content_type].join(':'))
128
+ end
129
+ end
130
+
131
+ def request_without_body(path:, request_method:, parameters:, operation_object:)
132
+ Request.new(path:, request_method:, parameters:,
133
+ operation_object: operation_object.resolved,
134
+ content_type: nil,
135
+ content_schema: nil,
136
+ required_body: false,
137
+ key: [path, request_method, nil].join(':'))
128
138
  end
129
139
 
130
- def build_responses(responses:)
140
+ def build_responses(responses:, request:)
131
141
  return [] unless responses
132
142
 
133
143
  responses.flat_map do |status, response_object|
@@ -142,9 +152,10 @@ module OpenapiFirst
142
152
  headers:,
143
153
  headers_schema:,
144
154
  content_type:,
145
- content_schema:)
155
+ content_schema:,
156
+ key: [request.key, status, content_type].join(':'))
146
157
  end || Response.new(status:, headers:, headers_schema:, content_type: nil,
147
- content_schema: nil)
158
+ content_schema: nil, key: [request.key, status, nil].join(':'))
148
159
  end
149
160
  end
150
161
 
@@ -198,5 +209,6 @@ module OpenapiFirst
198
209
 
199
210
  ParsedParameters = Data.define(:path, :query, :header, :cookie, :path_schema, :query_schema, :header_schema,
200
211
  :cookie_schema)
212
+ private_constant :ParsedParameters
201
213
  end
202
214
  end
@@ -14,7 +14,7 @@ module OpenapiFirst
14
14
  @request_validation_error_response = OpenapiFirst.find_error_response(:default)
15
15
  @request_validation_raise_error = false
16
16
  @response_validation_raise_error = true
17
- @hooks = (HOOKS.map { [_1, []] }).to_h
17
+ @hooks = (HOOKS.map { [_1, Set.new] }).to_h
18
18
  end
19
19
 
20
20
  attr_reader :request_validation_error_response, :hooks
@@ -63,7 +63,7 @@ module OpenapiFirst
63
63
  # Validates the response against the API description.
64
64
  # @param rack_request [Rack::Request] The Rack request object.
65
65
  # @param rack_response [Rack::Response] The Rack response object.
66
- # @param raise_error [Boolean] Whether to raise an error if validation fails.
66
+ # @param raise_error [Boolean] Whethir to raise an error if validation fails.
67
67
  # @return [ValidatedResponse] The validated response object.
68
68
  def validate_response(rack_request, rack_response, raise_error: false)
69
69
  route = @router.match(rack_request.request_method, rack_request.path, content_type: rack_request.content_type)
@@ -8,7 +8,7 @@ module OpenapiFirst
8
8
  module_function
9
9
 
10
10
  def load(file_path)
11
- raise FileNotFoundError, "File not found #{file_path}" unless File.exist?(file_path)
11
+ raise FileNotFoundError, "File not found #{file_path.inspect}" unless File.exist?(file_path)
12
12
 
13
13
  body = File.read(file_path)
14
14
  extname = File.extname(file_path)
@@ -6,17 +6,17 @@ module OpenapiFirst
6
6
  # This is here to give traverse an OAD while keeping $refs intact
7
7
  # @visibility private
8
8
  module RefResolver
9
- def self.load(file_path)
10
- contents = OpenapiFirst::FileLoader.load(file_path)
11
- self.for(contents, dir: File.dirname(File.expand_path(file_path)))
9
+ def self.load(filepath)
10
+ contents = OpenapiFirst::FileLoader.load(filepath)
11
+ self.for(contents, filepath:)
12
12
  end
13
13
 
14
- def self.for(value, context: value, dir: Dir.pwd)
14
+ def self.for(value, filepath: nil, context: value)
15
15
  case value
16
16
  when ::Hash
17
- Hash.new(value, context:, dir:)
17
+ Hash.new(value, context:, filepath:)
18
18
  when ::Array
19
- Array.new(value, context:, dir:)
19
+ Array.new(value, context:, filepath:)
20
20
  when ::NilClass
21
21
  nil
22
22
  else
@@ -37,9 +37,11 @@ module OpenapiFirst
37
37
 
38
38
  # @visibility private
39
39
  module Resolvable
40
- def initialize(value, context: value, dir: nil)
40
+ def initialize(value, context: value, filepath: nil)
41
41
  @value = value
42
42
  @context = context
43
+ @filepath = filepath
44
+ dir = File.dirname(File.expand_path(filepath)) if filepath
43
45
  @dir = (dir && File.absolute_path(dir)) || Dir.pwd
44
46
  end
45
47
 
@@ -50,12 +52,14 @@ module OpenapiFirst
50
52
  # The object where this node was found in
51
53
  attr_reader :context
52
54
 
55
+ private attr_reader :filepath
56
+
53
57
  def resolve_ref(pointer)
54
58
  if pointer.start_with?('#')
55
59
  value = Hana::Pointer.new(pointer[1..]).eval(context)
56
60
  raise "Unknown reference #{pointer} in #{context}" unless value
57
61
 
58
- return RefResolver.for(value, dir:)
62
+ return RefResolver.for(value, filepath:, context:)
59
63
  end
60
64
 
61
65
  relative_path, file_pointer = pointer.split('#')
@@ -63,9 +67,12 @@ module OpenapiFirst
63
67
  return RefResolver.load(full_path) unless file_pointer
64
68
 
65
69
  file_contents = FileLoader.load(full_path)
66
- new_dir = File.dirname(full_path)
67
70
  value = Hana::Pointer.new(file_pointer).eval(file_contents)
68
- RefResolver.for(value, dir: new_dir)
71
+ RefResolver.for(value, filepath: full_path, context: file_contents)
72
+ rescue OpenapiFirst::FileNotFoundError => e
73
+ message = "Problem with reference resolving #{pointer.inspect} in " \
74
+ "file #{File.absolute_path(filepath).inspect}: #{e.message}"
75
+ raise OpenapiFirst::FileNotFoundError, message
69
76
  end
70
77
  end
71
78
 
@@ -91,18 +98,18 @@ module OpenapiFirst
91
98
  def [](key)
92
99
  return resolve_ref(@value['$ref'])[key] if !@value.key?(key) && @value.key?('$ref')
93
100
 
94
- RefResolver.for(@value[key], dir:, context:)
101
+ RefResolver.for(@value[key], filepath:, context:)
95
102
  end
96
103
 
97
104
  def fetch(key)
98
105
  return resolve_ref(@value['$ref']).fetch(key) if !@value.key?(key) && @value.key?('$ref')
99
106
 
100
- RefResolver.for(@value.fetch(key), dir:, context:)
107
+ RefResolver.for(@value.fetch(key), filepath:, context:)
101
108
  end
102
109
 
103
110
  def each
104
111
  resolved.each do |key, value|
105
- yield key, RefResolver.for(value, dir:, context:)
112
+ yield key, RefResolver.for(value, filepath:, context:)
106
113
  end
107
114
  end
108
115
 
@@ -126,12 +133,12 @@ module OpenapiFirst
126
133
  item = @value[index]
127
134
  return resolve_ref(item['$ref']) if item.is_a?(::Hash) && item.key?('$ref')
128
135
 
129
- RefResolver.for(item, dir:, context:)
136
+ RefResolver.for(item, filepath:, context:)
130
137
  end
131
138
 
132
139
  def each
133
140
  resolved.each do |item|
134
- yield RefResolver.for(item, dir:, context:)
141
+ yield RefResolver.for(item, filepath:, context:)
135
142
  end
136
143
  end
137
144
 
@@ -10,13 +10,16 @@ module OpenapiFirst
10
10
  # An 3.x Operation object can accept multiple requests, because it can handle multiple content-types.
11
11
  # This class represents one of those requests.
12
12
  class Request
13
+ # rubocop:disable Metrics/MethodLength
13
14
  def initialize(path:, request_method:, operation_object:,
14
- parameters:, content_type:, content_schema:, required_body:)
15
+ parameters:, content_type:, content_schema:, required_body:, key:)
15
16
  @path = path
16
17
  @request_method = request_method
17
18
  @content_type = content_type
18
19
  @content_schema = content_schema
19
20
  @operation = operation_object
21
+ @allow_empty_content = content_type.nil? || required_body == false
22
+ @key = key
20
23
  @request_parser = RequestParser.new(
21
24
  query_parameters: parameters.query,
22
25
  path_parameters: parameters.path,
@@ -33,8 +36,13 @@ module OpenapiFirst
33
36
  cookie_schema: parameters.cookie_schema
34
37
  )
35
38
  end
39
+ # rubocop:enable Metrics/MethodLength
36
40
 
37
- attr_reader :content_type, :content_schema, :operation, :request_method, :path
41
+ attr_reader :content_type, :content_schema, :operation, :request_method, :path, :key
42
+
43
+ def allow_empty_content?
44
+ @allow_empty_content
45
+ end
38
46
 
39
47
  def validate(request, route_params:)
40
48
  parsed_request = nil
@@ -36,11 +36,28 @@ module OpenapiFirst
36
36
  Failure.fail!(:invalid_body, message: 'Failed to parse request body as JSON')
37
37
  end)
38
38
 
39
- register('multipart/form-data', lambda { |request|
40
- request.POST.transform_values do |value|
41
- value.is_a?(Hash) && value[:tempfile] ? value[:tempfile].read : value
39
+ # Parses multipart/form-data requests and currently puts the contents of a file upload at the parsed hash values.
40
+ # NOTE: This behavior will probably change in the next major version.
41
+ # The uploaded file should not be read during request validation.
42
+ module MultipartBodyParser
43
+ def self.call(request)
44
+ request.POST.transform_values do |value|
45
+ unpack_value(value)
46
+ end
42
47
  end
43
- })
48
+
49
+ def self.unpack_value(value)
50
+ return value.map { unpack_value(_1) } if value.is_a?(Array)
51
+ return value unless value.is_a?(Hash)
52
+ return value[:tempfile]&.read if value.key?(:tempfile)
53
+
54
+ value.transform_values do |v|
55
+ unpack_value(v)
56
+ end
57
+ end
58
+ end
59
+
60
+ register('multipart/form-data', MultipartBodyParser)
44
61
 
45
62
  register('application/x-www-form-urlencoded', lambda(&:POST))
46
63
  end
@@ -9,12 +9,13 @@ module OpenapiFirst
9
9
  # This is not a direct reflecton of the OpenAPI 3.X response definition, but a combination of
10
10
  # status, content type and content schema.
11
11
  class Response
12
- def initialize(status:, headers:, headers_schema:, content_type:, content_schema:)
12
+ def initialize(status:, headers:, headers_schema:, content_type:, content_schema:, key:)
13
13
  @status = status
14
14
  @content_type = content_type
15
15
  @content_schema = content_schema
16
16
  @headers = headers
17
17
  @headers_schema = headers_schema
18
+ @key = key
18
19
  @parser = ResponseParser.new(headers:, content_type:)
19
20
  @validator = ResponseValidator.new(self)
20
21
  end
@@ -22,7 +23,7 @@ module OpenapiFirst
22
23
  # @attr_reader [Integer] status The HTTP status code of the response definition.
23
24
  # @attr_reader [String, nil] content_type Content type of this response.
24
25
  # @attr_reader [Schema, nil] content_schema the Schema of the response body.
25
- attr_reader :status, :content_type, :content_schema, :headers, :headers_schema
26
+ attr_reader :status, :content_type, :content_schema, :headers, :headers_schema, :key
26
27
 
27
28
  def validate(response)
28
29
  parsed_values = @parser.parse(response)
@@ -32,15 +32,18 @@ module OpenapiFirst
32
32
  request_methods.filter_map do |request_method, content|
33
33
  next if request_method == :template
34
34
 
35
- Route.new(path:, request_method:, requests: content[:requests].each_value,
35
+ Route.new(path:, request_method:, requests: content[:requests].each_value.lazy.uniq,
36
36
  responses: content[:responses].each_value.lazy.flat_map(&:values))
37
37
  end
38
38
  end
39
39
  end
40
40
 
41
41
  # Add a request definition
42
- def add_request(request, request_method:, path:, content_type: nil)
43
- route_at(path, request_method)[:requests][content_type] = request
42
+ def add_request(request, request_method:, path:, content_type: nil, allow_empty_content: false)
43
+ route = route_at(path, request_method)
44
+ requests = route[:requests]
45
+ requests[content_type] = request
46
+ requests[nil] = request if allow_empty_content
44
47
  end
45
48
 
46
49
  # Add a response definition
@@ -3,7 +3,7 @@
3
3
  module OpenapiFirst
4
4
  class Schema
5
5
  # One of multiple validation errors. Returned by Schema::ValidationResult#errors.
6
- ValidationError = Data.define(:message, :data_pointer, :schema_pointer, :type, :details) do
6
+ ValidationError = Data.define(:value, :message, :data_pointer, :schema_pointer, :type, :details, :schema) do
7
7
  # @deprecated Please use {#message} instead
8
8
  def error
9
9
  warn 'OpenapiFirst::Schema::ValidationError#error is deprecated. Use #message instead.'
@@ -16,11 +16,13 @@ module OpenapiFirst
16
16
  def errors
17
17
  @errors ||= @validation.map do |err|
18
18
  ValidationError.new(
19
+ value: err['data'],
19
20
  message: err['error'],
20
21
  data_pointer: err['data_pointer'],
21
22
  schema_pointer: err['schema_pointer'],
22
23
  type: err['type'],
23
- details: err['details']
24
+ details: err['details'],
25
+ schema: err['schema']
24
26
  )
25
27
  end
26
28
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'route_task'
4
+ require_relative 'response_task'
5
+ require_relative 'request_task'
6
+
7
+ module OpenapiFirst
8
+ module Test
9
+ module Coverage
10
+ # This stores the coverage data for one API description
11
+ # A plan can be #done? and has several #tasks which can be #finished?
12
+ class Plan
13
+ class UnknownRequestError < StandardError; end
14
+
15
+ def initialize(oad)
16
+ @oad = oad
17
+ @routes = []
18
+ @index = {}
19
+ @filepath = oad.filepath
20
+ oad.routes.each do |route|
21
+ add_route request_method: route.request_method,
22
+ path: route.path,
23
+ requests: route.requests,
24
+ responses: route.responses
25
+ end
26
+ end
27
+
28
+ attr_reader :filepath, :oad, :routes
29
+ private attr_reader :index
30
+
31
+ def track_request(validated_request)
32
+ index[validated_request.request_definition.key].track(validated_request) if validated_request.known?
33
+ end
34
+
35
+ def track_response(validated_response)
36
+ index[validated_response.response_definition.key].track(validated_response) if validated_response.known?
37
+ end
38
+
39
+ def done?
40
+ tasks.all?(&:finished?)
41
+ end
42
+
43
+ def coverage
44
+ done = tasks.count(&:finished?)
45
+ return 0 if done.zero?
46
+
47
+ all = tasks.count
48
+ (done / (all.to_f / 100)).to_i
49
+ end
50
+
51
+ def tasks
52
+ index.values
53
+ end
54
+
55
+ private
56
+
57
+ def add_route(request_method:, path:, requests:, responses:)
58
+ request_tasks = requests.to_a.map do |request|
59
+ index[request.key] = RequestTask.new(request)
60
+ end
61
+ response_tasks = responses.to_a.map do |response|
62
+ index[response.key] = ResponseTask.new(response)
63
+ end
64
+ @routes << RouteTask.new(path:, request_method:, requests: request_tasks, responses: response_tasks)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module OpenapiFirst
6
+ module Test
7
+ module Coverage
8
+ # @visibility private
9
+ class RequestTask
10
+ extend Forwardable
11
+
12
+ def_delegators :@request, :path, :request_method, :content_type
13
+
14
+ def initialize(request_definition)
15
+ @request = request_definition
16
+ @requested = false
17
+ end
18
+
19
+ attr_reader :request
20
+
21
+ def track(validated_request)
22
+ @requested = true
23
+ @valid ||= true if validated_request.valid?
24
+ end
25
+
26
+ def requested?
27
+ @requested == true
28
+ end
29
+
30
+ def any_valid_request?
31
+ @valid == true
32
+ end
33
+
34
+ def finished?
35
+ requested? && any_valid_request?
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module OpenapiFirst
6
+ module Test
7
+ module Coverage
8
+ # @visibility private
9
+ class ResponseTask
10
+ extend Forwardable
11
+
12
+ def_delegators :@response, :status, :content_type, :key
13
+
14
+ def initialize(response_definition)
15
+ @response = response_definition
16
+ @responded = false
17
+ end
18
+
19
+ attr_reader :response
20
+
21
+ def track(validated_response)
22
+ @responded = true
23
+ @valid ||= true if validated_response.valid?
24
+ end
25
+
26
+ def responded?
27
+ @responded == true
28
+ end
29
+
30
+ def any_valid_response?
31
+ @valid == true
32
+ end
33
+
34
+ def finished?
35
+ responded? && any_valid_response?
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module Test
5
+ module Coverage
6
+ RouteTask = Data.define(:path, :request_method, :requests, :responses) do
7
+ def finished?
8
+ requests.all?(&:finished?) && responses.all?(&:finished?)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module Test
5
+ module Coverage
6
+ # This is the default formatter
7
+ class TerminalFormatter
8
+ # This takes a list of Coverage::Plan instances and outputs a String
9
+ def format(coverage_result)
10
+ @out = StringIO.new
11
+ coverage_result.plans.each { |plan| format_plan(plan) }
12
+ @out.string
13
+ end
14
+
15
+ private attr_reader :out
16
+
17
+ private
18
+
19
+ def puts(string)
20
+ @out.puts(string)
21
+ end
22
+
23
+ def print(string)
24
+ @out.print(string)
25
+ end
26
+
27
+ def format_plan(plan)
28
+ filepath = plan.filepath
29
+ puts ['', "API validation coverage for #{filepath}: #{plan.coverage}%"]
30
+ return if plan.done?
31
+
32
+ plan.routes.each do |route|
33
+ next if route.finished?
34
+
35
+ format_requests(route.requests)
36
+ next if route.requests.none?(&:requested?)
37
+
38
+ format_responses(route.responses)
39
+ end
40
+ end
41
+
42
+ def format_requests(requests)
43
+ requests.each do |request|
44
+ if request.finished?
45
+ puts green "✓ #{request_label(request)}"
46
+ else
47
+ puts red "❌ #{request_label(request)} – #{explain_unfinished_request(request)}"
48
+ end
49
+ end
50
+ end
51
+
52
+ def format_responses(responses)
53
+ responses.each do |response|
54
+ if response.finished?
55
+ puts green " ✓ #{response_label(response)}"
56
+ else
57
+ puts red " ❌ #{response_label(response)} – #{explain_unfinished_response(response)}"
58
+ end
59
+ end
60
+ end
61
+
62
+ def green(text)
63
+ "\e[32m#{text}\e[0m"
64
+ end
65
+
66
+ def red(text)
67
+ "\e[31m#{text}\e[0m"
68
+ end
69
+
70
+ def orange(text)
71
+ "\e[33m#{text}\e[0m"
72
+ end
73
+
74
+ def request_label(request)
75
+ name = "#{request.request_method.upcase} #{request.path}" # TODO: add required query parameters?
76
+ name << " (#{request.content_type})" if request.content_type
77
+ name
78
+ end
79
+
80
+ def explain_unfinished_request(request)
81
+ return 'No requests tracked!' unless request.requested?
82
+
83
+ 'All requests invalid!' unless request.any_valid_request?
84
+ end
85
+
86
+ def response_label(response)
87
+ name = +''
88
+ name += response.status.to_s
89
+ name += "(#{response.content_type})" if response.content_type
90
+ name
91
+ end
92
+
93
+ def explain_unfinished_response(response)
94
+ return 'No responses tracked!' unless response.responded?
95
+
96
+ 'All responses invalid!' unless response.any_valid_response?
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'coverage/plan'
4
+
5
+ module OpenapiFirst
6
+ module Test
7
+ # The Coverage module is about tracking request and response validation
8
+ # to assess if all parts of the API description have been tested.
9
+ # Currently it does not care about unknown requests that are not part of any API description.
10
+ module Coverage
11
+ autoload :TerminalFormatter, 'openapi_first/test/coverage/terminal_formatter'
12
+
13
+ Result = Data.define(:plans, :coverage)
14
+
15
+ class << self
16
+ def start
17
+ @after_request_validation = lambda do |validated_request, oad|
18
+ track_request(validated_request, oad)
19
+ end
20
+
21
+ @after_response_validation = lambda do |validated_response, request, oad|
22
+ track_response(validated_response, request, oad)
23
+ end
24
+
25
+ OpenapiFirst.configure do |config|
26
+ config.after_request_validation(&@after_request_validation)
27
+ config.after_response_validation(&@after_response_validation)
28
+ end
29
+ end
30
+
31
+ def stop
32
+ configuration = OpenapiFirst.configuration
33
+ configuration.hooks[:after_request_validation].delete(@after_request_validation)
34
+ configuration.hooks[:after_response_validation].delete(@after_response_validation)
35
+ end
36
+
37
+ # Clear current coverage run
38
+ def reset
39
+ @current_run = nil
40
+ end
41
+
42
+ def track_request(request, oad)
43
+ current_run[oad.filepath].track_request(request)
44
+ end
45
+
46
+ def track_response(response, _request, oad)
47
+ current_run[oad.filepath].track_response(response)
48
+ end
49
+
50
+ def result
51
+ Result.new(plans:, coverage:)
52
+ end
53
+
54
+ private
55
+
56
+ # Returns all plans (Plan) that were registered for this run
57
+ def plans
58
+ current_run.values
59
+ end
60
+
61
+ def coverage
62
+ return 0 if plans.empty?
63
+
64
+ plans.sum(&:coverage) / plans.length
65
+ end
66
+
67
+ def current_run
68
+ @current_run ||= Test.definitions.values.to_h do |oad|
69
+ [oad.filepath, Plan.new(oad)]
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -4,7 +4,6 @@ require_relative 'minitest_helpers'
4
4
  require_relative 'plain_helpers'
5
5
 
6
6
  module OpenapiFirst
7
- # Test integration
8
7
  module Test
9
8
  # Methods to use in integration tests
10
9
  module Methods
@@ -18,8 +18,13 @@ module OpenapiFirst
18
18
  "from #{request.request_method.upcase} #{request.path}."
19
19
  end
20
20
 
21
- api.validate_request(request, raise_error: true)
22
- api.validate_response(request, response, raise_error: true)
21
+ validated = api.validate_request(request, raise_error: false)
22
+ # :nocov:
23
+ raise validated.error.exception if validated.invalid?
24
+
25
+ validated = api.validate_response(request, response, raise_error: false)
26
+ raise validated.error.exception if validated.invalid?
27
+ # :nocov:
23
28
  end
24
29
  end
25
30
  end
@@ -1,33 +1,91 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'test/methods'
4
-
5
3
  module OpenapiFirst
6
4
  # Test integration
7
5
  module Test
6
+ autoload :Coverage, 'openapi_first/test/coverage'
7
+ autoload :Methods, 'openapi_first/test/methods'
8
+
8
9
  def self.minitest?(base)
9
10
  base.include?(::Minitest::Assertions)
10
11
  rescue NameError
11
12
  false
12
13
  end
13
14
 
14
- class NotRegisteredError < StandardError; end
15
+ # Helper class to setup tests
16
+ class Setup
17
+ def register(*)
18
+ Test.register(*)
19
+ end
20
+ end
15
21
 
16
- DEFINITIONS = {} # rubocop:disable Style/MutableConstant
22
+ def self.setup
23
+ unless block_given?
24
+ raise ArgumentError, "Please provide a block to #{self.class}.setup to register you API descriptions"
25
+ end
26
+
27
+ Coverage.start
28
+ setup = Setup.new
29
+ yield setup
30
+ return unless definitions.empty?
31
+
32
+ raise NotRegisteredError,
33
+ 'No API descriptions have been registered. ' \
34
+ 'Please register your API description via ' \
35
+ "OpenapiFirst::Test.setup { |test| test.register('myopenapi.yaml') }"
36
+ end
17
37
 
18
- def self.definitions = DEFINITIONS
38
+ # Print the coverage report
39
+ # @param formatter A formatter to define the report.
40
+ # @output [IO] An output where to puts the report.
41
+ def self.report_coverage(formatter: Coverage::TerminalFormatter)
42
+ coverage_result = Coverage.result
43
+ puts formatter.new.format(coverage_result)
44
+ puts "The overal API validation coverage of this run is: #{coverage_result.coverage}%"
45
+ end
19
46
 
20
- def self.register(path, as: :default)
21
- definitions[as] = OpenapiFirst.load(path)
47
+ # Returns the Rack app wrapped with silent request, response validation
48
+ # You can use this if you want to track coverage via Test::Coverage, but don't want to use
49
+ # the middlewares or manual request, response validation.
50
+ def self.app(app, spec: nil, api: :default)
51
+ spec ||= self[api]
52
+ Rack::Builder.app do
53
+ use OpenapiFirst::Middlewares::ResponseValidation, spec:, raise_error: false
54
+ use OpenapiFirst::Middlewares::RequestValidation, spec:, raise_error: false, error_response: false
55
+ run app
56
+ end
22
57
  end
23
58
 
24
- def self.[](api)
25
- definitions.fetch(api) do
26
- option = api == :default ? '' : ", as: #{api.inspect}"
27
- raise(NotRegisteredError,
28
- "API description '#{api.inspect}' not found." \
29
- "Please call OpenapiFirst::Test.register('myopenapi.yaml'#{option}) " \
30
- 'once before calling assert_api_conform.')
59
+ class NotRegisteredError < StandardError; end
60
+ class AlreadyRegisteredError < StandardError; end
61
+
62
+ @definitions = {}
63
+
64
+ class << self
65
+ attr_reader :definitions
66
+
67
+ def register(path, as: :default)
68
+ if definitions.key?(:default)
69
+ raise(
70
+ AlreadyRegisteredError,
71
+ "#{definitions[as].filepath.inspect} is already registered " \
72
+ "as ':default' so you cannot register #{path.inspect} without " \
73
+ 'giving it a custom name. Please call register with a custom key like: ' \
74
+ "OpenapiFirst::Test.register(#{path.inspect}, as: :my_other_api)"
75
+ )
76
+ end
77
+
78
+ definitions[as] = OpenapiFirst.load(path)
79
+ end
80
+
81
+ def [](api)
82
+ definitions.fetch(api) do
83
+ option = api == :default ? '' : ", as: #{api.inspect}"
84
+ raise(NotRegisteredError,
85
+ "API description '#{api.inspect}' not found." \
86
+ "Please call OpenapiFirst::Test.register('myopenapi.yaml'#{option}) " \
87
+ 'once before running tests.')
88
+ end
31
89
  end
32
90
  end
33
91
  end
@@ -39,6 +39,9 @@ module OpenapiFirst
39
39
  error.nil?
40
40
  end
41
41
 
42
+ # Returns true if the response is defined.
43
+ def known? = response_definition != nil
44
+
42
45
  # Checks if the response is invalid.
43
46
  # @return [Boolean]
44
47
  def invalid?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiFirst
4
- VERSION = '2.2.3'
4
+ VERSION = '2.3.0'
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openapi_first
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.3
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andreas Haller
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-01-22 00:00:00.000000000 Z
10
+ date: 2025-02-14 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: hana
@@ -93,7 +93,6 @@ files:
93
93
  - LICENSE.txt
94
94
  - README.md
95
95
  - lib/openapi_first.rb
96
- - lib/openapi_first/body_parser.rb
97
96
  - lib/openapi_first/builder.rb
98
97
  - lib/openapi_first/configuration.rb
99
98
  - lib/openapi_first/definition.rb
@@ -122,6 +121,12 @@ files:
122
121
  - lib/openapi_first/schema/validation_error.rb
123
122
  - lib/openapi_first/schema/validation_result.rb
124
123
  - lib/openapi_first/test.rb
124
+ - lib/openapi_first/test/coverage.rb
125
+ - lib/openapi_first/test/coverage/plan.rb
126
+ - lib/openapi_first/test/coverage/request_task.rb
127
+ - lib/openapi_first/test/coverage/response_task.rb
128
+ - lib/openapi_first/test/coverage/route_task.rb
129
+ - lib/openapi_first/test/coverage/terminal_formatter.rb
125
130
  - lib/openapi_first/test/methods.rb
126
131
  - lib/openapi_first/test/minitest_helpers.rb
127
132
  - lib/openapi_first/test/plain_helpers.rb
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module OpenapiFirst
4
- # @!visibility private
5
- module BodyParser
6
- def self.[](content_type)
7
- case content_type
8
- when /json/i
9
- JsonBodyParser
10
- when %r{multipart/form-data}i
11
- MultipartBodyParser
12
- else
13
- DefaultBodyParser
14
- end
15
- end
16
-
17
- def self.read_body(request)
18
- body = request.body&.read
19
- request.body.rewind if request.body.respond_to?(:rewind)
20
- body
21
- end
22
-
23
- JsonBodyParser = lambda do |request|
24
- body = read_body(request)
25
- return if body.nil? || body.empty?
26
-
27
- JSON.parse(body)
28
- rescue JSON::ParserError
29
- Failure.fail!(:invalid_body, message: 'Failed to parse request body as JSON')
30
- end
31
-
32
- MultipartBodyParser = lambda do |request|
33
- request.POST.transform_values do |value|
34
- value.is_a?(Hash) && value[:tempfile] ? value[:tempfile].read : value
35
- end
36
- end
37
-
38
- # This returns the post data parsed by rack or the raw body
39
- DefaultBodyParser = lambda do |request|
40
- return request.POST if request.form_data?
41
-
42
- read_body(request)
43
- end
44
- end
45
- end