openapi_first 1.0.0.beta1 → 1.0.0.beta4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/ruby.yml +8 -20
- data/.rubocop.yml +1 -1
- data/CHANGELOG.md +23 -0
- data/Gemfile +4 -1
- data/Gemfile.lock +39 -51
- data/README.md +27 -25
- data/benchmarks/Gemfile.lock +28 -33
- data/benchmarks/apps/openapi_first_with_hanami_api.ru +1 -2
- data/benchmarks/apps/openapi_first_with_plain_rack.ru +32 -0
- data/benchmarks/apps/openapi_first_with_response_validation.ru +14 -11
- data/benchmarks/apps/openapi_first_with_sinatra.ru +29 -0
- data/lib/openapi_first/body_parser_middleware.rb +1 -1
- data/lib/openapi_first/config.rb +19 -0
- data/lib/openapi_first/default_error_response.rb +47 -0
- data/lib/openapi_first/definition.rb +8 -1
- data/lib/openapi_first/error_response.rb +31 -0
- data/lib/openapi_first/errors.rb +3 -40
- data/lib/openapi_first/operation.rb +33 -14
- data/lib/openapi_first/operation_schemas.rb +52 -0
- data/lib/openapi_first/plugins.rb +17 -0
- data/lib/openapi_first/request_body_validator.rb +41 -0
- data/lib/openapi_first/request_validation.rb +66 -84
- data/lib/openapi_first/response_validation.rb +38 -7
- data/lib/openapi_first/response_validator.rb +1 -1
- data/lib/openapi_first/router.rb +15 -14
- data/lib/openapi_first/schema_validation.rb +22 -21
- data/lib/openapi_first/string_keyed_hash.rb +20 -0
- data/lib/openapi_first/validation_result.rb +15 -0
- data/lib/openapi_first/version.rb +1 -1
- data/lib/openapi_first.rb +30 -21
- data/openapi_first.gemspec +4 -12
- metadata +20 -117
- data/benchmarks/apps/openapi_first.ru +0 -22
- data/lib/openapi_first/utils.rb +0 -35
- data/lib/openapi_first/validation_format.rb +0 -55
- /data/benchmarks/apps/{committee.ru → committee_with_hanami_api.ru} +0 -0
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OpenapiFirst
|
4
|
+
class DefaultErrorResponse < ErrorResponse
|
5
|
+
OpenapiFirst::Plugins.register_error_response(:default, self)
|
6
|
+
|
7
|
+
def body
|
8
|
+
MultiJson.dump({ errors: serialized_errors })
|
9
|
+
end
|
10
|
+
|
11
|
+
def serialized_errors
|
12
|
+
return default_errors unless validation_output
|
13
|
+
|
14
|
+
key = pointer_key
|
15
|
+
[
|
16
|
+
{
|
17
|
+
source: { key => pointer(validation_output['instanceLocation']) },
|
18
|
+
title: validation_output['error']
|
19
|
+
}
|
20
|
+
]
|
21
|
+
end
|
22
|
+
|
23
|
+
def default_errors
|
24
|
+
[{
|
25
|
+
status: status.to_s,
|
26
|
+
title:
|
27
|
+
}]
|
28
|
+
end
|
29
|
+
|
30
|
+
def pointer_key
|
31
|
+
case location
|
32
|
+
when :request_body
|
33
|
+
:pointer
|
34
|
+
when :query, :path
|
35
|
+
:parameter
|
36
|
+
else
|
37
|
+
location
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def pointer(data_pointer)
|
42
|
+
return data_pointer if location == :request_body
|
43
|
+
|
44
|
+
data_pointer.delete_prefix('/')
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -3,6 +3,7 @@
|
|
3
3
|
require_relative 'operation'
|
4
4
|
|
5
5
|
module OpenapiFirst
|
6
|
+
# Represents an OpenAPI API Description document
|
6
7
|
class Definition
|
7
8
|
attr_reader :filepath, :operations
|
8
9
|
|
@@ -11,9 +12,15 @@ module OpenapiFirst
|
|
11
12
|
methods = %w[get head post put patch delete trace options]
|
12
13
|
@operations = resolved['paths'].flat_map do |path, path_item|
|
13
14
|
path_item.slice(*methods).map do |request_method, _operation_object|
|
14
|
-
Operation.new(path, request_method, path_item)
|
15
|
+
Operation.new(path, request_method, path_item, openapi_version: detect_version(resolved))
|
15
16
|
end
|
16
17
|
end
|
17
18
|
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def detect_version(resolved)
|
23
|
+
(resolved['openapi'] || resolved['swagger'])[0..2]
|
24
|
+
end
|
18
25
|
end
|
19
26
|
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OpenapiFirst
|
4
|
+
# This is the base class for error responses
|
5
|
+
class ErrorResponse
|
6
|
+
## @param status [Integer] The HTTP status code.
|
7
|
+
## @param title [String] The title of the error. Usually the name of the HTTP status code.
|
8
|
+
## @param location [Symbol] The location of the error (:request_body, :query, :header, :cookie, :path).
|
9
|
+
## @param validation_result [ValidationResult]
|
10
|
+
def initialize(status:, location:, title:, validation_result:)
|
11
|
+
@status = status
|
12
|
+
@title = title
|
13
|
+
@location = location
|
14
|
+
@validation_output = validation_result&.output
|
15
|
+
@schema = validation_result&.schema
|
16
|
+
@data = validation_result&.data
|
17
|
+
end
|
18
|
+
|
19
|
+
attr_reader :status, :location, :title, :schema, :data, :validation_output
|
20
|
+
|
21
|
+
def render
|
22
|
+
Rack::Response.new(body, status, Rack::CONTENT_TYPE => content_type).finish
|
23
|
+
end
|
24
|
+
|
25
|
+
def content_type = 'application/json'
|
26
|
+
|
27
|
+
def body
|
28
|
+
raise NotImplementedError
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
data/lib/openapi_first/errors.rb
CHANGED
@@ -5,15 +5,6 @@ module OpenapiFirst
|
|
5
5
|
|
6
6
|
class NotFoundError < Error; end
|
7
7
|
|
8
|
-
class HandlerNotFoundError < Error; end
|
9
|
-
|
10
|
-
class NotImplementedError < Error
|
11
|
-
def initialize(message)
|
12
|
-
warn 'NotImplementedError is deprecated. Handle HandlerNotFoundError instead'
|
13
|
-
super
|
14
|
-
end
|
15
|
-
end
|
16
|
-
|
17
8
|
class ResponseInvalid < Error; end
|
18
9
|
|
19
10
|
class ResponseCodeNotFoundError < ResponseInvalid; end
|
@@ -22,37 +13,9 @@ module OpenapiFirst
|
|
22
13
|
|
23
14
|
class ResponseBodyInvalidError < ResponseInvalid; end
|
24
15
|
|
16
|
+
class ResponseHeaderInvalidError < ResponseInvalid; end
|
17
|
+
|
25
18
|
class BodyParsingError < Error; end
|
26
19
|
|
27
|
-
class RequestInvalidError < Error
|
28
|
-
def initialize(serialized_errors)
|
29
|
-
message = error_message(serialized_errors)
|
30
|
-
super message
|
31
|
-
end
|
32
|
-
|
33
|
-
private
|
34
|
-
|
35
|
-
def error_message(errors)
|
36
|
-
errors.map do |error|
|
37
|
-
[human_source(error), human_error(error)].compact.join(' ')
|
38
|
-
end.join(', ')
|
39
|
-
end
|
40
|
-
|
41
|
-
def human_source(error)
|
42
|
-
return unless error[:source]
|
43
|
-
|
44
|
-
source_key = error[:source].keys.first
|
45
|
-
source = {
|
46
|
-
pointer: 'Request body invalid:',
|
47
|
-
parameter: 'Query parameter invalid:'
|
48
|
-
}.fetch(source_key, source_key)
|
49
|
-
name = error[:source].values.first
|
50
|
-
source += " #{name}" unless name.nil? || name.empty?
|
51
|
-
source
|
52
|
-
end
|
53
|
-
|
54
|
-
def human_error(error)
|
55
|
-
error[:title]
|
56
|
-
end
|
57
|
-
end
|
20
|
+
class RequestInvalidError < Error; end
|
58
21
|
end
|
@@ -3,10 +3,10 @@
|
|
3
3
|
require 'forwardable'
|
4
4
|
require 'set'
|
5
5
|
require_relative 'schema_validation'
|
6
|
-
require_relative '
|
6
|
+
require_relative 'operation_schemas'
|
7
7
|
|
8
8
|
module OpenapiFirst
|
9
|
-
class Operation
|
9
|
+
class Operation # rubocop:disable Metrics/ClassLength
|
10
10
|
extend Forwardable
|
11
11
|
def_delegators :operation_object,
|
12
12
|
:[],
|
@@ -15,12 +15,13 @@ module OpenapiFirst
|
|
15
15
|
WRITE_METHODS = Set.new(%w[post put patch delete]).freeze
|
16
16
|
private_constant :WRITE_METHODS
|
17
17
|
|
18
|
-
attr_reader :path, :method
|
18
|
+
attr_reader :path, :method, :openapi_version
|
19
19
|
|
20
|
-
def initialize(path, request_method, path_item_object)
|
20
|
+
def initialize(path, request_method, path_item_object, openapi_version:)
|
21
21
|
@path = path
|
22
22
|
@method = request_method
|
23
23
|
@path_item_object = path_item_object
|
24
|
+
@openapi_version = openapi_version
|
24
25
|
end
|
25
26
|
|
26
27
|
def operation_id
|
@@ -39,7 +40,7 @@ module OpenapiFirst
|
|
39
40
|
operation_object['requestBody']
|
40
41
|
end
|
41
42
|
|
42
|
-
def
|
43
|
+
def response_body_schema(status, content_type)
|
43
44
|
content = response_for(status)['content']
|
44
45
|
return if content.nil? || content.empty?
|
45
46
|
|
@@ -52,16 +53,18 @@ module OpenapiFirst
|
|
52
53
|
raise ResponseContentTypeNotFoundError, message
|
53
54
|
end
|
54
55
|
schema = media_type['schema']
|
55
|
-
|
56
|
+
return unless schema
|
57
|
+
|
58
|
+
SchemaValidation.new(schema, write: false, openapi_version:)
|
56
59
|
end
|
57
60
|
|
58
61
|
def request_body_schema(request_content_type)
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
62
|
+
(@request_body_schema ||= {})[request_content_type] ||= begin
|
63
|
+
content = operation_object.dig('requestBody', 'content')
|
64
|
+
media_type = find_content_for_content_type(content, request_content_type)
|
65
|
+
schema = media_type&.fetch('schema', nil)
|
66
|
+
SchemaValidation.new(schema, write: write?, openapi_version:) if schema
|
67
|
+
end
|
65
68
|
end
|
66
69
|
|
67
70
|
def response_for(status)
|
@@ -73,12 +76,12 @@ module OpenapiFirst
|
|
73
76
|
end
|
74
77
|
|
75
78
|
def name
|
76
|
-
"#{method.upcase} #{path} (#{operation_id})"
|
79
|
+
@name ||= "#{method.upcase} #{path} (#{operation_id})"
|
77
80
|
end
|
78
81
|
|
79
82
|
def valid_request_content_type?(request_content_type)
|
80
83
|
content = operation_object.dig('requestBody', 'content')
|
81
|
-
return unless content
|
84
|
+
return false unless content
|
82
85
|
|
83
86
|
!!find_content_for_content_type(content, request_content_type)
|
84
87
|
end
|
@@ -91,6 +94,17 @@ module OpenapiFirst
|
|
91
94
|
@path_parameters ||= all_parameters.filter { |p| p['in'] == 'path' }
|
92
95
|
end
|
93
96
|
|
97
|
+
IGNORED_HEADERS = Set['Content-Type', 'Accept', 'Authorization'].freeze
|
98
|
+
private_constant :IGNORED_HEADERS
|
99
|
+
|
100
|
+
def header_parameters
|
101
|
+
@header_parameters ||= all_parameters.filter { |p| p['in'] == 'header' && !IGNORED_HEADERS.include?(p['name']) }
|
102
|
+
end
|
103
|
+
|
104
|
+
def cookie_parameters
|
105
|
+
@cookie_parameters ||= all_parameters.filter { |p| p['in'] == 'cookie' }
|
106
|
+
end
|
107
|
+
|
94
108
|
def all_parameters
|
95
109
|
@all_parameters ||= begin
|
96
110
|
parameters = @path_item_object['parameters']&.dup || []
|
@@ -100,6 +114,11 @@ module OpenapiFirst
|
|
100
114
|
end
|
101
115
|
end
|
102
116
|
|
117
|
+
# visibility: private
|
118
|
+
def schemas
|
119
|
+
@schemas ||= OperationSchemas.new(self)
|
120
|
+
end
|
121
|
+
|
103
122
|
private
|
104
123
|
|
105
124
|
def response_by_code(status)
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'openapi_parameters/parameter'
|
4
|
+
require_relative 'schema_validation'
|
5
|
+
|
6
|
+
module OpenapiFirst
|
7
|
+
# This class is basically a cache for JSON Schemas of parameters
|
8
|
+
class OperationSchemas
|
9
|
+
# @operation [OpenapiFirst::Operation]
|
10
|
+
def initialize(operation)
|
11
|
+
@operation = operation
|
12
|
+
end
|
13
|
+
|
14
|
+
attr_reader :operation
|
15
|
+
|
16
|
+
# Return JSON Schema of for all query parameters
|
17
|
+
def query_parameters_schema
|
18
|
+
@query_parameters_schema ||= build_json_schema(operation.query_parameters)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Return JSON Schema of for all path parameters
|
22
|
+
def path_parameters_schema
|
23
|
+
@path_parameters_schema ||= build_json_schema(operation.path_parameters)
|
24
|
+
end
|
25
|
+
|
26
|
+
def header_parameters_schema
|
27
|
+
@header_parameters_schema ||= build_json_schema(operation.header_parameters)
|
28
|
+
end
|
29
|
+
|
30
|
+
def cookie_parameters_schema
|
31
|
+
@cookie_parameters_schema ||= build_json_schema(operation.cookie_parameters)
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
# Build JSON Schema for given parameter definitions
|
37
|
+
# @parameter_defs [Array<Hash>] Parameter definitions
|
38
|
+
def build_json_schema(parameter_defs)
|
39
|
+
init_schema = {
|
40
|
+
'type' => 'object',
|
41
|
+
'properties' => {},
|
42
|
+
'required' => []
|
43
|
+
}
|
44
|
+
schema = parameter_defs.each_with_object(init_schema) do |parameter_def, result|
|
45
|
+
parameter = OpenapiParameters::Parameter.new(parameter_def)
|
46
|
+
result['properties'][parameter.name] = parameter.schema if parameter.schema
|
47
|
+
result['required'] << parameter.name if parameter.required?
|
48
|
+
end
|
49
|
+
SchemaValidation.new(schema, openapi_version: operation.openapi_version)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OpenapiFirst
|
4
|
+
module Plugins
|
5
|
+
ERROR_RESPONSES = {} # rubocop:disable Style/MutableConstant
|
6
|
+
|
7
|
+
def self.register_error_response(name, klass)
|
8
|
+
ERROR_RESPONSES[name] = klass
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.find_error_response(name)
|
12
|
+
return name if name.is_a?(Class)
|
13
|
+
|
14
|
+
ERROR_RESPONSES.fetch(name)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OpenapiFirst
|
4
|
+
class RequestBodyValidator
|
5
|
+
def initialize(operation, env)
|
6
|
+
@operation = operation
|
7
|
+
@env = env
|
8
|
+
@parsed_request_body = env[REQUEST_BODY]
|
9
|
+
end
|
10
|
+
|
11
|
+
def validate!
|
12
|
+
content_type = Rack::Request.new(@env).content_type
|
13
|
+
validate_request_content_type!(@operation, content_type)
|
14
|
+
validate_request_body!(@operation, @parsed_request_body, content_type)
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def validate_request_content_type!(operation, content_type)
|
20
|
+
operation.valid_request_content_type?(content_type) || OpenapiFirst.error!(415)
|
21
|
+
end
|
22
|
+
|
23
|
+
def validate_request_body!(operation, body, content_type)
|
24
|
+
validate_request_body_presence!(body, operation)
|
25
|
+
return if content_type.nil?
|
26
|
+
|
27
|
+
schema = operation&.request_body_schema(content_type)
|
28
|
+
return unless schema
|
29
|
+
|
30
|
+
validation_result = schema.validate(body)
|
31
|
+
OpenapiFirst.error!(400, :request_body, validation_result:) if validation_result.error?
|
32
|
+
body
|
33
|
+
end
|
34
|
+
|
35
|
+
def validate_request_body_presence!(body, operation)
|
36
|
+
return unless operation.request_body['required'] && body.nil?
|
37
|
+
|
38
|
+
OpenapiFirst.error!(400, :request_body, title: 'Request body is required')
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -3,7 +3,9 @@
|
|
3
3
|
require 'rack'
|
4
4
|
require 'multi_json'
|
5
5
|
require_relative 'use_router'
|
6
|
-
require_relative '
|
6
|
+
require_relative 'error_response'
|
7
|
+
require_relative 'request_body_validator'
|
8
|
+
require_relative 'string_keyed_hash'
|
7
9
|
require 'openapi_parameters'
|
8
10
|
|
9
11
|
module OpenapiFirst
|
@@ -13,118 +15,98 @@ module OpenapiFirst
|
|
13
15
|
def initialize(app, options = {})
|
14
16
|
@app = app
|
15
17
|
@raise = options.fetch(:raise_error, false)
|
18
|
+
@error_response_class =
|
19
|
+
Plugins.find_error_response(options.fetch(:error_response, Config.default_options.error_response))
|
16
20
|
end
|
17
21
|
|
18
|
-
def call(env)
|
22
|
+
def call(env)
|
19
23
|
operation = env[OPERATION]
|
20
24
|
return @app.call(env) unless operation
|
21
25
|
|
22
|
-
error =
|
23
|
-
query_params = OpenapiParameters::Query.new(operation.query_parameters).unpack(env['QUERY_STRING'])
|
24
|
-
validate_query_parameters!(operation, query_params)
|
25
|
-
env[PARAMS].merge!(query_params)
|
26
|
-
|
27
|
-
return @app.call(env) unless operation.request_body
|
28
|
-
|
29
|
-
content_type = Rack::Request.new(env).content_type
|
30
|
-
validate_request_content_type!(operation, content_type)
|
31
|
-
parsed_request_body = env[REQUEST_BODY]
|
32
|
-
validate_request_body!(operation, parsed_request_body, content_type)
|
33
|
-
nil
|
34
|
-
end
|
26
|
+
error = validate_request(operation, env)
|
35
27
|
if error
|
36
|
-
|
28
|
+
location, title = error.values_at(:location, :title)
|
29
|
+
raise RequestInvalidError, error_message(title, location) if @raise
|
37
30
|
|
38
|
-
return
|
31
|
+
return error_response(error).render
|
39
32
|
end
|
40
33
|
@app.call(env)
|
41
34
|
end
|
42
35
|
|
43
36
|
private
|
44
37
|
|
45
|
-
def
|
46
|
-
|
47
|
-
return if content_type.nil?
|
38
|
+
def error_message(title, location)
|
39
|
+
return title unless location
|
48
40
|
|
49
|
-
|
50
|
-
return unless schema
|
51
|
-
|
52
|
-
errors = schema.validate(body)
|
53
|
-
throw_error(400, serialize_request_body_errors(errors)) if errors.any?
|
54
|
-
body
|
55
|
-
end
|
56
|
-
|
57
|
-
def validate_request_content_type!(operation, content_type)
|
58
|
-
operation.valid_request_content_type?(content_type) || throw_error(415)
|
41
|
+
"#{TOPICS.fetch(location)} #{title}"
|
59
42
|
end
|
60
43
|
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
44
|
+
TOPICS = {
|
45
|
+
request_body: 'Request body invalid:',
|
46
|
+
query: 'Query parameter invalid:',
|
47
|
+
header: 'Header parameter invalid:',
|
48
|
+
path: 'Path segment invalid:',
|
49
|
+
cookie: 'Cookie value invalid:'
|
50
|
+
}.freeze
|
51
|
+
private_constant :TOPICS
|
52
|
+
|
53
|
+
def error_response(error_object)
|
54
|
+
@error_response_class.new(**error_object)
|
65
55
|
end
|
66
56
|
|
67
|
-
def
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
57
|
+
def validate_request(operation, env)
|
58
|
+
catch(:error) do
|
59
|
+
env[PARAMS] = {}
|
60
|
+
validate_query_params!(operation, env)
|
61
|
+
validate_path_params!(operation, env)
|
62
|
+
validate_cookie_params!(operation, env)
|
63
|
+
validate_header_params!(operation, env)
|
64
|
+
RequestBodyValidator.new(operation, env).validate! if operation.request_body
|
65
|
+
nil
|
66
|
+
end
|
72
67
|
end
|
73
68
|
|
74
|
-
def
|
75
|
-
|
76
|
-
|
77
|
-
errors: errors
|
78
|
-
}
|
79
|
-
end
|
69
|
+
def validate_path_params!(operation, env)
|
70
|
+
path_parameters = operation.path_parameters
|
71
|
+
return if path_parameters.empty?
|
80
72
|
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
)
|
73
|
+
hashy = StringKeyedHash.new(env[Router::RAW_PATH_PARAMS])
|
74
|
+
unpacked_path_params = OpenapiParameters::Path.new(path_parameters).unpack(hashy)
|
75
|
+
validation_result = operation.schemas.path_parameters_schema.validate(unpacked_path_params)
|
76
|
+
OpenapiFirst.error!(400, :path, validation_result:) if validation_result.error?
|
77
|
+
env[PATH_PARAMS] = unpacked_path_params
|
78
|
+
env[PARAMS].merge!(unpacked_path_params)
|
87
79
|
end
|
88
80
|
|
89
|
-
def
|
90
|
-
|
91
|
-
|
92
|
-
source: {
|
93
|
-
pointer: error['data_pointer']
|
94
|
-
}
|
95
|
-
}.update(ValidationFormat.error_details(error))
|
96
|
-
end
|
97
|
-
end
|
81
|
+
def validate_query_params!(operation, env)
|
82
|
+
query_parameters = operation.query_parameters
|
83
|
+
return if operation.query_parameters.empty?
|
98
84
|
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
}
|
105
|
-
parameter_defs.each_with_object(init_schema) do |parameter_def, schema|
|
106
|
-
parameter = OpenapiParameters::Parameter.new(parameter_def)
|
107
|
-
schema['properties'][parameter.name] = parameter.schema if parameter.schema
|
108
|
-
schema['required'] << parameter.name if parameter.required?
|
109
|
-
end
|
85
|
+
unpacked_query_params = OpenapiParameters::Query.new(query_parameters).unpack(env['QUERY_STRING'])
|
86
|
+
validation_result = operation.schemas.query_parameters_schema.validate(unpacked_query_params)
|
87
|
+
OpenapiFirst.error!(400, :query, validation_result:) if validation_result.error?
|
88
|
+
env[QUERY_PARAMS] = unpacked_query_params
|
89
|
+
env[PARAMS].merge!(unpacked_query_params)
|
110
90
|
end
|
111
91
|
|
112
|
-
def
|
113
|
-
|
114
|
-
return unless
|
92
|
+
def validate_cookie_params!(operation, env)
|
93
|
+
cookie_parameters = operation.cookie_parameters
|
94
|
+
return unless cookie_parameters&.any?
|
115
95
|
|
116
|
-
|
117
|
-
|
118
|
-
|
96
|
+
unpacked_params = OpenapiParameters::Cookie.new(cookie_parameters).unpack(env['HTTP_COOKIE'])
|
97
|
+
validation_result = operation.schemas.cookie_parameters_schema.validate(unpacked_params)
|
98
|
+
OpenapiFirst.error!(400, :cookie, validation_result:) if validation_result.error?
|
99
|
+
env[COOKIE_PARAMS] = unpacked_params
|
119
100
|
end
|
120
101
|
|
121
|
-
def
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
102
|
+
def validate_header_params!(operation, env)
|
103
|
+
header_parameters = operation.header_parameters
|
104
|
+
return if header_parameters.empty?
|
105
|
+
|
106
|
+
unpacked_header_params = OpenapiParameters::Header.new(header_parameters).unpack_env(env)
|
107
|
+
validation_result = operation.schemas.header_parameters_schema.validate(unpacked_header_params)
|
108
|
+
OpenapiFirst.error!(400, :header, validation_result:) if validation_result.error?
|
109
|
+
env[HEADER_PARAMS] = unpacked_header_params
|
128
110
|
end
|
129
111
|
end
|
130
112
|
end
|
@@ -2,7 +2,6 @@
|
|
2
2
|
|
3
3
|
require 'multi_json'
|
4
4
|
require_relative 'use_router'
|
5
|
-
require_relative 'validation_format'
|
6
5
|
|
7
6
|
module OpenapiFirst
|
8
7
|
class ResponseValidation
|
@@ -26,8 +25,9 @@ module OpenapiFirst
|
|
26
25
|
return validate_status_only(operation, status) if status == 204
|
27
26
|
|
28
27
|
content_type = headers[Rack::CONTENT_TYPE]
|
29
|
-
response_schema = operation.
|
28
|
+
response_schema = operation.response_body_schema(status, content_type)
|
30
29
|
validate_response_body(response_schema, body) if response_schema
|
30
|
+
validate_response_headers(operation, status, headers)
|
31
31
|
end
|
32
32
|
|
33
33
|
private
|
@@ -40,14 +40,45 @@ module OpenapiFirst
|
|
40
40
|
full_body = +''
|
41
41
|
response.each { |chunk| full_body << chunk }
|
42
42
|
data = full_body.empty? ? {} : load_json(full_body)
|
43
|
-
|
44
|
-
|
45
|
-
|
43
|
+
validation = schema.validate(data)
|
44
|
+
raise ResponseBodyInvalidError, validation.message if validation.error?
|
45
|
+
end
|
46
|
+
|
47
|
+
def validate_response_headers(operation, status, response_headers)
|
48
|
+
response_header_definitions = operation.response_for(status)&.dig('headers')
|
49
|
+
return unless response_header_definitions
|
50
|
+
|
51
|
+
unpacked_headers = unpack_response_headers(response_header_definitions, response_headers)
|
52
|
+
response_header_definitions.each do |name, definition|
|
53
|
+
next if name == 'Content-Type'
|
54
|
+
|
55
|
+
validate_response_header(name, definition, unpacked_headers, openapi_version: operation.openapi_version)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def validate_response_header(name, definition, unpacked_headers, openapi_version:)
|
60
|
+
unless unpacked_headers.key?(name)
|
61
|
+
raise ResponseHeaderInvalidError, "Required response header '#{name}' is missing" if definition['required']
|
62
|
+
|
63
|
+
return
|
64
|
+
end
|
65
|
+
|
66
|
+
return unless definition.key?('schema')
|
67
|
+
|
68
|
+
validation = SchemaValidation.new(definition['schema'], openapi_version:)
|
69
|
+
value = unpacked_headers[name]
|
70
|
+
validation_result = validation.validate(value)
|
71
|
+
raise ResponseHeaderInvalidError, validation_result.message if validation_result.error?
|
72
|
+
end
|
73
|
+
|
74
|
+
def unpack_response_headers(response_header_definitions, response_headers)
|
75
|
+
headers_as_parameters = response_header_definitions.map do |name, definition|
|
76
|
+
definition.merge('name' => name)
|
46
77
|
end
|
47
|
-
|
78
|
+
OpenapiParameters::Header.new(headers_as_parameters).unpack(response_headers)
|
48
79
|
end
|
49
80
|
|
50
|
-
def
|
81
|
+
def format_response_error(error)
|
51
82
|
return "Write-only field appears in response: #{error['data_pointer']}" if error['type'] == 'writeOnly'
|
52
83
|
|
53
84
|
JSONSchemer::Errors.pretty(error)
|
@@ -7,7 +7,7 @@ module OpenapiFirst
|
|
7
7
|
class ResponseValidator
|
8
8
|
def initialize(spec)
|
9
9
|
@spec = spec
|
10
|
-
@router = Router.new(->(_env) {}, spec
|
10
|
+
@router = Router.new(->(_env) {}, spec:, raise_error: true)
|
11
11
|
@response_validation = ResponseValidation.new(->(response) { response.to_a })
|
12
12
|
end
|
13
13
|
|