openapi_first 0.12.5 → 0.14.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/.rubocop.yml +6 -46
- data/CHANGELOG.md +16 -1
- data/Gemfile.lock +60 -59
- data/README.md +25 -10
- data/benchmarks/Gemfile +2 -1
- data/benchmarks/Gemfile.lock +58 -58
- data/benchmarks/apps/committee.ru +14 -18
- data/benchmarks/apps/hanami_api.ru +21 -0
- data/benchmarks/apps/hanami_router.ru +1 -1
- data/benchmarks/apps/sinatra.ru +1 -1
- data/benchmarks/apps/syro.ru +3 -3
- data/benchmarks/benchmarks.rb +11 -14
- data/lib/openapi_first.rb +9 -4
- data/lib/openapi_first/app.rb +3 -5
- data/lib/openapi_first/{find_handler.rb → default_operation_resolver.rb} +5 -13
- data/lib/openapi_first/definition.rb +9 -8
- data/lib/openapi_first/operation.rb +65 -25
- data/lib/openapi_first/request_validation.rb +29 -30
- data/lib/openapi_first/responder.rb +4 -4
- data/lib/openapi_first/response_validation.rb +2 -1
- data/lib/openapi_first/router.rb +6 -4
- data/lib/openapi_first/schema_validation.rb +36 -0
- data/lib/openapi_first/utils.rb +7 -14
- data/lib/openapi_first/validation_format.rb +22 -0
- data/lib/openapi_first/version.rb +1 -1
- data/openapi_first.gemspec +4 -2
- metadata +13 -11
@@ -1,30 +1,26 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require 'committee'
|
4
|
-
require 'syro'
|
5
3
|
require 'multi_json'
|
4
|
+
require 'committee'
|
5
|
+
require 'hanami/api'
|
6
6
|
|
7
|
-
app =
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
res.json MultiJson.dump(hello: 'world', id: inbox[:id])
|
12
|
-
end
|
13
|
-
end
|
7
|
+
app = Class.new(Hanami::API) do
|
8
|
+
get '/hello/:id' do
|
9
|
+
json(hello: 'world', id: params.fetch(:id))
|
10
|
+
end
|
14
11
|
|
15
|
-
|
16
|
-
|
17
|
-
|
12
|
+
get '/hello' do
|
13
|
+
json([{ hello: 'world' }])
|
14
|
+
end
|
18
15
|
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
end
|
16
|
+
post '/hello' do
|
17
|
+
status 201
|
18
|
+
json(hello: 'world')
|
23
19
|
end
|
24
|
-
end
|
20
|
+
end.new
|
25
21
|
|
26
22
|
use Committee::Middleware::RequestValidation,
|
27
23
|
schema_path: './apps/openapi.yaml',
|
28
|
-
|
24
|
+
parse_response_by_content_type: true
|
29
25
|
|
30
26
|
run app
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'multi_json'
|
4
|
+
require 'hanami/api'
|
5
|
+
|
6
|
+
app = Class.new(Hanami::API) do
|
7
|
+
get '/hello/:id' do
|
8
|
+
json(hello: 'world', id: params.fetch(:id))
|
9
|
+
end
|
10
|
+
|
11
|
+
get '/hello' do
|
12
|
+
json([{ hello: 'world' }])
|
13
|
+
end
|
14
|
+
|
15
|
+
post '/hello' do
|
16
|
+
status 201
|
17
|
+
json(hello: 'world')
|
18
|
+
end
|
19
|
+
end.new
|
20
|
+
|
21
|
+
run app
|
@@ -4,7 +4,7 @@ require 'hanami/router'
|
|
4
4
|
require 'multi_json'
|
5
5
|
|
6
6
|
app = Hanami::Router.new do
|
7
|
-
get '/hello', to: ->(_env) { [200, {}, [MultiJson.dump(hello: 'world')]] }
|
7
|
+
get '/hello', to: ->(_env) { [200, {}, [MultiJson.dump([{ hello: 'world' }])]] }
|
8
8
|
get '/hello/:id', to: lambda { |env|
|
9
9
|
[200, {}, [MultiJson.dump(hello: 'world', id: env['router.params'][:id])]]
|
10
10
|
}
|
data/benchmarks/apps/sinatra.ru
CHANGED
data/benchmarks/apps/syro.ru
CHANGED
@@ -7,17 +7,17 @@ app = Syro.new do
|
|
7
7
|
on 'hello' do
|
8
8
|
on :id do
|
9
9
|
get do
|
10
|
-
res.json
|
10
|
+
res.json({ hello: 'world', id: inbox[:id] })
|
11
11
|
end
|
12
12
|
end
|
13
13
|
|
14
14
|
get do
|
15
|
-
res.json
|
15
|
+
res.json([{ hello: 'world' }])
|
16
16
|
end
|
17
17
|
|
18
18
|
post do
|
19
19
|
res.status = 201
|
20
|
-
res.json
|
20
|
+
res.json({ hello: 'world' })
|
21
21
|
end
|
22
22
|
end
|
23
23
|
end
|
data/benchmarks/benchmarks.rb
CHANGED
@@ -19,28 +19,25 @@ apps = Dir['./apps/*.ru'].each_with_object({}) do |config, hash|
|
|
19
19
|
end
|
20
20
|
apps.freeze
|
21
21
|
|
22
|
+
bench = lambda do |app|
|
23
|
+
examples.each do |example|
|
24
|
+
env, expected_status = example
|
25
|
+
100.times { app.call(env) }
|
26
|
+
response = app.call(env)
|
27
|
+
raise unless response[0] == expected_status
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
22
31
|
Benchmark.ips do |x|
|
23
32
|
apps.each do |config, app|
|
24
|
-
x.report(config)
|
25
|
-
examples.each do |example|
|
26
|
-
env, expected_status = example
|
27
|
-
response = app.call(env)
|
28
|
-
raise unless response[0] == expected_status
|
29
|
-
end
|
30
|
-
end
|
33
|
+
x.report(config) { bench.call(app) }
|
31
34
|
end
|
32
35
|
x.compare!
|
33
36
|
end
|
34
37
|
|
35
38
|
Benchmark.memory do |x|
|
36
39
|
apps.each do |config, app|
|
37
|
-
x.report(config)
|
38
|
-
examples.each do |example|
|
39
|
-
env, expected_status = example
|
40
|
-
response = app.call(env)
|
41
|
-
raise unless response[0] == expected_status
|
42
|
-
end
|
43
|
-
end
|
40
|
+
x.report(config) { bench.call(app) }
|
44
41
|
end
|
45
42
|
x.compare!
|
46
43
|
end
|
data/lib/openapi_first.rb
CHANGED
@@ -25,10 +25,9 @@ module OpenapiFirst
|
|
25
25
|
|
26
26
|
def self.load(spec_path, only: nil)
|
27
27
|
content = YAML.load_file(spec_path)
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
Definition.new(parsed)
|
28
|
+
resolved = OasParser::Parser.new(spec_path, content).resolve
|
29
|
+
resolved['paths'].filter!(&->(key, _) { only.call(key) }) if only
|
30
|
+
Definition.new(resolved, spec_path)
|
32
31
|
end
|
33
32
|
|
34
33
|
def self.app(
|
@@ -78,11 +77,17 @@ module OpenapiFirst
|
|
78
77
|
end
|
79
78
|
|
80
79
|
class Error < StandardError; end
|
80
|
+
|
81
81
|
class NotFoundError < Error; end
|
82
|
+
|
82
83
|
class NotImplementedError < RuntimeError; end
|
84
|
+
|
83
85
|
class ResponseInvalid < Error; end
|
86
|
+
|
84
87
|
class ResponseCodeNotFoundError < ResponseInvalid; end
|
88
|
+
|
85
89
|
class ResponseContentTypeNotFoundError < ResponseInvalid; end
|
90
|
+
|
86
91
|
class ResponseBodyInvalidError < ResponseInvalid; end
|
87
92
|
|
88
93
|
class RequestInvalidError < Error
|
data/lib/openapi_first/app.rb
CHANGED
@@ -11,17 +11,15 @@ module OpenapiFirst
|
|
11
11
|
namespace:,
|
12
12
|
router_raise_error: false,
|
13
13
|
request_validation_raise_error: false,
|
14
|
-
response_validation: false
|
14
|
+
response_validation: false,
|
15
|
+
resolver: nil
|
15
16
|
)
|
16
17
|
@stack = Rack::Builder.app do
|
17
18
|
freeze_app
|
18
19
|
use OpenapiFirst::Router, spec: spec, raise_error: router_raise_error, parent_app: parent_app
|
19
20
|
use OpenapiFirst::RequestValidation, raise_error: request_validation_raise_error
|
20
21
|
use OpenapiFirst::ResponseValidation if response_validation
|
21
|
-
run OpenapiFirst::Responder.new(
|
22
|
-
spec: spec,
|
23
|
-
namespace: namespace
|
24
|
-
)
|
22
|
+
run OpenapiFirst::Responder.new(namespace: namespace, resolver: resolver)
|
25
23
|
end
|
26
24
|
end
|
27
25
|
|
@@ -3,22 +3,14 @@
|
|
3
3
|
require_relative 'utils'
|
4
4
|
|
5
5
|
module OpenapiFirst
|
6
|
-
class
|
7
|
-
def initialize(
|
6
|
+
class DefaultOperationResolver
|
7
|
+
def initialize(namespace)
|
8
8
|
@namespace = namespace
|
9
|
-
@handlers =
|
10
|
-
operation_id = operation.operation_id
|
11
|
-
handler = find_handler(operation_id)
|
12
|
-
if handler.nil?
|
13
|
-
warn "#{self.class.name} cannot not find handler for '#{operation.operation_id}' (#{operation.method} #{operation.path}). This operation will be ignored." # rubocop:disable Layout/LineLength
|
14
|
-
next
|
15
|
-
end
|
16
|
-
hash[operation_id] = handler
|
17
|
-
end
|
9
|
+
@handlers = {}
|
18
10
|
end
|
19
11
|
|
20
|
-
def
|
21
|
-
@handlers[
|
12
|
+
def call(operation)
|
13
|
+
@handlers[operation.name] ||= find_handler(operation['x-handler'] || operation['operationId'])
|
22
14
|
end
|
23
15
|
|
24
16
|
def find_handler(operation_id)
|
@@ -4,15 +4,16 @@ require_relative 'operation'
|
|
4
4
|
|
5
5
|
module OpenapiFirst
|
6
6
|
class Definition
|
7
|
-
attr_reader :filepath
|
7
|
+
attr_reader :filepath, :operations
|
8
8
|
|
9
|
-
def initialize(
|
10
|
-
@filepath =
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
9
|
+
def initialize(resolved, filepath)
|
10
|
+
@filepath = filepath
|
11
|
+
methods = %w[get head post put patch delete trace options]
|
12
|
+
@operations = resolved['paths'].flat_map do |path, path_item|
|
13
|
+
path_item.slice(*methods).map do |request_method, _operation_object|
|
14
|
+
Operation.new(path, request_method, path_item)
|
15
|
+
end
|
16
|
+
end
|
16
17
|
end
|
17
18
|
end
|
18
19
|
end
|
@@ -2,32 +2,49 @@
|
|
2
2
|
|
3
3
|
require 'forwardable'
|
4
4
|
require 'json_schemer'
|
5
|
+
require_relative 'schema_validation'
|
5
6
|
require_relative 'utils'
|
6
7
|
require_relative 'response_object'
|
7
8
|
|
8
9
|
module OpenapiFirst
|
9
|
-
class Operation
|
10
|
+
class Operation # rubocop:disable Metrics/ClassLength
|
10
11
|
extend Forwardable
|
11
|
-
def_delegators
|
12
|
-
:
|
13
|
-
:
|
14
|
-
:request_body,
|
15
|
-
:operation_id
|
12
|
+
def_delegators :operation_object,
|
13
|
+
:[],
|
14
|
+
:dig
|
16
15
|
|
17
|
-
|
18
|
-
|
16
|
+
WRITE_METHODS = Set.new(%w[post put patch delete]).freeze
|
17
|
+
private_constant :WRITE_METHODS
|
18
|
+
|
19
|
+
attr_reader :path, :method
|
20
|
+
|
21
|
+
def initialize(path, request_method, path_item_object)
|
22
|
+
@path = path
|
23
|
+
@method = request_method
|
24
|
+
@path_item_object = path_item_object
|
25
|
+
end
|
26
|
+
|
27
|
+
def operation_id
|
28
|
+
operation_object['operationId']
|
19
29
|
end
|
20
30
|
|
21
|
-
def
|
22
|
-
|
31
|
+
def read?
|
32
|
+
!write?
|
23
33
|
end
|
24
34
|
|
25
|
-
def
|
26
|
-
|
35
|
+
def write?
|
36
|
+
WRITE_METHODS.include?(method)
|
37
|
+
end
|
38
|
+
|
39
|
+
def request_body
|
40
|
+
operation_object['requestBody']
|
27
41
|
end
|
28
42
|
|
29
43
|
def parameters_schema
|
30
|
-
@parameters_schema ||=
|
44
|
+
@parameters_schema ||= begin
|
45
|
+
parameters_json_schema = build_parameters_json_schema
|
46
|
+
parameters_json_schema && SchemaValidation.new(parameters_json_schema)
|
47
|
+
end
|
31
48
|
end
|
32
49
|
|
33
50
|
def content_type_for(status)
|
@@ -42,22 +59,28 @@ module OpenapiFirst
|
|
42
59
|
raise ResponseInvalid, "Response has no content-type for '#{name}'" unless content_type
|
43
60
|
|
44
61
|
media_type = find_content_for_content_type(content, content_type)
|
62
|
+
|
45
63
|
unless media_type
|
46
64
|
message = "Response content type not found '#{content_type}' for '#{name}'"
|
47
65
|
raise ResponseContentTypeNotFoundError, message
|
48
66
|
end
|
49
|
-
media_type['schema']
|
67
|
+
schema = media_type['schema']
|
68
|
+
SchemaValidation.new(schema, write: false) if schema
|
50
69
|
end
|
51
70
|
|
52
|
-
def
|
53
|
-
content =
|
71
|
+
def request_body_schema(request_content_type)
|
72
|
+
content = operation_object.dig('requestBody', 'content')
|
54
73
|
media_type = find_content_for_content_type(content, request_content_type)
|
55
|
-
media_type&.fetch('schema', nil)
|
74
|
+
schema = media_type&.fetch('schema', nil)
|
75
|
+
return unless schema
|
76
|
+
|
77
|
+
SchemaValidation.new(schema, write: write?)
|
56
78
|
end
|
57
79
|
|
58
80
|
def response_for(status)
|
59
|
-
|
60
|
-
|
81
|
+
response_content = response_by_code(status)
|
82
|
+
return response_content if response_content
|
83
|
+
|
61
84
|
message = "Response status code or default not found: #{status} for '#{name}'"
|
62
85
|
raise OpenapiFirst::ResponseCodeNotFoundError, message
|
63
86
|
end
|
@@ -68,6 +91,15 @@ module OpenapiFirst
|
|
68
91
|
|
69
92
|
private
|
70
93
|
|
94
|
+
def response_by_code(status)
|
95
|
+
operation_object.dig('responses', status.to_s) ||
|
96
|
+
operation_object.dig('responses', 'default')
|
97
|
+
end
|
98
|
+
|
99
|
+
def operation_object
|
100
|
+
@path_item_object[method]
|
101
|
+
end
|
102
|
+
|
71
103
|
def find_content_for_content_type(content, request_content_type)
|
72
104
|
content.fetch(request_content_type) do |_|
|
73
105
|
type = request_content_type.split(';')[0]
|
@@ -76,24 +108,32 @@ module OpenapiFirst
|
|
76
108
|
end
|
77
109
|
|
78
110
|
def build_parameters_json_schema
|
79
|
-
|
111
|
+
parameters = all_parameters
|
112
|
+
return unless parameters&.any?
|
80
113
|
|
81
|
-
|
82
|
-
params = Rack::Utils.parse_nested_query(parameter
|
114
|
+
parameters.each_with_object(new_node) do |parameter, schema|
|
115
|
+
params = Rack::Utils.parse_nested_query(parameter['name'])
|
83
116
|
generate_schema(schema, params, parameter)
|
84
117
|
end
|
85
118
|
end
|
86
119
|
|
87
|
-
def
|
120
|
+
def all_parameters
|
121
|
+
parameters = @path_item_object['parameters'] || []
|
122
|
+
parameters_on_operation = operation_object['parameters']
|
123
|
+
parameters.concat(parameters_on_operation) if parameters_on_operation
|
124
|
+
parameters
|
125
|
+
end
|
126
|
+
|
127
|
+
def generate_schema(schema, params, parameter)
|
88
128
|
required = Set.new(schema['required'])
|
89
129
|
params.each do |key, value|
|
90
|
-
required << key if parameter
|
130
|
+
required << key if parameter['required']
|
91
131
|
if value.is_a? Hash
|
92
132
|
property_schema = new_node
|
93
133
|
generate_schema(property_schema, value, parameter)
|
94
134
|
Utils.deep_merge!(schema['properties'], { key => property_schema })
|
95
135
|
else
|
96
|
-
schema['properties'][key] = parameter
|
136
|
+
schema['properties'][key] = parameter['schema']
|
97
137
|
end
|
98
138
|
end
|
99
139
|
schema['required'] = required.to_a
|
@@ -16,7 +16,7 @@ module OpenapiFirst
|
|
16
16
|
@raise = raise_error
|
17
17
|
end
|
18
18
|
|
19
|
-
def call(env) # rubocop:disable Metrics/AbcSize
|
19
|
+
def call(env) # rubocop:disable Metrics/AbcSize
|
20
20
|
operation = env[OpenapiFirst::OPERATION]
|
21
21
|
return @app.call(env) unless operation
|
22
22
|
|
@@ -45,17 +45,17 @@ module OpenapiFirst
|
|
45
45
|
validate_request_body_presence!(body, operation)
|
46
46
|
return if body.empty?
|
47
47
|
|
48
|
-
schema = request_body_schema(content_type
|
48
|
+
schema = operation&.request_body_schema(content_type)
|
49
49
|
return unless schema
|
50
50
|
|
51
51
|
parsed_request_body = parse_request_body!(body)
|
52
|
-
errors =
|
52
|
+
errors = schema.validate(parsed_request_body)
|
53
53
|
halt_with_error(400, serialize_request_body_errors(errors)) if errors.any?
|
54
|
-
env[INBOX].merge! env[REQUEST_BODY] = parsed_request_body
|
54
|
+
env[INBOX].merge! env[REQUEST_BODY] = Utils.deep_symbolize(parsed_request_body)
|
55
55
|
end
|
56
56
|
|
57
57
|
def parse_request_body!(body)
|
58
|
-
MultiJson.load(body
|
58
|
+
MultiJson.load(body)
|
59
59
|
rescue MultiJson::ParseError => e
|
60
60
|
err = { title: 'Failed to parse body as JSON' }
|
61
61
|
err[:detail] = e.cause unless ENV['RACK_ENV'] == 'production'
|
@@ -63,21 +63,17 @@ module OpenapiFirst
|
|
63
63
|
end
|
64
64
|
|
65
65
|
def validate_request_content_type!(content_type, operation)
|
66
|
-
return if operation.request_body.content
|
66
|
+
return if operation.request_body.dig('content', content_type)
|
67
67
|
|
68
68
|
halt_with_error(415)
|
69
69
|
end
|
70
70
|
|
71
71
|
def validate_request_body_presence!(body, operation)
|
72
|
-
return unless operation.request_body
|
72
|
+
return unless operation.request_body['required'] && body.empty?
|
73
73
|
|
74
74
|
halt_with_error(415, 'Request body is required')
|
75
75
|
end
|
76
76
|
|
77
|
-
def validate_json_schema(schema, object)
|
78
|
-
schema.validate(Utils.deep_stringify(object))
|
79
|
-
end
|
80
|
-
|
81
77
|
def default_error(status, title = Rack::Utils::HTTP_STATUS_CODES[status])
|
82
78
|
{
|
83
79
|
status: status.to_s,
|
@@ -95,14 +91,6 @@ module OpenapiFirst
|
|
95
91
|
).finish
|
96
92
|
end
|
97
93
|
|
98
|
-
def request_body_schema(content_type, operation)
|
99
|
-
return unless operation
|
100
|
-
|
101
|
-
schema = operation.request_body_schema_for(content_type)
|
102
|
-
|
103
|
-
JSONSchemer.schema(schema) if schema
|
104
|
-
end
|
105
|
-
|
106
94
|
def serialize_request_body_errors(validation_errors)
|
107
95
|
validation_errors.map do |error|
|
108
96
|
{
|
@@ -114,14 +102,11 @@ module OpenapiFirst
|
|
114
102
|
end
|
115
103
|
|
116
104
|
def validate_query_parameters!(env, operation, params)
|
117
|
-
|
118
|
-
return unless
|
119
|
-
|
120
|
-
params = filtered_params(
|
121
|
-
errors =
|
122
|
-
operation.parameters_schema,
|
123
|
-
params
|
124
|
-
)
|
105
|
+
schema = operation.parameters_schema
|
106
|
+
return unless schema
|
107
|
+
|
108
|
+
params = filtered_params(schema.raw_schema, params)
|
109
|
+
errors = schema.validate(Utils.deep_stringify(params))
|
125
110
|
halt_with_error(400, serialize_query_parameter_errors(errors)) if errors.any?
|
126
111
|
env[PARAMETERS] = params
|
127
112
|
env[INBOX].merge! params
|
@@ -141,8 +126,9 @@ module OpenapiFirst
|
|
141
126
|
|
142
127
|
def serialize_query_parameter_errors(validation_errors)
|
143
128
|
validation_errors.map do |error|
|
129
|
+
pointer = error['data_pointer'][1..].to_s
|
144
130
|
{
|
145
|
-
source: { parameter:
|
131
|
+
source: { parameter: pointer }
|
146
132
|
}.update(ValidationFormat.error_details(error))
|
147
133
|
end
|
148
134
|
end
|
@@ -150,14 +136,27 @@ module OpenapiFirst
|
|
150
136
|
def parse_parameter(value, schema)
|
151
137
|
return filtered_params(schema, value) if schema['properties']
|
152
138
|
|
139
|
+
return parse_array_parameter(value, schema) if schema['type'] == 'array'
|
140
|
+
|
141
|
+
parse_simple_value(value, schema)
|
142
|
+
end
|
143
|
+
|
144
|
+
def parse_array_parameter(value, schema)
|
145
|
+
array = value.is_a?(Array) ? value : value.split(',')
|
146
|
+
return array unless schema['items']
|
147
|
+
|
148
|
+
array.map! { |e| parse_simple_value(e, schema['items']) }
|
149
|
+
end
|
150
|
+
|
151
|
+
def parse_simple_value(value, schema)
|
152
|
+
return to_boolean(value) if schema['type'] == 'boolean'
|
153
|
+
|
153
154
|
begin
|
154
155
|
return Integer(value, 10) if schema['type'] == 'integer'
|
155
156
|
return Float(value) if schema['type'] == 'number'
|
156
157
|
rescue ArgumentError
|
157
158
|
value
|
158
159
|
end
|
159
|
-
return to_boolean(value) if schema['type'] == 'boolean'
|
160
|
-
|
161
160
|
value
|
162
161
|
end
|
163
162
|
|