openapi_first 1.0.0.beta3 → 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 +13 -0
- data/Gemfile +4 -1
- data/Gemfile.lock +39 -37
- data/README.md +17 -11
- data/benchmarks/Gemfile.lock +21 -20
- data/benchmarks/apps/openapi_first_with_plain_rack.ru +2 -2
- 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 -3
- data/openapi_first.gemspec +4 -9
- metadata +16 -66
- data/lib/openapi_first/utils.rb +0 -19
- data/lib/openapi_first/validation_format.rb +0 -55
@@ -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
|
|
data/lib/openapi_first/router.rb
CHANGED
@@ -7,6 +7,9 @@ require_relative 'body_parser_middleware'
|
|
7
7
|
|
8
8
|
module OpenapiFirst
|
9
9
|
class Router
|
10
|
+
# The unconverted path parameters before they are converted to the types defined in the API description
|
11
|
+
RAW_PATH_PARAMS = 'openapi.raw_path_params'
|
12
|
+
|
10
13
|
def initialize(
|
11
14
|
app,
|
12
15
|
options
|
@@ -63,17 +66,16 @@ module OpenapiFirst
|
|
63
66
|
env[Rack::PATH_INFO] = env.delete(ORIGINAL_PATH) if env[ORIGINAL_PATH]
|
64
67
|
end
|
65
68
|
|
66
|
-
def handle_body_parsing_error(
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
).finish
|
69
|
+
def handle_body_parsing_error(_exception)
|
70
|
+
message = 'Failed to parse body as application/json'
|
71
|
+
raise RequestInvalidError, message if @raise
|
72
|
+
|
73
|
+
error = {
|
74
|
+
status: 400,
|
75
|
+
title: message
|
76
|
+
}
|
77
|
+
|
78
|
+
ErrorResponse::Default.new(**error).finish
|
77
79
|
end
|
78
80
|
|
79
81
|
def build_router(operations)
|
@@ -89,7 +91,7 @@ module OpenapiFirst
|
|
89
91
|
end
|
90
92
|
raise_error = @raise
|
91
93
|
Rack::Builder.app do
|
92
|
-
use
|
94
|
+
use(BodyParserMiddleware, raise_error:)
|
93
95
|
run router
|
94
96
|
end
|
95
97
|
end
|
@@ -99,8 +101,7 @@ module OpenapiFirst
|
|
99
101
|
env[OPERATION] = operation
|
100
102
|
path_info = env.delete(ORIGINAL_PATH)
|
101
103
|
env[REQUEST_BODY] = env.delete(ROUTER_PARSED_BODY) if env.key?(ROUTER_PARSED_BODY)
|
102
|
-
|
103
|
-
env[PARAMS] = OpenapiParameters::Path.new(operation.path_parameters).unpack(route_params)
|
104
|
+
env[RAW_PATH_PARAMS] = env['router.params']
|
104
105
|
env[Rack::PATH_INFO] = path_info
|
105
106
|
@app.call(env)
|
106
107
|
end
|
@@ -1,46 +1,47 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'json_schemer'
|
4
|
+
require_relative 'validation_result'
|
4
5
|
|
5
6
|
module OpenapiFirst
|
6
7
|
class SchemaValidation
|
7
8
|
attr_reader :raw_schema
|
8
9
|
|
9
|
-
|
10
|
+
SCHEMAS = {
|
11
|
+
'3.1' => 'https://spec.openapis.org/oas/3.1/dialect/base',
|
12
|
+
'3.0' => 'json-schemer://openapi30/schema'
|
13
|
+
}.freeze
|
14
|
+
|
15
|
+
def initialize(schema, openapi_version:, write: true)
|
10
16
|
@raw_schema = schema
|
11
|
-
custom_keywords = {}
|
12
|
-
custom_keywords['writeOnly'] = proc { |data| !data } unless write
|
13
|
-
custom_keywords['readOnly'] = proc { |data| !data } if write
|
14
17
|
@schemer = JSONSchemer.schema(
|
15
18
|
schema,
|
16
|
-
|
19
|
+
access_mode: write ? 'write' : 'read',
|
20
|
+
meta_schema: SCHEMAS.fetch(openapi_version),
|
17
21
|
insert_property_defaults: true,
|
18
|
-
|
19
|
-
|
20
|
-
binary_format(data, property, property_schema, parent)
|
21
|
-
end
|
22
|
+
output_format: 'detailed',
|
23
|
+
before_property_validation: method(:before_property_validation)
|
22
24
|
)
|
23
25
|
end
|
24
26
|
|
25
|
-
def validate(
|
26
|
-
|
27
|
+
def validate(data)
|
28
|
+
ValidationResult.new(
|
29
|
+
output: @schemer.validate(data),
|
30
|
+
schema: raw_schema,
|
31
|
+
data:
|
32
|
+
)
|
27
33
|
end
|
28
34
|
|
29
35
|
private
|
30
36
|
|
31
|
-
def
|
32
|
-
|
33
|
-
|
34
|
-
property_schema['type'] = 'object'
|
35
|
-
property_schema.delete('format')
|
36
|
-
data[property].transform_keys!(&:to_s)
|
37
|
+
def before_property_validation(data, property, property_schema, parent)
|
38
|
+
binary_format(data, property, property_schema, parent)
|
37
39
|
end
|
38
40
|
|
39
|
-
def
|
40
|
-
return unless property_schema.is_a?(Hash) && property_schema['
|
41
|
+
def binary_format(data, property, property_schema, _parent)
|
42
|
+
return unless property_schema.is_a?(Hash) && property_schema['format'] == 'binary'
|
41
43
|
|
42
|
-
|
43
|
-
property_schema.delete('nullable')
|
44
|
+
data[property] = data[property][:tempfile].read
|
44
45
|
end
|
45
46
|
end
|
46
47
|
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OpenapiFirst
|
4
|
+
class StringKeyedHash
|
5
|
+
extend Forwardable
|
6
|
+
def_delegators :@orig, :empty?
|
7
|
+
|
8
|
+
def initialize(original)
|
9
|
+
@orig = original
|
10
|
+
end
|
11
|
+
|
12
|
+
def key?(key)
|
13
|
+
@orig.key?(key.to_sym)
|
14
|
+
end
|
15
|
+
|
16
|
+
def [](key)
|
17
|
+
@orig[key.to_sym]
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OpenapiFirst
|
4
|
+
ValidationResult = Struct.new(:output, :schema, :data, keyword_init: true) do
|
5
|
+
def valid? = output['valid']
|
6
|
+
def error? = !output['valid']
|
7
|
+
|
8
|
+
# Returns a message that is used in exception messages.
|
9
|
+
def message
|
10
|
+
return if valid?
|
11
|
+
|
12
|
+
(output['errors']&.map { |e| e['error'] }&.join('. ') || output['error'])&.concat('.')
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|