openapi_first 0.11.0 → 0.12.2
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/CHANGELOG.md +18 -0
- data/Gemfile.lock +13 -13
- data/README.md +19 -10
- data/benchmarks/Gemfile.lock +8 -8
- data/benchmarks/apps/openapi_first.ru +1 -1
- data/examples/app.rb +6 -1
- data/lib/openapi_first.rb +45 -8
- data/lib/openapi_first/app.rb +4 -3
- data/lib/openapi_first/definition.rb +0 -12
- data/lib/openapi_first/find_handler.rb +25 -21
- data/lib/openapi_first/operation.rb +14 -7
- data/lib/openapi_first/request_validation.rb +21 -13
- data/lib/openapi_first/responder.rb +10 -3
- data/lib/openapi_first/response_validation.rb +21 -21
- data/lib/openapi_first/response_validator.rb +6 -47
- data/lib/openapi_first/router.rb +26 -31
- data/lib/openapi_first/validation_format.rb +3 -1
- data/lib/openapi_first/version.rb +1 -1
- data/openapi_first.gemspec +7 -7
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 689e572886b7ca556ffb00c89368195e129d954a03a3899f3d7d8401bd724196
|
4
|
+
data.tar.gz: e2669f12188188ac232d0c52b8a371e221ce65e589f2bd06dc6b7547fd0cafc2
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c3409b992b56a09eb9e4bc75d20e6711a4ebda0bd93c0422316343ea48b4f8816394f1506b675c56e42f3dad339a0b7aa9c4834f0372045815a81888724ad45e
|
7
|
+
data.tar.gz: 2893535646d84f3ca6db6efc63c5334961929b9f6e561b8d36f771ca4ad0b9e25af5c5b06292ae9745610fa6dd229516c4aa4b42c01c52fc176294a1713b8767
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,23 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
+
## 0.12.2
|
4
|
+
- Allow response to have no media type object specified
|
5
|
+
|
6
|
+
## 0.12.1
|
7
|
+
- Fix response when handler returns 404 or 405
|
8
|
+
- Don't validate the response content if status is 205 (no content)
|
9
|
+
|
10
|
+
## 0.12.0
|
11
|
+
- Change `ResponseValidator` to raise an exception if it found a problem
|
12
|
+
- Params have symbolized keys now
|
13
|
+
- Remove `not_found` option from Router. Return 405 if HTTP verb is not allowed (via Hanami::Router)
|
14
|
+
- Add `raise_error` option to OpenapiFirst.app (false by default)
|
15
|
+
- Add ResponseValidation to OpenapiFirst.app if raise_error option is true
|
16
|
+
- Rename `raise` option to `raise_error`
|
17
|
+
- Add `raise_error` option to RequestValidation middleware
|
18
|
+
- Raise error if handler could not be found by Responder
|
19
|
+
- Add `Operation#name` that returns a human readable name for an operation
|
20
|
+
|
3
21
|
## 0.11.0
|
4
22
|
- Raise error if you forgot to add the Router middleware
|
5
23
|
- Make OpenapiFirst.app raise an error in test env when request path is not specified
|
data/Gemfile.lock
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
openapi_first (0.
|
4
|
+
openapi_first (0.12.2)
|
5
5
|
deep_merge (>= 1.2.1)
|
6
6
|
hanami-router (~> 2.0.alpha3)
|
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.2)
|
17
17
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
18
18
|
i18n (>= 0.7, < 2)
|
19
19
|
minitest (~> 5.1)
|
@@ -21,12 +21,12 @@ GEM
|
|
21
21
|
zeitwerk (~> 2.2, >= 2.2.2)
|
22
22
|
addressable (2.7.0)
|
23
23
|
public_suffix (>= 2.0.2, < 5.0)
|
24
|
-
ast (2.4.
|
24
|
+
ast (2.4.1)
|
25
25
|
builder (3.2.4)
|
26
26
|
coderay (1.1.3)
|
27
27
|
concurrent-ruby (1.1.6)
|
28
28
|
deep_merge (1.2.1)
|
29
|
-
diff-lcs (1.
|
29
|
+
diff-lcs (1.4.2)
|
30
30
|
ecma-re-validator (0.2.1)
|
31
31
|
regexp_parser (~> 1.2)
|
32
32
|
hana (1.3.6)
|
@@ -39,7 +39,7 @@ GEM
|
|
39
39
|
transproc (~> 1.0)
|
40
40
|
hansi (0.2.0)
|
41
41
|
hash-deep-merge (0.1.1)
|
42
|
-
i18n (1.8.
|
42
|
+
i18n (1.8.3)
|
43
43
|
concurrent-ruby (~> 1.0)
|
44
44
|
json_schemer (0.2.11)
|
45
45
|
ecma-re-validator (~> 0.2)
|
@@ -57,7 +57,7 @@ GEM
|
|
57
57
|
mustermann (= 1.1.1)
|
58
58
|
nokogiri (1.10.9)
|
59
59
|
mini_portile2 (~> 2.4.0)
|
60
|
-
oas_parser (0.25.
|
60
|
+
oas_parser (0.25.2)
|
61
61
|
activesupport (>= 4.0.0)
|
62
62
|
addressable (~> 2.3)
|
63
63
|
builder (~> 3.2.3)
|
@@ -65,19 +65,19 @@ GEM
|
|
65
65
|
hash-deep-merge
|
66
66
|
mustermann-contrib (~> 1.1.1)
|
67
67
|
nokogiri
|
68
|
-
parallel (1.19.
|
69
|
-
parser (2.7.1.
|
70
|
-
ast (~> 2.4.
|
68
|
+
parallel (1.19.2)
|
69
|
+
parser (2.7.1.4)
|
70
|
+
ast (~> 2.4.1)
|
71
71
|
pry (0.13.1)
|
72
72
|
coderay (~> 1.1)
|
73
73
|
method_source (~> 1.0)
|
74
74
|
public_suffix (4.0.5)
|
75
|
-
rack (2.2.
|
75
|
+
rack (2.2.3)
|
76
76
|
rack-test (1.1.0)
|
77
77
|
rack (>= 1.0, < 3)
|
78
78
|
rainbow (3.0.0)
|
79
79
|
rake (13.0.1)
|
80
|
-
regexp_parser (1.7.
|
80
|
+
regexp_parser (1.7.1)
|
81
81
|
rexml (3.2.4)
|
82
82
|
rspec (3.9.0)
|
83
83
|
rspec-core (~> 3.9.0)
|
@@ -92,13 +92,13 @@ GEM
|
|
92
92
|
diff-lcs (>= 1.2.0, < 2.0)
|
93
93
|
rspec-support (~> 3.9.0)
|
94
94
|
rspec-support (3.9.3)
|
95
|
-
rubocop (0.
|
95
|
+
rubocop (0.86.0)
|
96
96
|
parallel (~> 1.10)
|
97
97
|
parser (>= 2.7.0.1)
|
98
98
|
rainbow (>= 2.2.2, < 4.0)
|
99
99
|
regexp_parser (>= 1.7)
|
100
100
|
rexml
|
101
|
-
rubocop-ast (>= 0.0.3)
|
101
|
+
rubocop-ast (>= 0.0.3, < 1.0)
|
102
102
|
ruby-progressbar (~> 1.7)
|
103
103
|
unicode-display_width (>= 1.4.0, < 2.0)
|
104
104
|
rubocop-ast (0.0.3)
|
data/README.md
CHANGED
@@ -8,6 +8,12 @@ Start with writing an OpenAPI file that describes the API, which you are about t
|
|
8
8
|
|
9
9
|
You can use OpenapiFirst via its [Rack middlewares](#rack-middlewares) or in [standalone mode](#standalone-usage).
|
10
10
|
|
11
|
+
## Alternatives
|
12
|
+
|
13
|
+
This gem is inspired by [committee](https://github.com/interagent/committee) (Ruby) and [connexion](https://github.com/zalando/connexion) (Python).
|
14
|
+
|
15
|
+
Here's a [comparison between committee and openapi_first](https://gist.github.com/ahx/1538c31f0652f459861713b5259e366a).
|
16
|
+
|
11
17
|
## Rack middlewares
|
12
18
|
OpenapiFirst consists of these Rack middlewares:
|
13
19
|
|
@@ -30,8 +36,7 @@ Options and their defaults:
|
|
30
36
|
| Name | Possible values | Description | Default
|
31
37
|
|:---|---|---|---|
|
32
38
|
|`spec:`| | The spec loaded via `OpenapiFirst.load` ||
|
33
|
-
| `
|
34
|
-
| `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)
|
39
|
+
| `raise_error:` |`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)
|
35
40
|
|
36
41
|
## OpenapiFirst::RequestValidation
|
37
42
|
|
@@ -41,6 +46,13 @@ This middleware returns a 400 status code with a body that describes the error i
|
|
41
46
|
use OpenapiFirst::RequestValidation
|
42
47
|
```
|
43
48
|
|
49
|
+
|
50
|
+
Options and their defaults:
|
51
|
+
|
52
|
+
| Name | Possible values | Description | Default
|
53
|
+
|:---|---|---|---|
|
54
|
+
| `raise_error:` |`false`, `true` | If set to true the middleware raises `OpenapiFirst::RequestInvalidError` instead of returning 4xx. | `false` (don't raise an exception)
|
55
|
+
|
44
56
|
The error responses conform with [JSON:API](https://jsonapi.org).
|
45
57
|
|
46
58
|
Here's an example response body for a missing query parameter "search":
|
@@ -117,7 +129,7 @@ There are two ways to set the response body:
|
|
117
129
|
- Returning a value which will get converted to JSON
|
118
130
|
|
119
131
|
## OpenapiFirst::ResponseValidation
|
120
|
-
This middleware is especially useful when testing. It raises an error if the response is not valid.
|
132
|
+
This middleware is especially useful when testing. It *always* raises an error if the response is not valid.
|
121
133
|
|
122
134
|
```ruby
|
123
135
|
use OpenapiFirst::ResponseValidation if ENV['RACK_ENV'] == 'test'
|
@@ -130,7 +142,7 @@ Instead of composing these middlewares yourself you can use `OpenapiFirst.app`.
|
|
130
142
|
module Pets
|
131
143
|
def self.find_pet(params, res)
|
132
144
|
{
|
133
|
-
id: params[
|
145
|
+
id: params[:id],
|
134
146
|
name: 'Oscar'
|
135
147
|
}
|
136
148
|
end
|
@@ -177,7 +189,7 @@ OpenapiFirst uses [`multi_json`](https://rubygems.org/gems/multi_json).
|
|
177
189
|
|
178
190
|
## Manual response validation
|
179
191
|
|
180
|
-
|
192
|
+
Instead of using the ResponseValidation middleware you can validate the response in your test manually via [rack-test](https://github.com/rack-test/rack-test) and ResponseValidator.
|
181
193
|
|
182
194
|
```ruby
|
183
195
|
# In your test (rspec example):
|
@@ -185,7 +197,8 @@ require 'openapi_first'
|
|
185
197
|
spec = OpenapiFirst.load('petstore.yaml')
|
186
198
|
validator = OpenapiFirst::ResponseValidator.new(spec)
|
187
199
|
|
188
|
-
|
200
|
+
# This will raise an exception if it found an error
|
201
|
+
validator.validate(last_request, last_response)
|
189
202
|
```
|
190
203
|
|
191
204
|
## Handling only certain paths
|
@@ -236,10 +249,6 @@ end
|
|
236
249
|
|
237
250
|
Out of scope. Use [Prism](https://github.com/stoplightio/prism) or [fakeit](https://github.com/JustinFeng/fakeit).
|
238
251
|
|
239
|
-
## Alternatives
|
240
|
-
|
241
|
-
This gem is inspired by [committee](https://github.com/interagent/committee) (Ruby) and [connexion](https://github.com/zalando/connexion) (Python).
|
242
|
-
|
243
252
|
## Development
|
244
253
|
|
245
254
|
Run `bin/setup` to install dependencies.
|
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.12.2)
|
5
5
|
deep_merge (>= 1.2.1)
|
6
6
|
hanami-router (~> 2.0.alpha3)
|
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.2)
|
17
17
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
18
18
|
i18n (>= 0.7, < 2)
|
19
19
|
minitest (~> 5.1)
|
@@ -31,7 +31,7 @@ GEM
|
|
31
31
|
rack (>= 1.5)
|
32
32
|
concurrent-ruby (1.1.6)
|
33
33
|
deep_merge (1.2.1)
|
34
|
-
dry-configurable (0.11.
|
34
|
+
dry-configurable (0.11.6)
|
35
35
|
concurrent-ruby (~> 1.0)
|
36
36
|
dry-core (~> 0.4, >= 0.4.7)
|
37
37
|
dry-equalizer (~> 0.2)
|
@@ -72,9 +72,9 @@ 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
|
-
json_schema (0.20.
|
77
|
+
json_schema (0.20.9)
|
78
78
|
json_schemer (0.2.11)
|
79
79
|
ecma-re-validator (~> 0.2)
|
80
80
|
hana (~> 1.3)
|
@@ -93,7 +93,7 @@ GEM
|
|
93
93
|
mustermann (>= 1.0.0)
|
94
94
|
nokogiri (1.10.9)
|
95
95
|
mini_portile2 (~> 2.4.0)
|
96
|
-
oas_parser (0.25.
|
96
|
+
oas_parser (0.25.2)
|
97
97
|
activesupport (>= 4.0.0)
|
98
98
|
addressable (~> 2.3)
|
99
99
|
builder (~> 3.2.3)
|
@@ -103,12 +103,12 @@ GEM
|
|
103
103
|
nokogiri
|
104
104
|
openapi_parser (0.11.2)
|
105
105
|
public_suffix (4.0.5)
|
106
|
-
rack (2.2.
|
106
|
+
rack (2.2.3)
|
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
@@ -19,6 +19,10 @@ module OpenapiFirst
|
|
19
19
|
INBOX = 'openapi_first.inbox'
|
20
20
|
HANDLER = 'openapi_first.handler'
|
21
21
|
|
22
|
+
def self.env
|
23
|
+
ENV['RACK_ENV'] || ENV['HANAMI_ENV'] || ENV['RAILS_ENV']
|
24
|
+
end
|
25
|
+
|
22
26
|
def self.load(spec_path, only: nil)
|
23
27
|
content = YAML.load_file(spec_path)
|
24
28
|
raw = OasParser::Parser.new(spec_path, content).resolve
|
@@ -27,15 +31,14 @@ module OpenapiFirst
|
|
27
31
|
Definition.new(parsed)
|
28
32
|
end
|
29
33
|
|
30
|
-
def self.app(spec, namespace:)
|
34
|
+
def self.app(spec, namespace:, raise_error: false)
|
31
35
|
spec = OpenapiFirst.load(spec) if spec.is_a?(String)
|
32
|
-
|
33
|
-
App.new(nil, spec, namespace: namespace, router_raise: test)
|
36
|
+
App.new(nil, spec, namespace: namespace, raise_error: raise_error)
|
34
37
|
end
|
35
38
|
|
36
|
-
def self.middleware(spec, namespace:)
|
39
|
+
def self.middleware(spec, namespace:, raise_error: false)
|
37
40
|
spec = OpenapiFirst.load(spec) if spec.is_a?(String)
|
38
|
-
AppWithOptions.new(spec, namespace: namespace,
|
41
|
+
AppWithOptions.new(spec, namespace: namespace, raise_error: raise_error)
|
39
42
|
end
|
40
43
|
|
41
44
|
class AppWithOptions
|
@@ -51,7 +54,41 @@ module OpenapiFirst
|
|
51
54
|
|
52
55
|
class Error < StandardError; end
|
53
56
|
class NotFoundError < Error; end
|
54
|
-
class
|
55
|
-
class
|
56
|
-
class
|
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
|
57
94
|
end
|
data/lib/openapi_first/app.rb
CHANGED
@@ -5,11 +5,12 @@ require 'logger'
|
|
5
5
|
|
6
6
|
module OpenapiFirst
|
7
7
|
class App
|
8
|
-
def initialize(parent_app, spec, namespace:,
|
8
|
+
def initialize(parent_app, spec, namespace:, raise_error:)
|
9
9
|
@stack = Rack::Builder.app do
|
10
10
|
freeze_app
|
11
|
-
use OpenapiFirst::Router, spec: spec,
|
12
|
-
use OpenapiFirst::RequestValidation
|
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
|
13
14
|
run OpenapiFirst::Responder.new(
|
14
15
|
spec: spec,
|
15
16
|
namespace: namespace
|
@@ -14,17 +14,5 @@ module OpenapiFirst
|
|
14
14
|
def operations
|
15
15
|
@spec.endpoints.map { |e| Operation.new(e) }
|
16
16
|
end
|
17
|
-
|
18
|
-
def find_operation!(request)
|
19
|
-
@spec
|
20
|
-
.path_by_path(request.path)
|
21
|
-
.endpoint_by_method(request.request_method.downcase)
|
22
|
-
end
|
23
|
-
|
24
|
-
def find_operation(request)
|
25
|
-
find_operation!(request)
|
26
|
-
rescue OasParser::PathNotFound, OasParser::MethodNotFound
|
27
|
-
nil
|
28
|
-
end
|
29
17
|
end
|
30
18
|
end
|
@@ -5,14 +5,10 @@ require_relative 'utils'
|
|
5
5
|
module OpenapiFirst
|
6
6
|
class FindHandler
|
7
7
|
def initialize(spec, namespace)
|
8
|
-
@spec = spec
|
9
8
|
@namespace = namespace
|
10
|
-
|
11
|
-
|
12
|
-
def all
|
13
|
-
@spec.operations.each_with_object({}) do |operation, hash|
|
9
|
+
@handlers = spec.operations.each_with_object({}) do |operation, hash|
|
14
10
|
operation_id = operation.operation_id
|
15
|
-
handler =
|
11
|
+
handler = find_handler(operation_id)
|
16
12
|
if handler.nil?
|
17
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
|
18
14
|
next
|
@@ -21,22 +17,17 @@ module OpenapiFirst
|
|
21
17
|
end
|
22
18
|
end
|
23
19
|
|
24
|
-
def
|
20
|
+
def [](operation_id)
|
21
|
+
@handlers[operation_id]
|
22
|
+
end
|
23
|
+
|
24
|
+
def find_handler(operation_id)
|
25
25
|
name = operation_id.match(/:*(.*)/)&.to_a&.at(1)
|
26
26
|
return if name.nil?
|
27
27
|
|
28
|
-
|
29
|
-
|
30
|
-
|
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) }
|
28
|
+
catch :halt do
|
29
|
+
return find_class_method_handler(name) if name.include?('.')
|
30
|
+
return find_instance_method_handler(name) if name.include?('#')
|
40
31
|
end
|
41
32
|
method_name = Utils.underscore(name)
|
42
33
|
return unless @namespace.respond_to?(method_name)
|
@@ -44,11 +35,24 @@ module OpenapiFirst
|
|
44
35
|
@namespace.method(method_name)
|
45
36
|
end
|
46
37
|
|
47
|
-
|
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
|
48
52
|
|
49
53
|
def find_const(parent, name)
|
50
54
|
name = Utils.classify(name)
|
51
|
-
|
55
|
+
throw :halt unless parent.const_defined?(name, false)
|
52
56
|
|
53
57
|
parent.const_get(name, false)
|
54
58
|
end
|
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'forwardable'
|
4
|
+
require 'json_schemer'
|
4
5
|
require_relative 'utils'
|
5
6
|
require_relative 'response_object'
|
6
7
|
|
@@ -25,6 +26,10 @@ module OpenapiFirst
|
|
25
26
|
@parameters_json_schema ||= build_parameters_json_schema
|
26
27
|
end
|
27
28
|
|
29
|
+
def parameters_schema
|
30
|
+
@parameters_schema ||= parameters_json_schema && JSONSchemer.schema(parameters_json_schema)
|
31
|
+
end
|
32
|
+
|
28
33
|
def content_type_for(status)
|
29
34
|
content = response_for(status)['content']
|
30
35
|
content.keys[0] if content
|
@@ -34,10 +39,12 @@ module OpenapiFirst
|
|
34
39
|
content = response_for(status)['content']
|
35
40
|
return if content.nil? || content.empty?
|
36
41
|
|
42
|
+
raise ResponseInvalid, "Response has no content-type for '#{name}'" unless content_type
|
43
|
+
|
37
44
|
media_type = content[content_type]
|
38
45
|
unless media_type
|
39
|
-
message = "Response
|
40
|
-
raise
|
46
|
+
message = "Response content type not found '#{content_type}' for '#{name}'"
|
47
|
+
raise ResponseContentTypeNotFoundError, message
|
41
48
|
end
|
42
49
|
media_type['schema']
|
43
50
|
end
|
@@ -45,16 +52,16 @@ module OpenapiFirst
|
|
45
52
|
def response_for(status)
|
46
53
|
@operation.response_by_code(status.to_s, use_default: true).raw
|
47
54
|
rescue OasParser::ResponseCodeNotFound
|
48
|
-
message = "Response status code or default not found: #{status} for '#{
|
55
|
+
message = "Response status code or default not found: #{status} for '#{name}'"
|
49
56
|
raise OpenapiFirst::ResponseCodeNotFoundError, message
|
50
57
|
end
|
51
58
|
|
52
|
-
|
53
|
-
|
54
|
-
def operation_name
|
55
|
-
"#{method.upcase} #{path}"
|
59
|
+
def name
|
60
|
+
"#{method.upcase} #{path} (#{operation_id})"
|
56
61
|
end
|
57
62
|
|
63
|
+
private
|
64
|
+
|
58
65
|
def build_parameters_json_schema
|
59
66
|
return unless @operation.parameters&.any?
|
60
67
|
|
@@ -11,8 +11,9 @@ module OpenapiFirst
|
|
11
11
|
class RequestValidation # rubocop:disable Metrics/ClassLength
|
12
12
|
prepend RouterRequired
|
13
13
|
|
14
|
-
def initialize(app)
|
14
|
+
def initialize(app, raise_error: false)
|
15
15
|
@app = app
|
16
|
+
@raise = raise_error
|
16
17
|
end
|
17
18
|
|
18
19
|
def call(env) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
@@ -49,32 +50,32 @@ module OpenapiFirst
|
|
49
50
|
|
50
51
|
parsed_request_body = parse_request_body!(body)
|
51
52
|
errors = validate_json_schema(schema, parsed_request_body)
|
52
|
-
|
53
|
+
halt_with_error(400, serialize_request_body_errors(errors)) if errors.any?
|
53
54
|
env[INBOX].merge! env[REQUEST_BODY] = parsed_request_body
|
54
55
|
end
|
55
56
|
|
56
57
|
def parse_request_body!(body)
|
57
|
-
MultiJson.load(body)
|
58
|
+
MultiJson.load(body, symbolize_keys: true)
|
58
59
|
rescue MultiJson::ParseError => e
|
59
60
|
err = { title: 'Failed to parse body as JSON' }
|
60
61
|
err[:detail] = e.cause unless ENV['RACK_ENV'] == 'production'
|
61
|
-
|
62
|
+
halt_with_error(400, [err])
|
62
63
|
end
|
63
64
|
|
64
65
|
def validate_request_content_type!(content_type, operation)
|
65
66
|
return if operation.request_body.content[content_type]
|
66
67
|
|
67
|
-
|
68
|
+
halt_with_error(415)
|
68
69
|
end
|
69
70
|
|
70
71
|
def validate_request_body_presence!(body, operation)
|
71
72
|
return unless operation.request_body.required && body.empty?
|
72
73
|
|
73
|
-
|
74
|
+
halt_with_error(415, 'Request body is required')
|
74
75
|
end
|
75
76
|
|
76
77
|
def validate_json_schema(schema, object)
|
77
|
-
|
78
|
+
schema.validate(Utils.deep_stringify(object))
|
78
79
|
end
|
79
80
|
|
80
81
|
def default_error(status, title = Rack::Utils::HTTP_STATUS_CODES[status])
|
@@ -84,8 +85,10 @@ module OpenapiFirst
|
|
84
85
|
}
|
85
86
|
end
|
86
87
|
|
87
|
-
def
|
88
|
-
|
88
|
+
def halt_with_error(status, errors = [default_error(status)])
|
89
|
+
raise RequestInvalidError, errors if @raise
|
90
|
+
|
91
|
+
halt Rack::Response.new(
|
89
92
|
MultiJson.dump(errors: errors),
|
90
93
|
status,
|
91
94
|
Rack::CONTENT_TYPE => 'application/vnd.api+json'
|
@@ -95,7 +98,8 @@ module OpenapiFirst
|
|
95
98
|
def request_body_schema(content_type, operation)
|
96
99
|
return unless operation
|
97
100
|
|
98
|
-
operation.request_body.content[content_type]&.fetch('schema')
|
101
|
+
schema = operation.request_body.content[content_type]&.fetch('schema')
|
102
|
+
JSONSchemer.schema(schema) if schema
|
99
103
|
end
|
100
104
|
|
101
105
|
def serialize_request_body_errors(validation_errors)
|
@@ -113,8 +117,11 @@ module OpenapiFirst
|
|
113
117
|
return unless json_schema
|
114
118
|
|
115
119
|
params = filtered_params(json_schema, params)
|
116
|
-
errors =
|
117
|
-
|
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?
|
118
125
|
env[PARAMETERS] = params
|
119
126
|
env[INBOX].merge! params
|
120
127
|
end
|
@@ -122,7 +129,8 @@ module OpenapiFirst
|
|
122
129
|
def filtered_params(json_schema, params)
|
123
130
|
json_schema['properties']
|
124
131
|
.each_with_object({}) do |key_value, result|
|
125
|
-
parameter_name
|
132
|
+
parameter_name = key_value[0].to_sym
|
133
|
+
schema = key_value[1]
|
126
134
|
next unless params.key?(parameter_name)
|
127
135
|
|
128
136
|
value = params[parameter_name]
|
@@ -6,15 +6,15 @@ require_relative 'find_handler'
|
|
6
6
|
|
7
7
|
module OpenapiFirst
|
8
8
|
class Responder
|
9
|
-
def initialize(spec:, namespace:)
|
10
|
-
@
|
9
|
+
def initialize(spec:, namespace:, resolver: FindHandler.new(spec, namespace))
|
10
|
+
@resolver = resolver
|
11
11
|
@namespace = namespace
|
12
12
|
end
|
13
13
|
|
14
14
|
def call(env)
|
15
15
|
operation = env[OpenapiFirst::OPERATION]
|
16
16
|
res = Rack::Response.new
|
17
|
-
handler =
|
17
|
+
handler = find_handler(operation)
|
18
18
|
result = handler.call(env[INBOX], res)
|
19
19
|
res.write serialize(result) if result && res.body.empty?
|
20
20
|
res[Rack::CONTENT_TYPE] ||= operation.content_type_for(res.status)
|
@@ -23,6 +23,13 @@ module OpenapiFirst
|
|
23
23
|
|
24
24
|
private
|
25
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
|
+
|
26
33
|
def serialize(result)
|
27
34
|
return result if result.is_a?(String)
|
28
35
|
|
@@ -17,45 +17,45 @@ module OpenapiFirst
|
|
17
17
|
operation = env[OPERATION]
|
18
18
|
return @app.call(env) unless operation
|
19
19
|
|
20
|
-
|
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
|
+
return validate_status_only(operation, status) if status == 204
|
28
|
+
|
21
29
|
content_type = headers[Rack::CONTENT_TYPE]
|
22
30
|
response_schema = operation.response_schema_for(status, content_type)
|
23
31
|
validate_response_body(response_schema, body) if response_schema
|
24
|
-
|
25
|
-
[status, headers, body]
|
26
32
|
end
|
27
33
|
|
28
34
|
private
|
29
35
|
|
30
|
-
def
|
31
|
-
|
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
|
36
|
+
def validate_status_only(operation, status)
|
37
|
+
operation.response_for(status)
|
44
38
|
end
|
45
39
|
|
46
40
|
def validate_response_body(schema, response)
|
47
41
|
full_body = +''
|
48
42
|
response.each { |chunk| full_body << chunk }
|
49
|
-
data = full_body.empty? ? {} :
|
43
|
+
data = full_body.empty? ? {} : load_json(full_body)
|
50
44
|
errors = JSONSchemer.schema(schema).validate(data).to_a.map do |error|
|
51
|
-
|
45
|
+
error_message_for(error)
|
52
46
|
end
|
53
47
|
raise ResponseBodyInvalidError, errors.join(', ') if errors.any?
|
54
48
|
end
|
55
49
|
|
56
|
-
def
|
50
|
+
def load_json(string)
|
51
|
+
MultiJson.load(string)
|
52
|
+
rescue MultiJson::ParseError
|
53
|
+
string
|
54
|
+
end
|
55
|
+
|
56
|
+
def error_message_for(error)
|
57
57
|
err = ValidationFormat.error_details(error)
|
58
|
-
[err[:title],
|
58
|
+
[err[:title], error['data_pointer'], err[:detail]].compact.join(' ')
|
59
59
|
end
|
60
60
|
end
|
61
61
|
end
|
@@ -3,61 +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
|
-
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
|
-
|
27
|
-
private
|
28
|
-
|
29
|
-
def validation_errors(request, response)
|
30
|
-
content = response_for(request, response)&.content
|
31
|
-
return unless content
|
32
|
-
|
33
|
-
content_type = content[response.content_type]
|
34
|
-
return ["Content type not found: '#{response.content_type}'"] unless content_type
|
35
|
-
|
36
|
-
response_schema = content_type['schema']
|
37
|
-
return unless response_schema
|
38
|
-
|
39
|
-
response_data = MultiJson.load(response.body)
|
40
|
-
validate_json_schema(response_schema, response_data)
|
41
|
-
end
|
42
|
-
|
43
|
-
def validate_json_schema(schema, data)
|
44
|
-
JSONSchemer.schema(schema).validate(data).to_a.map do |error|
|
45
|
-
format_error(error)
|
46
|
-
end
|
47
|
-
end
|
48
|
-
|
49
|
-
def format_error(error)
|
50
|
-
ValidationFormat.error_details(error)
|
51
|
-
.merge!(
|
52
|
-
data_pointer: error['data_pointer'],
|
53
|
-
schema_pointer: error['schema_pointer']
|
54
|
-
)
|
55
|
-
end
|
56
|
-
|
57
|
-
def response_for(request, response)
|
58
|
-
@spec
|
59
|
-
.find_operation!(request)
|
60
|
-
&.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])
|
61
20
|
end
|
62
21
|
end
|
63
22
|
end
|
data/lib/openapi_first/router.rb
CHANGED
@@ -6,53 +6,47 @@ require_relative 'utils'
|
|
6
6
|
|
7
7
|
module OpenapiFirst
|
8
8
|
class Router
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
9
|
+
def initialize(
|
10
|
+
app,
|
11
|
+
spec:,
|
12
|
+
raise_error: false,
|
13
|
+
parent_app: nil
|
14
|
+
)
|
13
15
|
@app = app
|
14
|
-
@parent_app =
|
15
|
-
@raise =
|
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)
|
16
|
+
@parent_app = parent_app
|
17
|
+
@raise = raise_error
|
22
18
|
@filepath = spec.filepath
|
23
19
|
@router = build_router(spec.operations)
|
24
20
|
end
|
25
21
|
|
26
22
|
def call(env)
|
27
23
|
env[OPERATION] = nil
|
28
|
-
|
29
|
-
|
24
|
+
response = call_router(env)
|
25
|
+
if env[OPERATION].nil?
|
26
|
+
return @parent_app.call(env) if @parent_app # This should only happen if used via OpenapiFirst.middlware
|
30
27
|
|
31
|
-
|
32
|
-
req = Rack::Request.new(env)
|
33
|
-
msg = "Could not find definition for #{req.request_method} '#{req.path}' in API description #{@filepath}"
|
34
|
-
raise NotFoundError, msg
|
28
|
+
raise_error(env) if @raise
|
35
29
|
end
|
36
|
-
|
37
|
-
|
38
|
-
@failure_app.call(env)
|
30
|
+
response
|
39
31
|
end
|
40
32
|
|
41
|
-
|
33
|
+
ORIGINAL_PATH = 'openapi_first.path_info'
|
42
34
|
|
43
|
-
|
44
|
-
return DEFAULT_NOT_FOUND_APP if option.nil?
|
45
|
-
return @app if option == :continue
|
35
|
+
private
|
46
36
|
|
47
|
-
|
37
|
+
def raise_error(env)
|
38
|
+
req = Rack::Request.new(env)
|
39
|
+
msg = "Could not find definition for #{req.request_method} '#{req.path}' in API description #{@filepath}"
|
40
|
+
raise NotFoundError, msg
|
48
41
|
end
|
49
42
|
|
50
|
-
def
|
51
|
-
|
43
|
+
def call_router(env)
|
44
|
+
# Changing and restoring PATH_INFO is needed, because Hanami::Router does not respect existing script_path
|
45
|
+
env[ORIGINAL_PATH] = env[Rack::PATH_INFO]
|
52
46
|
env[Rack::PATH_INFO] = Rack::Request.new(env).path
|
53
|
-
@router.
|
47
|
+
@router.call(env)
|
54
48
|
ensure
|
55
|
-
env[Rack::PATH_INFO] =
|
49
|
+
env[Rack::PATH_INFO] = env.delete(ORIGINAL_PATH) if env[ORIGINAL_PATH]
|
56
50
|
end
|
57
51
|
|
58
52
|
def build_router(operations) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
@@ -68,7 +62,8 @@ module OpenapiFirst
|
|
68
62
|
normalized_path,
|
69
63
|
to: lambda do |env|
|
70
64
|
env[OPERATION] = operation
|
71
|
-
env[PARAMETERS] =
|
65
|
+
env[PARAMETERS] = env['router.params']
|
66
|
+
env[Rack::PATH_INFO] = env.delete(ORIGINAL_PATH)
|
72
67
|
@app.call(env)
|
73
68
|
end
|
74
69
|
)
|
@@ -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.2
|
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-06-
|
11
|
+
date: 2020-06-24 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: deep_merge
|