openapi_first 0.12.0.alpha1 → 0.12.3

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: 476fbf7b7c6b6d5111d7dcb1fa4cd5197744c989315ea7b82b4e648956d7fee4
4
- data.tar.gz: 10e9f751d733284f21eccbad29cf12347ce9924eeba6ed4c4afd43450c952e2e
3
+ metadata.gz: 8f5e77e342d4dba0c6ac4d535511148333efac5bc4b2cea62ee6d8b75219233e
4
+ data.tar.gz: 9b3341115e77bd4abc4b14d89464093d78483e251cf69e2b2508461c98aa1ccf
5
5
  SHA512:
6
- metadata.gz: b0e7387ededf44b1613c8de8663590086ecc063f940f603e52cae799db52600ac257767bbe5c861ad856270a1c4ac9af8d4eb65b8cdc0166fb84f56e05487f8f
7
- data.tar.gz: 636a22a59d02af532543e78f46ba7cb2b1acca3f329ff4a75410e3ccedd7f3c95b999bf99c54842d110f0d605827d935737fc58fa4b048c26df44b34f285d46c
6
+ metadata.gz: 86f19dc76569748825731014124b689b7bddf5cce6a6fe38b5d95adb4643eeeeaa3ae6024a290aaf2aed92127147cc948a55faa062b5d97b99b7d2fe65df98cd
7
+ data.tar.gz: 27cef03d9ef7e0b49af33154e44ca68d3e0e860dcd94cbf6d2cdc4661ff90a4be590b92365aabfcdffe330f2aa2e96c12f7277ee2fa572fe02a2cc2c263e34b5
@@ -32,3 +32,5 @@ Style/HashTransformKeys:
32
32
  Enabled: true
33
33
  Style/HashTransformValues:
34
34
  Enabled: true
35
+ Style/RedundantFetchBlock:
36
+ Enabled: true
@@ -1,8 +1,25 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased (0.12.x)
3
+ ## 0.12.3
4
+ - Add `response_validation:`, `router_raise_error` options to standalone mode.
5
+
6
+ ## 0.12.2
7
+ - Allow response to have no media type object specified
8
+
9
+ ## 0.12.1
10
+ - Fix response when handler returns 404 or 405
11
+ - Don't validate the response content if status is 205 (no content)
12
+
13
+ ## 0.12.0
14
+ - Change `ResponseValidator` to raise an exception if it found a problem
15
+ - Params have symbolized keys now
16
+ - Remove `not_found` option from Router. Return 405 if HTTP verb is not allowed (via Hanami::Router)
17
+ - Add `raise_error` option to OpenapiFirst.app (false by default)
18
+ - Add ResponseValidation to OpenapiFirst.app if raise_error option is true
4
19
  - Rename `raise` option to `raise_error`
5
20
  - Add `raise_error` option to RequestValidation middleware
21
+ - Raise error if handler could not be found by Responder
22
+ - Add `Operation#name` that returns a human readable name for an operation
6
23
 
7
24
  ## 0.11.0
8
25
  - Raise error if you forgot to add the Router middleware
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- openapi_first (0.12.0.alpha1)
4
+ openapi_first (0.12.3)
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.4)
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)
@@ -49,13 +49,13 @@ GEM
49
49
  method_source (1.0.0)
50
50
  mini_portile2 (2.4.0)
51
51
  minitest (5.14.1)
52
- multi_json (1.14.1)
52
+ multi_json (1.15.0)
53
53
  mustermann (1.1.1)
54
54
  ruby2_keywords (~> 0.0.1)
55
55
  mustermann-contrib (1.1.1)
56
56
  hansi (~> 0.2.0)
57
57
  mustermann (= 1.1.1)
58
- nokogiri (1.10.9)
58
+ nokogiri (1.10.10)
59
59
  mini_portile2 (~> 2.4.0)
60
60
  oas_parser (0.25.1)
61
61
  activesupport (>= 4.0.0)
@@ -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,16 +92,16 @@ 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.87.1)
96
96
  parallel (~> 1.10)
97
- parser (>= 2.7.0.1)
97
+ parser (>= 2.7.1.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.1.0, < 1.0)
102
102
  ruby-progressbar (~> 1.7)
103
103
  unicode-display_width (>= 1.4.0, < 2.0)
104
- rubocop-ast (0.0.3)
104
+ rubocop-ast (0.1.0)
105
105
  parser (>= 2.7.0.1)
106
106
  ruby-progressbar (1.10.1)
107
107
  ruby2_keywords (0.0.2)
@@ -111,7 +111,7 @@ GEM
111
111
  thread_safe (~> 0.1)
112
112
  unicode-display_width (1.7.0)
113
113
  uri_template (0.7.0)
114
- zeitwerk (2.3.0)
114
+ zeitwerk (2.3.1)
115
115
 
116
116
  PLATFORMS
117
117
  ruby
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,7 +36,6 @@ 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
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
@@ -137,7 +142,7 @@ Instead of composing these middlewares yourself you can use `OpenapiFirst.app`.
137
142
  module Pets
138
143
  def self.find_pet(params, res)
139
144
  {
140
- id: params['id'],
145
+ id: params[:id],
141
146
  name: 'Oscar'
142
147
  }
143
148
  end
@@ -145,7 +150,12 @@ end
145
150
 
146
151
  # In config.ru:
147
152
  require 'openapi_first'
148
- run OpenapiFirst.app('./openapi/openapi.yaml', namespace: Pets)
153
+ run OpenapiFirst.app(
154
+ './openapi/openapi.yaml',
155
+ namespace: Pets,
156
+ response_validation: ENV['RACK_ENV'] == 'test',
157
+ router_raise_error: ENV['RACK_ENV'] == 'test'
158
+ )
149
159
  ```
150
160
 
151
161
  The above will use the mentioned Rack middlewares to:
@@ -154,6 +164,17 @@ The above will use the mentioned Rack middlewares to:
154
164
  - Map the request to a method call `Pets.find_pet` based on the `operationId` in the API description
155
165
  - Set the response content type according to your spec (here with the default status code `200`)
156
166
 
167
+ ### Options and their defaults:
168
+
169
+ | Name | Possible values | Description | Default
170
+ |:---|---|---|---|
171
+ | `spec_path` || A filepath to an OpenAPI definition file. |
172
+ | `namespace:` || A class or module where to find the handler methods.|
173
+ | `response_validation:` | `true`, `false` | If set to true it raises an exception if the response is invalid. This is useful during testing. | `false`
174
+ | `router_raise_error:` | `true`, `false` | If set to true it raises an exception (subclass of `OpenapiFirst::Error` when a request path/method is not specified. This is useful during testing. | `false`
175
+ | `request_validation_raise_error:` | `true`, `false` | If set to true it raises an exception (subclass of `OpenapiFirst::Error` when a request is not valid. | `false`
176
+
177
+
157
178
  Handler functions (`find_pet`) are called with two arguments:
158
179
 
159
180
  - `params` - Holds the parsed request body, filtered query params and path parameters
@@ -184,7 +205,7 @@ OpenapiFirst uses [`multi_json`](https://rubygems.org/gems/multi_json).
184
205
 
185
206
  ## Manual response validation
186
207
 
187
- 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).
208
+ 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.
188
209
 
189
210
  ```ruby
190
211
  # In your test (rspec example):
@@ -192,7 +213,8 @@ require 'openapi_first'
192
213
  spec = OpenapiFirst.load('petstore.yaml')
193
214
  validator = OpenapiFirst::ResponseValidator.new(spec)
194
215
 
195
- expect(validator.validate(last_request, last_response).errors).to be_empty
216
+ # This will raise an exception if it found an error
217
+ validator.validate(last_request, last_response)
196
218
  ```
197
219
 
198
220
  ## Handling only certain paths
@@ -243,10 +265,6 @@ end
243
265
 
244
266
  Out of scope. Use [Prism](https://github.com/stoplightio/prism) or [fakeit](https://github.com/JustinFeng/fakeit).
245
267
 
246
- ## Alternatives
247
-
248
- This gem is inspired by [committee](https://github.com/interagent/committee) (Ruby) and [connexion](https://github.com/zalando/connexion) (Python).
249
-
250
268
  ## Development
251
269
 
252
270
  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.3)
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)
@@ -25,13 +25,13 @@ GEM
25
25
  benchmark-memory (0.1.2)
26
26
  memory_profiler (~> 0.9)
27
27
  builder (3.2.4)
28
- committee (4.0.0)
28
+ committee (4.1.0)
29
29
  json_schema (~> 0.14, >= 0.14.3)
30
30
  openapi_parser (>= 0.11.1)
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)
@@ -83,7 +83,7 @@ GEM
83
83
  memory_profiler (0.9.14)
84
84
  mini_portile2 (2.4.0)
85
85
  minitest (5.14.1)
86
- multi_json (1.14.1)
86
+ multi_json (1.15.0)
87
87
  mustermann (1.1.1)
88
88
  ruby2_keywords (~> 0.0.1)
89
89
  mustermann-contrib (1.1.1)
@@ -91,7 +91,7 @@ GEM
91
91
  mustermann (= 1.1.1)
92
92
  mustermann-grape (1.0.1)
93
93
  mustermann (>= 1.0.0)
94
- nokogiri (1.10.9)
94
+ nokogiri (1.10.10)
95
95
  mini_portile2 (~> 2.4.0)
96
96
  oas_parser (0.25.1)
97
97
  activesupport (>= 4.0.0)
@@ -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)
@@ -125,7 +125,7 @@ GEM
125
125
  tzinfo (1.2.7)
126
126
  thread_safe (~> 0.1)
127
127
  uri_template (0.7.0)
128
- zeitwerk (2.3.0)
128
+ zeitwerk (2.3.1)
129
129
 
130
130
  PLATFORMS
131
131
  ruby
@@ -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,10 @@ 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
+ router_raise_error: OpenapiFirst.env == 'test',
21
+ response_validation: OpenapiFirst.env == 'test'
22
+ )
@@ -31,14 +31,39 @@ module OpenapiFirst
31
31
  Definition.new(parsed)
32
32
  end
33
33
 
34
- def self.app(spec, namespace:, raise_error: OpenapiFirst.env == 'test')
34
+ def self.app(
35
+ spec,
36
+ namespace:,
37
+ router_raise_error: false,
38
+ request_validation_raise_error: false,
39
+ response_validation: false
40
+ )
35
41
  spec = OpenapiFirst.load(spec) if spec.is_a?(String)
36
- App.new(nil, spec, namespace: namespace, raise_error: raise_error)
42
+ App.new(
43
+ nil,
44
+ spec,
45
+ namespace: namespace,
46
+ router_raise_error: router_raise_error,
47
+ request_validation_raise_error: request_validation_raise_error,
48
+ response_validation: response_validation
49
+ )
37
50
  end
38
51
 
39
- def self.middleware(spec, namespace:, raise_error: false)
52
+ def self.middleware(
53
+ spec,
54
+ namespace:,
55
+ router_raise_error: false,
56
+ request_validation_raise_error: false,
57
+ response_validation: false
58
+ )
40
59
  spec = OpenapiFirst.load(spec) if spec.is_a?(String)
41
- AppWithOptions.new(spec, namespace: namespace, raise_error: raise_error)
60
+ AppWithOptions.new(
61
+ spec,
62
+ namespace: namespace,
63
+ router_raise_error: router_raise_error,
64
+ request_validation_raise_error: request_validation_raise_error,
65
+ response_validation: response_validation
66
+ )
42
67
  end
43
68
 
44
69
  class AppWithOptions
@@ -54,9 +79,11 @@ module OpenapiFirst
54
79
 
55
80
  class Error < StandardError; end
56
81
  class NotFoundError < Error; end
57
- class ResponseCodeNotFoundError < Error; end
58
- class ResponseMediaTypeNotFoundError < Error; end
59
- class ResponseBodyInvalidError < Error; end
82
+ class NotImplementedError < RuntimeError; end
83
+ class ResponseInvalid < Error; end
84
+ class ResponseCodeNotFoundError < ResponseInvalid; end
85
+ class ResponseContentTypeNotFoundError < ResponseInvalid; end
86
+ class ResponseBodyInvalidError < ResponseInvalid; end
60
87
 
61
88
  class RequestInvalidError < Error
62
89
  def initialize(serialized_errors)
@@ -5,11 +5,19 @@ require 'logger'
5
5
 
6
6
  module OpenapiFirst
7
7
  class App
8
- def initialize(parent_app, spec, namespace:, raise_error:)
8
+ def initialize( # rubocop:disable Metrics/ParameterLists
9
+ parent_app,
10
+ spec,
11
+ namespace:,
12
+ router_raise_error: false,
13
+ request_validation_raise_error: false,
14
+ response_validation: false
15
+ )
9
16
  @stack = Rack::Builder.app do
10
17
  freeze_app
11
- use OpenapiFirst::Router, spec: spec, raise_error: raise_error, parent_app: parent_app
12
- use OpenapiFirst::RequestValidation, raise_error: raise_error
18
+ use OpenapiFirst::Router, spec: spec, raise_error: router_raise_error, parent_app: parent_app
19
+ use OpenapiFirst::RequestValidation, raise_error: request_validation_raise_error
20
+ use OpenapiFirst::ResponseValidation if response_validation
13
21
  run OpenapiFirst::Responder.new(
14
22
  spec: spec,
15
23
  namespace: namespace
@@ -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
 
@@ -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]
@@ -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
@@ -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,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
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
+ if env[OPERATION].nil?
26
+ return @parent_app.call(env) if @parent_app # This should only happen if used via OpenapiFirst.middlware
35
27
 
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
28
+ raise_error(env) if @raise
40
29
  end
41
- return @parent_app.call(env) if @parent_app
42
-
43
- @failure_app.call(env)
30
+ response
44
31
  end
45
32
 
46
- private
33
+ ORIGINAL_PATH = 'openapi_first.path_info'
47
34
 
48
- def find_failure_app(option)
49
- return DEFAULT_NOT_FOUND_APP if option.nil?
50
- return @app if option == :continue
35
+ private
51
36
 
52
- 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
53
41
  end
54
42
 
55
- def find_route(env)
56
- 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]
57
46
  env[Rack::PATH_INFO] = Rack::Request.new(env).path
58
- @router.recognize(env)
47
+ @router.call(env)
59
48
  ensure
60
- env[Rack::PATH_INFO] = original_path_info
49
+ env[Rack::PATH_INFO] = env.delete(ORIGINAL_PATH) if env[ORIGINAL_PATH]
61
50
  end
62
51
 
63
52
  def build_router(operations) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
@@ -73,7 +62,8 @@ module OpenapiFirst
73
62
  normalized_path,
74
63
  to: lambda do |env|
75
64
  env[OPERATION] = operation
76
- env[PARAMETERS] = Utils.deep_stringify(env['router.params'])
65
+ env[PARAMETERS] = env['router.params']
66
+ env[Rack::PATH_INFO] = env.delete(ORIGINAL_PATH)
77
67
  @app.call(env)
78
68
  end
79
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.12.0.alpha1'
4
+ VERSION = '0.12.3'
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.alpha1
4
+ version: 0.12.3
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-05 00:00:00.000000000 Z
11
+ date: 2020-07-10 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: