openapi_first 1.0.0.beta5 → 1.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/.github/workflows/ruby.yml +2 -1
- data/CHANGELOG.md +23 -2
- data/Gemfile +2 -0
- data/Gemfile.lock +16 -18
- data/Gemfile.rack2 +15 -0
- data/Gemfile.rack2.lock +99 -0
- data/README.md +99 -130
- data/lib/openapi_first/body_parser.rb +29 -0
- data/lib/openapi_first/configuration.rb +20 -0
- data/lib/openapi_first/definition/cookie_parameters.rb +12 -0
- data/lib/openapi_first/definition/header_parameters.rb +12 -0
- data/lib/openapi_first/definition/operation.rb +116 -0
- data/lib/openapi_first/definition/parameters.rb +47 -0
- data/lib/openapi_first/definition/path_item.rb +32 -0
- data/lib/openapi_first/definition/path_parameters.rb +12 -0
- data/lib/openapi_first/definition/query_parameters.rb +12 -0
- data/lib/openapi_first/definition/request_body.rb +43 -0
- data/lib/openapi_first/definition/response.rb +25 -0
- data/lib/openapi_first/definition/responses.rb +83 -0
- data/lib/openapi_first/definition.rb +61 -8
- data/lib/openapi_first/error_response.rb +22 -27
- data/lib/openapi_first/errors.rb +2 -14
- data/lib/openapi_first/failure.rb +55 -0
- data/lib/openapi_first/middlewares/request_validation.rb +52 -0
- data/lib/openapi_first/middlewares/response_validation.rb +35 -0
- data/lib/openapi_first/plugins/default/error_response.rb +74 -0
- data/lib/openapi_first/plugins/default.rb +11 -0
- data/lib/openapi_first/plugins/jsonapi/error_response.rb +58 -0
- data/lib/openapi_first/plugins/jsonapi.rb +11 -0
- data/lib/openapi_first/plugins.rb +9 -7
- data/lib/openapi_first/request_validation/request_body_validator.rb +41 -0
- data/lib/openapi_first/request_validation/validator.rb +81 -0
- data/lib/openapi_first/response_validation/validator.rb +101 -0
- data/lib/openapi_first/runtime_request.rb +84 -0
- data/lib/openapi_first/runtime_response.rb +31 -0
- data/lib/openapi_first/schema/validation_error.rb +18 -0
- data/lib/openapi_first/schema/validation_result.rb +32 -0
- data/lib/openapi_first/{json_schema.rb → schema.rb} +9 -5
- data/lib/openapi_first/version.rb +1 -1
- data/lib/openapi_first.rb +32 -28
- data/openapi_first.gemspec +10 -9
- metadata +55 -67
- data/.rspec +0 -3
- data/.rubocop.yml +0 -14
- data/Rakefile +0 -15
- data/benchmarks/Gemfile +0 -16
- data/benchmarks/Gemfile.lock +0 -142
- data/benchmarks/README.md +0 -29
- data/benchmarks/apps/committee_with_hanami_api.ru +0 -26
- data/benchmarks/apps/committee_with_response_validation.ru +0 -29
- data/benchmarks/apps/committee_with_sinatra.ru +0 -31
- data/benchmarks/apps/grape.ru +0 -21
- data/benchmarks/apps/hanami_api.ru +0 -21
- data/benchmarks/apps/hanami_router.ru +0 -14
- data/benchmarks/apps/openapi.yaml +0 -268
- data/benchmarks/apps/openapi_first_with_hanami_api.ru +0 -24
- data/benchmarks/apps/openapi_first_with_plain_rack.ru +0 -32
- data/benchmarks/apps/openapi_first_with_response_validation.ru +0 -25
- data/benchmarks/apps/openapi_first_with_sinatra.ru +0 -29
- data/benchmarks/apps/roda.ru +0 -27
- data/benchmarks/apps/sinatra.ru +0 -26
- data/benchmarks/apps/syro.ru +0 -25
- data/benchmarks/benchmark-wrk.sh +0 -3
- data/benchmarks/benchmarks.rb +0 -48
- data/benchmarks/post.lua +0 -3
- data/bin/console +0 -15
- data/bin/setup +0 -8
- data/examples/README.md +0 -13
- data/examples/app.rb +0 -18
- data/examples/config.ru +0 -7
- data/examples/openapi.yaml +0 -29
- data/lib/openapi_first/body_parser_middleware.rb +0 -40
- data/lib/openapi_first/config.rb +0 -20
- data/lib/openapi_first/error_responses/default.rb +0 -58
- data/lib/openapi_first/error_responses/json_api.rb +0 -58
- data/lib/openapi_first/json_schema/result.rb +0 -17
- data/lib/openapi_first/operation.rb +0 -170
- data/lib/openapi_first/request_body_validator.rb +0 -41
- data/lib/openapi_first/request_validation.rb +0 -118
- data/lib/openapi_first/request_validation_error.rb +0 -31
- data/lib/openapi_first/response_validation.rb +0 -93
- data/lib/openapi_first/response_validator.rb +0 -21
- data/lib/openapi_first/router.rb +0 -102
- data/lib/openapi_first/string_keyed_hash.rb +0 -20
- data/lib/openapi_first/use_router.rb +0 -18
data/lib/openapi_first/config.rb
DELETED
@@ -1,20 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module OpenapiFirst
|
4
|
-
class Config
|
5
|
-
def initialize(error_response: :default, request_validation_raise_error: false)
|
6
|
-
@error_response = Plugins.find_error_response(error_response)
|
7
|
-
@request_validation_raise_error = request_validation_raise_error
|
8
|
-
end
|
9
|
-
|
10
|
-
attr_reader :error_response, :request_validation_raise_error
|
11
|
-
|
12
|
-
def self.default_options
|
13
|
-
@default_options ||= new
|
14
|
-
end
|
15
|
-
|
16
|
-
def self.default_options=(options)
|
17
|
-
@default_options = new(**options)
|
18
|
-
end
|
19
|
-
end
|
20
|
-
end
|
@@ -1,58 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module OpenapiFirst
|
4
|
-
module ErrorResponses
|
5
|
-
class Default < ErrorResponse
|
6
|
-
OpenapiFirst::Plugins.register_error_response(:default, self)
|
7
|
-
|
8
|
-
def body
|
9
|
-
MultiJson.dump({ errors: serialized_errors })
|
10
|
-
end
|
11
|
-
|
12
|
-
def content_type
|
13
|
-
'application/json'
|
14
|
-
end
|
15
|
-
|
16
|
-
def serialized_errors
|
17
|
-
return default_errors unless validation_output
|
18
|
-
|
19
|
-
key = pointer_key
|
20
|
-
validation_errors&.map do |error|
|
21
|
-
{
|
22
|
-
status: status.to_s,
|
23
|
-
source: { key => pointer(error['instanceLocation']) },
|
24
|
-
title: error['error']
|
25
|
-
}
|
26
|
-
end
|
27
|
-
end
|
28
|
-
|
29
|
-
def validation_errors
|
30
|
-
validation_output['errors'] || [validation_output]
|
31
|
-
end
|
32
|
-
|
33
|
-
def default_errors
|
34
|
-
[{
|
35
|
-
status: status.to_s,
|
36
|
-
title: message
|
37
|
-
}]
|
38
|
-
end
|
39
|
-
|
40
|
-
def pointer_key
|
41
|
-
case location
|
42
|
-
when :body
|
43
|
-
:pointer
|
44
|
-
when :query, :path
|
45
|
-
:parameter
|
46
|
-
else
|
47
|
-
location
|
48
|
-
end
|
49
|
-
end
|
50
|
-
|
51
|
-
def pointer(data_pointer)
|
52
|
-
return data_pointer if location == :body
|
53
|
-
|
54
|
-
data_pointer.delete_prefix('/')
|
55
|
-
end
|
56
|
-
end
|
57
|
-
end
|
58
|
-
end
|
@@ -1,58 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module OpenapiFirst
|
4
|
-
module ErrorResponses
|
5
|
-
class JsonApi < ErrorResponse
|
6
|
-
OpenapiFirst::Plugins.register_error_response(:json_api, self)
|
7
|
-
|
8
|
-
def body
|
9
|
-
MultiJson.dump({ errors: serialized_errors })
|
10
|
-
end
|
11
|
-
|
12
|
-
def content_type
|
13
|
-
'application/vnd.api+json'
|
14
|
-
end
|
15
|
-
|
16
|
-
def serialized_errors
|
17
|
-
return default_errors unless validation_output
|
18
|
-
|
19
|
-
key = pointer_key
|
20
|
-
validation_errors&.map do |error|
|
21
|
-
{
|
22
|
-
status: status.to_s,
|
23
|
-
source: { key => pointer(error['instanceLocation']) },
|
24
|
-
title: error['error']
|
25
|
-
}
|
26
|
-
end
|
27
|
-
end
|
28
|
-
|
29
|
-
def validation_errors
|
30
|
-
validation_output['errors'] || [validation_output]
|
31
|
-
end
|
32
|
-
|
33
|
-
def default_errors
|
34
|
-
[{
|
35
|
-
status: status.to_s,
|
36
|
-
title: message
|
37
|
-
}]
|
38
|
-
end
|
39
|
-
|
40
|
-
def pointer_key
|
41
|
-
case location
|
42
|
-
when :body
|
43
|
-
:pointer
|
44
|
-
when :query, :path
|
45
|
-
:parameter
|
46
|
-
else
|
47
|
-
location
|
48
|
-
end
|
49
|
-
end
|
50
|
-
|
51
|
-
def pointer(data_pointer)
|
52
|
-
return data_pointer if location == :body
|
53
|
-
|
54
|
-
data_pointer.delete_prefix('/')
|
55
|
-
end
|
56
|
-
end
|
57
|
-
end
|
58
|
-
end
|
@@ -1,17 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module OpenapiFirst
|
4
|
-
class JsonSchema
|
5
|
-
Result = Struct.new(:output, :schema, :data, keyword_init: true) do
|
6
|
-
def valid? = output['valid']
|
7
|
-
def error? = !output['valid']
|
8
|
-
|
9
|
-
# Returns a message that is used in exception messages.
|
10
|
-
def message
|
11
|
-
return if valid?
|
12
|
-
|
13
|
-
(output['errors']&.map { |e| e['error'] }&.join('. ') || output['error'])
|
14
|
-
end
|
15
|
-
end
|
16
|
-
end
|
17
|
-
end
|
@@ -1,170 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'forwardable'
|
4
|
-
require 'set'
|
5
|
-
require_relative 'json_schema'
|
6
|
-
|
7
|
-
module OpenapiFirst
|
8
|
-
class Operation # rubocop:disable Metrics/ClassLength
|
9
|
-
extend Forwardable
|
10
|
-
def_delegators :operation_object,
|
11
|
-
:[],
|
12
|
-
:dig
|
13
|
-
|
14
|
-
WRITE_METHODS = Set.new(%w[post put patch delete]).freeze
|
15
|
-
private_constant :WRITE_METHODS
|
16
|
-
|
17
|
-
attr_reader :path, :method, :openapi_version
|
18
|
-
|
19
|
-
def initialize(path, request_method, path_item_object, openapi_version:)
|
20
|
-
@path = path
|
21
|
-
@method = request_method
|
22
|
-
@path_item_object = path_item_object
|
23
|
-
@openapi_version = openapi_version
|
24
|
-
end
|
25
|
-
|
26
|
-
def operation_id
|
27
|
-
operation_object['operationId']
|
28
|
-
end
|
29
|
-
|
30
|
-
def read?
|
31
|
-
!write?
|
32
|
-
end
|
33
|
-
|
34
|
-
def write?
|
35
|
-
WRITE_METHODS.include?(method)
|
36
|
-
end
|
37
|
-
|
38
|
-
def request_body
|
39
|
-
operation_object['requestBody']
|
40
|
-
end
|
41
|
-
|
42
|
-
def response_body_schema(status, content_type)
|
43
|
-
content = response_for(status)['content']
|
44
|
-
return if content.nil? || content.empty?
|
45
|
-
|
46
|
-
raise ResponseInvalid, "Response has no content-type for '#{name}'" unless content_type
|
47
|
-
|
48
|
-
media_type = find_content_for_content_type(content, content_type)
|
49
|
-
|
50
|
-
unless media_type
|
51
|
-
message = "Response content type not found '#{content_type}' for '#{name}'"
|
52
|
-
raise ResponseContentTypeNotFoundError, message
|
53
|
-
end
|
54
|
-
schema = media_type['schema']
|
55
|
-
return unless schema
|
56
|
-
|
57
|
-
JsonSchema.new(schema, write: false, openapi_version:)
|
58
|
-
end
|
59
|
-
|
60
|
-
def request_body_schema(request_content_type)
|
61
|
-
(@request_body_schema ||= {})[request_content_type] ||= begin
|
62
|
-
content = operation_object.dig('requestBody', 'content')
|
63
|
-
media_type = find_content_for_content_type(content, request_content_type)
|
64
|
-
schema = media_type&.fetch('schema', nil)
|
65
|
-
JsonSchema.new(schema, write: write?, openapi_version:) if schema
|
66
|
-
end
|
67
|
-
end
|
68
|
-
|
69
|
-
def response_for(status)
|
70
|
-
response_content = response_by_code(status)
|
71
|
-
return response_content if response_content
|
72
|
-
|
73
|
-
message = "Response status code or default not found: #{status} for '#{name}'"
|
74
|
-
raise OpenapiFirst::ResponseCodeNotFoundError, message
|
75
|
-
end
|
76
|
-
|
77
|
-
def name
|
78
|
-
@name ||= "#{method.upcase} #{path} (#{operation_id})"
|
79
|
-
end
|
80
|
-
|
81
|
-
def valid_request_content_type?(request_content_type)
|
82
|
-
content = operation_object.dig('requestBody', 'content')
|
83
|
-
return false unless content
|
84
|
-
|
85
|
-
!!find_content_for_content_type(content, request_content_type)
|
86
|
-
end
|
87
|
-
|
88
|
-
def query_parameters
|
89
|
-
@query_parameters ||= all_parameters.filter { |p| p['in'] == 'query' }
|
90
|
-
end
|
91
|
-
|
92
|
-
def path_parameters
|
93
|
-
@path_parameters ||= all_parameters.filter { |p| p['in'] == 'path' }
|
94
|
-
end
|
95
|
-
|
96
|
-
IGNORED_HEADERS = Set['Content-Type', 'Accept', 'Authorization'].freeze
|
97
|
-
private_constant :IGNORED_HEADERS
|
98
|
-
|
99
|
-
def header_parameters
|
100
|
-
@header_parameters ||= all_parameters.filter { |p| p['in'] == 'header' && !IGNORED_HEADERS.include?(p['name']) }
|
101
|
-
end
|
102
|
-
|
103
|
-
def cookie_parameters
|
104
|
-
@cookie_parameters ||= all_parameters.filter { |p| p['in'] == 'cookie' }
|
105
|
-
end
|
106
|
-
|
107
|
-
def all_parameters
|
108
|
-
@all_parameters ||= begin
|
109
|
-
parameters = @path_item_object['parameters']&.dup || []
|
110
|
-
parameters_on_operation = operation_object['parameters']
|
111
|
-
parameters.concat(parameters_on_operation) if parameters_on_operation
|
112
|
-
parameters
|
113
|
-
end
|
114
|
-
end
|
115
|
-
|
116
|
-
# Return JSON Schema of for all query parameters
|
117
|
-
def query_parameters_schema
|
118
|
-
@query_parameters_schema ||= build_json_schema(query_parameters)
|
119
|
-
end
|
120
|
-
|
121
|
-
# Return JSON Schema of for all path parameters
|
122
|
-
def path_parameters_schema
|
123
|
-
@path_parameters_schema ||= build_json_schema(path_parameters)
|
124
|
-
end
|
125
|
-
|
126
|
-
def header_parameters_schema
|
127
|
-
@header_parameters_schema ||= build_json_schema(header_parameters)
|
128
|
-
end
|
129
|
-
|
130
|
-
def cookie_parameters_schema
|
131
|
-
@cookie_parameters_schema ||= build_json_schema(cookie_parameters)
|
132
|
-
end
|
133
|
-
|
134
|
-
private
|
135
|
-
|
136
|
-
# Build JSON Schema for given parameter definitions
|
137
|
-
# @parameter_defs [Array<Hash>] Parameter definitions
|
138
|
-
def build_json_schema(parameter_defs)
|
139
|
-
init_schema = {
|
140
|
-
'type' => 'object',
|
141
|
-
'properties' => {},
|
142
|
-
'required' => []
|
143
|
-
}
|
144
|
-
schema = parameter_defs.each_with_object(init_schema) do |parameter_def, result|
|
145
|
-
parameter = OpenapiParameters::Parameter.new(parameter_def)
|
146
|
-
result['properties'][parameter.name] = parameter.schema if parameter.schema
|
147
|
-
result['required'] << parameter.name if parameter.required?
|
148
|
-
end
|
149
|
-
JsonSchema.new(schema, openapi_version:)
|
150
|
-
end
|
151
|
-
|
152
|
-
def response_by_code(status)
|
153
|
-
operation_object.dig('responses', status.to_s) ||
|
154
|
-
operation_object.dig('responses', "#{status / 100}XX") ||
|
155
|
-
operation_object.dig('responses', "#{status / 100}xx") ||
|
156
|
-
operation_object.dig('responses', 'default')
|
157
|
-
end
|
158
|
-
|
159
|
-
def operation_object
|
160
|
-
@path_item_object[method]
|
161
|
-
end
|
162
|
-
|
163
|
-
def find_content_for_content_type(content, request_content_type)
|
164
|
-
content.fetch(request_content_type) do |_|
|
165
|
-
type = request_content_type.split(';')[0]
|
166
|
-
content[type] || content["#{type.split('/')[0]}/*"] || content['*/*']
|
167
|
-
end
|
168
|
-
end
|
169
|
-
end
|
170
|
-
end
|
@@ -1,41 +0,0 @@
|
|
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) || RequestValidation.fail!(415, :header)
|
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
|
-
schema_validation = schema.validate(body)
|
31
|
-
RequestValidation.fail!(400, :body, schema_validation:) if schema_validation.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
|
-
RequestValidation.fail!(400, :body)
|
39
|
-
end
|
40
|
-
end
|
41
|
-
end
|
@@ -1,118 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'rack'
|
4
|
-
require 'multi_json'
|
5
|
-
require_relative 'use_router'
|
6
|
-
require_relative 'error_response'
|
7
|
-
require_relative 'request_body_validator'
|
8
|
-
require_relative 'string_keyed_hash'
|
9
|
-
require_relative 'request_validation_error'
|
10
|
-
require 'openapi_parameters'
|
11
|
-
|
12
|
-
module OpenapiFirst
|
13
|
-
# A Rack middleware to validate requests against an OpenAPI API description
|
14
|
-
class RequestValidation
|
15
|
-
prepend UseRouter
|
16
|
-
|
17
|
-
FAIL = :request_validation_failed
|
18
|
-
private_constant :FAIL
|
19
|
-
|
20
|
-
# @param status [Integer] The intended HTTP status code (usually 400)
|
21
|
-
# @param location [Symbol] One of :body, :header, :cookie, :query, :path
|
22
|
-
# @param schema_validation [OpenapiFirst::JsonSchema::Result]
|
23
|
-
def self.fail!(status, location, schema_validation: nil)
|
24
|
-
throw FAIL, RequestValidationError.new(
|
25
|
-
status:,
|
26
|
-
location:,
|
27
|
-
schema_validation:
|
28
|
-
)
|
29
|
-
end
|
30
|
-
|
31
|
-
# @param app The parent Rack application
|
32
|
-
# @param options An optional Hash of configuration options to override defaults
|
33
|
-
# :error_response A Boolean indicating whether to raise an error if validation fails.
|
34
|
-
# default: OpenapiFirst::ErrorResponses::Default (Config.default_options.error_response)
|
35
|
-
# :raise_error The Class to use for error responses.
|
36
|
-
# default: false (Config.default_options.request_validation_raise_error)
|
37
|
-
def initialize(app, options = {})
|
38
|
-
@app = app
|
39
|
-
@raise = options.fetch(:raise_error, Config.default_options.request_validation_raise_error)
|
40
|
-
@error_response_class =
|
41
|
-
Plugins.find_error_response(options.fetch(:error_response, Config.default_options.error_response))
|
42
|
-
end
|
43
|
-
|
44
|
-
def call(env)
|
45
|
-
operation = env[OPERATION]
|
46
|
-
return @app.call(env) unless operation
|
47
|
-
|
48
|
-
error = validate_request(operation, env)
|
49
|
-
if error
|
50
|
-
raise RequestInvalidError, error.error_message if @raise
|
51
|
-
|
52
|
-
return @error_response_class.new(env, error).render
|
53
|
-
end
|
54
|
-
@app.call(env)
|
55
|
-
end
|
56
|
-
|
57
|
-
private
|
58
|
-
|
59
|
-
def validate_request(operation, env)
|
60
|
-
catch(FAIL) do
|
61
|
-
env[PARAMS] = {}
|
62
|
-
validate_query_params!(operation, env)
|
63
|
-
validate_path_params!(operation, env)
|
64
|
-
validate_cookie_params!(operation, env)
|
65
|
-
validate_header_params!(operation, env)
|
66
|
-
validate_request_body!(operation, env)
|
67
|
-
nil
|
68
|
-
end
|
69
|
-
end
|
70
|
-
|
71
|
-
def validate_path_params!(operation, env)
|
72
|
-
path_parameters = operation.path_parameters
|
73
|
-
return if path_parameters.empty?
|
74
|
-
|
75
|
-
hashy = StringKeyedHash.new(env[Router::RAW_PATH_PARAMS])
|
76
|
-
unpacked_path_params = OpenapiParameters::Path.new(path_parameters).unpack(hashy)
|
77
|
-
schema_validation = operation.path_parameters_schema.validate(unpacked_path_params)
|
78
|
-
RequestValidation.fail!(400, :path, schema_validation:) if schema_validation.error?
|
79
|
-
env[PATH_PARAMS] = unpacked_path_params
|
80
|
-
env[PARAMS].merge!(unpacked_path_params)
|
81
|
-
end
|
82
|
-
|
83
|
-
def validate_query_params!(operation, env)
|
84
|
-
query_parameters = operation.query_parameters
|
85
|
-
return if operation.query_parameters.empty?
|
86
|
-
|
87
|
-
unpacked_query_params = OpenapiParameters::Query.new(query_parameters).unpack(env['QUERY_STRING'])
|
88
|
-
schema_validation = operation.query_parameters_schema.validate(unpacked_query_params)
|
89
|
-
RequestValidation.fail!(400, :query, schema_validation:) if schema_validation.error?
|
90
|
-
env[QUERY_PARAMS] = unpacked_query_params
|
91
|
-
env[PARAMS].merge!(unpacked_query_params)
|
92
|
-
end
|
93
|
-
|
94
|
-
def validate_cookie_params!(operation, env)
|
95
|
-
cookie_parameters = operation.cookie_parameters
|
96
|
-
return unless cookie_parameters&.any?
|
97
|
-
|
98
|
-
unpacked_params = OpenapiParameters::Cookie.new(cookie_parameters).unpack(env['HTTP_COOKIE'])
|
99
|
-
schema_validation = operation.cookie_parameters_schema.validate(unpacked_params)
|
100
|
-
RequestValidation.fail!(400, :cookie, schema_validation:) if schema_validation.error?
|
101
|
-
env[COOKIE_PARAMS] = unpacked_params
|
102
|
-
end
|
103
|
-
|
104
|
-
def validate_header_params!(operation, env)
|
105
|
-
header_parameters = operation.header_parameters
|
106
|
-
return if header_parameters.empty?
|
107
|
-
|
108
|
-
unpacked_header_params = OpenapiParameters::Header.new(header_parameters).unpack_env(env)
|
109
|
-
schema_validation = operation.header_parameters_schema.validate(unpacked_header_params)
|
110
|
-
RequestValidation.fail!(400, :header, schema_validation:) if schema_validation.error?
|
111
|
-
env[HEADER_PARAMS] = unpacked_header_params
|
112
|
-
end
|
113
|
-
|
114
|
-
def validate_request_body!(operation, env)
|
115
|
-
RequestBodyValidator.new(operation, env).validate! if operation.request_body
|
116
|
-
end
|
117
|
-
end
|
118
|
-
end
|
@@ -1,31 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module OpenapiFirst
|
4
|
-
class RequestValidationError
|
5
|
-
def initialize(status:, location:, message: nil, schema_validation: nil)
|
6
|
-
@status = status
|
7
|
-
@location = location
|
8
|
-
@message = message
|
9
|
-
@schema_validation = schema_validation
|
10
|
-
end
|
11
|
-
|
12
|
-
attr_reader :status, :request, :location, :schema_validation
|
13
|
-
|
14
|
-
def message
|
15
|
-
@message || schema_validation&.message || Rack::Utils::HTTP_STATUS_CODES[status]
|
16
|
-
end
|
17
|
-
|
18
|
-
def error_message
|
19
|
-
"#{TOPICS.fetch(location)} #{message}"
|
20
|
-
end
|
21
|
-
|
22
|
-
TOPICS = {
|
23
|
-
body: 'Request body invalid:',
|
24
|
-
query: 'Query parameter invalid:',
|
25
|
-
header: 'Header parameter invalid:',
|
26
|
-
path: 'Path segment invalid:',
|
27
|
-
cookie: 'Cookie value invalid:'
|
28
|
-
}.freeze
|
29
|
-
private_constant :TOPICS
|
30
|
-
end
|
31
|
-
end
|
@@ -1,93 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'multi_json'
|
4
|
-
require_relative 'use_router'
|
5
|
-
|
6
|
-
module OpenapiFirst
|
7
|
-
class ResponseValidation
|
8
|
-
prepend UseRouter
|
9
|
-
|
10
|
-
def initialize(app, _options = {})
|
11
|
-
@app = app
|
12
|
-
end
|
13
|
-
|
14
|
-
def call(env)
|
15
|
-
operation = env[OPERATION]
|
16
|
-
return @app.call(env) unless operation
|
17
|
-
|
18
|
-
response = @app.call(env)
|
19
|
-
validate(response, operation)
|
20
|
-
response
|
21
|
-
end
|
22
|
-
|
23
|
-
def validate(response, operation)
|
24
|
-
status, headers, body = response.to_a
|
25
|
-
return validate_status_only(operation, status) if status == 204
|
26
|
-
|
27
|
-
content_type = headers[Rack::CONTENT_TYPE]
|
28
|
-
response_schema = operation.response_body_schema(status, content_type)
|
29
|
-
validate_response_body(response_schema, body) if response_schema
|
30
|
-
validate_response_headers(operation, status, headers)
|
31
|
-
end
|
32
|
-
|
33
|
-
private
|
34
|
-
|
35
|
-
def validate_status_only(operation, status)
|
36
|
-
operation.response_for(status)
|
37
|
-
end
|
38
|
-
|
39
|
-
def validate_response_body(schema, response)
|
40
|
-
full_body = +''
|
41
|
-
response.each { |chunk| full_body << chunk }
|
42
|
-
data = full_body.empty? ? {} : load_json(full_body)
|
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 = JsonSchema.new(definition['schema'], openapi_version:)
|
69
|
-
value = unpacked_headers[name]
|
70
|
-
schema_validation = validation.validate(value)
|
71
|
-
raise ResponseHeaderInvalidError, schema_validation.message if schema_validation.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)
|
77
|
-
end
|
78
|
-
OpenapiParameters::Header.new(headers_as_parameters).unpack(response_headers)
|
79
|
-
end
|
80
|
-
|
81
|
-
def format_response_error(error)
|
82
|
-
return "Write-only field appears in response: #{error['data_pointer']}" if error['type'] == 'writeOnly'
|
83
|
-
|
84
|
-
JSONSchemer::Errors.pretty(error)
|
85
|
-
end
|
86
|
-
|
87
|
-
def load_json(string)
|
88
|
-
MultiJson.load(string)
|
89
|
-
rescue MultiJson::ParseError
|
90
|
-
string
|
91
|
-
end
|
92
|
-
end
|
93
|
-
end
|
@@ -1,21 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require_relative 'response_validation'
|
4
|
-
require_relative 'router'
|
5
|
-
|
6
|
-
module OpenapiFirst
|
7
|
-
# A class to run manual response validation
|
8
|
-
class ResponseValidator
|
9
|
-
def initialize(spec)
|
10
|
-
@spec = spec
|
11
|
-
@router = Router.new(->(_env) {}, spec:, raise_error: true)
|
12
|
-
@response_validation = ResponseValidation.new(->(response) { response.to_a })
|
13
|
-
end
|
14
|
-
|
15
|
-
def validate(request, response)
|
16
|
-
env = request.env.dup
|
17
|
-
@router.call(env)
|
18
|
-
@response_validation.validate(response, env[OPERATION])
|
19
|
-
end
|
20
|
-
end
|
21
|
-
end
|