openapi_first 1.4.3 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -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 +24 -3
- 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 +19 -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
|