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
@@ -0,0 +1,116 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'forwardable'
|
4
|
+
require 'set'
|
5
|
+
require_relative 'request_body'
|
6
|
+
require_relative 'query_parameters'
|
7
|
+
require_relative 'header_parameters'
|
8
|
+
require_relative 'path_parameters'
|
9
|
+
require_relative 'cookie_parameters'
|
10
|
+
require_relative 'responses'
|
11
|
+
|
12
|
+
module OpenapiFirst
|
13
|
+
class Definition
|
14
|
+
class Operation
|
15
|
+
extend Forwardable
|
16
|
+
def_delegators :operation_object,
|
17
|
+
:[],
|
18
|
+
:dig
|
19
|
+
|
20
|
+
WRITE_METHODS = Set.new(%w[post put patch delete]).freeze
|
21
|
+
private_constant :WRITE_METHODS
|
22
|
+
|
23
|
+
attr_reader :path, :method, :openapi_version
|
24
|
+
|
25
|
+
def initialize(path, request_method, path_item_object, openapi_version:)
|
26
|
+
@path = path
|
27
|
+
@method = request_method
|
28
|
+
@path_item_object = path_item_object
|
29
|
+
@openapi_version = openapi_version
|
30
|
+
@operation_object = @path_item_object[request_method]
|
31
|
+
end
|
32
|
+
|
33
|
+
def operation_id
|
34
|
+
operation_object['operationId']
|
35
|
+
end
|
36
|
+
|
37
|
+
def read?
|
38
|
+
!write?
|
39
|
+
end
|
40
|
+
|
41
|
+
def write?
|
42
|
+
WRITE_METHODS.include?(method)
|
43
|
+
end
|
44
|
+
|
45
|
+
def request_body
|
46
|
+
@request_body ||= RequestBody.new(operation_object['requestBody'], self) if operation_object['requestBody']
|
47
|
+
end
|
48
|
+
|
49
|
+
def response_status_defined?(status)
|
50
|
+
responses.status_defined?(status)
|
51
|
+
end
|
52
|
+
|
53
|
+
def_delegators :responses, :response_for
|
54
|
+
|
55
|
+
def schema_for(content_type)
|
56
|
+
content = @request_body_object['content']
|
57
|
+
return unless content&.any?
|
58
|
+
|
59
|
+
content_schemas&.fetch(content_type) do
|
60
|
+
type = content_type.split(';')[0]
|
61
|
+
content_schemas[type] || content_schemas["#{type.split('/')[0]}/*"] || content_schemas['*/*']
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def name
|
66
|
+
@name ||= "#{method.upcase} #{path} (#{operation_id})"
|
67
|
+
end
|
68
|
+
|
69
|
+
def query_parameters
|
70
|
+
@query_parameters ||= build_parameters(all_parameters.filter { |p| p['in'] == 'query' }, QueryParameters)
|
71
|
+
end
|
72
|
+
|
73
|
+
def path_parameters
|
74
|
+
@path_parameters ||= build_parameters(all_parameters.filter { |p| p['in'] == 'path' }, PathParameters)
|
75
|
+
end
|
76
|
+
|
77
|
+
IGNORED_HEADERS = Set['Content-Type', 'Accept', 'Authorization'].freeze
|
78
|
+
private_constant :IGNORED_HEADERS
|
79
|
+
|
80
|
+
def header_parameters
|
81
|
+
@header_parameters ||= build_parameters(find_header_parameters, HeaderParameters)
|
82
|
+
end
|
83
|
+
|
84
|
+
def cookie_parameters
|
85
|
+
@cookie_parameters ||= build_parameters(all_parameters.filter { |p| p['in'] == 'cookie' }, CookieParameters)
|
86
|
+
end
|
87
|
+
|
88
|
+
private
|
89
|
+
|
90
|
+
def all_parameters
|
91
|
+
@all_parameters ||= begin
|
92
|
+
parameters = @path_item_object['parameters']&.dup || []
|
93
|
+
parameters_on_operation = operation_object['parameters']
|
94
|
+
parameters.concat(parameters_on_operation) if parameters_on_operation
|
95
|
+
parameters
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def responses
|
100
|
+
@responses ||= Responses.new(self, operation_object['responses'])
|
101
|
+
end
|
102
|
+
|
103
|
+
attr_reader :operation_object
|
104
|
+
|
105
|
+
def build_parameters(parameters, klass)
|
106
|
+
klass.new(parameters, openapi_version:) if parameters.any?
|
107
|
+
end
|
108
|
+
|
109
|
+
def find_header_parameters
|
110
|
+
all_parameters.filter do |p|
|
111
|
+
p['in'] == 'header' && !IGNORED_HEADERS.include?(p['name'])
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'forwardable'
|
4
|
+
require_relative '../schema'
|
5
|
+
|
6
|
+
module OpenapiFirst
|
7
|
+
class Parameters
|
8
|
+
extend Forwardable
|
9
|
+
|
10
|
+
def initialize(parameter_definitions, openapi_version:)
|
11
|
+
@parameter_definitions = parameter_definitions
|
12
|
+
@openapi_version = openapi_version
|
13
|
+
end
|
14
|
+
|
15
|
+
def_delegators :parameters, :map
|
16
|
+
|
17
|
+
def empty?
|
18
|
+
@parameter_definitions.empty?
|
19
|
+
end
|
20
|
+
|
21
|
+
def schema
|
22
|
+
@schema ||= build_schema
|
23
|
+
end
|
24
|
+
|
25
|
+
def parameters
|
26
|
+
@parameter_definitions.map do |parameter_object|
|
27
|
+
OpenapiParameters::Parameter.new(parameter_object)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def build_schema
|
34
|
+
init_schema = {
|
35
|
+
'type' => 'object',
|
36
|
+
'properties' => {},
|
37
|
+
'required' => []
|
38
|
+
}
|
39
|
+
schema = @parameter_definitions.each_with_object(init_schema) do |parameter_def, result|
|
40
|
+
parameter = OpenapiParameters::Parameter.new(parameter_def)
|
41
|
+
result['properties'][parameter.name] = parameter.schema if parameter.schema
|
42
|
+
result['required'] << parameter.name if parameter.required?
|
43
|
+
end
|
44
|
+
Schema.new(schema, openapi_version: @openapi_version)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'operation'
|
4
|
+
|
5
|
+
module OpenapiFirst
|
6
|
+
class Definition
|
7
|
+
class PathItem
|
8
|
+
def initialize(path, path_item_object, openapi_version:)
|
9
|
+
@path = path
|
10
|
+
@path_item_object = path_item_object
|
11
|
+
@openapi_version = openapi_version
|
12
|
+
end
|
13
|
+
|
14
|
+
attr_reader :path
|
15
|
+
|
16
|
+
def operation(request_method)
|
17
|
+
return unless @path_item_object[request_method]
|
18
|
+
|
19
|
+
Operation.new(
|
20
|
+
@path, request_method, @path_item_object, openapi_version: @openapi_version
|
21
|
+
)
|
22
|
+
end
|
23
|
+
|
24
|
+
METHODS = %w[get head post put patch delete trace options].freeze
|
25
|
+
private_constant :METHODS
|
26
|
+
|
27
|
+
def operations
|
28
|
+
@operations ||= @path_item_object.slice(*METHODS).keys.map { |method| operation(method) }
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'openapi_parameters'
|
4
|
+
require_relative 'parameters'
|
5
|
+
|
6
|
+
module OpenapiFirst
|
7
|
+
class PathParameters < Parameters
|
8
|
+
def unpack(original_path_params)
|
9
|
+
OpenapiParameters::Path.new(@parameter_definitions).unpack(original_path_params)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'openapi_parameters'
|
4
|
+
require_relative 'parameters'
|
5
|
+
|
6
|
+
module OpenapiFirst
|
7
|
+
class QueryParameters < Parameters
|
8
|
+
def unpack(env)
|
9
|
+
OpenapiParameters::Query.new(@parameter_definitions).unpack(env['QUERY_STRING'])
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../schema'
|
4
|
+
|
5
|
+
module OpenapiFirst
|
6
|
+
class RequestBody
|
7
|
+
def initialize(request_body_object, operation)
|
8
|
+
@request_body_object = request_body_object
|
9
|
+
@operation = operation
|
10
|
+
end
|
11
|
+
|
12
|
+
def description
|
13
|
+
@request_body_object['description']
|
14
|
+
end
|
15
|
+
|
16
|
+
def required?
|
17
|
+
!!@request_body_object['required']
|
18
|
+
end
|
19
|
+
|
20
|
+
def schema_for(content_type)
|
21
|
+
content = @request_body_object['content']
|
22
|
+
return unless content&.any?
|
23
|
+
|
24
|
+
content_schemas&.fetch(content_type) do
|
25
|
+
type = content_type.split(';')[0]
|
26
|
+
content_schemas[type] || content_schemas["#{type.split('/')[0]}/*"] || content_schemas['*/*']
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def content_schemas
|
33
|
+
@content_schemas ||= @request_body_object['content']&.each_with_object({}) do |kv, result|
|
34
|
+
type, media_type = kv
|
35
|
+
schema_object = media_type['schema']
|
36
|
+
next unless schema_object
|
37
|
+
|
38
|
+
result[type] = Schema.new(schema_object, write: @operation.write?,
|
39
|
+
openapi_version: @operation.openapi_version)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OpenapiFirst
|
4
|
+
class Definition
|
5
|
+
class Response
|
6
|
+
def initialize(operation:, status:, response_object:, content_type:, content_schema:)
|
7
|
+
@operation = operation
|
8
|
+
@response_object = response_object
|
9
|
+
@status = status
|
10
|
+
@content_type = content_type
|
11
|
+
@content_schema = content_schema
|
12
|
+
end
|
13
|
+
|
14
|
+
attr_reader :operation, :status, :content_type, :content_schema
|
15
|
+
|
16
|
+
def headers
|
17
|
+
@response_object['headers']
|
18
|
+
end
|
19
|
+
|
20
|
+
def description
|
21
|
+
@response_object['description']
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'response'
|
4
|
+
|
5
|
+
module OpenapiFirst
|
6
|
+
class Definition
|
7
|
+
# @visibility private
|
8
|
+
class Responses
|
9
|
+
def initialize(operation, responses_object)
|
10
|
+
@operation = operation
|
11
|
+
@responses_object = responses_object
|
12
|
+
end
|
13
|
+
|
14
|
+
def status_defined?(status)
|
15
|
+
!!find_response_object(status)
|
16
|
+
end
|
17
|
+
|
18
|
+
def response_for(status, response_content_type)
|
19
|
+
response_object = find_response_object(status)
|
20
|
+
return unless response_object
|
21
|
+
return response_without_content(status, response_object) unless content_defined?(response_object)
|
22
|
+
|
23
|
+
defined_content_type = find_defined_content_type(response_object, response_content_type)
|
24
|
+
return unless defined_content_type
|
25
|
+
|
26
|
+
content_schema = find_content_schema(response_object, response_content_type)
|
27
|
+
Response.new(operation:, status:, response_object:, content_type: defined_content_type, content_schema:)
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
attr_reader :openapi_version, :operation
|
33
|
+
|
34
|
+
def response_without_content(status, response_object)
|
35
|
+
Response.new(operation:, status:, response_object:, content_type: nil, content_schema: nil)
|
36
|
+
end
|
37
|
+
|
38
|
+
def find_defined_content_type(response_object, content_type)
|
39
|
+
return if content_type.nil?
|
40
|
+
|
41
|
+
content = response_object['content']
|
42
|
+
return content_type if content.key?(content_type)
|
43
|
+
|
44
|
+
type = content_type.split(';')[0]
|
45
|
+
return type if content.key?(type)
|
46
|
+
|
47
|
+
key = "#{type.split('/')[0]}/*"
|
48
|
+
return key if content.key?(key)
|
49
|
+
|
50
|
+
key = '*/*'
|
51
|
+
key if content.key?(key)
|
52
|
+
end
|
53
|
+
|
54
|
+
def content_defined?(response_object)
|
55
|
+
response_object['content']&.any?
|
56
|
+
end
|
57
|
+
|
58
|
+
def find_content_schema(response_object, response_content_type)
|
59
|
+
return unless response_content_type
|
60
|
+
|
61
|
+
content_object = find_response_body(response_object['content'], response_content_type)
|
62
|
+
content_schema_object = content_object&.fetch('schema', nil)
|
63
|
+
return unless content_schema_object
|
64
|
+
|
65
|
+
Schema.new(content_schema_object, write: false, openapi_version: operation.openapi_version)
|
66
|
+
end
|
67
|
+
|
68
|
+
def find_response_object(status)
|
69
|
+
@responses_object[status.to_s] ||
|
70
|
+
@responses_object["#{status / 100}XX"] ||
|
71
|
+
@responses_object["#{status / 100}xx"] ||
|
72
|
+
@responses_object['default']
|
73
|
+
end
|
74
|
+
|
75
|
+
def find_response_body(content, content_type)
|
76
|
+
content&.fetch(content_type) do |_|
|
77
|
+
type = content_type.split(';')[0]
|
78
|
+
content[type] || content["#{type.split('/')[0]}/*"] || content['*/*']
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -1,24 +1,77 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
3
|
+
require 'mustermann/template'
|
4
|
+
require_relative 'definition/path_item'
|
5
|
+
require_relative 'runtime_request'
|
4
6
|
|
5
7
|
module OpenapiFirst
|
6
8
|
# Represents an OpenAPI API Description document
|
7
9
|
class Definition
|
8
|
-
attr_reader :filepath, :
|
10
|
+
attr_reader :filepath, :paths, :openapi_version
|
9
11
|
|
10
12
|
def initialize(resolved, filepath)
|
11
13
|
@filepath = filepath
|
12
|
-
|
13
|
-
@
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
14
|
+
@paths = resolved['paths']
|
15
|
+
@openapi_version = detect_version(resolved)
|
16
|
+
end
|
17
|
+
|
18
|
+
def request(rack_request)
|
19
|
+
path_item, path_params = find_path_item_and_params(rack_request.path)
|
20
|
+
operation = path_item&.operation(rack_request.request_method.downcase)
|
21
|
+
RuntimeRequest.new(
|
22
|
+
request: rack_request,
|
23
|
+
path_item:,
|
24
|
+
operation:,
|
25
|
+
path_params:
|
26
|
+
)
|
27
|
+
end
|
28
|
+
|
29
|
+
def response(rack_request, rack_response)
|
30
|
+
request(rack_request).response(rack_response)
|
31
|
+
end
|
32
|
+
|
33
|
+
def operations
|
34
|
+
@operations ||= path_items.flat_map(&:operations)
|
35
|
+
end
|
36
|
+
|
37
|
+
def path(pathname)
|
38
|
+
return unless paths.key?(pathname)
|
39
|
+
|
40
|
+
PathItem.new(pathname, paths[pathname], openapi_version:)
|
18
41
|
end
|
19
42
|
|
20
43
|
private
|
21
44
|
|
45
|
+
def path_items
|
46
|
+
@path_items ||= paths.flat_map do |path, path_item_object|
|
47
|
+
PathItem.new(path, path_item_object, openapi_version:)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def find_path_item_and_params(request_path)
|
52
|
+
if paths.key?(request_path)
|
53
|
+
return [
|
54
|
+
PathItem.new(request_path, paths[request_path], openapi_version:),
|
55
|
+
{}
|
56
|
+
]
|
57
|
+
end
|
58
|
+
search_for_path_item(request_path)
|
59
|
+
end
|
60
|
+
|
61
|
+
def search_for_path_item(request_path)
|
62
|
+
paths.find do |path, path_item_object|
|
63
|
+
template = Mustermann::Template.new(path)
|
64
|
+
path_params = template.params(request_path)
|
65
|
+
next unless path_params
|
66
|
+
next unless path_params.size == template.names.size
|
67
|
+
|
68
|
+
return [
|
69
|
+
PathItem.new(path, path_item_object, openapi_version:),
|
70
|
+
path_params
|
71
|
+
]
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
22
75
|
def detect_version(resolved)
|
23
76
|
(resolved['openapi'] || resolved['swagger'])[0..2]
|
24
77
|
end
|
@@ -1,45 +1,40 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module OpenapiFirst
|
4
|
-
# This is the base
|
5
|
-
|
6
|
-
## @param
|
7
|
-
|
8
|
-
|
9
|
-
@env = env
|
10
|
-
@request_validation_error = request_validation_error
|
4
|
+
# This is the base module for error responses
|
5
|
+
module ErrorResponse
|
6
|
+
## @param failure [OpenapiFirst::Failure]
|
7
|
+
def initialize(failure: nil)
|
8
|
+
@failure = failure
|
11
9
|
end
|
12
10
|
|
13
|
-
|
11
|
+
attr_reader :failure
|
14
12
|
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
def validation_output
|
20
|
-
schema_validation&.output
|
13
|
+
# The response body
|
14
|
+
def body
|
15
|
+
raise NotImplementedError
|
21
16
|
end
|
22
17
|
|
23
|
-
|
24
|
-
|
18
|
+
# The response content-type
|
19
|
+
def content_type
|
20
|
+
raise NotImplementedError
|
25
21
|
end
|
26
22
|
|
27
|
-
|
28
|
-
|
29
|
-
|
23
|
+
STATUS = {
|
24
|
+
not_found: 404,
|
25
|
+
method_not_allowed: 405,
|
26
|
+
unsupported_media_type: 415
|
27
|
+
}.freeze
|
28
|
+
private_constant :STATUS
|
30
29
|
|
31
|
-
|
32
|
-
|
30
|
+
# The response status
|
31
|
+
def status
|
32
|
+
STATUS[failure.error_type] || 400
|
33
33
|
end
|
34
34
|
|
35
|
+
# Render this error response
|
35
36
|
def render
|
36
37
|
Rack::Response.new(body, status, Rack::CONTENT_TYPE => content_type).finish
|
37
38
|
end
|
38
|
-
|
39
|
-
def content_type = 'application/json'
|
40
|
-
|
41
|
-
def body
|
42
|
-
raise NotImplementedError
|
43
|
-
end
|
44
39
|
end
|
45
40
|
end
|
data/lib/openapi_first/errors.rb
CHANGED
@@ -2,20 +2,8 @@
|
|
2
2
|
|
3
3
|
module OpenapiFirst
|
4
4
|
class Error < StandardError; end
|
5
|
-
|
6
5
|
class NotFoundError < Error; end
|
7
|
-
|
8
|
-
class ResponseInvalid < Error; end
|
9
|
-
|
10
|
-
class ResponseCodeNotFoundError < ResponseInvalid; end
|
11
|
-
|
12
|
-
class ResponseContentTypeNotFoundError < ResponseInvalid; end
|
13
|
-
|
14
|
-
class ResponseBodyInvalidError < ResponseInvalid; end
|
15
|
-
|
16
|
-
class ResponseHeaderInvalidError < ResponseInvalid; end
|
17
|
-
|
18
|
-
class BodyParsingError < Error; end
|
19
|
-
|
20
6
|
class RequestInvalidError < Error; end
|
7
|
+
class ResponseNotFoundError < Error; end
|
8
|
+
class ResponseInvalidError < Error; end
|
21
9
|
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OpenapiFirst
|
4
|
+
class Failure
|
5
|
+
FAILURE = :openapi_first_validation_failure
|
6
|
+
|
7
|
+
TYPES = {
|
8
|
+
not_found: [NotFoundError, 'Request path is not defined.'],
|
9
|
+
method_not_allowed: [RequestInvalidError, 'Request method is not defined.'],
|
10
|
+
unsupported_media_type: [RequestInvalidError, 'Request content type is not defined.'],
|
11
|
+
invalid_body: [RequestInvalidError, 'Request body invalid:'],
|
12
|
+
invalid_query: [RequestInvalidError, 'Query parameter is invalid:'],
|
13
|
+
invalid_header: [RequestInvalidError, 'Request header is invalid:'],
|
14
|
+
invalid_path: [RequestInvalidError, 'Path segment is invalid:'],
|
15
|
+
invalid_cookie: [RequestInvalidError, 'Cookie value is invalid:'],
|
16
|
+
response_not_found: [ResponseNotFoundError, 'Response is not defined.'],
|
17
|
+
invalid_response_body: [ResponseInvalidError, 'Response body is invalid:'],
|
18
|
+
invalid_response_header: [ResponseInvalidError, 'Response header is invalid:']
|
19
|
+
}.freeze
|
20
|
+
private_constant :TYPES
|
21
|
+
|
22
|
+
# @param error_type [Symbol] See Failure::TYPES.keys
|
23
|
+
# @param errors [Array<OpenapiFirst::Schema::ValidationResult>]
|
24
|
+
def self.fail!(error_type, message: nil, errors: nil)
|
25
|
+
throw FAILURE, new(
|
26
|
+
error_type,
|
27
|
+
message:,
|
28
|
+
errors:
|
29
|
+
)
|
30
|
+
end
|
31
|
+
|
32
|
+
# @param type [Symbol] See TYPES.keys
|
33
|
+
# @param message [String] A generic error message
|
34
|
+
# @param errors [Array<OpenapiFirst::Schema::ValidationError>]
|
35
|
+
def initialize(error_type, message: nil, errors: nil)
|
36
|
+
unless TYPES.key?(error_type)
|
37
|
+
raise ArgumentError,
|
38
|
+
"error_type must be one of #{TYPES.keys} but was #{error_type.inspect}"
|
39
|
+
end
|
40
|
+
|
41
|
+
@error_type = error_type
|
42
|
+
@message = message
|
43
|
+
@errors = errors
|
44
|
+
end
|
45
|
+
|
46
|
+
attr_reader :error_type, :message, :errors
|
47
|
+
|
48
|
+
# Raise an exception that fits the failure.
|
49
|
+
def raise!
|
50
|
+
exception, message_prefix = TYPES.fetch(error_type)
|
51
|
+
|
52
|
+
raise exception, "#{message_prefix} #{@message || errors&.map(&:error)&.join('. ')}"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rack'
|
4
|
+
module OpenapiFirst
|
5
|
+
module Middlewares
|
6
|
+
# A Rack middleware to validate requests against an OpenAPI API description
|
7
|
+
class RequestValidation
|
8
|
+
# @param app The parent Rack application
|
9
|
+
# @param options An optional Hash of configuration options to override defaults
|
10
|
+
# :raise_error A Boolean indicating whether to raise an error if validation fails.
|
11
|
+
# default: false
|
12
|
+
# :error_response The Class to use for error responses.
|
13
|
+
# default: OpenapiFirst::Plugins::Default::ErrorResponse (Config.default_options.error_response)
|
14
|
+
def initialize(app, options = {})
|
15
|
+
@app = app
|
16
|
+
@raise = options.fetch(:raise_error, OpenapiFirst.configuration.request_validation_raise_error)
|
17
|
+
@error_response_class = error_response(options[:error_response])
|
18
|
+
|
19
|
+
spec = options.fetch(:spec)
|
20
|
+
raise "You have to pass spec: when initializing #{self.class}" unless spec
|
21
|
+
|
22
|
+
@definition = spec.is_a?(Definition) ? spec : OpenapiFirst.load(spec)
|
23
|
+
end
|
24
|
+
|
25
|
+
def call(env)
|
26
|
+
request = find_request(env)
|
27
|
+
return @app.call(env) unless request
|
28
|
+
|
29
|
+
failure = if @raise
|
30
|
+
request.validate!
|
31
|
+
else
|
32
|
+
request.validate
|
33
|
+
end
|
34
|
+
return @error_response_class.new(failure:).render if failure
|
35
|
+
|
36
|
+
@app.call(env)
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def find_request(env)
|
42
|
+
env[REQUEST] ||= @definition.request(Rack::Request.new(env))
|
43
|
+
end
|
44
|
+
|
45
|
+
def error_response(mod)
|
46
|
+
return OpenapiFirst.plugin(mod)::ErrorResponse if mod.is_a?(Symbol)
|
47
|
+
|
48
|
+
mod || OpenapiFirst.configuration.request_validation_error_response
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|