openapi_first 0.12.0.alpha2 → 0.12.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4d0848ff82fa49a93053ec107ca28c419c1be8b9122301a8819742685e8b7995
4
- data.tar.gz: 11cf9a3c60b619cd55d7f3f552a2c367553aa69ed047ddd101eff1b460ee4c53
3
+ metadata.gz: '03752095ad50ff4a6142b3f716f00aba532191a12f700227eca0d2d3c9c7a6b1'
4
+ data.tar.gz: 7c0271d081181b18999ce75dc72ee9fa4677eb18fc0ee02ed1a40505da9aa072
5
5
  SHA512:
6
- metadata.gz: 5cabc3434dd9c4801b3f6ec030ec4ad5ef6905762a2643ef59a99f78057e6b108f90bf24696d29cab776dfa61a571be4973af7f62af07f5672f64996fb77086d
7
- data.tar.gz: b7391c86d982be35d680e56f904261b8cfbd846a8206c2f339adc703248ad2476121fb8534a9562a20e081a2e5d5a8696231937affb23a72fe917a546a7e34c2
6
+ metadata.gz: f7a2454ed16c69e7d0b32f610ed985640735bba36a1e28798319b501f3f1eb80bb14b8329c8794f0dd87e3d38db037210319e3e25f82b1b5e0110f63c24ea4d3
7
+ data.tar.gz: 479eedd62e341f834ed258504d2699a94cfa924301ae85a016d075c56df870041e3b0504829bbce456694e138f7598129c20684731257ff6be23d5dba4b9b2f8
@@ -1,6 +1,9 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
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`
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- openapi_first (0.12.0.alpha2)
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.0)
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.0)
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.0)
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['id'],
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
- Response validation is useful to make sure your app responds as described in your API description. You usually do this in your tests using [rack-test](https://github.com/rack-test/rack-test).
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
- expect(validator.validate(last_request, last_response).errors).to be_empty
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- openapi_first (0.12.0.alpha2)
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.2)
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)
@@ -5,7 +5,7 @@ require 'openapi_first'
5
5
 
6
6
  namespace = Module.new do
7
7
  def self.find_thing(params, _res)
8
- { hello: 'world', id: params.fetch('id') }
8
+ { hello: 'world', id: params.fetch(:id) }
9
9
  end
10
10
 
11
11
  def self.find_things(_params, _res)
@@ -13,7 +13,7 @@ module Web
13
13
  end
14
14
 
15
15
  oas_path = File.absolute_path('./openapi.yaml', __dir__)
16
- pp OpenapiFirst.env == 'test'
16
+
17
17
  App = OpenapiFirst.app(
18
18
  oas_path,
19
19
  namespace: Web,
@@ -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 ResponseCodeNotFoundError < Error; end
59
- class ResponseMediaTypeNotFoundError < Error; end
60
- class ResponseBodyInvalidError < Error; end
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: '#{content_type}' for '#{name}'"
40
- raise ResponseMediaTypeNotFoundError, message
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
- halt(error_response(400, serialize_request_body_errors(errors))) if errors.any?
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
- halt(error_response(400, [err]))
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
- halt(error_response(415))
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
- halt(error_response(415, 'Request body is required'))
74
+ halt_with_error(415, 'Request body is required')
75
75
  end
76
76
 
77
77
  def validate_json_schema(schema, object)
78
- JSONSchemer.schema(schema).validate(object)
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 error_response(status, errors = [default_error(status)])
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 = JSONSchemer.schema(json_schema).validate(params)
120
- halt error_response(400, serialize_query_parameter_errors(errors)) if errors.any?
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, schema = key_value
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
- status, headers, body = @app.call(env)
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
- format_error(error)
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 format_error(error)
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
- operation = env[OPERATION]
63
- operation&.response_for(response.status)
19
+ @response_validation.validate(response, env[OPERATION])
64
20
  end
65
21
  end
66
22
  end
@@ -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
- route = find_route(env)
34
- return route.call(env) if route.routable?
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
- if @raise
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
- return @parent_app.call(env) if @parent_app
42
-
43
- @failure_app.call(env)
31
+ response
44
32
  end
45
33
 
46
- private
34
+ UNKNOWN_ROUTE_STATUS = [404, 405].freeze
35
+ ORIGINAL_PATH = 'openapi_first.path_info'
47
36
 
48
- def find_failure_app(option)
49
- return DEFAULT_NOT_FOUND_APP if option.nil?
50
- return @app if option == :continue
37
+ private
51
38
 
52
- option if option.respond_to?(:call)
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 find_route(env)
56
- original_path_info = env[Rack::PATH_INFO]
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.recognize(env)
49
+ @router.call(env)
59
50
  ensure
60
- env[Rack::PATH_INFO] = original_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] = Utils.deep_stringify(env['router.params'])
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
  )
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiFirst
4
- VERSION = '0.12.0.alpha2'
4
+ VERSION = '0.12.0'
5
5
  end
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.alpha2
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-09 00:00:00.000000000 Z
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: 1.3.1
241
+ version: '0'
242
242
  requirements: []
243
243
  rubygems_version: 3.1.2
244
244
  signing_key: