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 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: