openapi_first 0.10.2 → 0.12.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 +12 -0
- data/CHANGELOG.md +19 -0
- data/Gemfile.lock +17 -15
- data/README.md +129 -86
- data/benchmarks/Gemfile.lock +12 -12
- data/benchmarks/apps/openapi_first.ru +1 -1
- data/examples/app.rb +6 -1
- data/lib/openapi_first.rb +48 -6
- data/lib/openapi_first/app.rb +9 -11
- data/lib/openapi_first/definition.rb +3 -12
- data/lib/openapi_first/find_handler.rb +60 -0
- data/lib/openapi_first/operation.rb +28 -5
- data/lib/openapi_first/request_validation.rb +24 -17
- data/lib/openapi_first/responder.rb +46 -0
- data/lib/openapi_first/response_object.rb +21 -0
- data/lib/openapi_first/response_validation.rb +57 -0
- data/lib/openapi_first/response_validator.rb +6 -43
- data/lib/openapi_first/router.rb +33 -53
- data/lib/openapi_first/router_required.rb +13 -0
- data/lib/openapi_first/validation_format.rb +3 -1
- data/lib/openapi_first/version.rb +1 -1
- data/openapi_first.gemspec +7 -7
- metadata +9 -5
- data/lib/openapi_first/operation_resolver.rb +0 -27
data/benchmarks/Gemfile.lock
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: ..
|
|
3
3
|
specs:
|
|
4
|
-
openapi_first (0.
|
|
4
|
+
openapi_first (0.12.0)
|
|
5
5
|
deep_merge (>= 1.2.1)
|
|
6
|
-
hanami-router (~> 2.0.
|
|
6
|
+
hanami-router (~> 2.0.alpha3)
|
|
7
7
|
hanami-utils (~> 2.0.alpha1)
|
|
8
8
|
json_schemer (~> 0.2)
|
|
9
9
|
multi_json (~> 1.14)
|
|
@@ -13,7 +13,7 @@ PATH
|
|
|
13
13
|
GEM
|
|
14
14
|
remote: https://rubygems.org/
|
|
15
15
|
specs:
|
|
16
|
-
activesupport (6.0.3)
|
|
16
|
+
activesupport (6.0.3.1)
|
|
17
17
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
|
18
18
|
i18n (>= 0.7, < 2)
|
|
19
19
|
minitest (~> 5.1)
|
|
@@ -25,9 +25,9 @@ GEM
|
|
|
25
25
|
benchmark-memory (0.1.2)
|
|
26
26
|
memory_profiler (~> 0.9)
|
|
27
27
|
builder (3.2.4)
|
|
28
|
-
committee (
|
|
28
|
+
committee (4.0.0)
|
|
29
29
|
json_schema (~> 0.14, >= 0.14.3)
|
|
30
|
-
openapi_parser (>= 0.
|
|
30
|
+
openapi_parser (>= 0.11.1)
|
|
31
31
|
rack (>= 1.5)
|
|
32
32
|
concurrent-ruby (1.1.6)
|
|
33
33
|
deep_merge (1.2.1)
|
|
@@ -55,7 +55,7 @@ GEM
|
|
|
55
55
|
dry-logic (~> 1.0, >= 1.0.2)
|
|
56
56
|
ecma-re-validator (0.2.1)
|
|
57
57
|
regexp_parser (~> 1.2)
|
|
58
|
-
grape (1.3.
|
|
58
|
+
grape (1.3.3)
|
|
59
59
|
activesupport
|
|
60
60
|
builder
|
|
61
61
|
dry-types (>= 1.1)
|
|
@@ -63,7 +63,7 @@ GEM
|
|
|
63
63
|
rack (>= 1.3.0)
|
|
64
64
|
rack-accept
|
|
65
65
|
hana (1.3.6)
|
|
66
|
-
hanami-router (2.0.0.
|
|
66
|
+
hanami-router (2.0.0.alpha3)
|
|
67
67
|
mustermann (~> 1.0)
|
|
68
68
|
mustermann-contrib (~> 1.0)
|
|
69
69
|
rack (~> 2.0)
|
|
@@ -72,7 +72,7 @@ GEM
|
|
|
72
72
|
transproc (~> 1.0)
|
|
73
73
|
hansi (0.2.0)
|
|
74
74
|
hash-deep-merge (0.1.1)
|
|
75
|
-
i18n (1.8.
|
|
75
|
+
i18n (1.8.3)
|
|
76
76
|
concurrent-ruby (~> 1.0)
|
|
77
77
|
json_schema (0.20.8)
|
|
78
78
|
json_schemer (0.2.11)
|
|
@@ -82,7 +82,7 @@ GEM
|
|
|
82
82
|
uri_template (~> 0.7)
|
|
83
83
|
memory_profiler (0.9.14)
|
|
84
84
|
mini_portile2 (2.4.0)
|
|
85
|
-
minitest (5.14.
|
|
85
|
+
minitest (5.14.1)
|
|
86
86
|
multi_json (1.14.1)
|
|
87
87
|
mustermann (1.1.1)
|
|
88
88
|
ruby2_keywords (~> 0.0.1)
|
|
@@ -101,14 +101,14 @@ GEM
|
|
|
101
101
|
hash-deep-merge
|
|
102
102
|
mustermann-contrib (~> 1.1.1)
|
|
103
103
|
nokogiri
|
|
104
|
-
openapi_parser (0.
|
|
105
|
-
public_suffix (4.0.
|
|
104
|
+
openapi_parser (0.11.2)
|
|
105
|
+
public_suffix (4.0.5)
|
|
106
106
|
rack (2.2.2)
|
|
107
107
|
rack-accept (0.4.5)
|
|
108
108
|
rack (>= 0.4)
|
|
109
109
|
rack-protection (2.0.8.1)
|
|
110
110
|
rack
|
|
111
|
-
regexp_parser (1.7.
|
|
111
|
+
regexp_parser (1.7.1)
|
|
112
112
|
ruby2_keywords (0.0.2)
|
|
113
113
|
seg (1.2.0)
|
|
114
114
|
sinatra (2.0.8.1)
|
data/examples/app.rb
CHANGED
data/lib/openapi_first.rb
CHANGED
|
@@ -8,7 +8,8 @@ require 'openapi_first/inbox'
|
|
|
8
8
|
require 'openapi_first/router'
|
|
9
9
|
require 'openapi_first/request_validation'
|
|
10
10
|
require 'openapi_first/response_validator'
|
|
11
|
-
require 'openapi_first/
|
|
11
|
+
require 'openapi_first/response_validation'
|
|
12
|
+
require 'openapi_first/responder'
|
|
12
13
|
require 'openapi_first/app'
|
|
13
14
|
|
|
14
15
|
module OpenapiFirst
|
|
@@ -18,6 +19,10 @@ module OpenapiFirst
|
|
|
18
19
|
INBOX = 'openapi_first.inbox'
|
|
19
20
|
HANDLER = 'openapi_first.handler'
|
|
20
21
|
|
|
22
|
+
def self.env
|
|
23
|
+
ENV['RACK_ENV'] || ENV['HANAMI_ENV'] || ENV['RAILS_ENV']
|
|
24
|
+
end
|
|
25
|
+
|
|
21
26
|
def self.load(spec_path, only: nil)
|
|
22
27
|
content = YAML.load_file(spec_path)
|
|
23
28
|
raw = OasParser::Parser.new(spec_path, content).resolve
|
|
@@ -26,14 +31,14 @@ module OpenapiFirst
|
|
|
26
31
|
Definition.new(parsed)
|
|
27
32
|
end
|
|
28
33
|
|
|
29
|
-
def self.app(spec, namespace:)
|
|
34
|
+
def self.app(spec, namespace:, raise_error: false)
|
|
30
35
|
spec = OpenapiFirst.load(spec) if spec.is_a?(String)
|
|
31
|
-
App.new(nil, spec, namespace: namespace)
|
|
36
|
+
App.new(nil, spec, namespace: namespace, raise_error: raise_error)
|
|
32
37
|
end
|
|
33
38
|
|
|
34
|
-
def self.middleware(spec, namespace:)
|
|
39
|
+
def self.middleware(spec, namespace:, raise_error: false)
|
|
35
40
|
spec = OpenapiFirst.load(spec) if spec.is_a?(String)
|
|
36
|
-
AppWithOptions.new(spec, namespace: namespace)
|
|
41
|
+
AppWithOptions.new(spec, namespace: namespace, raise_error: raise_error)
|
|
37
42
|
end
|
|
38
43
|
|
|
39
44
|
class AppWithOptions
|
|
@@ -48,5 +53,42 @@ module OpenapiFirst
|
|
|
48
53
|
end
|
|
49
54
|
|
|
50
55
|
class Error < StandardError; end
|
|
51
|
-
class
|
|
56
|
+
class NotFoundError < Error; end
|
|
57
|
+
class NotImplementedError < RuntimeError; end
|
|
58
|
+
class ResponseInvalid < Error; end
|
|
59
|
+
class ResponseCodeNotFoundError < ResponseInvalid; end
|
|
60
|
+
class ResponseContentTypeNotFoundError < ResponseInvalid; end
|
|
61
|
+
class ResponseBodyInvalidError < ResponseInvalid; end
|
|
62
|
+
|
|
63
|
+
class RequestInvalidError < Error
|
|
64
|
+
def initialize(serialized_errors)
|
|
65
|
+
message = error_message(serialized_errors)
|
|
66
|
+
super message
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def error_message(errors)
|
|
72
|
+
errors.map do |error|
|
|
73
|
+
[human_source(error), human_error(error)].compact.join(' ')
|
|
74
|
+
end.join(', ')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def human_source(error)
|
|
78
|
+
return unless error[:source]
|
|
79
|
+
|
|
80
|
+
source_key = error[:source].keys.first
|
|
81
|
+
source = {
|
|
82
|
+
pointer: 'Request body invalid:',
|
|
83
|
+
parameter: 'Query parameter invalid:'
|
|
84
|
+
}.fetch(source_key, source_key)
|
|
85
|
+
name = error[:source].values.first
|
|
86
|
+
source += " #{name}" unless name.nil? || name.empty?
|
|
87
|
+
source
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def human_error(error)
|
|
91
|
+
error[:title]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
52
94
|
end
|
data/lib/openapi_first/app.rb
CHANGED
|
@@ -1,22 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'rack'
|
|
4
|
+
require 'logger'
|
|
4
5
|
|
|
5
6
|
module OpenapiFirst
|
|
6
7
|
class App
|
|
7
|
-
def initialize(
|
|
8
|
-
parent_app,
|
|
9
|
-
spec,
|
|
10
|
-
namespace:
|
|
11
|
-
)
|
|
8
|
+
def initialize(parent_app, spec, namespace:, raise_error:)
|
|
12
9
|
@stack = Rack::Builder.app do
|
|
13
10
|
freeze_app
|
|
14
|
-
use OpenapiFirst::Router,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
11
|
+
use OpenapiFirst::Router, spec: spec, raise_error: raise_error, parent_app: parent_app
|
|
12
|
+
use OpenapiFirst::RequestValidation, raise_error: raise_error
|
|
13
|
+
use OpenapiFirst::ResponseValidation if raise_error
|
|
14
|
+
run OpenapiFirst::Responder.new(
|
|
15
|
+
spec: spec,
|
|
16
|
+
namespace: namespace
|
|
17
|
+
)
|
|
20
18
|
end
|
|
21
19
|
end
|
|
22
20
|
|
|
@@ -4,24 +4,15 @@ require_relative 'operation'
|
|
|
4
4
|
|
|
5
5
|
module OpenapiFirst
|
|
6
6
|
class Definition
|
|
7
|
+
attr_reader :filepath
|
|
8
|
+
|
|
7
9
|
def initialize(parsed)
|
|
10
|
+
@filepath = parsed.path
|
|
8
11
|
@spec = parsed
|
|
9
12
|
end
|
|
10
13
|
|
|
11
14
|
def operations
|
|
12
15
|
@spec.endpoints.map { |e| Operation.new(e) }
|
|
13
16
|
end
|
|
14
|
-
|
|
15
|
-
def find_operation!(request)
|
|
16
|
-
@spec
|
|
17
|
-
.path_by_path(request.path)
|
|
18
|
-
.endpoint_by_method(request.request_method.downcase)
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def find_operation(request)
|
|
22
|
-
find_operation!(request)
|
|
23
|
-
rescue OasParser::PathNotFound, OasParser::MethodNotFound
|
|
24
|
-
nil
|
|
25
|
-
end
|
|
26
17
|
end
|
|
27
18
|
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'utils'
|
|
4
|
+
|
|
5
|
+
module OpenapiFirst
|
|
6
|
+
class FindHandler
|
|
7
|
+
def initialize(spec, namespace)
|
|
8
|
+
@namespace = namespace
|
|
9
|
+
@handlers = spec.operations.each_with_object({}) do |operation, hash|
|
|
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
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def [](operation_id)
|
|
21
|
+
@handlers[operation_id]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def find_handler(operation_id)
|
|
25
|
+
name = operation_id.match(/:*(.*)/)&.to_a&.at(1)
|
|
26
|
+
return if name.nil?
|
|
27
|
+
|
|
28
|
+
catch :halt do
|
|
29
|
+
return find_class_method_handler(name) if name.include?('.')
|
|
30
|
+
return find_instance_method_handler(name) if name.include?('#')
|
|
31
|
+
end
|
|
32
|
+
method_name = Utils.underscore(name)
|
|
33
|
+
return unless @namespace.respond_to?(method_name)
|
|
34
|
+
|
|
35
|
+
@namespace.method(method_name)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def find_class_method_handler(name)
|
|
39
|
+
module_name, method_name = name.split('.')
|
|
40
|
+
klass = find_const(@namespace, module_name)
|
|
41
|
+
klass.method(Utils.underscore(method_name))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def find_instance_method_handler(name)
|
|
45
|
+
module_name, klass_name = name.split('#')
|
|
46
|
+
const = find_const(@namespace, module_name)
|
|
47
|
+
klass = find_const(const, klass_name)
|
|
48
|
+
return ->(params, res) { klass.new.call(params, res) } if klass.instance_method(:initialize).arity.zero?
|
|
49
|
+
|
|
50
|
+
->(params, res) { klass.new(params.env).call(params, res) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def find_const(parent, name)
|
|
54
|
+
name = Utils.classify(name)
|
|
55
|
+
throw :halt unless parent.const_defined?(name, false)
|
|
56
|
+
|
|
57
|
+
parent.const_get(name, false)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'forwardable'
|
|
4
|
+
require 'json_schemer'
|
|
4
5
|
require_relative 'utils'
|
|
6
|
+
require_relative 'response_object'
|
|
5
7
|
|
|
6
8
|
module OpenapiFirst
|
|
7
9
|
class Operation
|
|
@@ -24,17 +26,38 @@ module OpenapiFirst
|
|
|
24
26
|
@parameters_json_schema ||= build_parameters_json_schema
|
|
25
27
|
end
|
|
26
28
|
|
|
29
|
+
def parameters_schema
|
|
30
|
+
@parameters_schema ||= parameters_json_schema && JSONSchemer.schema(parameters_json_schema)
|
|
31
|
+
end
|
|
32
|
+
|
|
27
33
|
def content_type_for(status)
|
|
28
|
-
content =
|
|
29
|
-
.response_by_code(status.to_s, use_default: true)
|
|
30
|
-
.content
|
|
34
|
+
content = response_for(status)['content']
|
|
31
35
|
content.keys[0] if content
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def response_schema_for(status, content_type)
|
|
39
|
+
content = response_for(status)['content']
|
|
40
|
+
return if content.nil? || content.empty?
|
|
41
|
+
|
|
42
|
+
media_type = content[content_type]
|
|
43
|
+
unless media_type
|
|
44
|
+
message = "Response content type not found '#{content_type}' for '#{name}'"
|
|
45
|
+
raise ResponseContentTypeNotFoundError, message
|
|
46
|
+
end
|
|
47
|
+
media_type['schema']
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def response_for(status)
|
|
51
|
+
@operation.response_by_code(status.to_s, use_default: true).raw
|
|
32
52
|
rescue OasParser::ResponseCodeNotFound
|
|
33
|
-
|
|
34
|
-
message = "Response status code or default not found: #{status} for '#{operation_name}'" # rubocop:disable Layout/LineLength
|
|
53
|
+
message = "Response status code or default not found: #{status} for '#{name}'"
|
|
35
54
|
raise OpenapiFirst::ResponseCodeNotFoundError, message
|
|
36
55
|
end
|
|
37
56
|
|
|
57
|
+
def name
|
|
58
|
+
"#{method.upcase} #{path} (#{operation_id})"
|
|
59
|
+
end
|
|
60
|
+
|
|
38
61
|
private
|
|
39
62
|
|
|
40
63
|
def build_parameters_json_schema
|
|
@@ -4,12 +4,16 @@ require 'rack'
|
|
|
4
4
|
require 'json_schemer'
|
|
5
5
|
require 'multi_json'
|
|
6
6
|
require_relative 'inbox'
|
|
7
|
+
require_relative 'router_required'
|
|
7
8
|
require_relative 'validation_format'
|
|
8
9
|
|
|
9
10
|
module OpenapiFirst
|
|
10
11
|
class RequestValidation # rubocop:disable Metrics/ClassLength
|
|
11
|
-
|
|
12
|
+
prepend RouterRequired
|
|
13
|
+
|
|
14
|
+
def initialize(app, raise_error: false)
|
|
12
15
|
@app = app
|
|
16
|
+
@raise = raise_error
|
|
13
17
|
end
|
|
14
18
|
|
|
15
19
|
def call(env) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
@@ -46,34 +50,32 @@ module OpenapiFirst
|
|
|
46
50
|
|
|
47
51
|
parsed_request_body = parse_request_body!(body)
|
|
48
52
|
errors = validate_json_schema(schema, parsed_request_body)
|
|
49
|
-
if errors.any?
|
|
50
|
-
halt(error_response(400, serialize_request_body_errors(errors)))
|
|
51
|
-
end
|
|
53
|
+
halt_with_error(400, serialize_request_body_errors(errors)) if errors.any?
|
|
52
54
|
env[INBOX].merge! env[REQUEST_BODY] = parsed_request_body
|
|
53
55
|
end
|
|
54
56
|
|
|
55
57
|
def parse_request_body!(body)
|
|
56
|
-
MultiJson.load(body)
|
|
58
|
+
MultiJson.load(body, symbolize_keys: true)
|
|
57
59
|
rescue MultiJson::ParseError => e
|
|
58
60
|
err = { title: 'Failed to parse body as JSON' }
|
|
59
61
|
err[:detail] = e.cause unless ENV['RACK_ENV'] == 'production'
|
|
60
|
-
|
|
62
|
+
halt_with_error(400, [err])
|
|
61
63
|
end
|
|
62
64
|
|
|
63
65
|
def validate_request_content_type!(content_type, operation)
|
|
64
66
|
return if operation.request_body.content[content_type]
|
|
65
67
|
|
|
66
|
-
|
|
68
|
+
halt_with_error(415)
|
|
67
69
|
end
|
|
68
70
|
|
|
69
71
|
def validate_request_body_presence!(body, operation)
|
|
70
72
|
return unless operation.request_body.required && body.empty?
|
|
71
73
|
|
|
72
|
-
|
|
74
|
+
halt_with_error(415, 'Request body is required')
|
|
73
75
|
end
|
|
74
76
|
|
|
75
77
|
def validate_json_schema(schema, object)
|
|
76
|
-
|
|
78
|
+
schema.validate(Utils.deep_stringify(object))
|
|
77
79
|
end
|
|
78
80
|
|
|
79
81
|
def default_error(status, title = Rack::Utils::HTTP_STATUS_CODES[status])
|
|
@@ -83,8 +85,10 @@ module OpenapiFirst
|
|
|
83
85
|
}
|
|
84
86
|
end
|
|
85
87
|
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
+
def halt_with_error(status, errors = [default_error(status)])
|
|
89
|
+
raise RequestInvalidError, errors if @raise
|
|
90
|
+
|
|
91
|
+
halt Rack::Response.new(
|
|
88
92
|
MultiJson.dump(errors: errors),
|
|
89
93
|
status,
|
|
90
94
|
Rack::CONTENT_TYPE => 'application/vnd.api+json'
|
|
@@ -94,7 +98,8 @@ module OpenapiFirst
|
|
|
94
98
|
def request_body_schema(content_type, operation)
|
|
95
99
|
return unless operation
|
|
96
100
|
|
|
97
|
-
operation.request_body.content[content_type]&.fetch('schema')
|
|
101
|
+
schema = operation.request_body.content[content_type]&.fetch('schema')
|
|
102
|
+
JSONSchemer.schema(schema) if schema
|
|
98
103
|
end
|
|
99
104
|
|
|
100
105
|
def serialize_request_body_errors(validation_errors)
|
|
@@ -112,10 +117,11 @@ module OpenapiFirst
|
|
|
112
117
|
return unless json_schema
|
|
113
118
|
|
|
114
119
|
params = filtered_params(json_schema, params)
|
|
115
|
-
errors =
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
120
|
+
errors = validate_json_schema(
|
|
121
|
+
operation.parameters_schema,
|
|
122
|
+
params
|
|
123
|
+
)
|
|
124
|
+
halt_with_error(400, serialize_query_parameter_errors(errors)) if errors.any?
|
|
119
125
|
env[PARAMETERS] = params
|
|
120
126
|
env[INBOX].merge! params
|
|
121
127
|
end
|
|
@@ -123,7 +129,8 @@ module OpenapiFirst
|
|
|
123
129
|
def filtered_params(json_schema, params)
|
|
124
130
|
json_schema['properties']
|
|
125
131
|
.each_with_object({}) do |key_value, result|
|
|
126
|
-
parameter_name
|
|
132
|
+
parameter_name = key_value[0].to_sym
|
|
133
|
+
schema = key_value[1]
|
|
127
134
|
next unless params.key?(parameter_name)
|
|
128
135
|
|
|
129
136
|
value = params[parameter_name]
|