openapi_first 0.11.0 → 0.12.2

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: 3a7011597803fb49d100073027c9545634d497971b34ac49c2eeb0916258f424
4
- data.tar.gz: 1f552e13bd915d41d39e2fffcc7a026b5abd3ba5c19ed4f726e3538c68a893df
3
+ metadata.gz: 689e572886b7ca556ffb00c89368195e129d954a03a3899f3d7d8401bd724196
4
+ data.tar.gz: e2669f12188188ac232d0c52b8a371e221ce65e589f2bd06dc6b7547fd0cafc2
5
5
  SHA512:
6
- metadata.gz: 50053573288bde3ce62df411c706dc9fe64a4395b7110a539cf4e1b685b1953140cdbc8d9ad7e7d8b0eee4d9b4211243f7c3e85ef6b79a44720f9b56ae316c3f
7
- data.tar.gz: 017b082c94490f45cc0cb90935c0f5a463958740d858b56f2d6baf1b2e579f9e5820acda5cc5f53c0d9fe50cc261600a1f8c93c498f165997f026f68617c888f
6
+ metadata.gz: c3409b992b56a09eb9e4bc75d20e6711a4ebda0bd93c0422316343ea48b4f8816394f1506b675c56e42f3dad339a0b7aa9c4834f0372045815a81888724ad45e
7
+ data.tar.gz: 2893535646d84f3ca6db6efc63c5334961929b9f6e561b8d36f771ca4ad0b9e25af5c5b06292ae9745610fa6dd229516c4aa4b42c01c52fc176294a1713b8767
@@ -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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- openapi_first (0.11.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.1)
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.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)
28
28
  deep_merge (1.2.1)
29
- diff-lcs (1.3)
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.2)
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.1)
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.1)
69
- parser (2.7.1.3)
70
- ast (~> 2.4.0)
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.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.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,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.85.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
- | `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)
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['id'],
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
- 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.
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
- 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)
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.
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- openapi_first (0.11.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.1)
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.5)
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.2)
75
+ i18n (1.8.3)
76
76
  concurrent-ruby (~> 1.0)
77
- json_schema (0.20.8)
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.1)
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.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.0)
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)
@@ -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,4 +13,9 @@ module Web
13
13
  end
14
14
 
15
15
  oas_path = File.absolute_path('./openapi.yaml', __dir__)
16
- App = OpenapiFirst.app(oas_path, namespace: Web)
16
+
17
+ App = OpenapiFirst.app(
18
+ oas_path,
19
+ namespace: Web,
20
+ raise_error: OpenapiFirst.env == 'test'
21
+ )
@@ -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
- test = ENV['RACK_ENV'] == 'test'
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, router_raise: false)
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 ResponseCodeNotFoundError < Error; end
55
- class ResponseMediaTypeNotFoundError < Error; end
56
- class ResponseBodyInvalidError < Error; end
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
@@ -5,11 +5,12 @@ require 'logger'
5
5
 
6
6
  module OpenapiFirst
7
7
  class App
8
- def initialize(parent_app, spec, namespace:, router_raise:)
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, raise: router_raise, parent_app: parent_app
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
- end
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 = find_by_operation_id(operation_id)
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 find_by_operation_id(operation_id) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
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
- 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) }
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
- private
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
- return unless parent.const_defined?(name, false)
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 media type found: '#{content_type}' for '#{operation_name}'"
40
- raise ResponseMediaTypeNotFoundError, message
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 '#{operation_name}'"
55
+ message = "Response status code or default not found: #{status} for '#{name}'"
49
56
  raise OpenapiFirst::ResponseCodeNotFoundError, message
50
57
  end
51
58
 
52
- private
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
- 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?
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
- halt(error_response(400, [err]))
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
- halt(error_response(415))
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
- halt(error_response(415, 'Request body is required'))
74
+ halt_with_error(415, 'Request body is required')
74
75
  end
75
76
 
76
77
  def validate_json_schema(schema, object)
77
- JSONSchemer.schema(schema).validate(object)
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 error_response(status, errors = [default_error(status)])
88
- Rack::Response.new(
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 = JSONSchemer.schema(json_schema).validate(params)
117
- 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?
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, schema = key_value
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
- @handlers = FindHandler.new(spec, namespace).all
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 = @handlers[operation.operation_id]
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
- 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
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 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
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? ? {} : MultiJson.load(full_body)
43
+ data = full_body.empty? ? {} : load_json(full_body)
50
44
  errors = JSONSchemer.schema(schema).validate(data).to_a.map do |error|
51
- format_error(error)
45
+ error_message_for(error)
52
46
  end
53
47
  raise ResponseBodyInvalidError, errors.join(', ') if errors.any?
54
48
  end
55
49
 
56
- def format_error(error)
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], 'at', error['data_pointer'], err[:detail]].compact.join(' ')
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
- errors = validation_errors(request, response)
15
- Validation.new(errors || [])
16
- rescue OasParser::ResponseCodeNotFound, OasParser::MethodNotFound => e
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
@@ -6,53 +6,47 @@ 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
- def initialize(app, options) # rubocop:disable Metrics/MethodLength
9
+ def initialize(
10
+ app,
11
+ spec:,
12
+ raise_error: false,
13
+ parent_app: nil
14
+ )
13
15
  @app = app
14
- @parent_app = options.fetch(:parent_app, nil)
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)
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
- endpoint = find_endpoint(env)
29
- return endpoint.call(env) if endpoint
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
- if @raise
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
- return @parent_app.call(env) if @parent_app
37
-
38
- @failure_app.call(env)
30
+ response
39
31
  end
40
32
 
41
- private
33
+ ORIGINAL_PATH = 'openapi_first.path_info'
42
34
 
43
- def find_failure_app(option)
44
- return DEFAULT_NOT_FOUND_APP if option.nil?
45
- return @app if option == :continue
35
+ private
46
36
 
47
- option if option.respond_to?(:call)
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 find_endpoint(env)
51
- original_path_info = env[Rack::PATH_INFO]
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.recognize(env).endpoint
47
+ @router.call(env)
54
48
  ensure
55
- env[Rack::PATH_INFO] = original_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] = Utils.deep_stringify(env['router.params'])
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: 'is not valid' }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiFirst
4
- VERSION = '0.11.0'
4
+ VERSION = '0.12.2'
5
5
  end
@@ -32,13 +32,13 @@ Gem::Specification.new do |spec|
32
32
  spec.bindir = 'exe'
33
33
  spec.require_paths = ['lib']
34
34
 
35
- spec.add_dependency 'deep_merge', '>= 1.2.1'
36
- spec.add_dependency 'hanami-router', '~> 2.0.alpha3'
37
- spec.add_dependency 'hanami-utils', '~> 2.0.alpha1'
38
- spec.add_dependency 'json_schemer', '~> 0.2'
39
- spec.add_dependency 'multi_json', '~> 1.14'
40
- spec.add_dependency 'oas_parser', '~> 0.25.1'
41
- spec.add_dependency 'rack', '~> 2.2'
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.11.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-03 00:00:00.000000000 Z
11
+ date: 2020-06-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: deep_merge