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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +49 -1
- data/README.md +105 -28
- data/lib/openapi_first/body_parser.rb +8 -11
- data/lib/openapi_first/builder.rb +81 -0
- data/lib/openapi_first/configuration.rb +25 -4
- data/lib/openapi_first/definition.rb +44 -100
- data/lib/openapi_first/error_response.rb +2 -2
- data/lib/openapi_first/error_responses/default.rb +73 -0
- data/lib/openapi_first/error_responses/jsonapi.rb +59 -0
- data/lib/openapi_first/errors.rb +26 -4
- data/lib/openapi_first/failure.rb +29 -26
- data/lib/openapi_first/json_refs.rb +1 -3
- data/lib/openapi_first/middlewares/request_validation.rb +2 -2
- data/lib/openapi_first/middlewares/response_validation.rb +4 -3
- data/lib/openapi_first/request.rb +92 -0
- data/lib/openapi_first/request_parser.rb +35 -0
- data/lib/openapi_first/request_validator.rb +25 -0
- data/lib/openapi_first/response.rb +57 -0
- data/lib/openapi_first/response_parser.rb +49 -0
- data/lib/openapi_first/response_validator.rb +27 -0
- data/lib/openapi_first/router/find_content.rb +17 -0
- data/lib/openapi_first/router/find_response.rb +45 -0
- data/lib/openapi_first/{definition → router}/path_template.rb +9 -1
- data/lib/openapi_first/router.rb +100 -0
- data/lib/openapi_first/schema/validation_error.rb +16 -10
- data/lib/openapi_first/schema/validation_result.rb +8 -6
- data/lib/openapi_first/schema.rb +4 -8
- data/lib/openapi_first/test/methods.rb +21 -0
- data/lib/openapi_first/test.rb +27 -0
- data/lib/openapi_first/validated_request.rb +81 -0
- data/lib/openapi_first/validated_response.rb +33 -0
- data/lib/openapi_first/validators/request_body.rb +39 -0
- data/lib/openapi_first/validators/request_parameters.rb +61 -0
- data/lib/openapi_first/validators/response_body.rb +30 -0
- data/lib/openapi_first/validators/response_headers.rb +25 -0
- data/lib/openapi_first/version.rb +1 -1
- data/lib/openapi_first.rb +40 -21
- metadata +25 -20
- data/lib/openapi_first/definition/operation.rb +0 -197
- data/lib/openapi_first/definition/path_item.rb +0 -40
- data/lib/openapi_first/definition/request_body.rb +0 -46
- data/lib/openapi_first/definition/response.rb +0 -32
- data/lib/openapi_first/definition/responses.rb +0 -87
- data/lib/openapi_first/plugins/default/error_response.rb +0 -74
- data/lib/openapi_first/plugins/default.rb +0 -11
- data/lib/openapi_first/plugins/jsonapi/error_response.rb +0 -60
- data/lib/openapi_first/plugins/jsonapi.rb +0 -11
- data/lib/openapi_first/plugins.rb +0 -25
- data/lib/openapi_first/request_validation/request_body_validator.rb +0 -41
- data/lib/openapi_first/request_validation/validator.rb +0 -82
- data/lib/openapi_first/response_validation/validator.rb +0 -98
- data/lib/openapi_first/runtime_request.rb +0 -166
- data/lib/openapi_first/runtime_response.rb +0 -124
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OpenapiFirst
|
4
|
+
module ErrorResponses
|
5
|
+
# An error reponse that returns application/problem+json with a list of "errors"
|
6
|
+
# See also https://www.rfc-editor.org/rfc/rfc9457.html
|
7
|
+
class Default
|
8
|
+
include OpenapiFirst::ErrorResponse
|
9
|
+
OpenapiFirst.register_error_response(:default, self)
|
10
|
+
|
11
|
+
TITLES = {
|
12
|
+
not_found: 'Not Found',
|
13
|
+
method_not_allowed: 'Request Method Not Allowed',
|
14
|
+
unsupported_media_type: 'Unsupported Media Type',
|
15
|
+
invalid_body: 'Bad Request Body',
|
16
|
+
invalid_query: 'Bad Query Parameter',
|
17
|
+
invalid_header: 'Bad Request Header',
|
18
|
+
invalid_path: 'Bad Request Path',
|
19
|
+
invalid_cookie: 'Bad Request Cookie'
|
20
|
+
}.freeze
|
21
|
+
private_constant :TITLES
|
22
|
+
|
23
|
+
def body
|
24
|
+
result = {
|
25
|
+
title:,
|
26
|
+
status:
|
27
|
+
}
|
28
|
+
result[:errors] = errors if failure.errors
|
29
|
+
MultiJson.dump(result)
|
30
|
+
end
|
31
|
+
|
32
|
+
def type = failure.type
|
33
|
+
|
34
|
+
def title
|
35
|
+
TITLES.fetch(type)
|
36
|
+
end
|
37
|
+
|
38
|
+
def content_type
|
39
|
+
'application/problem+json'
|
40
|
+
end
|
41
|
+
|
42
|
+
def errors
|
43
|
+
key = pointer_key
|
44
|
+
failure.errors.map do |error|
|
45
|
+
{
|
46
|
+
message: error.message,
|
47
|
+
key => pointer(error.data_pointer),
|
48
|
+
code: error.type
|
49
|
+
}
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def pointer_key
|
54
|
+
case type
|
55
|
+
when :invalid_body
|
56
|
+
:pointer
|
57
|
+
when :invalid_query, :invalid_path
|
58
|
+
:parameter
|
59
|
+
when :invalid_header
|
60
|
+
:header
|
61
|
+
when :invalid_cookie
|
62
|
+
:cookie
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def pointer(data_pointer)
|
67
|
+
return data_pointer if type == :invalid_body
|
68
|
+
|
69
|
+
data_pointer.delete_prefix('/')
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OpenapiFirst
|
4
|
+
module ErrorResponses
|
5
|
+
# A JSON:API conform error response. See https://jsonapi.org/.
|
6
|
+
class Jsonapi
|
7
|
+
include OpenapiFirst::ErrorResponse
|
8
|
+
OpenapiFirst.register_error_response(:jsonapi, self)
|
9
|
+
|
10
|
+
def body
|
11
|
+
MultiJson.dump({ errors: serialized_errors })
|
12
|
+
end
|
13
|
+
|
14
|
+
def content_type
|
15
|
+
'application/vnd.api+json'
|
16
|
+
end
|
17
|
+
|
18
|
+
def serialized_errors
|
19
|
+
return default_errors unless failure.errors
|
20
|
+
|
21
|
+
key = pointer_key
|
22
|
+
failure.errors.map do |error|
|
23
|
+
{
|
24
|
+
status: status.to_s,
|
25
|
+
source: { key => pointer(error.data_pointer) },
|
26
|
+
title: error.message,
|
27
|
+
code: error.type
|
28
|
+
}
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def default_errors
|
33
|
+
[{
|
34
|
+
status: status.to_s,
|
35
|
+
title: Rack::Utils::HTTP_STATUS_CODES[status]
|
36
|
+
}]
|
37
|
+
end
|
38
|
+
|
39
|
+
def pointer_key
|
40
|
+
case failure.type
|
41
|
+
when :invalid_body
|
42
|
+
:pointer
|
43
|
+
when :invalid_query, :invalid_path
|
44
|
+
:parameter
|
45
|
+
when :invalid_header
|
46
|
+
:header
|
47
|
+
when :invalid_cookie
|
48
|
+
:cookie
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def pointer(data_pointer)
|
53
|
+
return data_pointer if failure.type == :invalid_body
|
54
|
+
|
55
|
+
data_pointer.delete_prefix('/')
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
data/lib/openapi_first/errors.rb
CHANGED
@@ -4,13 +4,35 @@ module OpenapiFirst
|
|
4
4
|
# @!visibility private
|
5
5
|
class Error < StandardError; end
|
6
6
|
# @!visibility private
|
7
|
+
class FileNotFoundError < Error; end
|
8
|
+
# @!visibility private
|
7
9
|
class ParseError < Error; end
|
10
|
+
|
8
11
|
# @!visibility private
|
9
|
-
class
|
12
|
+
class RequestInvalidError < Error
|
13
|
+
def initialize(message, validated_request)
|
14
|
+
super(message)
|
15
|
+
@request = validated_request
|
16
|
+
end
|
17
|
+
|
18
|
+
# @attr_reader [OpenapiFirst::ValidatedRequest] request The validated request
|
19
|
+
attr_reader :request
|
20
|
+
end
|
21
|
+
|
10
22
|
# @!visibility private
|
11
|
-
class
|
23
|
+
class NotFoundError < RequestInvalidError; end
|
24
|
+
|
12
25
|
# @!visibility private
|
13
|
-
class
|
26
|
+
class ResponseInvalidError < Error
|
27
|
+
def initialize(message, validated_response)
|
28
|
+
super(message)
|
29
|
+
@response = validated_response
|
30
|
+
end
|
31
|
+
|
32
|
+
# @attr_reader [OpenapiFirst::ValidatedResponse] request The validated response
|
33
|
+
attr_reader :response
|
34
|
+
end
|
35
|
+
|
14
36
|
# @!visibility private
|
15
|
-
class
|
37
|
+
class ResponseNotFoundError < ResponseInvalidError; end
|
16
38
|
end
|
@@ -1,10 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module OpenapiFirst
|
4
|
-
# A failure object returned when validation of request or response has failed.
|
4
|
+
# A failure object returned when validation or parsing of a request or response has failed.
|
5
|
+
# This returned in ValidatedRequest#error and ValidatedResponse#error.
|
5
6
|
class Failure
|
6
|
-
FAILURE = :openapi_first_validation_failure
|
7
|
-
|
8
7
|
TYPES = {
|
9
8
|
not_found: [NotFoundError, 'Request path is not defined.'],
|
10
9
|
method_not_allowed: [RequestInvalidError, 'Request method is not defined.'],
|
@@ -14,64 +13,68 @@ module OpenapiFirst
|
|
14
13
|
invalid_header: [RequestInvalidError, 'Request header is invalid:'],
|
15
14
|
invalid_path: [RequestInvalidError, 'Path segment is invalid:'],
|
16
15
|
invalid_cookie: [RequestInvalidError, 'Cookie value is invalid:'],
|
17
|
-
response_not_found: [ResponseNotFoundError
|
16
|
+
response_not_found: [ResponseNotFoundError],
|
18
17
|
invalid_response_body: [ResponseInvalidError, 'Response body is invalid:'],
|
19
18
|
invalid_response_header: [ResponseInvalidError, 'Response header is invalid:']
|
20
19
|
}.freeze
|
21
20
|
private_constant :TYPES
|
22
21
|
|
23
|
-
# @param
|
22
|
+
# @param type [Symbol] See Failure::TYPES.keys
|
24
23
|
# @param errors [Array<OpenapiFirst::Schema::ValidationError>]
|
25
|
-
def self.fail!(
|
24
|
+
def self.fail!(type, message: nil, errors: nil)
|
26
25
|
throw FAILURE, new(
|
27
|
-
|
26
|
+
type,
|
28
27
|
message:,
|
29
28
|
errors:
|
30
29
|
)
|
31
30
|
end
|
32
31
|
|
33
|
-
# @param
|
32
|
+
# @param type [Symbol] See TYPES.keys
|
34
33
|
# @param message [String] A generic error message
|
35
34
|
# @param errors [Array<OpenapiFirst::Schema::ValidationError>]
|
36
|
-
def initialize(
|
37
|
-
unless TYPES.key?(
|
35
|
+
def initialize(type, message: nil, errors: nil)
|
36
|
+
unless TYPES.key?(type)
|
38
37
|
raise ArgumentError,
|
39
|
-
"
|
38
|
+
"type must be one of #{TYPES.keys} but was #{type.inspect}"
|
40
39
|
end
|
41
40
|
|
42
|
-
@
|
41
|
+
@type = type
|
43
42
|
@message = message
|
44
43
|
@errors = errors
|
45
44
|
end
|
46
45
|
|
47
|
-
# @attr_reader [Symbol]
|
48
|
-
# @alias type error_type
|
46
|
+
# @attr_reader [Symbol] type The type of the failure. See TYPES.keys.
|
49
47
|
# Example: :invalid_body
|
50
|
-
attr_reader :
|
51
|
-
alias type error_type
|
52
|
-
|
53
|
-
# @attr_reader [String] message A generic error message
|
54
|
-
attr_reader :message
|
48
|
+
attr_reader :type
|
55
49
|
|
56
50
|
# @attr_reader [Array<OpenapiFirst::Schema::ValidationError>] errors Schema validation errors
|
57
51
|
attr_reader :errors
|
58
52
|
|
59
|
-
#
|
60
|
-
def
|
61
|
-
|
62
|
-
|
53
|
+
# A generic error message
|
54
|
+
def message
|
55
|
+
@message ||= exception_message
|
56
|
+
end
|
57
|
+
|
58
|
+
def exception(context = nil)
|
59
|
+
TYPES.fetch(type).first.new(exception_message, context)
|
63
60
|
end
|
64
61
|
|
65
62
|
def exception_message
|
66
|
-
_, message_prefix = TYPES.fetch(
|
63
|
+
_, message_prefix = TYPES.fetch(type)
|
64
|
+
|
65
|
+
[message_prefix, @message || generate_message].compact.join(' ')
|
66
|
+
end
|
67
67
|
|
68
|
-
|
68
|
+
# @deprecated Please use {#type} instead
|
69
|
+
def error_type
|
70
|
+
warn 'OpenapiFirst::Failure#error_type is deprecated. Use #type instead.'
|
71
|
+
type
|
69
72
|
end
|
70
73
|
|
71
74
|
private
|
72
75
|
|
73
76
|
def generate_message
|
74
|
-
messages = errors&.take(3)&.map(&:
|
77
|
+
messages = errors&.take(3)&.map(&:message)
|
75
78
|
messages << "... (#{errors.size} errors total)" if errors && errors.size > 3
|
76
79
|
messages&.join('. ')
|
77
80
|
end
|
@@ -27,7 +27,7 @@ module OpenapiFirst
|
|
27
27
|
|
28
28
|
def call(env)
|
29
29
|
validated = @definition.validate_request(Rack::Request.new(env), raise_error: @raise)
|
30
|
-
env[REQUEST]
|
30
|
+
env[REQUEST] = validated
|
31
31
|
failure = validated.error
|
32
32
|
return @error_response_class.new(failure:).render if failure
|
33
33
|
|
@@ -37,7 +37,7 @@ module OpenapiFirst
|
|
37
37
|
private
|
38
38
|
|
39
39
|
def error_response(mod)
|
40
|
-
return OpenapiFirst.
|
40
|
+
return OpenapiFirst.find_error_response(mod) if mod.is_a?(Symbol)
|
41
41
|
|
42
42
|
mod || OpenapiFirst.configuration.request_validation_error_response
|
43
43
|
end
|
@@ -8,9 +8,11 @@ module OpenapiFirst
|
|
8
8
|
class ResponseValidation
|
9
9
|
# @param app The parent Rack application
|
10
10
|
# @param options Hash
|
11
|
-
# :spec
|
11
|
+
# :spec [String, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition
|
12
|
+
# :raise_error [Boolean] Whether to raise an error if validation fails. default: true
|
12
13
|
def initialize(app, options = {})
|
13
14
|
@app = app
|
15
|
+
@raise = options.fetch(:raise_error, OpenapiFirst.configuration.response_validation_raise_error)
|
14
16
|
|
15
17
|
spec = options.fetch(:spec)
|
16
18
|
raise "You have to pass spec: when initializing #{self.class}" unless spec
|
@@ -24,8 +26,7 @@ module OpenapiFirst
|
|
24
26
|
def call(env)
|
25
27
|
status, headers, body = @app.call(env)
|
26
28
|
body = read_body(body)
|
27
|
-
@definition.validate_response(Rack::Request.new(env), Rack::Response[status, headers, body], raise_error:
|
28
|
-
env[REQUEST] ||= @definition.request(Rack::Request.new(env))
|
29
|
+
@definition.validate_response(Rack::Request.new(env), Rack::Response[status, headers, body], raise_error: @raise)
|
29
30
|
[status, headers, body]
|
30
31
|
end
|
31
32
|
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'request_parser'
|
4
|
+
require_relative 'request_validator'
|
5
|
+
require_relative 'validated_request'
|
6
|
+
|
7
|
+
module OpenapiFirst
|
8
|
+
# Represents one request definition of an OpenAPI description.
|
9
|
+
# Note that this is not the same as an OpenAPI 3.x Operation.
|
10
|
+
# An 3.x Operation object can accept multiple requests, because it can handle multiple content-types.
|
11
|
+
# This class represents one of those requests.
|
12
|
+
class Request
|
13
|
+
def initialize(path:, request_method:, operation_object:,
|
14
|
+
parameters:, content_type:, content_schema:, required_body:,
|
15
|
+
hooks:, openapi_version:)
|
16
|
+
@path = path
|
17
|
+
@request_method = request_method
|
18
|
+
@content_type = content_type
|
19
|
+
@content_schema = content_schema
|
20
|
+
@required_request_body = required_body == true
|
21
|
+
@operation = operation_object
|
22
|
+
@parameters = build_parameters(parameters)
|
23
|
+
@request_parser = RequestParser.new(
|
24
|
+
query_parameters: @parameters[:query],
|
25
|
+
path_parameters: @parameters[:path],
|
26
|
+
header_parameters: @parameters[:header],
|
27
|
+
cookie_parameters: @parameters[:cookie],
|
28
|
+
content_type:
|
29
|
+
)
|
30
|
+
@validator = RequestValidator.new(self, hooks:, openapi_version:)
|
31
|
+
end
|
32
|
+
|
33
|
+
attr_reader :content_type, :content_schema, :operation, :request_method, :path
|
34
|
+
|
35
|
+
def validate(request, route_params:)
|
36
|
+
parsed_values = {}
|
37
|
+
error = catch FAILURE do
|
38
|
+
parsed_values = @request_parser.parse(request, route_params:)
|
39
|
+
@validator.call(parsed_values)
|
40
|
+
nil
|
41
|
+
end
|
42
|
+
ValidatedRequest.new(request, parsed_values:, error:, request_definition: self)
|
43
|
+
end
|
44
|
+
|
45
|
+
# These return a Schema instance for each type of parameters
|
46
|
+
%i[path query header cookie].each do |location|
|
47
|
+
define_method(:"#{location}_schema") do
|
48
|
+
build_parameters_schema(@parameters[location])
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def required_request_body?
|
53
|
+
@required_request_body
|
54
|
+
end
|
55
|
+
|
56
|
+
def operation_id
|
57
|
+
@operation['operationId']
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
IGNORED_HEADERS = Set['Content-Type', 'Accept', 'Authorization'].freeze
|
63
|
+
private_constant :IGNORED_HEADERS
|
64
|
+
|
65
|
+
def build_parameters(parameter_definitions)
|
66
|
+
result = {}
|
67
|
+
parameter_definitions&.each do |parameter|
|
68
|
+
(result[parameter['in'].to_sym] ||= []) << parameter
|
69
|
+
end
|
70
|
+
result[:header]&.reject! { IGNORED_HEADERS.include?(_1['name']) }
|
71
|
+
result
|
72
|
+
end
|
73
|
+
|
74
|
+
def build_parameters_schema(parameters)
|
75
|
+
return unless parameters
|
76
|
+
|
77
|
+
properties = {}
|
78
|
+
required = []
|
79
|
+
parameters.each do |parameter|
|
80
|
+
schema = parameter['schema']
|
81
|
+
name = parameter['name']
|
82
|
+
properties[name] = schema if schema
|
83
|
+
required << name if parameter['required']
|
84
|
+
end
|
85
|
+
|
86
|
+
{
|
87
|
+
'properties' => properties,
|
88
|
+
'required' => required
|
89
|
+
}
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'openapi_parameters'
|
4
|
+
require_relative 'body_parser'
|
5
|
+
|
6
|
+
module OpenapiFirst
|
7
|
+
# Parse a request
|
8
|
+
class RequestParser
|
9
|
+
def initialize(
|
10
|
+
query_parameters:,
|
11
|
+
path_parameters:,
|
12
|
+
header_parameters:,
|
13
|
+
cookie_parameters:,
|
14
|
+
content_type:
|
15
|
+
)
|
16
|
+
@query_parser = OpenapiParameters::Query.new(query_parameters) if query_parameters
|
17
|
+
@path_parser = OpenapiParameters::Path.new(path_parameters) if path_parameters
|
18
|
+
@headers_parser = OpenapiParameters::Header.new(header_parameters) if header_parameters
|
19
|
+
@cookies_parser = OpenapiParameters::Cookie.new(cookie_parameters) if cookie_parameters
|
20
|
+
@body_parser = BodyParser.new(content_type) if content_type
|
21
|
+
end
|
22
|
+
|
23
|
+
attr_reader :query, :path, :headers, :cookies
|
24
|
+
|
25
|
+
def parse(request, route_params:)
|
26
|
+
result = {}
|
27
|
+
result[:path] = @path_parser.unpack(route_params) if @path_parser
|
28
|
+
result[:query] = @query_parser.unpack(request.env[Rack::QUERY_STRING]) if @query_parser
|
29
|
+
result[:headers] = @headers_parser.unpack_env(request.env) if @headers_parser
|
30
|
+
result[:cookies] = @cookies_parser.unpack(request.env[Rack::HTTP_COOKIE]) if @cookies_parser
|
31
|
+
result[:body] = @body_parser.parse(request) if @body_parser
|
32
|
+
result
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'failure'
|
4
|
+
require_relative 'validators/request_parameters'
|
5
|
+
require_relative 'validators/request_body'
|
6
|
+
|
7
|
+
module OpenapiFirst
|
8
|
+
# Validates a Request against a request definition.
|
9
|
+
class RequestValidator
|
10
|
+
VALIDATORS = [
|
11
|
+
Validators::RequestParameters,
|
12
|
+
Validators::RequestBody
|
13
|
+
].freeze
|
14
|
+
|
15
|
+
def initialize(request_definition, openapi_version:, hooks: {})
|
16
|
+
@validators = VALIDATORS.filter_map do |klass|
|
17
|
+
klass.for(request_definition, hooks:, openapi_version:)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def call(parsed_request)
|
22
|
+
@validators.each { |v| v.call(parsed_request) }
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'response_parser'
|
4
|
+
require_relative 'response_validator'
|
5
|
+
require_relative 'validated_response'
|
6
|
+
|
7
|
+
module OpenapiFirst
|
8
|
+
# Represents a response definition in the OpenAPI document.
|
9
|
+
# This is not a direct reflecton of the OpenAPI 3.X response definition, but a combination of
|
10
|
+
# status, content type and content schema.
|
11
|
+
class Response
|
12
|
+
def initialize(status:, headers:, content_type:, content_schema:, openapi_version:)
|
13
|
+
@status = status
|
14
|
+
@content_type = content_type
|
15
|
+
@content_schema = content_schema
|
16
|
+
@headers = headers
|
17
|
+
@headers_schema = build_headers_schema(headers)
|
18
|
+
@parser = ResponseParser.new(headers:, content_type:)
|
19
|
+
@validator = ResponseValidator.new(self, openapi_version:)
|
20
|
+
end
|
21
|
+
|
22
|
+
# @attr_reader [Integer] status The HTTP status code of the response definition.
|
23
|
+
# @attr_reader [String, nil] content_type Content type of this response.
|
24
|
+
# @attr_reader [Schema, nil] content_schema the Schema of the response body.
|
25
|
+
attr_reader :status, :content_type, :content_schema, :headers, :headers_schema
|
26
|
+
|
27
|
+
def validate(response)
|
28
|
+
parsed_values = @parser.parse(response)
|
29
|
+
error = @validator.call(parsed_values)
|
30
|
+
ValidatedResponse.new(response, parsed_values:, error:, response_definition: self)
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def parse(request)
|
36
|
+
@parser.parse(request)
|
37
|
+
end
|
38
|
+
|
39
|
+
def build_headers_schema(headers_object)
|
40
|
+
return unless headers_object&.any?
|
41
|
+
|
42
|
+
properties = {}
|
43
|
+
required = []
|
44
|
+
headers_object.each do |name, header|
|
45
|
+
schema = header['schema']
|
46
|
+
next if name.casecmp('content-type').zero?
|
47
|
+
|
48
|
+
properties[name] = schema if schema
|
49
|
+
required << name if header['required']
|
50
|
+
end
|
51
|
+
{
|
52
|
+
'properties' => properties,
|
53
|
+
'required' => required
|
54
|
+
}
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OpenapiFirst
|
4
|
+
ParsedResponse = Data.define(:body, :headers)
|
5
|
+
|
6
|
+
# Parse a response
|
7
|
+
class ResponseParser
|
8
|
+
def initialize(headers:, content_type:)
|
9
|
+
@headers = headers
|
10
|
+
@content_type = content_type
|
11
|
+
end
|
12
|
+
|
13
|
+
attr_reader :headers, :content_type
|
14
|
+
|
15
|
+
def parse(rack_response)
|
16
|
+
ParsedResponse.new(
|
17
|
+
body: parse_body(rack_response),
|
18
|
+
headers: parse_headers(rack_response)
|
19
|
+
)
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def parse_body(rack_response)
|
25
|
+
MultiJson.load(read_body(rack_response)) if /json/i.match?(content_type)
|
26
|
+
rescue MultiJson::ParseError
|
27
|
+
Failure.fail!(:invalid_response_body, message: 'Response body is invalid: Failed to parse response body as JSON')
|
28
|
+
end
|
29
|
+
|
30
|
+
def read_body(rack_response)
|
31
|
+
buffered_body = +''
|
32
|
+
if rack_response.body.respond_to?(:each)
|
33
|
+
rack_response.body.each { |chunk| buffered_body.to_s << chunk }
|
34
|
+
return buffered_body
|
35
|
+
end
|
36
|
+
rack_response.body
|
37
|
+
end
|
38
|
+
|
39
|
+
def parse_headers(rack_response)
|
40
|
+
return {} if headers.nil?
|
41
|
+
|
42
|
+
# TODO: memoize unpacker
|
43
|
+
headers_as_parameters = headers.map do |name, definition|
|
44
|
+
definition.merge('name' => name, 'in' => 'header')
|
45
|
+
end
|
46
|
+
OpenapiParameters::Header.new(headers_as_parameters).unpack(rack_response.headers)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'validators/response_headers'
|
4
|
+
require_relative 'validators/response_body'
|
5
|
+
|
6
|
+
module OpenapiFirst
|
7
|
+
# Entry point for response validators
|
8
|
+
class ResponseValidator
|
9
|
+
VALIDATORS = [
|
10
|
+
Validators::ResponseHeaders,
|
11
|
+
Validators::ResponseBody
|
12
|
+
].freeze
|
13
|
+
|
14
|
+
def initialize(response_definition, openapi_version:)
|
15
|
+
@validators = VALIDATORS.filter_map do |klass|
|
16
|
+
klass.for(response_definition, openapi_version:)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def call(parsed_response)
|
21
|
+
catch FAILURE do
|
22
|
+
@validators.each { |v| v.call(parsed_response) }
|
23
|
+
nil
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OpenapiFirst
|
4
|
+
class Router
|
5
|
+
# @visibility private
|
6
|
+
module FindContent
|
7
|
+
def self.call(contents, content_type)
|
8
|
+
return contents[nil] if content_type.nil? || content_type.empty?
|
9
|
+
|
10
|
+
contents.fetch(content_type) do
|
11
|
+
type = content_type.split(';')[0]
|
12
|
+
contents[type] || contents["#{type.split('/')[0]}/*"] || contents['*/*'] || contents[nil]
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'find_content'
|
4
|
+
|
5
|
+
module OpenapiFirst
|
6
|
+
class Router
|
7
|
+
# @visibility private
|
8
|
+
module FindResponse
|
9
|
+
Match = Data.define(:response, :error)
|
10
|
+
|
11
|
+
def self.call(responses, status, content_type, request_method:, path:)
|
12
|
+
contents = find_status(responses, status)
|
13
|
+
if contents.nil?
|
14
|
+
message = "Status #{status} is not defined for #{request_method} #{path}. " \
|
15
|
+
"Defined statuses are: #{responses.keys.join(', ')}."
|
16
|
+
return Match.new(error: Failure.new(:response_not_found, message:), response: nil)
|
17
|
+
end
|
18
|
+
response = FindContent.call(contents, content_type)
|
19
|
+
if response.nil?
|
20
|
+
message = "#{content_error(content_type, request_method:,
|
21
|
+
path:)} Content-Type should be #{contents.keys.join(' or ')}."
|
22
|
+
return Match.new(error: Failure.new(:response_not_found, message:), response: nil)
|
23
|
+
end
|
24
|
+
|
25
|
+
Match.new(response:, error: nil)
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.content_error(content_type, request_method:, path:)
|
29
|
+
return 'Response Content-Type must not be empty.' if content_type.nil? || content_type.empty?
|
30
|
+
|
31
|
+
"Response Content-Type #{content_type} is not defined for #{request_method} #{path}."
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.find_status(responses, status)
|
35
|
+
# According to OAS status has to be a string,
|
36
|
+
# but there are a few API descriptions out there that use integers because of YAML.
|
37
|
+
|
38
|
+
responses[status] || responses[status.to_s] ||
|
39
|
+
responses["#{status / 100}XX"] ||
|
40
|
+
responses["#{status / 100}xx"] ||
|
41
|
+
responses['default']
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|