openapi_first 0.10.1 → 0.12.0.alpha2
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 +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
|