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
|
@@ -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,57 @@
|
|
|
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
|
+
response = @app.call(env)
|
|
21
|
+
validate(response, operation)
|
|
22
|
+
response
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate(response, operation)
|
|
26
|
+
status, headers, body = response.to_a
|
|
27
|
+
content_type = headers[Rack::CONTENT_TYPE]
|
|
28
|
+
raise ResponseInvalid, "Response has no content-type for '#{operation.name}'" unless content_type
|
|
29
|
+
|
|
30
|
+
response_schema = operation.response_schema_for(status, content_type)
|
|
31
|
+
validate_response_body(response_schema, body) if response_schema
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def validate_response_body(schema, response)
|
|
37
|
+
full_body = +''
|
|
38
|
+
response.each { |chunk| full_body << chunk }
|
|
39
|
+
data = full_body.empty? ? {} : load_json(full_body)
|
|
40
|
+
errors = JSONSchemer.schema(schema).validate(data).to_a.map do |error|
|
|
41
|
+
error_message_for(error)
|
|
42
|
+
end
|
|
43
|
+
raise ResponseBodyInvalidError, errors.join(', ') if errors.any?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def load_json(string)
|
|
47
|
+
MultiJson.load(string)
|
|
48
|
+
rescue MultiJson::ParseError
|
|
49
|
+
string
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def error_message_for(error)
|
|
53
|
+
err = ValidationFormat.error_details(error)
|
|
54
|
+
[err[:title], error['data_pointer'], err[:detail]].compact.join(' ')
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -3,57 +3,20 @@
|
|
|
3
3
|
require 'json_schemer'
|
|
4
4
|
require 'multi_json'
|
|
5
5
|
require_relative 'validation'
|
|
6
|
+
require_relative 'router'
|
|
6
7
|
|
|
7
8
|
module OpenapiFirst
|
|
8
9
|
class ResponseValidator
|
|
9
10
|
def initialize(spec)
|
|
10
11
|
@spec = spec
|
|
12
|
+
@router = Router.new(->(_env) {}, spec: spec, raise_error: true)
|
|
13
|
+
@response_validation = ResponseValidation.new(->(response) { response.to_a })
|
|
11
14
|
end
|
|
12
15
|
|
|
13
16
|
def validate(request, response)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
Validation.new([e.message])
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
private
|
|
21
|
-
|
|
22
|
-
def validation_errors(request, response)
|
|
23
|
-
content = response_for(request, response)&.content
|
|
24
|
-
return unless content
|
|
25
|
-
|
|
26
|
-
content_type = content[response.content_type]
|
|
27
|
-
unless content_type
|
|
28
|
-
return ["Content type not found: '#{response.content_type}'"]
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
response_schema = content_type['schema']
|
|
32
|
-
return unless response_schema
|
|
33
|
-
|
|
34
|
-
response_data = MultiJson.load(response.body)
|
|
35
|
-
validate_json_schema(response_schema, response_data)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def validate_json_schema(schema, data)
|
|
39
|
-
JSONSchemer.schema(schema).validate(data).to_a.map do |error|
|
|
40
|
-
format_error(error)
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def format_error(error)
|
|
45
|
-
ValidationFormat.error_details(error)
|
|
46
|
-
.merge!(
|
|
47
|
-
data_pointer: error['data_pointer'],
|
|
48
|
-
schema_pointer: error['schema_pointer']
|
|
49
|
-
).tap do |formatted|
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def response_for(request, response)
|
|
54
|
-
@spec
|
|
55
|
-
.find_operation!(request)
|
|
56
|
-
&.response_by_code(response.status.to_s, use_default: true)
|
|
17
|
+
env = request.env.dup
|
|
18
|
+
@router.call(env)
|
|
19
|
+
@response_validation.validate(response, env[OPERATION])
|
|
57
20
|
end
|
|
58
21
|
end
|
|
59
22
|
end
|
data/lib/openapi_first/router.rb
CHANGED
|
@@ -6,64 +6,49 @@ require_relative 'utils'
|
|
|
6
6
|
|
|
7
7
|
module OpenapiFirst
|
|
8
8
|
class Router
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
def initialize(
|
|
10
|
+
app,
|
|
11
|
+
spec:,
|
|
12
|
+
raise_error: false,
|
|
13
|
+
parent_app: nil
|
|
14
|
+
)
|
|
12
15
|
@app = app
|
|
13
|
-
@
|
|
14
|
-
@
|
|
15
|
-
@
|
|
16
|
+
@parent_app = parent_app
|
|
17
|
+
@raise = raise_error
|
|
18
|
+
@filepath = spec.filepath
|
|
19
|
+
@router = build_router(spec.operations)
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
def call(env)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
23
|
+
env[OPERATION] = nil
|
|
24
|
+
response = call_router(env)
|
|
25
|
+
status = response[0]
|
|
26
|
+
if UNKNOWN_ROUTE_STATUS.include?(status)
|
|
27
|
+
return @parent_app.call(env) if @parent_app # This should only happen if used via OpenapiFirst.middlware
|
|
22
28
|
|
|
23
|
-
|
|
29
|
+
raise_error(env) if @raise
|
|
30
|
+
end
|
|
31
|
+
response
|
|
24
32
|
end
|
|
25
33
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return if name.nil?
|
|
34
|
+
UNKNOWN_ROUTE_STATUS = [404, 405].freeze
|
|
35
|
+
ORIGINAL_PATH = 'openapi_first.path_info'
|
|
29
36
|
|
|
30
|
-
|
|
31
|
-
module_name, method_name = name.split('.')
|
|
32
|
-
klass = find_const(@namespace, module_name)
|
|
33
|
-
return klass&.method(Utils.underscore(method_name))
|
|
34
|
-
end
|
|
35
|
-
if name.include?('#')
|
|
36
|
-
module_name, klass_name = name.split('#')
|
|
37
|
-
const = find_const(@namespace, module_name)
|
|
38
|
-
klass = find_const(const, klass_name)
|
|
39
|
-
if klass.instance_method(:initialize).arity.zero?
|
|
40
|
-
return ->(params, res) { klass.new.call(params, res) }
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
return ->(params, res) { klass.new(params.env).call(params, res) }
|
|
44
|
-
end
|
|
45
|
-
method_name = Utils.underscore(name)
|
|
46
|
-
return unless @namespace.respond_to?(method_name)
|
|
37
|
+
private
|
|
47
38
|
|
|
48
|
-
|
|
39
|
+
def raise_error(env)
|
|
40
|
+
req = Rack::Request.new(env)
|
|
41
|
+
msg = "Could not find definition for #{req.request_method} '#{req.path}' in API description #{@filepath}"
|
|
42
|
+
raise NotFoundError, msg
|
|
49
43
|
end
|
|
50
44
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
original_path_info = env[Rack::PATH_INFO]
|
|
55
|
-
# Overwrite PATH_INFO temporarily, because hanami-router does not respect SCRIPT_NAME # rubocop:disable Layout/LineLength
|
|
45
|
+
def call_router(env)
|
|
46
|
+
# Changing and restoring PATH_INFO is needed, because Hanami::Router does not respect existing script_path
|
|
47
|
+
env[ORIGINAL_PATH] = env[Rack::PATH_INFO]
|
|
56
48
|
env[Rack::PATH_INFO] = Rack::Request.new(env).path
|
|
57
|
-
@router.
|
|
49
|
+
@router.call(env)
|
|
58
50
|
ensure
|
|
59
|
-
env[Rack::PATH_INFO] =
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def find_const(parent, name)
|
|
63
|
-
name = Utils.classify(name)
|
|
64
|
-
return unless parent.const_defined?(name, false)
|
|
65
|
-
|
|
66
|
-
parent.const_get(name, false)
|
|
51
|
+
env[Rack::PATH_INFO] = env.delete(ORIGINAL_PATH) if env[ORIGINAL_PATH]
|
|
67
52
|
end
|
|
68
53
|
|
|
69
54
|
def build_router(operations) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
@@ -71,12 +56,7 @@ module OpenapiFirst
|
|
|
71
56
|
operations.each do |operation|
|
|
72
57
|
normalized_path = operation.path.gsub('{', ':').gsub('}', '')
|
|
73
58
|
if operation.operation_id.nil?
|
|
74
|
-
warn "operationId is missing in '#{operation.method} #{operation.path}'. I am ignoring this operation."
|
|
75
|
-
next
|
|
76
|
-
end
|
|
77
|
-
handler = @namespace && find_handler(operation.operation_id)
|
|
78
|
-
if @namespace && handler.nil?
|
|
79
|
-
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
|
|
59
|
+
warn "operationId is missing in '#{operation.method} #{operation.path}'. I am ignoring this operation."
|
|
80
60
|
next
|
|
81
61
|
end
|
|
82
62
|
router.public_send(
|
|
@@ -84,8 +64,8 @@ module OpenapiFirst
|
|
|
84
64
|
normalized_path,
|
|
85
65
|
to: lambda do |env|
|
|
86
66
|
env[OPERATION] = operation
|
|
87
|
-
env[PARAMETERS] =
|
|
88
|
-
env[
|
|
67
|
+
env[PARAMETERS] = env['router.params']
|
|
68
|
+
env[Rack::PATH_INFO] = env.delete(ORIGINAL_PATH)
|
|
89
69
|
@app.call(env)
|
|
90
70
|
end
|
|
91
71
|
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenapiFirst
|
|
4
|
+
module RouterRequired
|
|
5
|
+
def call(env)
|
|
6
|
+
unless env.key?(OPERATION)
|
|
7
|
+
raise 'OpenapiFirst::Router missing in middleware stack. Did you forget adding OpenapiFirst::Router?'
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
super
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -5,6 +5,7 @@ module OpenapiFirst
|
|
|
5
5
|
SIMPLE_TYPES = %w[string integer].freeze
|
|
6
6
|
|
|
7
7
|
# rubocop:disable Metrics/MethodLength
|
|
8
|
+
# rubocop:disable Metrics/AbcSize
|
|
8
9
|
def self.error_details(error)
|
|
9
10
|
if error['type'] == 'pattern'
|
|
10
11
|
{
|
|
@@ -23,9 +24,10 @@ module OpenapiFirst
|
|
|
23
24
|
elsif error['schema'] == false
|
|
24
25
|
{ title: 'unknown fields are not allowed' }
|
|
25
26
|
else
|
|
26
|
-
{ title:
|
|
27
|
+
{ title: "is not valid: #{error['data'].inspect}" }
|
|
27
28
|
end
|
|
28
29
|
end
|
|
29
30
|
# rubocop:enable Metrics/MethodLength
|
|
31
|
+
# rubocop:enable Metrics/AbcSize
|
|
30
32
|
end
|
|
31
33
|
end
|
data/openapi_first.gemspec
CHANGED
|
@@ -32,13 +32,13 @@ Gem::Specification.new do |spec|
|
|
|
32
32
|
spec.bindir = 'exe'
|
|
33
33
|
spec.require_paths = ['lib']
|
|
34
34
|
|
|
35
|
-
spec.
|
|
36
|
-
spec.
|
|
37
|
-
spec.
|
|
38
|
-
spec.
|
|
39
|
-
spec.
|
|
40
|
-
spec.
|
|
41
|
-
spec.
|
|
35
|
+
spec.add_runtime_dependency 'deep_merge', '>= 1.2.1'
|
|
36
|
+
spec.add_runtime_dependency 'hanami-router', '~> 2.0.alpha3'
|
|
37
|
+
spec.add_runtime_dependency 'hanami-utils', '~> 2.0.alpha1'
|
|
38
|
+
spec.add_runtime_dependency 'json_schemer', '~> 0.2'
|
|
39
|
+
spec.add_runtime_dependency 'multi_json', '~> 1.14'
|
|
40
|
+
spec.add_runtime_dependency 'oas_parser', '~> 0.25.1'
|
|
41
|
+
spec.add_runtime_dependency 'rack', '~> 2.2'
|
|
42
42
|
|
|
43
43
|
spec.add_development_dependency 'bundler', '~> 2'
|
|
44
44
|
spec.add_development_dependency 'rack-test', '~> 1'
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: openapi_first
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.12.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andreas Haller
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2020-
|
|
11
|
+
date: 2020-06-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: deep_merge
|
|
@@ -30,14 +30,14 @@ dependencies:
|
|
|
30
30
|
requirements:
|
|
31
31
|
- - "~>"
|
|
32
32
|
- !ruby/object:Gem::Version
|
|
33
|
-
version: 2.0.
|
|
33
|
+
version: 2.0.alpha3
|
|
34
34
|
type: :runtime
|
|
35
35
|
prerelease: false
|
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
37
|
requirements:
|
|
38
38
|
- - "~>"
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
|
-
version: 2.0.
|
|
40
|
+
version: 2.0.alpha3
|
|
41
41
|
- !ruby/object:Gem::Dependency
|
|
42
42
|
name: hanami-utils
|
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -203,12 +203,16 @@ files:
|
|
|
203
203
|
- lib/openapi_first/app.rb
|
|
204
204
|
- lib/openapi_first/coverage.rb
|
|
205
205
|
- lib/openapi_first/definition.rb
|
|
206
|
+
- lib/openapi_first/find_handler.rb
|
|
206
207
|
- lib/openapi_first/inbox.rb
|
|
207
208
|
- lib/openapi_first/operation.rb
|
|
208
|
-
- lib/openapi_first/operation_resolver.rb
|
|
209
209
|
- lib/openapi_first/request_validation.rb
|
|
210
|
+
- lib/openapi_first/responder.rb
|
|
211
|
+
- lib/openapi_first/response_object.rb
|
|
212
|
+
- lib/openapi_first/response_validation.rb
|
|
210
213
|
- lib/openapi_first/response_validator.rb
|
|
211
214
|
- lib/openapi_first/router.rb
|
|
215
|
+
- lib/openapi_first/router_required.rb
|
|
212
216
|
- lib/openapi_first/utils.rb
|
|
213
217
|
- lib/openapi_first/validation.rb
|
|
214
218
|
- lib/openapi_first/validation_format.rb
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'rack'
|
|
4
|
-
require_relative 'inbox'
|
|
5
|
-
|
|
6
|
-
module OpenapiFirst
|
|
7
|
-
class OperationResolver
|
|
8
|
-
def call(env)
|
|
9
|
-
operation = env[OpenapiFirst::OPERATION]
|
|
10
|
-
res = Rack::Response.new
|
|
11
|
-
inbox = env[INBOX]
|
|
12
|
-
handler = env[HANDLER]
|
|
13
|
-
result = handler.call(inbox, res)
|
|
14
|
-
res.write serialize(result) if result && res.body.empty?
|
|
15
|
-
res[Rack::CONTENT_TYPE] ||= operation.content_type_for(res.status)
|
|
16
|
-
res.finish
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
private
|
|
20
|
-
|
|
21
|
-
def serialize(result)
|
|
22
|
-
return result if result.is_a?(String)
|
|
23
|
-
|
|
24
|
-
MultiJson.dump(result)
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|