openapi_first 0.10.2 → 0.11.0.alpha
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 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile.lock +11 -10
- data/README.md +13 -3
- data/benchmarks/Gemfile.lock +9 -9
- data/lib/openapi_first.rb +4 -0
- data/lib/openapi_first/app.rb +6 -5
- data/lib/openapi_first/definition.rb +3 -0
- data/lib/openapi_first/find_handler.rb +56 -0
- data/lib/openapi_first/operation.rb +23 -5
- data/lib/openapi_first/operation_resolver.rb +8 -3
- data/lib/openapi_first/request_validation.rb +2 -6
- data/lib/openapi_first/response_object.rb +21 -0
- data/lib/openapi_first/response_validation.rb +116 -0
- data/lib/openapi_first/response_validator.rb +9 -5
- data/lib/openapi_first/router.rb +24 -42
- data/lib/openapi_first/version.rb +1 -1
- data/openapi_first.gemspec +1 -1
- metadata +9 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c16372f591f02e7332d7d010c0a91e239e38592f41bf2eb5d88d7777058f66fd
|
4
|
+
data.tar.gz: e02ab1188d3cd8bc64ece82381316f4d7e8012c784e21d1ae2bb0b16bc24b141
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8d8f44debbe60e787de68246b822669991ba6d1aac3721cbe68f04ee51cffb99ea02b280be0ba3e6e28d0bf11a26b565d2e23792fb08f451462ab36990026970
|
7
|
+
data.tar.gz: 2545816e97f18afde6947c61893b81a2606fa5e27262d037f3fd1475430acf7c1ffa2bf5db70b6d06316cd6524380ac47290b656ae457200e29e8384da860dc9
|
data/.rubocop.yml
CHANGED
@@ -8,10 +8,16 @@ Metrics/BlockLength:
|
|
8
8
|
Exclude:
|
9
9
|
- 'spec/**/*.rb'
|
10
10
|
- '*.gemspec'
|
11
|
+
Layout/EmptyLinesAroundAttributeAccessor:
|
12
|
+
Enabled: true
|
11
13
|
Layout/SpaceAroundMethodCallOperator:
|
12
14
|
Enabled: true
|
15
|
+
Lint/DeprecatedOpenSSLConstant:
|
16
|
+
Enabled: true
|
13
17
|
Lint/RaiseException:
|
14
18
|
Enabled: true
|
19
|
+
Style/SlicingWithRange:
|
20
|
+
Enabled: true
|
15
21
|
Lint/StructNewOverride:
|
16
22
|
Enabled: true
|
17
23
|
Style/HashEachMethods:
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,10 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
+
## Unreleased
|
4
|
+
- Add ResponseValidation middleware that validates the response body
|
5
|
+
- Add `raise` option to Router middleware to raise an error if request could not be found in the API description similar to committee's raise option.
|
6
|
+
- Move namespace option from Router to OperationResolver
|
7
|
+
|
3
8
|
## 0.10.2
|
4
9
|
- Return 400 if request body has invalid JSON ([issue](https://github.com/ahx/openapi_first/issues/73)) thanks Thomas Frütel
|
5
10
|
|
data/Gemfile.lock
CHANGED
@@ -1,9 +1,9 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
openapi_first (0.
|
4
|
+
openapi_first (0.11.0.alpha)
|
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)
|
@@ -30,7 +30,7 @@ GEM
|
|
30
30
|
ecma-re-validator (0.2.1)
|
31
31
|
regexp_parser (~> 1.2)
|
32
32
|
hana (1.3.6)
|
33
|
-
hanami-router (2.0.0.
|
33
|
+
hanami-router (2.0.0.alpha3)
|
34
34
|
mustermann (~> 1.0)
|
35
35
|
mustermann-contrib (~> 1.0)
|
36
36
|
rack (~> 2.0)
|
@@ -41,7 +41,6 @@ GEM
|
|
41
41
|
hash-deep-merge (0.1.1)
|
42
42
|
i18n (1.8.2)
|
43
43
|
concurrent-ruby (~> 1.0)
|
44
|
-
jaro_winkler (1.5.4)
|
45
44
|
json_schemer (0.2.11)
|
46
45
|
ecma-re-validator (~> 0.2)
|
47
46
|
hana (~> 1.3)
|
@@ -49,7 +48,7 @@ GEM
|
|
49
48
|
uri_template (~> 0.7)
|
50
49
|
method_source (1.0.0)
|
51
50
|
mini_portile2 (2.4.0)
|
52
|
-
minitest (5.14.
|
51
|
+
minitest (5.14.1)
|
53
52
|
multi_json (1.14.1)
|
54
53
|
mustermann (1.1.1)
|
55
54
|
ruby2_keywords (~> 0.0.1)
|
@@ -72,7 +71,7 @@ GEM
|
|
72
71
|
pry (0.13.1)
|
73
72
|
coderay (~> 1.1)
|
74
73
|
method_source (~> 1.0)
|
75
|
-
public_suffix (4.0.
|
74
|
+
public_suffix (4.0.5)
|
76
75
|
rack (2.2.2)
|
77
76
|
rack-test (1.1.0)
|
78
77
|
rack (>= 1.0, < 3)
|
@@ -86,21 +85,23 @@ GEM
|
|
86
85
|
rspec-mocks (~> 3.9.0)
|
87
86
|
rspec-core (3.9.2)
|
88
87
|
rspec-support (~> 3.9.3)
|
89
|
-
rspec-expectations (3.9.
|
88
|
+
rspec-expectations (3.9.2)
|
90
89
|
diff-lcs (>= 1.2.0, < 2.0)
|
91
90
|
rspec-support (~> 3.9.0)
|
92
91
|
rspec-mocks (3.9.1)
|
93
92
|
diff-lcs (>= 1.2.0, < 2.0)
|
94
93
|
rspec-support (~> 3.9.0)
|
95
94
|
rspec-support (3.9.3)
|
96
|
-
rubocop (0.
|
97
|
-
jaro_winkler (~> 1.5.1)
|
95
|
+
rubocop (0.84.0)
|
98
96
|
parallel (~> 1.10)
|
99
97
|
parser (>= 2.7.0.1)
|
100
98
|
rainbow (>= 2.2.2, < 4.0)
|
101
99
|
rexml
|
100
|
+
rubocop-ast (>= 0.0.3)
|
102
101
|
ruby-progressbar (~> 1.7)
|
103
102
|
unicode-display_width (>= 1.4.0, < 2.0)
|
103
|
+
rubocop-ast (0.0.3)
|
104
|
+
parser (>= 2.7.0.1)
|
104
105
|
ruby-progressbar (1.10.1)
|
105
106
|
ruby2_keywords (0.0.2)
|
106
107
|
thread_safe (0.3.6)
|
data/README.md
CHANGED
@@ -7,10 +7,19 @@ Start with writing an OpenAPI file that describes the API, which you are about t
|
|
7
7
|
## Rack middlewares
|
8
8
|
OpenapiFirst consists of these Rack middlewares:
|
9
9
|
|
10
|
-
- `OpenapiFirst::Router`
|
11
|
-
- `OpenapiFirst::RequestValidation`
|
10
|
+
- `OpenapiFirst::Router` – Finds the operation for the current request or returns 404 if no operation was found. This can be customized.
|
11
|
+
- `OpenapiFirst::RequestValidation` – Validates the request against the API description and returns 400 if the request is invalid.
|
12
12
|
- `OpenapiFirst::OperationResolver` calls the [handler](#handlers) found for the operation.
|
13
13
|
|
14
|
+
## OpenapiFirst::Router
|
15
|
+
Options and their defaults:
|
16
|
+
|
17
|
+
| Name | Possible values | Description | Default
|
18
|
+
|:---|---|---|---|
|
19
|
+
| `not_found:` |`nil`, `:continue`, `Proc`| Specifies what to do if path was not found in the API description. `nil` (default) returns a 404 response. `:continue` does nothing an calls the next app. `Proc` (or something that responds to `call`) to customize the response. | `nil` (return 404)
|
20
|
+
| `raise:` |`false`, `true` | If set to true the middleware raises `OpenapiFirst::NotFoundError` when a path or method was not found in the API description. This is useful during testing to spot an incomplete API description. | `false` (don't raise an exception)
|
21
|
+
|
22
|
+
|
14
23
|
## Usage within your Rack webframework
|
15
24
|
If you just want to use the request validation part without any handlers you can use the rack middlewares standalone:
|
16
25
|
|
@@ -25,6 +34,7 @@ These variables will available in your rack env:
|
|
25
34
|
- `env[OpenapiFirst::OPERATION]` - Holds an Operation object that responsed about `operation_id` and `path`. This is useful for introspection.
|
26
35
|
- `env[OpenapiFirst::INBOX]`. Holds the (filtered) path and query parameters and the parsed request body.
|
27
36
|
|
37
|
+
|
28
38
|
## Standalone usage
|
29
39
|
You can implement your API in conveniently with just OpenapiFirst.
|
30
40
|
|
@@ -204,7 +214,7 @@ Out of scope. Use [Prism](https://github.com/stoplightio/prism) or [fakeit](http
|
|
204
214
|
|
205
215
|
## Alternatives
|
206
216
|
|
207
|
-
This gem is inspired by [committee](https://github.com/interagent/committee)
|
217
|
+
This gem is inspired by [committee](https://github.com/interagent/committee) (Ruby) and [connexion](https://github.com/zalando/connexion) (Python).
|
208
218
|
|
209
219
|
## Development
|
210
220
|
|
data/benchmarks/Gemfile.lock
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
PATH
|
2
2
|
remote: ..
|
3
3
|
specs:
|
4
|
-
openapi_first (0.
|
4
|
+
openapi_first (0.11.0.alpha)
|
5
5
|
deep_merge (>= 1.2.1)
|
6
6
|
hanami-router (~> 2.0.alpha2)
|
7
7
|
hanami-utils (~> 2.0.alpha1)
|
@@ -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)
|
@@ -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,8 +101,8 @@ 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)
|
data/lib/openapi_first.rb
CHANGED
@@ -8,6 +8,7 @@ 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/response_validation'
|
11
12
|
require 'openapi_first/operation_resolver'
|
12
13
|
require 'openapi_first/app'
|
13
14
|
|
@@ -48,5 +49,8 @@ module OpenapiFirst
|
|
48
49
|
end
|
49
50
|
|
50
51
|
class Error < StandardError; end
|
52
|
+
class NotFoundError < Error; end
|
51
53
|
class ResponseCodeNotFoundError < Error; end
|
54
|
+
class ResponseMediaTypeNotFoundError < Error; end
|
55
|
+
class ResponseBodyInvalidError < Error; end
|
52
56
|
end
|
data/lib/openapi_first/app.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
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
|
@@ -11,12 +12,12 @@ module OpenapiFirst
|
|
11
12
|
)
|
12
13
|
@stack = Rack::Builder.app do
|
13
14
|
freeze_app
|
14
|
-
use OpenapiFirst::Router,
|
15
|
-
spec: spec,
|
16
|
-
namespace: namespace,
|
17
|
-
parent_app: parent_app
|
15
|
+
use OpenapiFirst::Router, spec: spec, parent_app: parent_app
|
18
16
|
use OpenapiFirst::RequestValidation
|
19
|
-
run OpenapiFirst::OperationResolver.new
|
17
|
+
run OpenapiFirst::OperationResolver.new(
|
18
|
+
spec: spec,
|
19
|
+
namespace: namespace
|
20
|
+
)
|
20
21
|
end
|
21
22
|
end
|
22
23
|
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'utils'
|
4
|
+
|
5
|
+
module OpenapiFirst
|
6
|
+
class FindHandler
|
7
|
+
def initialize(spec, namespace)
|
8
|
+
@spec = spec
|
9
|
+
@namespace = namespace
|
10
|
+
end
|
11
|
+
|
12
|
+
def all
|
13
|
+
@spec.operations.each_with_object({}) do |operation, hash|
|
14
|
+
operation_id = operation.operation_id
|
15
|
+
handler = find_by_operation_id(operation_id)
|
16
|
+
if handler.nil?
|
17
|
+
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
|
18
|
+
next
|
19
|
+
end
|
20
|
+
hash[operation_id] = handler
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def find_by_operation_id(operation_id) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
25
|
+
name = operation_id.match(/:*(.*)/)&.to_a&.at(1)
|
26
|
+
return if name.nil?
|
27
|
+
|
28
|
+
if name.include?('.')
|
29
|
+
module_name, method_name = name.split('.')
|
30
|
+
klass = find_const(@namespace, module_name)
|
31
|
+
return klass&.method(Utils.underscore(method_name))
|
32
|
+
end
|
33
|
+
if name.include?('#')
|
34
|
+
module_name, klass_name = name.split('#')
|
35
|
+
const = find_const(@namespace, module_name)
|
36
|
+
klass = find_const(const, klass_name)
|
37
|
+
return ->(params, res) { klass.new.call(params, res) } if klass.instance_method(:initialize).arity.zero?
|
38
|
+
|
39
|
+
return ->(params, res) { klass.new(params.env).call(params, res) }
|
40
|
+
end
|
41
|
+
method_name = Utils.underscore(name)
|
42
|
+
return unless @namespace.respond_to?(method_name)
|
43
|
+
|
44
|
+
@namespace.method(method_name)
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def find_const(parent, name)
|
50
|
+
name = Utils.classify(name)
|
51
|
+
return unless parent.const_defined?(name, false)
|
52
|
+
|
53
|
+
parent.const_get(name, false)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
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,18 +26,35 @@ 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 media type found: '#{content_type}' for '#{operation_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 '#{operation_name}'"
|
35
49
|
raise OpenapiFirst::ResponseCodeNotFoundError, message
|
36
50
|
end
|
37
51
|
|
38
52
|
private
|
39
53
|
|
54
|
+
def operation_name
|
55
|
+
"#{method.upcase} #{path}"
|
56
|
+
end
|
57
|
+
|
40
58
|
def build_parameters_json_schema
|
41
59
|
return unless @operation.parameters&.any?
|
42
60
|
|
@@ -2,15 +2,20 @@
|
|
2
2
|
|
3
3
|
require 'rack'
|
4
4
|
require_relative 'inbox'
|
5
|
+
require_relative 'find_handler'
|
5
6
|
|
6
7
|
module OpenapiFirst
|
7
8
|
class OperationResolver
|
9
|
+
def initialize(spec:, namespace:)
|
10
|
+
@handlers = FindHandler.new(spec, namespace).all
|
11
|
+
@namespace = namespace
|
12
|
+
end
|
13
|
+
|
8
14
|
def call(env)
|
9
15
|
operation = env[OpenapiFirst::OPERATION]
|
10
16
|
res = Rack::Response.new
|
11
|
-
|
12
|
-
|
13
|
-
result = handler.call(inbox, res)
|
17
|
+
handler = @handlers[operation.operation_id]
|
18
|
+
result = handler.call(env[INBOX], res)
|
14
19
|
res.write serialize(result) if result && res.body.empty?
|
15
20
|
res[Rack::CONTENT_TYPE] ||= operation.content_type_for(res.status)
|
16
21
|
res.finish
|
@@ -46,9 +46,7 @@ module OpenapiFirst
|
|
46
46
|
|
47
47
|
parsed_request_body = parse_request_body!(body)
|
48
48
|
errors = validate_json_schema(schema, parsed_request_body)
|
49
|
-
if errors.any?
|
50
|
-
halt(error_response(400, serialize_request_body_errors(errors)))
|
51
|
-
end
|
49
|
+
halt(error_response(400, serialize_request_body_errors(errors))) if errors.any?
|
52
50
|
env[INBOX].merge! env[REQUEST_BODY] = parsed_request_body
|
53
51
|
end
|
54
52
|
|
@@ -113,9 +111,7 @@ module OpenapiFirst
|
|
113
111
|
|
114
112
|
params = filtered_params(json_schema, params)
|
115
113
|
errors = JSONSchemer.schema(json_schema).validate(params)
|
116
|
-
if errors.any?
|
117
|
-
halt error_response(400, serialize_query_parameter_errors(errors))
|
118
|
-
end
|
114
|
+
halt error_response(400, serialize_query_parameter_errors(errors)) if errors.any?
|
119
115
|
env[PARAMETERS] = params
|
120
116
|
env[INBOX].merge! params
|
121
117
|
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,116 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json_schemer'
|
4
|
+
require 'multi_json'
|
5
|
+
require_relative 'validation'
|
6
|
+
|
7
|
+
module OpenapiFirst
|
8
|
+
class ResponseValidation
|
9
|
+
def initialize(app)
|
10
|
+
@app = app
|
11
|
+
end
|
12
|
+
|
13
|
+
def call(env)
|
14
|
+
operation = env[OPERATION]
|
15
|
+
return @app.call(env) unless operation
|
16
|
+
|
17
|
+
status, headers, body = @app.call(env)
|
18
|
+
content_type = headers[Rack::CONTENT_TYPE]
|
19
|
+
response_schema = operation.response_schema_for(status, content_type)
|
20
|
+
validate_response_body(response_schema, body) if response_schema
|
21
|
+
|
22
|
+
[status, headers, body]
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def halt(status, body = '')
|
28
|
+
throw :halt, [status, {}, body]
|
29
|
+
end
|
30
|
+
|
31
|
+
def error(message)
|
32
|
+
{ title: message }
|
33
|
+
end
|
34
|
+
|
35
|
+
def error_response(status, errors)
|
36
|
+
Rack::Response.new(
|
37
|
+
MultiJson.dump(errors: errors),
|
38
|
+
status,
|
39
|
+
Rack::CONTENT_TYPE => 'application/vnd.api+json'
|
40
|
+
).finish
|
41
|
+
end
|
42
|
+
|
43
|
+
def validate_response_body(schema, response)
|
44
|
+
full_body = +''
|
45
|
+
response.each { |chunk| full_body << chunk }
|
46
|
+
data = full_body.empty? ? {} : MultiJson.load(full_body)
|
47
|
+
errors = JSONSchemer.schema(schema).validate(data).to_a.map do |error|
|
48
|
+
format_error(error)
|
49
|
+
end
|
50
|
+
raise ResponseBodyInvalidError, errors.join(', ') if errors.any?
|
51
|
+
end
|
52
|
+
|
53
|
+
def format_error(error)
|
54
|
+
err = ValidationFormat.error_details(error)
|
55
|
+
[err[:title], 'at', error['data_pointer'], err[:detail]].compact.join(' ')
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# frozen_string_literal: true
|
61
|
+
|
62
|
+
require 'json_schemer'
|
63
|
+
require 'multi_json'
|
64
|
+
require_relative 'validation'
|
65
|
+
|
66
|
+
module OpenapiFirst
|
67
|
+
class ResponseValidator
|
68
|
+
def initialize(spec)
|
69
|
+
@spec = spec
|
70
|
+
end
|
71
|
+
|
72
|
+
def validate(request, response)
|
73
|
+
errors = validation_errors(request, response)
|
74
|
+
Validation.new(errors || [])
|
75
|
+
rescue OasParser::ResponseCodeNotFound, OasParser::MethodNotFound => e
|
76
|
+
Validation.new([e.message])
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
def validation_errors(request, response)
|
82
|
+
content = response_for(request, response)&.content
|
83
|
+
return unless content
|
84
|
+
|
85
|
+
content_type = content[response.content_type]
|
86
|
+
return ["Content type not found: '#{response.content_type}'"] unless content_type
|
87
|
+
|
88
|
+
response_schema = content_type['schema']
|
89
|
+
return unless response_schema
|
90
|
+
|
91
|
+
response_data = MultiJson.load(response.body)
|
92
|
+
validate_json_schema(response_schema, response_data)
|
93
|
+
end
|
94
|
+
|
95
|
+
def validate_json_schema(schema, data)
|
96
|
+
JSONSchemer.schema(schema).validate(data).to_a.map do |error|
|
97
|
+
format_error(error)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def format_error(error)
|
102
|
+
ValidationFormat.error_details(error)
|
103
|
+
.merge!(
|
104
|
+
data_pointer: error['data_pointer'],
|
105
|
+
schema_pointer: error['schema_pointer']
|
106
|
+
).tap do |formatted|
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def response_for(request, response)
|
111
|
+
@spec
|
112
|
+
.find_operation!(request)
|
113
|
+
&.response_by_code(response.status.to_s, use_default: true)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -17,6 +17,13 @@ module OpenapiFirst
|
|
17
17
|
Validation.new([e.message])
|
18
18
|
end
|
19
19
|
|
20
|
+
def validate_operation(request, response)
|
21
|
+
errors = validation_errors(request, response)
|
22
|
+
Validation.new(errors || [])
|
23
|
+
rescue OasParser::ResponseCodeNotFound, OasParser::MethodNotFound => e
|
24
|
+
Validation.new([e.message])
|
25
|
+
end
|
26
|
+
|
20
27
|
private
|
21
28
|
|
22
29
|
def validation_errors(request, response)
|
@@ -24,9 +31,7 @@ module OpenapiFirst
|
|
24
31
|
return unless content
|
25
32
|
|
26
33
|
content_type = content[response.content_type]
|
27
|
-
unless content_type
|
28
|
-
return ["Content type not found: '#{response.content_type}'"]
|
29
|
-
end
|
34
|
+
return ["Content type not found: '#{response.content_type}'"] unless content_type
|
30
35
|
|
31
36
|
response_schema = content_type['schema']
|
32
37
|
return unless response_schema
|
@@ -46,8 +51,7 @@ module OpenapiFirst
|
|
46
51
|
.merge!(
|
47
52
|
data_pointer: error['data_pointer'],
|
48
53
|
schema_pointer: error['schema_pointer']
|
49
|
-
)
|
50
|
-
end
|
54
|
+
)
|
51
55
|
end
|
52
56
|
|
53
57
|
def response_for(request, response)
|
data/lib/openapi_first/router.rb
CHANGED
@@ -7,76 +7,59 @@ require_relative 'utils'
|
|
7
7
|
module OpenapiFirst
|
8
8
|
class Router
|
9
9
|
NOT_FOUND = Rack::Response.new('', 404).finish.freeze
|
10
|
+
DEFAULT_NOT_FOUND_APP = ->(_env) { NOT_FOUND }
|
10
11
|
|
11
|
-
def initialize(app, options)
|
12
|
+
def initialize(app, options) # rubocop:disable Metrics/MethodLength
|
12
13
|
@app = app
|
13
|
-
@namespace = options.fetch(:namespace, nil)
|
14
14
|
@parent_app = options.fetch(:parent_app, nil)
|
15
|
-
@
|
15
|
+
@raise = options.fetch(:raise, false)
|
16
|
+
@failure_app = find_failure_app(options[:not_found])
|
17
|
+
if @failure_app.nil?
|
18
|
+
raise ArgumentError,
|
19
|
+
'not_found must be nil, :continue or must respond to call'
|
20
|
+
end
|
21
|
+
spec = options.fetch(:spec)
|
22
|
+
@filepath = spec.filepath
|
23
|
+
@router = build_router(spec.operations)
|
16
24
|
end
|
17
25
|
|
18
26
|
def call(env)
|
19
27
|
endpoint = find_endpoint(env)
|
20
28
|
return endpoint.call(env) if endpoint
|
29
|
+
|
30
|
+
if @raise
|
31
|
+
req = Rack::Request.new(env)
|
32
|
+
msg = "Could not find definition for #{req.request_method} '#{req.path}' in API description #{@filepath}"
|
33
|
+
raise NotFoundError, msg
|
34
|
+
end
|
21
35
|
return @parent_app.call(env) if @parent_app
|
22
36
|
|
23
|
-
|
37
|
+
@failure_app.call(env)
|
24
38
|
end
|
25
39
|
|
26
|
-
|
27
|
-
name = operation_id.match(/:*(.*)/)&.to_a&.at(1)
|
28
|
-
return if name.nil?
|
29
|
-
|
30
|
-
if name.include?('.')
|
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
|
40
|
+
private
|
42
41
|
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
return unless @namespace.respond_to?(method_name)
|
42
|
+
def find_failure_app(option)
|
43
|
+
return DEFAULT_NOT_FOUND_APP if option.nil?
|
44
|
+
return @app if option == :continue
|
47
45
|
|
48
|
-
|
46
|
+
option if option.respond_to?(:call)
|
49
47
|
end
|
50
48
|
|
51
|
-
private
|
52
|
-
|
53
49
|
def find_endpoint(env)
|
54
50
|
original_path_info = env[Rack::PATH_INFO]
|
55
|
-
# Overwrite PATH_INFO temporarily, because hanami-router does not respect SCRIPT_NAME # rubocop:disable Layout/LineLength
|
56
51
|
env[Rack::PATH_INFO] = Rack::Request.new(env).path
|
57
52
|
@router.recognize(env).endpoint
|
58
53
|
ensure
|
59
54
|
env[Rack::PATH_INFO] = original_path_info
|
60
55
|
end
|
61
56
|
|
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)
|
67
|
-
end
|
68
|
-
|
69
57
|
def build_router(operations) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
70
58
|
router = Hanami::Router.new {}
|
71
59
|
operations.each do |operation|
|
72
60
|
normalized_path = operation.path.gsub('{', ':').gsub('}', '')
|
73
61
|
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
|
62
|
+
warn "operationId is missing in '#{operation.method} #{operation.path}'. I am ignoring this operation."
|
80
63
|
next
|
81
64
|
end
|
82
65
|
router.public_send(
|
@@ -85,7 +68,6 @@ module OpenapiFirst
|
|
85
68
|
to: lambda do |env|
|
86
69
|
env[OPERATION] = operation
|
87
70
|
env[PARAMETERS] = Utils.deep_stringify(env['router.params'])
|
88
|
-
env[HANDLER] = handler
|
89
71
|
@app.call(env)
|
90
72
|
end
|
91
73
|
)
|
data/openapi_first.gemspec
CHANGED
@@ -33,7 +33,7 @@ Gem::Specification.new do |spec|
|
|
33
33
|
spec.require_paths = ['lib']
|
34
34
|
|
35
35
|
spec.add_dependency 'deep_merge', '>= 1.2.1'
|
36
|
-
spec.add_dependency 'hanami-router', '~> 2.0.
|
36
|
+
spec.add_dependency 'hanami-router', '~> 2.0.alpha3'
|
37
37
|
spec.add_dependency 'hanami-utils', '~> 2.0.alpha1'
|
38
38
|
spec.add_dependency 'json_schemer', '~> 0.2'
|
39
39
|
spec.add_dependency 'multi_json', '~> 1.14'
|
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.11.0.alpha
|
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-05-
|
11
|
+
date: 2020-05-24 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,10 +203,13 @@ 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
209
|
- lib/openapi_first/operation_resolver.rb
|
209
210
|
- lib/openapi_first/request_validation.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
|
212
215
|
- lib/openapi_first/utils.rb
|
@@ -232,9 +235,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
232
235
|
version: '0'
|
233
236
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
234
237
|
requirements:
|
235
|
-
- - "
|
238
|
+
- - ">"
|
236
239
|
- !ruby/object:Gem::Version
|
237
|
-
version:
|
240
|
+
version: 1.3.1
|
238
241
|
requirements: []
|
239
242
|
rubygems_version: 3.1.2
|
240
243
|
signing_key:
|