openapi_first 1.0.0.beta3 → 1.0.0.beta4
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/.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
|