openapi_first 0.12.0.alpha1 → 0.12.3

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