openapi_first 3.3.1 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +1 -0
- data/lib/openapi_first/builder.rb +5 -2
- data/lib/openapi_first/configuration.rb +6 -0
- data/lib/openapi_first/definition.rb +43 -9
- data/lib/openapi_first/middlewares/request_validation.rb +21 -2
- data/lib/openapi_first/middlewares/response_validation.rb +2 -1
- data/lib/openapi_first/plugins/x_public.rb +29 -0
- data/lib/openapi_first/plugins.rb +44 -0
- data/lib/openapi_first/registry.rb +2 -2
- data/lib/openapi_first/request.rb +28 -15
- data/lib/openapi_first/request_body_parsers.rb +40 -8
- data/lib/openapi_first/request_validator.rb +5 -1
- data/lib/openapi_first/response.rb +2 -12
- data/lib/openapi_first/response_body_parsers.rb +2 -2
- data/lib/openapi_first/response_parser.rb +6 -3
- data/lib/openapi_first/response_validator.rb +4 -3
- data/lib/openapi_first/schema/hash.rb +1 -1
- data/lib/openapi_first/test/configuration.rb +45 -4
- data/lib/openapi_first/test/coverage/html_reporter/context.rb +89 -0
- data/lib/openapi_first/test/coverage/html_reporter.css +179 -0
- data/lib/openapi_first/test/coverage/html_reporter.html.erb +87 -0
- data/lib/openapi_first/test/coverage/html_reporter.rb +30 -0
- data/lib/openapi_first/test/coverage/plan.rb +4 -3
- data/lib/openapi_first/test/coverage/route_task.rb +5 -0
- data/lib/openapi_first/test/coverage/{terminal_formatter.rb → terminal_reporter.rb} +25 -33
- data/lib/openapi_first/test/coverage.rb +15 -7
- data/lib/openapi_first/test/logger.rb +17 -0
- data/lib/openapi_first/test/observe.rb +1 -1
- data/lib/openapi_first/test.rb +16 -6
- data/lib/openapi_first/validators/request_body.rb +3 -2
- data/lib/openapi_first/validators/request_parameters.rb +4 -4
- data/lib/openapi_first/validators/response_body.rb +2 -2
- data/lib/openapi_first/validators/response_headers.rb +4 -3
- data/lib/openapi_first/version.rb +1 -1
- data/lib/openapi_first.rb +8 -0
- metadata +11 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 25a078c6bc5ab1617e9dd79545146b3ef7a0f28dbf5d9550819907e8e36c5b1b
|
|
4
|
+
data.tar.gz: 4478e189440cf7915416eb10b61b8a62e7a7465ffd62d64bf9663b2fab1fafde
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ebee301cf2048cabd799f604073b34691434a55ee2f0787428588c33c4bc9ee49d551e7eee761641bfc61ac724e68f4e7774e6c25eb073aab6bdbcbf2ddd4f10
|
|
7
|
+
data.tar.gz: 53b1e8f5776ba5dfd41be46f9651e76e64a54ae849984a1b93980c7ab9da3353140fefb8ed4399679098f14d631d17ff30ab66cb7f4f9c7315308018aa727533
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 3.4.1
|
|
6
|
+
|
|
7
|
+
Fixed: Added missing ERB and css file to the gem
|
|
8
|
+
|
|
9
|
+
## 3.4.0
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- Use a new coverage reporter `OpenapiFirst::Test::Coverage::HtmlReporter` by default that writes a self-contained HTML coverage report to `coverage/openapi_coverage.html`.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- Apps using request validation middleware can call `OpenapiFirst::Failure.fail!` to produce an error result
|
|
16
|
+
- The `after_request_validation` now supports throwing a Failure (via `OpenapiFirst::Failure.fail!`), which will result in a failed request
|
|
17
|
+
- Added new hook `before_request_validation`. Called after a request routed to an operation but before the request is validated. You can throw via `Failure.fail!` to abort request validation immediately.
|
|
18
|
+
- `OpenapiFirst::Test.logger` is now configurable via the setup block: `OpenapiFirst::Test.setup { |test| test.logger = Logger.new($stderr) }`. The logger defaults to `Logger.new($stdout)`.
|
|
19
|
+
- Support `encoding[<field>].contentType` on `multipart/form-data` request bodies. Fields whose encoding declares a JSON content type are JSON-parsed before schema validation. Fixes [#398](https://github.com/ahx/openapi_first/issues/398).
|
|
20
|
+
- Start introducing a plugin system modeled after Sequels plugin system.
|
|
21
|
+
- Add an example plugin: 'x_public' (`OpenapiFirst.plugin :x_public`) that returns 404 unless the operatio has `x-public: true`
|
|
22
|
+
- Update openapi_parameters dependency to support `content` field in parameter. Solves [#476](https://github.com/ahx/openapi_first/issues/476)
|
|
23
|
+
|
|
24
|
+
### Deprecated
|
|
25
|
+
- Deprecated `OpenapiFirst::Test::Coverage::TerminalFormatter`. Use `TerminalReporter` instead
|
|
26
|
+
- Deprecated and `Test::Configuration#coverage_formatter_options`. Use `#coverage_reporter_options` instead.
|
|
27
|
+
- Deprecated `TerminalFormatter#format`. Use `#report` instead.
|
|
28
|
+
|
|
5
29
|
## 3.3.1
|
|
6
30
|
|
|
7
31
|
- Optimized caching towards reducing retained memory after calling `OpenapiFirst.load` without using a global cache. (Removed `OpenapiFirst.clear_cache!`.)
|
data/README.md
CHANGED
|
@@ -378,6 +378,7 @@ You can integrate your code at certain points during request/response validation
|
|
|
378
378
|
|
|
379
379
|
Available hooks:
|
|
380
380
|
|
|
381
|
+
- `before_request_validation`
|
|
381
382
|
- `after_request_validation`
|
|
382
383
|
- `after_response_validation`
|
|
383
384
|
- `after_request_parameter_property_validation`
|
|
@@ -13,7 +13,7 @@ require_relative 'ref_resolver'
|
|
|
13
13
|
module OpenapiFirst
|
|
14
14
|
# Builds parts of a Definition
|
|
15
15
|
# This knows how to read a resolved OpenAPI document and build {Request} and {Response} objects.
|
|
16
|
-
class Builder
|
|
16
|
+
class Builder
|
|
17
17
|
REQUEST_METHODS = %w[get head post put patch delete trace options query].freeze
|
|
18
18
|
|
|
19
19
|
# Builds a router from a resolved OpenAPI document.
|
|
@@ -130,7 +130,7 @@ module OpenapiFirst
|
|
|
130
130
|
after_property_validation: config.after_request_parameter_property_validation)
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
-
def build_requests(path:, request_method:, operation_object:, parameters:)
|
|
133
|
+
def build_requests(path:, request_method:, operation_object:, parameters:) # rubocop:disable Metrics/MethodLength
|
|
134
134
|
content_objects = operation_object.dig('requestBody', 'content')
|
|
135
135
|
if content_objects.nil?
|
|
136
136
|
return [
|
|
@@ -143,10 +143,12 @@ module OpenapiFirst
|
|
|
143
143
|
configuration: schemer_configuration,
|
|
144
144
|
after_property_validation: config.after_request_body_property_validation
|
|
145
145
|
)
|
|
146
|
+
encoding = content_object['encoding']&.resolved
|
|
146
147
|
Request.new(path:, request_method:, parameters:,
|
|
147
148
|
operation_object: operation_object.resolved,
|
|
148
149
|
content_type:,
|
|
149
150
|
content_schema:,
|
|
151
|
+
encoding:,
|
|
150
152
|
required_body:,
|
|
151
153
|
key: [path, request_method, content_type].join(':'))
|
|
152
154
|
end
|
|
@@ -157,6 +159,7 @@ module OpenapiFirst
|
|
|
157
159
|
operation_object: operation_object.resolved,
|
|
158
160
|
content_type: nil,
|
|
159
161
|
content_schema: nil,
|
|
162
|
+
encoding: nil,
|
|
160
163
|
required_body: false,
|
|
161
164
|
key: [path, request_method, nil].join(':'))
|
|
162
165
|
end
|
|
@@ -4,6 +4,7 @@ module OpenapiFirst
|
|
|
4
4
|
# Global configuration. Currently only used for the request validation middleware.
|
|
5
5
|
class Configuration
|
|
6
6
|
HOOKS = %i[
|
|
7
|
+
before_request_validation
|
|
7
8
|
after_request_validation
|
|
8
9
|
after_response_validation
|
|
9
10
|
after_request_parameter_property_validation
|
|
@@ -66,6 +67,11 @@ module OpenapiFirst
|
|
|
66
67
|
end
|
|
67
68
|
end
|
|
68
69
|
|
|
70
|
+
def plugin(name, **)
|
|
71
|
+
require_relative 'plugins'
|
|
72
|
+
Plugins.load(name).configure(self, **)
|
|
73
|
+
end
|
|
74
|
+
|
|
69
75
|
def request_validation_error_response=(mod)
|
|
70
76
|
@request_validation_error_response = if mod.is_a?(Symbol)
|
|
71
77
|
OpenapiFirst.find_error_response(mod)
|
|
@@ -44,6 +44,11 @@ module OpenapiFirst
|
|
|
44
44
|
# @return [Enumerable[Router::Route]]
|
|
45
45
|
def_delegators :@router, :routes
|
|
46
46
|
|
|
47
|
+
# @return [String,nil] The title from the OpenAPI document's `info.title`, if any.
|
|
48
|
+
def title
|
|
49
|
+
self['info']&.[]('title')
|
|
50
|
+
end
|
|
51
|
+
|
|
47
52
|
# Returns a unique identifier for this API definition
|
|
48
53
|
# @return [String] A unique key for this API definition
|
|
49
54
|
def key
|
|
@@ -69,17 +74,23 @@ module OpenapiFirst
|
|
|
69
74
|
# Validates the request against the API description.
|
|
70
75
|
# @param [Rack::Request] request The Rack request object.
|
|
71
76
|
# @param [Boolean] raise_error Whether to raise an error if validation fails.
|
|
77
|
+
# @yield [ValidatedRequest] Optional block called after successful validation.
|
|
78
|
+
# The block runs inside the same catch(FAILURE) as the after_request_validation hooks,
|
|
79
|
+
# so it may call OpenapiFirst::Failure.fail! to short-circuit and produce an error.
|
|
72
80
|
# @return [ValidatedRequest] The validated request object.
|
|
73
|
-
def validate_request(request, raise_error: false)
|
|
81
|
+
def validate_request(request, raise_error: false, &after_block)
|
|
74
82
|
route = @router.match(request.request_method, resolve_path(request), content_type: request.content_type)
|
|
75
|
-
if route.error
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
validated = if route.error
|
|
84
|
+
ValidatedRequest.new(request, error: route.error)
|
|
85
|
+
else
|
|
86
|
+
result = call_before_request_validation_hooks(request, route.request_definition)
|
|
87
|
+
result ||= route.request_definition.validate(request, route_params: route.params)
|
|
88
|
+
result.is_a?(Failure) ? ValidatedRequest.new(request, error: result) : result
|
|
89
|
+
end
|
|
90
|
+
validated = call_after_request_validation_hooks(request, validated, &after_block)
|
|
91
|
+
raise validated.error.exception(validated) if validated.error && raise_error
|
|
92
|
+
|
|
93
|
+
validated
|
|
83
94
|
end
|
|
84
95
|
|
|
85
96
|
# Validates the response against the API description.
|
|
@@ -106,6 +117,29 @@ module OpenapiFirst
|
|
|
106
117
|
|
|
107
118
|
private
|
|
108
119
|
|
|
120
|
+
def call_before_request_validation_hooks(request, request_definition)
|
|
121
|
+
return if @config.before_request_validation.none?
|
|
122
|
+
|
|
123
|
+
catch(FAILURE) do
|
|
124
|
+
@config.before_request_validation.each do |hook|
|
|
125
|
+
hook.call(request, request_definition, self)
|
|
126
|
+
end
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def call_after_request_validation_hooks(request, validated)
|
|
132
|
+
hooks = @config.after_request_validation
|
|
133
|
+
return validated if hooks.none? && !block_given?
|
|
134
|
+
|
|
135
|
+
error = catch(FAILURE) do
|
|
136
|
+
hooks.each { |hook| hook.call(validated, self) }
|
|
137
|
+
yield validated if block_given? && validated.valid?
|
|
138
|
+
return validated
|
|
139
|
+
end
|
|
140
|
+
ValidatedRequest.new(request, error: error)
|
|
141
|
+
end
|
|
142
|
+
|
|
109
143
|
def resolve_path(rack_request)
|
|
110
144
|
return rack_request.path.delete_prefix(path_prefix) if path_prefix && rack_request.path.start_with?(path_prefix)
|
|
111
145
|
return rack_request.path unless @config.path
|
|
@@ -3,7 +3,19 @@
|
|
|
3
3
|
require 'rack'
|
|
4
4
|
module OpenapiFirst
|
|
5
5
|
module Middlewares
|
|
6
|
-
# A Rack middleware to validate requests against an OpenAPI API description
|
|
6
|
+
# A Rack middleware to validate requests against an OpenAPI API description.
|
|
7
|
+
# All error responses can be customized via the +error_response:+ option.
|
|
8
|
+
# === Request body validation
|
|
9
|
+
#
|
|
10
|
+
# * Returns +415+ if the request content-type does not match the OpenAPI description.
|
|
11
|
+
# * Returns +400+ for invalid JSON, missing required fields, type/enum/schema violations, or +readOnly+ fields.
|
|
12
|
+
# * Empty bodies are accepted when the body is optional in the spec.
|
|
13
|
+
#
|
|
14
|
+
# === Parameter validation
|
|
15
|
+
#
|
|
16
|
+
# Query, path, header, and cookie parameters are validated and type-converted against the spec.
|
|
17
|
+
# Missing required parameters or type/format violations return +400+.
|
|
18
|
+
#
|
|
7
19
|
class RequestValidation
|
|
8
20
|
# @param app The parent Rack application
|
|
9
21
|
# @param spec [String, Symbol, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
|
|
@@ -37,10 +49,17 @@ module OpenapiFirst
|
|
|
37
49
|
attr_reader :app
|
|
38
50
|
|
|
39
51
|
def call(env)
|
|
40
|
-
|
|
52
|
+
rack_response = nil
|
|
53
|
+
app_called = false
|
|
54
|
+
validated = @definition.validate_request(Rack::Request.new(env), raise_error: @raise) do |v|
|
|
55
|
+
app_called = true
|
|
56
|
+
env[REQUEST] = v
|
|
57
|
+
rack_response = @app.call(env)
|
|
58
|
+
end
|
|
41
59
|
env[REQUEST] = validated
|
|
42
60
|
failure = validated.error
|
|
43
61
|
return @error_response_class.new(failure:).render if failure && @error_response_class
|
|
62
|
+
return rack_response if app_called
|
|
44
63
|
|
|
45
64
|
@app.call(env)
|
|
46
65
|
end
|
|
@@ -4,7 +4,8 @@ require 'rack'
|
|
|
4
4
|
|
|
5
5
|
module OpenapiFirst
|
|
6
6
|
module Middlewares
|
|
7
|
-
# A Rack middleware to validate requests against an OpenAPI API description
|
|
7
|
+
# A Rack middleware to validate requests against an OpenAPI API description.
|
|
8
|
+
# You probably want to use +OpenapiFirst::Test+ instead of this middleware.
|
|
8
9
|
class ResponseValidation
|
|
9
10
|
# @param spec [String, Symbol, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition.
|
|
10
11
|
# If you pass a Symbol, it will load the OAD registered via `OpenapiFirst.register`
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
module Plugins
|
|
5
|
+
# Enforces that only operations explicitly marked as public are accessible.
|
|
6
|
+
# Throws a :not_found failure for any matched operation that lacks the configured field.
|
|
7
|
+
#
|
|
8
|
+
# Options:
|
|
9
|
+
# field: [String] The OpenAPI extension field to check. Default: 'x-public'.
|
|
10
|
+
# if: [Proc] Optional condition proc. Receives the Rack::Request and must
|
|
11
|
+
# return truthy for the check to apply. Use this to restrict the
|
|
12
|
+
# plugin to certain hosts or path prefixes.
|
|
13
|
+
#
|
|
14
|
+
# Example:
|
|
15
|
+
# OpenapiFirst.plugin :x_public
|
|
16
|
+
# OpenapiFirst.plugin :x_public, field: 'x-visible'
|
|
17
|
+
# OpenapiFirst.plugin :x_public, if: ->(req) { req.host == 'api.example.com' }
|
|
18
|
+
module XPublic
|
|
19
|
+
def self.configure(config, field: 'x-public', **opts)
|
|
20
|
+
condition = opts[:if]
|
|
21
|
+
config.before_request_validation do |request, request_definition|
|
|
22
|
+
next if condition && !condition.call(request)
|
|
23
|
+
|
|
24
|
+
Failure.fail!(:not_found) unless request_definition.operation[field]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
# Plugin system for extending openapi_first behaviour. Modeled after Sequels plugin system.
|
|
5
|
+
#
|
|
6
|
+
# A plugin is a module under the +OpenapiFirst::Plugins+ namespace with a
|
|
7
|
+
# +.configure(config, **opts)+ class method. Plugins wire themselves in by
|
|
8
|
+
# registering hooks on the +Configuration+ object they receive.
|
|
9
|
+
#
|
|
10
|
+
# Loading a plugin:
|
|
11
|
+
#
|
|
12
|
+
# # Globally (applies to all definitions):
|
|
13
|
+
# OpenapiFirst.plugin :x_public
|
|
14
|
+
#
|
|
15
|
+
# # Per definition:
|
|
16
|
+
# OpenapiFirst.load('openapi.yaml') { |c| c.plugin :x_public, field: 'x-visible' }
|
|
17
|
+
#
|
|
18
|
+
# Writing a plugin:
|
|
19
|
+
#
|
|
20
|
+
# module OpenapiFirst::Plugins::MyPlugin
|
|
21
|
+
# def self.configure(config, **)
|
|
22
|
+
# config.after_request_validation do |validated_request|
|
|
23
|
+
# # ...
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# Third-party plugins are discovered by placing a module at the expected
|
|
29
|
+
# constant path and/or providing a file at <tt>openapi_first/plugins/<name></tt>
|
|
30
|
+
# on the load path.
|
|
31
|
+
module Plugins
|
|
32
|
+
def self.load(name)
|
|
33
|
+
module_name = name.to_s.split('_').map(&:capitalize).join
|
|
34
|
+
mod = const_get(module_name) if const_defined?(module_name, false)
|
|
35
|
+
mod ||= begin
|
|
36
|
+
require "openapi_first/plugins/#{name}"
|
|
37
|
+
const_get(module_name)
|
|
38
|
+
end
|
|
39
|
+
raise ArgumentError, "Plugin #{name.inspect} must respond to .configure" unless mod.respond_to?(:configure)
|
|
40
|
+
|
|
41
|
+
mod
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -13,7 +13,7 @@ module OpenapiFirst
|
|
|
13
13
|
# Register an OpenAPI definition for testing
|
|
14
14
|
# @param path_or_definition [String, Definition] Path to the OpenAPI file or a Definition object
|
|
15
15
|
# @param as [Symbol] Name to register the API definition as
|
|
16
|
-
def register(path_or_definition, as: :default)
|
|
16
|
+
def register(path_or_definition, as: :default, &)
|
|
17
17
|
if definitions.key?(as) && as == :default
|
|
18
18
|
raise(
|
|
19
19
|
AlreadyRegisteredError,
|
|
@@ -24,7 +24,7 @@ module OpenapiFirst
|
|
|
24
24
|
)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
definition = OpenapiFirst.load(path_or_definition)
|
|
27
|
+
definition = OpenapiFirst.load(path_or_definition, &)
|
|
28
28
|
definitions[as] = definition
|
|
29
29
|
definition
|
|
30
30
|
end
|
|
@@ -12,8 +12,8 @@ module OpenapiFirst
|
|
|
12
12
|
# An 3.x Operation object can accept multiple requests, because it can handle multiple content-types.
|
|
13
13
|
# This class represents one of those requests.
|
|
14
14
|
class Request
|
|
15
|
-
def initialize(path:, request_method:, operation_object:, # rubocop:disable Metrics/MethodLength
|
|
16
|
-
parameters:, content_type:, content_schema:, required_body:, key:)
|
|
15
|
+
def initialize(path:, request_method:, operation_object:, # rubocop:disable Metrics/MethodLength,Metrics/ParameterLists
|
|
16
|
+
parameters:, content_type:, content_schema:, required_body:, key:, encoding: nil)
|
|
17
17
|
@path = path
|
|
18
18
|
@request_method = request_method
|
|
19
19
|
@content_type = content_type
|
|
@@ -25,7 +25,7 @@ module OpenapiFirst
|
|
|
25
25
|
@path_parser = parameters.path&.then { |params| OpenapiParameters::Path.new(params) }
|
|
26
26
|
@headers_parser = parameters.header&.then { |params| OpenapiParameters::Header.new(params) }
|
|
27
27
|
@cookies_parser = parameters.cookie&.then { |params| OpenapiParameters::Cookie.new(params) }
|
|
28
|
-
@body_parsers =
|
|
28
|
+
@body_parsers = build_body_parser(content_type, encoding) if content_type
|
|
29
29
|
@validator = RequestValidator.new(
|
|
30
30
|
content_schema:,
|
|
31
31
|
required_request_body: required_body == true,
|
|
@@ -45,12 +45,8 @@ module OpenapiFirst
|
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
def validate(request, route_params:)
|
|
48
|
-
parsed_request =
|
|
49
|
-
error
|
|
50
|
-
parsed_request = parse_request(request, route_params:)
|
|
51
|
-
@validator.call(parsed_request)
|
|
52
|
-
nil
|
|
53
|
-
end
|
|
48
|
+
parsed_request, error = parse_request(request, route_params:)
|
|
49
|
+
error ||= @validator.call(parsed_request) if parsed_request
|
|
54
50
|
ValidatedRequest.new(request, parsed_request:, error:, request_definition: self, query_parser:)
|
|
55
51
|
end
|
|
56
52
|
|
|
@@ -58,22 +54,39 @@ module OpenapiFirst
|
|
|
58
54
|
@operation['operationId']
|
|
59
55
|
end
|
|
60
56
|
|
|
57
|
+
MULTIPART_CONTENT_TYPE = %r{\Amultipart/form-data\b}i
|
|
58
|
+
private_constant :MULTIPART_CONTENT_TYPE
|
|
59
|
+
|
|
61
60
|
private
|
|
62
61
|
|
|
63
62
|
def parse_request(request, route_params:)
|
|
64
|
-
|
|
63
|
+
query, query_error = parse_query(request.env[Rack::QUERY_STRING])
|
|
64
|
+
return [nil, query_error] if query_error
|
|
65
|
+
|
|
66
|
+
body = @body_parsers&.call(request)
|
|
67
|
+
return [nil, body] if body.is_a?(Failure)
|
|
68
|
+
|
|
69
|
+
[ParsedRequest.new(
|
|
65
70
|
path: @path_parser&.unpack(route_params),
|
|
66
|
-
query
|
|
71
|
+
query:,
|
|
67
72
|
headers: @headers_parser&.unpack_env(request.env),
|
|
68
73
|
cookies: @cookies_parser&.unpack(request.env[Rack::HTTP_COOKIE]),
|
|
69
|
-
body:
|
|
70
|
-
)
|
|
74
|
+
body:
|
|
75
|
+
), nil]
|
|
71
76
|
end
|
|
72
77
|
|
|
73
78
|
def parse_query(query_string)
|
|
74
|
-
@query_parser&.unpack(query_string)
|
|
79
|
+
[@query_parser&.unpack(query_string), nil]
|
|
75
80
|
rescue OpenapiParameters::InvalidParameterError
|
|
76
|
-
Failure.
|
|
81
|
+
[nil, Failure.new(:invalid_query, message: 'Invalid query parameter.')]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def build_body_parser(content_type, encoding)
|
|
85
|
+
if content_type.match?(MULTIPART_CONTENT_TYPE)
|
|
86
|
+
RequestBodyParsers::MultipartBodyParser.new(encoding: encoding || {})
|
|
87
|
+
else
|
|
88
|
+
RequestBodyParsers[content_type]
|
|
89
|
+
end
|
|
77
90
|
end
|
|
78
91
|
end
|
|
79
92
|
end
|
|
@@ -33,27 +33,59 @@ module OpenapiFirst
|
|
|
33
33
|
body = Utils.read_body(request)
|
|
34
34
|
JSON.parse(body) unless body.nil? || body.empty?
|
|
35
35
|
rescue JSON::ParserError
|
|
36
|
-
Failure.
|
|
36
|
+
Failure.new(:invalid_body, message: 'Failed to parse request body as JSON')
|
|
37
37
|
end)
|
|
38
38
|
|
|
39
39
|
# Parses multipart/form-data requests and currently puts the contents of a file upload at the parsed hash values.
|
|
40
40
|
# NOTE: This behavior will probably change in the next major version.
|
|
41
41
|
# The uploaded file should not be read during request validation.
|
|
42
|
-
|
|
42
|
+
#
|
|
43
|
+
# Honors the OpenAPI `encoding` map: when a top-level field has
|
|
44
|
+
# `contentType: application/json` (or any */json), the field's raw value
|
|
45
|
+
# is JSON-parsed before schema validation.
|
|
46
|
+
class MultipartBodyParser
|
|
47
|
+
def initialize(encoding: {})
|
|
48
|
+
@encoding = encoding || {}
|
|
49
|
+
end
|
|
50
|
+
|
|
43
51
|
def self.call(request)
|
|
44
|
-
request
|
|
45
|
-
|
|
52
|
+
new.call(request)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def call(request)
|
|
56
|
+
result = {}
|
|
57
|
+
request.POST.each do |name, value|
|
|
58
|
+
decoded = decode_field(name, value)
|
|
59
|
+
return decoded if decoded.is_a?(Failure)
|
|
60
|
+
|
|
61
|
+
result[name] = decoded
|
|
46
62
|
end
|
|
63
|
+
result
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def decode_field(name, value)
|
|
69
|
+
raw = unpack_value(value)
|
|
70
|
+
content_type = @encoding.dig(name, 'contentType')
|
|
71
|
+
return raw unless content_type && raw.is_a?(String) && json?(content_type)
|
|
72
|
+
|
|
73
|
+
JSON.parse(raw)
|
|
74
|
+
rescue JSON::ParserError => e
|
|
75
|
+
Failure.new(:invalid_body,
|
|
76
|
+
message: %(Failed to parse multipart field "#{name}" as JSON: #{e.message}))
|
|
47
77
|
end
|
|
48
78
|
|
|
49
|
-
def
|
|
79
|
+
def json?(content_type)
|
|
80
|
+
content_type.match?(%r{[/+]json\b}i)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def unpack_value(value)
|
|
50
84
|
return value.map { unpack_value(_1) } if value.is_a?(Array)
|
|
51
85
|
return value unless value.is_a?(Hash)
|
|
52
86
|
return value[:tempfile]&.read if value.key?(:tempfile)
|
|
53
87
|
|
|
54
|
-
value.transform_values
|
|
55
|
-
unpack_value(v)
|
|
56
|
-
end
|
|
88
|
+
value.transform_values { unpack_value(_1) }
|
|
57
89
|
end
|
|
58
90
|
end
|
|
59
91
|
|
|
@@ -25,19 +25,9 @@ module OpenapiFirst
|
|
|
25
25
|
attr_reader :status, :content_type, :content_schema, :headers, :key
|
|
26
26
|
|
|
27
27
|
def validate(response)
|
|
28
|
-
parsed_values =
|
|
29
|
-
error
|
|
30
|
-
parsed_values = @parser.parse(response)
|
|
31
|
-
nil
|
|
32
|
-
end
|
|
33
|
-
error ||= @validator.call(parsed_values)
|
|
28
|
+
parsed_values, error = @parser.parse(response)
|
|
29
|
+
error ||= @validator.call(parsed_values) if parsed_values
|
|
34
30
|
ValidatedResponse.new(response, parsed_values:, error:, response_definition: self)
|
|
35
31
|
end
|
|
36
|
-
|
|
37
|
-
private
|
|
38
|
-
|
|
39
|
-
def parse(request)
|
|
40
|
-
@parser.parse(request)
|
|
41
|
-
end
|
|
42
32
|
end
|
|
43
33
|
end
|
|
@@ -23,9 +23,9 @@ module OpenapiFirst
|
|
|
23
23
|
register(/json/i, lambda do |body|
|
|
24
24
|
JSON.parse(body)
|
|
25
25
|
rescue JSON::ParserError
|
|
26
|
-
return Failure.
|
|
26
|
+
return Failure.new(:invalid_response_body, message: 'JSON response body must not be empty') if body.empty?
|
|
27
27
|
|
|
28
|
-
Failure.
|
|
28
|
+
Failure.new(:invalid_response_body, message: 'Failed to parse response body as JSON')
|
|
29
29
|
end)
|
|
30
30
|
end
|
|
31
31
|
end
|
|
@@ -13,10 +13,13 @@ module OpenapiFirst
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def parse(rack_response)
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
body = @body_parser.call(read_body(rack_response))
|
|
17
|
+
return [nil, body] if body.is_a?(Failure)
|
|
18
|
+
|
|
19
|
+
[ParsedResponse.new(
|
|
20
|
+
body:,
|
|
18
21
|
headers: @headers_parser&.call(rack_response.headers) || {}
|
|
19
|
-
)
|
|
22
|
+
), nil]
|
|
20
23
|
end
|
|
21
24
|
|
|
22
25
|
private
|
|
@@ -18,10 +18,11 @@ module OpenapiFirst
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def call(parsed_response)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
@validators.each do |v|
|
|
22
|
+
result = v.call(parsed_response)
|
|
23
|
+
return result if result.is_a?(Failure)
|
|
24
24
|
end
|
|
25
|
+
nil
|
|
25
26
|
end
|
|
26
27
|
end
|
|
27
28
|
end
|