openapi_first 0.10.1 → 0.12.0.alpha2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +12 -0
- data/CHANGELOG.md +22 -0
- data/Gemfile.lock +15 -13
- data/README.md +126 -83
- data/benchmarks/Gemfile.lock +11 -11
- data/examples/app.rb +6 -1
- data/lib/openapi_first.rb +46 -5
- 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 +23 -5
- data/lib/openapi_first/request_validation.rb +18 -8
- data/lib/openapi_first/responder.rb +46 -0
- data/lib/openapi_first/response_object.rb +21 -0
- data/lib/openapi_first/response_validation.rb +67 -0
- data/lib/openapi_first/response_validator.rb +17 -10
- data/lib/openapi_first/router.rb +35 -47
- 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 +11 -7
- data/lib/openapi_first/operation_resolver.rb +0 -27
data/examples/app.rb
CHANGED
@@ -13,4 +13,9 @@ module Web
|
|
13
13
|
end
|
14
14
|
|
15
15
|
oas_path = File.absolute_path('./openapi.yaml', __dir__)
|
16
|
-
|
16
|
+
pp OpenapiFirst.env == 'test'
|
17
|
+
App = OpenapiFirst.app(
|
18
|
+
oas_path,
|
19
|
+
namespace: Web,
|
20
|
+
raise_error: OpenapiFirst.env == 'test'
|
21
|
+
)
|
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,41 @@ module OpenapiFirst
|
|
48
53
|
end
|
49
54
|
|
50
55
|
class Error < StandardError; end
|
56
|
+
class NotFoundError < Error; end
|
57
|
+
class NotImplementedError < RuntimeError; end
|
51
58
|
class ResponseCodeNotFoundError < Error; end
|
59
|
+
class ResponseMediaTypeNotFoundError < Error; end
|
60
|
+
class ResponseBodyInvalidError < Error; end
|
61
|
+
|
62
|
+
class RequestInvalidError < Error
|
63
|
+
def initialize(serialized_errors)
|
64
|
+
message = error_message(serialized_errors)
|
65
|
+
super message
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def error_message(errors)
|
71
|
+
errors.map do |error|
|
72
|
+
[human_source(error), human_error(error)].compact.join(' ')
|
73
|
+
end.join(', ')
|
74
|
+
end
|
75
|
+
|
76
|
+
def human_source(error)
|
77
|
+
return unless error[:source]
|
78
|
+
|
79
|
+
source_key = error[:source].keys.first
|
80
|
+
source = {
|
81
|
+
pointer: 'Request body invalid:',
|
82
|
+
parameter: 'Query parameter invalid:'
|
83
|
+
}.fetch(source_key, source_key)
|
84
|
+
name = error[:source].values.first
|
85
|
+
source += " #{name}" unless name.nil? || name.empty?
|
86
|
+
source
|
87
|
+
end
|
88
|
+
|
89
|
+
def human_error(error)
|
90
|
+
error[:title]
|
91
|
+
end
|
92
|
+
end
|
52
93
|
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
|
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
require 'forwardable'
|
4
4
|
require_relative 'utils'
|
5
|
+
require_relative 'response_object'
|
5
6
|
|
6
7
|
module OpenapiFirst
|
7
8
|
class Operation
|
@@ -25,16 +26,33 @@ module OpenapiFirst
|
|
25
26
|
end
|
26
27
|
|
27
28
|
def content_type_for(status)
|
28
|
-
content =
|
29
|
-
.response_by_code(status.to_s, use_default: true)
|
30
|
-
.content
|
29
|
+
content = response_for(status)['content']
|
31
30
|
content.keys[0] if content
|
31
|
+
end
|
32
|
+
|
33
|
+
def response_schema_for(status, content_type)
|
34
|
+
content = response_for(status)['content']
|
35
|
+
return if content.nil? || content.empty?
|
36
|
+
|
37
|
+
media_type = content[content_type]
|
38
|
+
unless media_type
|
39
|
+
message = "Response content type not found: '#{content_type}' for '#{name}'"
|
40
|
+
raise ResponseMediaTypeNotFoundError, message
|
41
|
+
end
|
42
|
+
media_type['schema']
|
43
|
+
end
|
44
|
+
|
45
|
+
def response_for(status)
|
46
|
+
@operation.response_by_code(status.to_s, use_default: true).raw
|
32
47
|
rescue OasParser::ResponseCodeNotFound
|
33
|
-
|
34
|
-
message = "Response status code or default not found: #{status} for '#{operation_name}'" # rubocop:disable Layout/LineLength
|
48
|
+
message = "Response status code or default not found: #{status} for '#{name}'"
|
35
49
|
raise OpenapiFirst::ResponseCodeNotFoundError, message
|
36
50
|
end
|
37
51
|
|
52
|
+
def name
|
53
|
+
"#{method.upcase} #{path} (#{operation_id})"
|
54
|
+
end
|
55
|
+
|
38
56
|
private
|
39
57
|
|
40
58
|
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
|
@@ -44,14 +48,20 @@ module OpenapiFirst
|
|
44
48
|
schema = request_body_schema(content_type, operation)
|
45
49
|
return unless schema
|
46
50
|
|
47
|
-
parsed_request_body =
|
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(error_response(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
|
|
57
|
+
def parse_request_body!(body)
|
58
|
+
MultiJson.load(body)
|
59
|
+
rescue MultiJson::ParseError => e
|
60
|
+
err = { title: 'Failed to parse body as JSON' }
|
61
|
+
err[:detail] = e.cause unless ENV['RACK_ENV'] == 'production'
|
62
|
+
halt(error_response(400, [err]))
|
63
|
+
end
|
64
|
+
|
55
65
|
def validate_request_content_type!(content_type, operation)
|
56
66
|
return if operation.request_body.content[content_type]
|
57
67
|
|
@@ -76,6 +86,8 @@ module OpenapiFirst
|
|
76
86
|
end
|
77
87
|
|
78
88
|
def error_response(status, errors = [default_error(status)])
|
89
|
+
raise RequestInvalidError, errors if @raise
|
90
|
+
|
79
91
|
Rack::Response.new(
|
80
92
|
MultiJson.dump(errors: errors),
|
81
93
|
status,
|
@@ -105,9 +117,7 @@ module OpenapiFirst
|
|
105
117
|
|
106
118
|
params = filtered_params(json_schema, params)
|
107
119
|
errors = JSONSchemer.schema(json_schema).validate(params)
|
108
|
-
if errors.any?
|
109
|
-
halt error_response(400, serialize_query_parameter_errors(errors))
|
110
|
-
end
|
120
|
+
halt error_response(400, serialize_query_parameter_errors(errors)) if errors.any?
|
111
121
|
env[PARAMETERS] = params
|
112
122
|
env[INBOX].merge! params
|
113
123
|
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rack'
|
4
|
+
require_relative 'inbox'
|
5
|
+
require_relative 'find_handler'
|
6
|
+
|
7
|
+
module OpenapiFirst
|
8
|
+
class Responder
|
9
|
+
def initialize(spec:, namespace:, resolver: FindHandler.new(spec, namespace))
|
10
|
+
@resolver = resolver
|
11
|
+
@namespace = namespace
|
12
|
+
end
|
13
|
+
|
14
|
+
def call(env)
|
15
|
+
operation = env[OpenapiFirst::OPERATION]
|
16
|
+
res = Rack::Response.new
|
17
|
+
handler = find_handler(operation)
|
18
|
+
result = handler.call(env[INBOX], res)
|
19
|
+
res.write serialize(result) if result && res.body.empty?
|
20
|
+
res[Rack::CONTENT_TYPE] ||= operation.content_type_for(res.status)
|
21
|
+
res.finish
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def find_handler(operation)
|
27
|
+
handler = @resolver[operation.operation_id]
|
28
|
+
raise NotImplementedError, "Could not find handler for #{operation.name}" unless handler
|
29
|
+
|
30
|
+
handler
|
31
|
+
end
|
32
|
+
|
33
|
+
def serialize(result)
|
34
|
+
return result if result.is_a?(String)
|
35
|
+
|
36
|
+
MultiJson.dump(result)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
class OperationResolver < Responder
|
41
|
+
def initialize(spec:, namespace:)
|
42
|
+
warn "#{self.class.name} was renamed to #{OpenapiFirst::Responder.name}"
|
43
|
+
super
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'forwardable'
|
4
|
+
require_relative 'utils'
|
5
|
+
|
6
|
+
module OpenapiFirst
|
7
|
+
# Represents an OpenAPI Response Object
|
8
|
+
class ResponseObject
|
9
|
+
extend Forwardable
|
10
|
+
def_delegators :@parsed,
|
11
|
+
:content
|
12
|
+
|
13
|
+
def_delegators :@raw,
|
14
|
+
:[]
|
15
|
+
|
16
|
+
def initialize(parsed)
|
17
|
+
@parsed = parsed
|
18
|
+
@raw = parsed.raw
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json_schemer'
|
4
|
+
require 'multi_json'
|
5
|
+
require_relative 'router_required'
|
6
|
+
require_relative 'validation'
|
7
|
+
|
8
|
+
module OpenapiFirst
|
9
|
+
class ResponseValidation
|
10
|
+
prepend RouterRequired
|
11
|
+
|
12
|
+
def initialize(app)
|
13
|
+
@app = app
|
14
|
+
end
|
15
|
+
|
16
|
+
def call(env)
|
17
|
+
operation = env[OPERATION]
|
18
|
+
return @app.call(env) unless operation
|
19
|
+
|
20
|
+
status, headers, body = @app.call(env)
|
21
|
+
content_type = headers[Rack::CONTENT_TYPE]
|
22
|
+
response_schema = operation.response_schema_for(status, content_type)
|
23
|
+
validate_response_body(response_schema, body) if response_schema
|
24
|
+
|
25
|
+
[status, headers, body]
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def halt(status, body = '')
|
31
|
+
throw :halt, [status, {}, body]
|
32
|
+
end
|
33
|
+
|
34
|
+
def error(message)
|
35
|
+
{ title: message }
|
36
|
+
end
|
37
|
+
|
38
|
+
def error_response(status, errors)
|
39
|
+
Rack::Response.new(
|
40
|
+
MultiJson.dump(errors: errors),
|
41
|
+
status,
|
42
|
+
Rack::CONTENT_TYPE => 'application/vnd.api+json'
|
43
|
+
).finish
|
44
|
+
end
|
45
|
+
|
46
|
+
def validate_response_body(schema, response)
|
47
|
+
full_body = +''
|
48
|
+
response.each { |chunk| full_body << chunk }
|
49
|
+
data = full_body.empty? ? {} : load_json(full_body)
|
50
|
+
errors = JSONSchemer.schema(schema).validate(data).to_a.map do |error|
|
51
|
+
format_error(error)
|
52
|
+
end
|
53
|
+
raise ResponseBodyInvalidError, errors.join(', ') if errors.any?
|
54
|
+
end
|
55
|
+
|
56
|
+
def load_json(string)
|
57
|
+
MultiJson.load(string)
|
58
|
+
rescue MultiJson::ParseError
|
59
|
+
string
|
60
|
+
end
|
61
|
+
|
62
|
+
def format_error(error)
|
63
|
+
err = ValidationFormat.error_details(error)
|
64
|
+
[err[:title], error['data_pointer'], err[:detail]].compact.join(' ')
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|