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