openapi_first 0.12.0.alpha2 → 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/CHANGELOG.md +4 -1
- data/Gemfile.lock +4 -4
- data/README.md +4 -4
- data/benchmarks/Gemfile.lock +2 -2
- data/benchmarks/apps/openapi_first.ru +1 -1
- data/examples/app.rb +1 -1
- data/lib/openapi_first.rb +4 -3
- data/lib/openapi_first/operation.rb +7 -2
- data/lib/openapi_first/request_validation.rb +17 -12
- data/lib/openapi_first/response_validation.rb +11 -21
- data/lib/openapi_first/response_validator.rb +2 -46
- data/lib/openapi_first/router.rb +21 -29
- data/lib/openapi_first/version.rb +1 -1
- metadata +4 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: '03752095ad50ff4a6142b3f716f00aba532191a12f700227eca0d2d3c9c7a6b1'
|
4
|
+
data.tar.gz: 7c0271d081181b18999ce75dc72ee9fa4677eb18fc0ee02ed1a40505da9aa072
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f7a2454ed16c69e7d0b32f610ed985640735bba36a1e28798319b501f3f1eb80bb14b8329c8794f0dd87e3d38db037210319e3e25f82b1b5e0110f63c24ea4d3
|
7
|
+
data.tar.gz: 479eedd62e341f834ed258504d2699a94cfa924301ae85a016d075c56df870041e3b0504829bbce456694e138f7598129c20684731257ff6be23d5dba4b9b2f8
|
data/CHANGELOG.md
CHANGED
@@ -1,6 +1,9 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
-
##
|
3
|
+
## 0.12.0
|
4
|
+
- Change `ResponseValidator` to raise an exception if it found a problem
|
5
|
+
- Params have symbolized keys now
|
6
|
+
- Remove `not_found` option from Router. Return 405 if HTTP verb is not allowed (via Hanami::Router)
|
4
7
|
- Add `raise_error` option to OpenapiFirst.app (false by default)
|
5
8
|
- Add ResponseValidation to OpenapiFirst.app if raise_error option is true
|
6
9
|
- Rename `raise` option to `raise_error`
|
data/Gemfile.lock
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
openapi_first (0.12.0
|
4
|
+
openapi_first (0.12.0)
|
5
5
|
deep_merge (>= 1.2.1)
|
6
6
|
hanami-router (~> 2.0.alpha3)
|
7
7
|
hanami-utils (~> 2.0.alpha1)
|
@@ -21,7 +21,7 @@ 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)
|
@@ -77,7 +77,7 @@ GEM
|
|
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,7 +92,7 @@ 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.85.
|
95
|
+
rubocop (0.85.1)
|
96
96
|
parallel (~> 1.10)
|
97
97
|
parser (>= 2.7.0.1)
|
98
98
|
rainbow (>= 2.2.2, < 4.0)
|
data/README.md
CHANGED
@@ -36,7 +36,6 @@ Options and their defaults:
|
|
36
36
|
| Name | Possible values | Description | Default
|
37
37
|
|:---|---|---|---|
|
38
38
|
|`spec:`| | The spec loaded via `OpenapiFirst.load` ||
|
39
|
-
| `not_found:` |`nil`, `:continue`, `Proc`| Specifies what to do if the 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)
|
40
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)
|
41
40
|
|
42
41
|
## OpenapiFirst::RequestValidation
|
@@ -143,7 +142,7 @@ Instead of composing these middlewares yourself you can use `OpenapiFirst.app`.
|
|
143
142
|
module Pets
|
144
143
|
def self.find_pet(params, res)
|
145
144
|
{
|
146
|
-
id: params[
|
145
|
+
id: params[:id],
|
147
146
|
name: 'Oscar'
|
148
147
|
}
|
149
148
|
end
|
@@ -190,7 +189,7 @@ OpenapiFirst uses [`multi_json`](https://rubygems.org/gems/multi_json).
|
|
190
189
|
|
191
190
|
## Manual response validation
|
192
191
|
|
193
|
-
|
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.
|
194
193
|
|
195
194
|
```ruby
|
196
195
|
# In your test (rspec example):
|
@@ -198,7 +197,8 @@ require 'openapi_first'
|
|
198
197
|
spec = OpenapiFirst.load('petstore.yaml')
|
199
198
|
validator = OpenapiFirst::ResponseValidator.new(spec)
|
200
199
|
|
201
|
-
|
200
|
+
# This will raise an exception if it found an error
|
201
|
+
validator.validate(last_request, last_response)
|
202
202
|
```
|
203
203
|
|
204
204
|
## Handling only certain paths
|
data/benchmarks/Gemfile.lock
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
PATH
|
2
2
|
remote: ..
|
3
3
|
specs:
|
4
|
-
openapi_first (0.12.0
|
4
|
+
openapi_first (0.12.0)
|
5
5
|
deep_merge (>= 1.2.1)
|
6
6
|
hanami-router (~> 2.0.alpha3)
|
7
7
|
hanami-utils (~> 2.0.alpha1)
|
@@ -72,7 +72,7 @@ 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
77
|
json_schema (0.20.8)
|
78
78
|
json_schemer (0.2.11)
|
data/examples/app.rb
CHANGED
data/lib/openapi_first.rb
CHANGED
@@ -55,9 +55,10 @@ module OpenapiFirst
|
|
55
55
|
class Error < StandardError; end
|
56
56
|
class NotFoundError < Error; end
|
57
57
|
class NotImplementedError < RuntimeError; end
|
58
|
-
class
|
59
|
-
class
|
60
|
-
class
|
58
|
+
class ResponseInvalid < Error; end
|
59
|
+
class ResponseCodeNotFoundError < ResponseInvalid; end
|
60
|
+
class ResponseContentTypeNotFoundError < ResponseInvalid; end
|
61
|
+
class ResponseBodyInvalidError < ResponseInvalid; end
|
61
62
|
|
62
63
|
class RequestInvalidError < Error
|
63
64
|
def initialize(serialized_errors)
|
@@ -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
|
@@ -36,8 +41,8 @@ module OpenapiFirst
|
|
36
41
|
|
37
42
|
media_type = content[content_type]
|
38
43
|
unless media_type
|
39
|
-
message = "Response content type not found
|
40
|
-
raise
|
44
|
+
message = "Response content type not found '#{content_type}' for '#{name}'"
|
45
|
+
raise ResponseContentTypeNotFoundError, message
|
41
46
|
end
|
42
47
|
media_type['schema']
|
43
48
|
end
|
@@ -50,32 +50,32 @@ module OpenapiFirst
|
|
50
50
|
|
51
51
|
parsed_request_body = parse_request_body!(body)
|
52
52
|
errors = validate_json_schema(schema, parsed_request_body)
|
53
|
-
|
53
|
+
halt_with_error(400, serialize_request_body_errors(errors)) if errors.any?
|
54
54
|
env[INBOX].merge! env[REQUEST_BODY] = parsed_request_body
|
55
55
|
end
|
56
56
|
|
57
57
|
def parse_request_body!(body)
|
58
|
-
MultiJson.load(body)
|
58
|
+
MultiJson.load(body, symbolize_keys: true)
|
59
59
|
rescue MultiJson::ParseError => e
|
60
60
|
err = { title: 'Failed to parse body as JSON' }
|
61
61
|
err[:detail] = e.cause unless ENV['RACK_ENV'] == 'production'
|
62
|
-
|
62
|
+
halt_with_error(400, [err])
|
63
63
|
end
|
64
64
|
|
65
65
|
def validate_request_content_type!(content_type, operation)
|
66
66
|
return if operation.request_body.content[content_type]
|
67
67
|
|
68
|
-
|
68
|
+
halt_with_error(415)
|
69
69
|
end
|
70
70
|
|
71
71
|
def validate_request_body_presence!(body, operation)
|
72
72
|
return unless operation.request_body.required && body.empty?
|
73
73
|
|
74
|
-
|
74
|
+
halt_with_error(415, 'Request body is required')
|
75
75
|
end
|
76
76
|
|
77
77
|
def validate_json_schema(schema, object)
|
78
|
-
|
78
|
+
schema.validate(Utils.deep_stringify(object))
|
79
79
|
end
|
80
80
|
|
81
81
|
def default_error(status, title = Rack::Utils::HTTP_STATUS_CODES[status])
|
@@ -85,10 +85,10 @@ module OpenapiFirst
|
|
85
85
|
}
|
86
86
|
end
|
87
87
|
|
88
|
-
def
|
88
|
+
def halt_with_error(status, errors = [default_error(status)])
|
89
89
|
raise RequestInvalidError, errors if @raise
|
90
90
|
|
91
|
-
Rack::Response.new(
|
91
|
+
halt Rack::Response.new(
|
92
92
|
MultiJson.dump(errors: errors),
|
93
93
|
status,
|
94
94
|
Rack::CONTENT_TYPE => 'application/vnd.api+json'
|
@@ -98,7 +98,8 @@ module OpenapiFirst
|
|
98
98
|
def request_body_schema(content_type, operation)
|
99
99
|
return unless operation
|
100
100
|
|
101
|
-
operation.request_body.content[content_type]&.fetch('schema')
|
101
|
+
schema = operation.request_body.content[content_type]&.fetch('schema')
|
102
|
+
JSONSchemer.schema(schema) if schema
|
102
103
|
end
|
103
104
|
|
104
105
|
def serialize_request_body_errors(validation_errors)
|
@@ -116,8 +117,11 @@ module OpenapiFirst
|
|
116
117
|
return unless json_schema
|
117
118
|
|
118
119
|
params = filtered_params(json_schema, params)
|
119
|
-
errors =
|
120
|
-
|
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?
|
121
125
|
env[PARAMETERS] = params
|
122
126
|
env[INBOX].merge! params
|
123
127
|
end
|
@@ -125,7 +129,8 @@ module OpenapiFirst
|
|
125
129
|
def filtered_params(json_schema, params)
|
126
130
|
json_schema['properties']
|
127
131
|
.each_with_object({}) do |key_value, result|
|
128
|
-
parameter_name
|
132
|
+
parameter_name = key_value[0].to_sym
|
133
|
+
schema = key_value[1]
|
129
134
|
next unless params.key?(parameter_name)
|
130
135
|
|
131
136
|
value = params[parameter_name]
|
@@ -17,38 +17,28 @@ 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
|
21
27
|
content_type = headers[Rack::CONTENT_TYPE]
|
28
|
+
raise ResponseInvalid, "Response has no content-type for '#{operation.name}'" unless content_type
|
29
|
+
|
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 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
36
|
def validate_response_body(schema, response)
|
47
37
|
full_body = +''
|
48
38
|
response.each { |chunk| full_body << chunk }
|
49
39
|
data = full_body.empty? ? {} : load_json(full_body)
|
50
40
|
errors = JSONSchemer.schema(schema).validate(data).to_a.map do |error|
|
51
|
-
|
41
|
+
error_message_for(error)
|
52
42
|
end
|
53
43
|
raise ResponseBodyInvalidError, errors.join(', ') if errors.any?
|
54
44
|
end
|
@@ -59,7 +49,7 @@ module OpenapiFirst
|
|
59
49
|
string
|
60
50
|
end
|
61
51
|
|
62
|
-
def
|
52
|
+
def error_message_for(error)
|
63
53
|
err = ValidationFormat.error_details(error)
|
64
54
|
[err[:title], error['data_pointer'], err[:detail]].compact.join(' ')
|
65
55
|
end
|
@@ -10,57 +10,13 @@ module OpenapiFirst
|
|
10
10
|
def initialize(spec)
|
11
11
|
@spec = spec
|
12
12
|
@router = Router.new(->(_env) {}, spec: spec, raise_error: true)
|
13
|
+
@response_validation = ResponseValidation.new(->(response) { response.to_a })
|
13
14
|
end
|
14
15
|
|
15
16
|
def validate(request, response)
|
16
|
-
errors = validation_errors(request, response)
|
17
|
-
Validation.new(errors || [])
|
18
|
-
rescue OpenapiFirst::ResponseCodeNotFoundError, OpenapiFirst::NotFoundError => e
|
19
|
-
Validation.new([e.message])
|
20
|
-
end
|
21
|
-
|
22
|
-
def validate_operation(request, response)
|
23
|
-
errors = validation_errors(request, response)
|
24
|
-
Validation.new(errors || [])
|
25
|
-
rescue OpenapiFirst::ResponseCodeNotFoundError, OpenapiFirst::NotFoundError => e
|
26
|
-
Validation.new([e.message])
|
27
|
-
end
|
28
|
-
|
29
|
-
private
|
30
|
-
|
31
|
-
def validation_errors(request, response)
|
32
|
-
content = response_for(request, response)&.fetch('content', nil)
|
33
|
-
return unless content
|
34
|
-
|
35
|
-
content_type = content[response.content_type]
|
36
|
-
return ["Content type not found: '#{response.content_type}'"] unless content_type
|
37
|
-
|
38
|
-
response_schema = content_type['schema']
|
39
|
-
return unless response_schema
|
40
|
-
|
41
|
-
response_data = MultiJson.load(response.body)
|
42
|
-
validate_json_schema(response_schema, response_data)
|
43
|
-
end
|
44
|
-
|
45
|
-
def validate_json_schema(schema, data)
|
46
|
-
JSONSchemer.schema(schema).validate(data).to_a.map do |error|
|
47
|
-
format_error(error)
|
48
|
-
end
|
49
|
-
end
|
50
|
-
|
51
|
-
def format_error(error)
|
52
|
-
ValidationFormat.error_details(error)
|
53
|
-
.merge!(
|
54
|
-
data_pointer: error['data_pointer'],
|
55
|
-
schema_pointer: error['schema_pointer']
|
56
|
-
)
|
57
|
-
end
|
58
|
-
|
59
|
-
def response_for(request, response)
|
60
17
|
env = request.env.dup
|
61
18
|
@router.call(env)
|
62
|
-
|
63
|
-
operation&.response_for(response.status)
|
19
|
+
@response_validation.validate(response, env[OPERATION])
|
64
20
|
end
|
65
21
|
end
|
66
22
|
end
|
data/lib/openapi_first/router.rb
CHANGED
@@ -6,58 +6,49 @@ require_relative 'utils'
|
|
6
6
|
|
7
7
|
module OpenapiFirst
|
8
8
|
class Router
|
9
|
-
NOT_FOUND = Rack::Response.new('', 404).finish.freeze
|
10
|
-
DEFAULT_NOT_FOUND_APP = ->(_env) { NOT_FOUND }
|
11
|
-
|
12
9
|
def initialize(
|
13
10
|
app,
|
14
11
|
spec:,
|
15
12
|
raise_error: false,
|
16
|
-
parent_app: nil
|
17
|
-
not_found: nil
|
13
|
+
parent_app: nil
|
18
14
|
)
|
19
15
|
@app = app
|
20
16
|
@parent_app = parent_app
|
21
17
|
@raise = raise_error
|
22
|
-
@failure_app = find_failure_app(not_found)
|
23
|
-
if @failure_app.nil?
|
24
|
-
raise ArgumentError,
|
25
|
-
'not_found must be nil, :continue or must respond to call'
|
26
|
-
end
|
27
18
|
@filepath = spec.filepath
|
28
19
|
@router = build_router(spec.operations)
|
29
20
|
end
|
30
21
|
|
31
22
|
def call(env)
|
32
23
|
env[OPERATION] = nil
|
33
|
-
|
34
|
-
|
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
|
35
28
|
|
36
|
-
|
37
|
-
req = Rack::Request.new(env)
|
38
|
-
msg = "Could not find definition for #{req.request_method} '#{req.path}' in API description #{@filepath}"
|
39
|
-
raise NotFoundError, msg
|
29
|
+
raise_error(env) if @raise
|
40
30
|
end
|
41
|
-
|
42
|
-
|
43
|
-
@failure_app.call(env)
|
31
|
+
response
|
44
32
|
end
|
45
33
|
|
46
|
-
|
34
|
+
UNKNOWN_ROUTE_STATUS = [404, 405].freeze
|
35
|
+
ORIGINAL_PATH = 'openapi_first.path_info'
|
47
36
|
|
48
|
-
|
49
|
-
return DEFAULT_NOT_FOUND_APP if option.nil?
|
50
|
-
return @app if option == :continue
|
37
|
+
private
|
51
38
|
|
52
|
-
|
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
|
53
43
|
end
|
54
44
|
|
55
|
-
def
|
56
|
-
|
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]
|
57
48
|
env[Rack::PATH_INFO] = Rack::Request.new(env).path
|
58
|
-
@router.
|
49
|
+
@router.call(env)
|
59
50
|
ensure
|
60
|
-
env[Rack::PATH_INFO] =
|
51
|
+
env[Rack::PATH_INFO] = env.delete(ORIGINAL_PATH) if env[ORIGINAL_PATH]
|
61
52
|
end
|
62
53
|
|
63
54
|
def build_router(operations) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
@@ -73,7 +64,8 @@ module OpenapiFirst
|
|
73
64
|
normalized_path,
|
74
65
|
to: lambda do |env|
|
75
66
|
env[OPERATION] = operation
|
76
|
-
env[PARAMETERS] =
|
67
|
+
env[PARAMETERS] = env['router.params']
|
68
|
+
env[Rack::PATH_INFO] = env.delete(ORIGINAL_PATH)
|
77
69
|
@app.call(env)
|
78
70
|
end
|
79
71
|
)
|
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.12.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-06-
|
11
|
+
date: 2020-06-13 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: deep_merge
|
@@ -236,9 +236,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
236
236
|
version: '0'
|
237
237
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
238
238
|
requirements:
|
239
|
-
- - "
|
239
|
+
- - ">="
|
240
240
|
- !ruby/object:Gem::Version
|
241
|
-
version:
|
241
|
+
version: '0'
|
242
242
|
requirements: []
|
243
243
|
rubygems_version: 3.1.2
|
244
244
|
signing_key:
|