openapi_first 0.19.0 → 0.21.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -73,6 +73,16 @@ paths:
73
73
  operationId: create_thing
74
74
  description: Create a thing
75
75
  tags: ["Metadata"]
76
+ requestBody:
77
+ content:
78
+ application/json:
79
+ schema:
80
+ type: object
81
+ required:
82
+ - say
83
+ properties:
84
+ say:
85
+ type: string
76
86
  responses:
77
87
  "201":
78
88
  description: OK
@@ -84,3 +94,175 @@ paths:
84
94
  properties:
85
95
  hello:
86
96
  type: string
97
+ /pets:
98
+ get:
99
+ description: |
100
+ Returns all pets from the system that the user has access to
101
+ Nam sed condimentum est. Maecenas tempor sagittis sapien, nec rhoncus sem sagittis sit amet. Aenean at gravida augue, ac iaculis sem. Curabitur odio lorem, ornare eget elementum nec, cursus id lectus. Duis mi turpis, pulvinar ac eros ac, tincidunt varius justo. In hac habitasse platea dictumst. Integer at adipiscing ante, a sagittis ligula. Aenean pharetra tempor ante molestie imperdiet. Vivamus id aliquam diam. Cras quis velit non tortor eleifend sagittis. Praesent at enim pharetra urna volutpat venenatis eget eget mauris. In eleifend fermentum facilisis. Praesent enim enim, gravida ac sodales sed, placerat id erat. Suspendisse lacus dolor, consectetur non augue vel, vehicula interdum libero. Morbi euismod sagittis libero sed lacinia.
102
+
103
+ Sed tempus felis lobortis leo pulvinar rutrum. Nam mattis velit nisl, eu condimentum ligula luctus nec. Phasellus semper velit eget aliquet faucibus. In a mattis elit. Phasellus vel urna viverra, condimentum lorem id, rhoncus nibh. Ut pellentesque posuere elementum. Sed a varius odio. Morbi rhoncus ligula libero, vel eleifend nunc tristique vitae. Fusce et sem dui. Aenean nec scelerisque tortor. Fusce malesuada accumsan magna vel tempus. Quisque mollis felis eu dolor tristique, sit amet auctor felis gravida. Sed libero lorem, molestie sed nisl in, accumsan tempor nisi. Fusce sollicitudin massa ut lacinia mattis. Sed vel eleifend lorem. Pellentesque vitae felis pretium, pulvinar elit eu, euismod sapien.
104
+ operationId: find_pets
105
+ parameters:
106
+ - name: tags
107
+ in: query
108
+ description: tags to filter by
109
+ required: false
110
+ style: form
111
+ schema:
112
+ type: array
113
+ items:
114
+ type: string
115
+ - name: limit
116
+ in: query
117
+ description: maximum number of results to return
118
+ required: false
119
+ schema:
120
+ type: integer
121
+ format: int32
122
+ responses:
123
+ '200':
124
+ description: pet response
125
+ content:
126
+ application/json:
127
+ schema:
128
+ type: array
129
+ items:
130
+ $ref: '#/components/schemas/Pet'
131
+ default:
132
+ description: unexpected error
133
+ content:
134
+ application/json:
135
+ schema:
136
+ $ref: '#/components/schemas/Error'
137
+ post:
138
+ description: Creates a new pet in the store. Duplicates are allowed
139
+ operationId: create_pet
140
+ requestBody:
141
+ description: Pet to add to the store
142
+ required: true
143
+ content:
144
+ application/json:
145
+ schema:
146
+ $ref: '#/components/schemas/NewPet'
147
+ responses:
148
+ '200':
149
+ description: pet response
150
+ content:
151
+ application/json:
152
+ schema:
153
+ $ref: '#/components/schemas/Pet'
154
+ default:
155
+ description: unexpected error
156
+ content:
157
+ application/json:
158
+ schema:
159
+ $ref: '#/components/schemas/Error'
160
+ /pets/{id}:
161
+ parameters:
162
+ - name: id
163
+ in: path
164
+ description: ID of pet to fetch
165
+ required: true
166
+ schema:
167
+ type: integer
168
+ format: int64
169
+ get:
170
+ description: Returns a user based on a single ID, if the user does not have access to the pet
171
+ operationId: find_pet
172
+ responses:
173
+ '200':
174
+ description: pet response
175
+ content:
176
+ application/json:
177
+ schema:
178
+ $ref: '#/components/schemas/Pet'
179
+ default:
180
+ description: unexpected error
181
+ content:
182
+ application/json:
183
+ schema:
184
+ $ref: '#/components/schemas/Error'
185
+ delete:
186
+ description: deletes a single pet based on the ID supplied
187
+ operationId: delete_pet
188
+ parameters:
189
+ - name: id
190
+ in: path
191
+ description: ID of pet to delete
192
+ required: true
193
+ schema:
194
+ type: integer
195
+ format: int64
196
+ responses:
197
+ '204':
198
+ description: pet deleted
199
+ default:
200
+ description: unexpected error
201
+ content:
202
+ application/json:
203
+ schema:
204
+ $ref: '#/components/schemas/Error'
205
+ patch:
206
+ description: Updates a pet
207
+ operationId: update_pet
208
+ requestBody:
209
+ description: Changes
210
+ required: false
211
+ content:
212
+ application/json:
213
+ schema:
214
+ $ref: '#/components/schemas/NewPet'
215
+ responses:
216
+ '200':
217
+ description: pet response
218
+ content:
219
+ application/json:
220
+ schema:
221
+ $ref: '#/components/schemas/Pet'
222
+ default:
223
+ description: unexpected error
224
+ content:
225
+ application/json:
226
+ schema:
227
+ $ref: '#/components/schemas/Error'
228
+
229
+ components:
230
+ schemas:
231
+ Pet:
232
+ allOf:
233
+ - $ref: '#/components/schemas/NewPet'
234
+ - required:
235
+ - id
236
+ properties:
237
+ id:
238
+ type: integer
239
+ format: int64
240
+
241
+ NewPet:
242
+ required:
243
+ - type
244
+ - attributes
245
+ properties:
246
+ type:
247
+ type: string
248
+ enum:
249
+ - pet
250
+ - plant
251
+ attributes:
252
+ additionalProperties: false
253
+ type: object
254
+ required: [name]
255
+ properties:
256
+ name:
257
+ type: string
258
+
259
+ Error:
260
+ required:
261
+ - code
262
+ - message
263
+ properties:
264
+ code:
265
+ type: integer
266
+ format: int32
267
+ message:
268
+ type: string
@@ -19,8 +19,7 @@ app = Class.new(Hanami::API) do
19
19
  end
20
20
  end.new
21
21
 
22
- oas_path = File.absolute_path('./openapi.yaml', __dir__)
23
- use OpenapiFirst::Router, spec: OpenapiFirst.load(oas_path)
22
+ use OpenapiFirst::Router, spec: File.absolute_path('./openapi.yaml', __dir__)
24
23
  use OpenapiFirst::RequestValidation
25
24
 
26
25
  run app
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+
3
+ wrk -t12 -c400 -d10s --latency -s post.lua http://localhost:9292/hello
@@ -3,18 +3,23 @@
3
3
  require 'benchmark/ips'
4
4
  require 'benchmark/memory'
5
5
  require 'rack'
6
+ require 'json'
6
7
  ENV['RACK_ENV'] = 'production'
7
8
 
8
9
  examples = [
9
10
  [Rack::MockRequest.env_for('/hello'), 200],
10
11
  [Rack::MockRequest.env_for('/unknown'), 404],
11
- [Rack::MockRequest.env_for('/hello', method: 'POST'), 201],
12
+ [
13
+ Rack::MockRequest.env_for('/hello', method: 'POST', input: JSON.dump({ say: 'hi!' }),
14
+ 'CONTENT_TYPE' => 'application/json'), 201
15
+ ],
12
16
  [Rack::MockRequest.env_for('/hello/1'), 200],
13
17
  [Rack::MockRequest.env_for('/hello/123'), 200],
14
18
  [Rack::MockRequest.env_for('/hello?filter[id]=1,2'), 200]
15
19
  ]
16
20
 
17
- apps = Dir['./apps/*.ru'].each_with_object({}) do |config, hash|
21
+ glob = ARGV[0] || './apps/*.ru'
22
+ apps = Dir[glob].each_with_object({}) do |config, hash|
18
23
  hash[config] = Rack::Builder.parse_file(config).first
19
24
  end
20
25
  apps.freeze
@@ -24,7 +29,7 @@ bench = lambda do |app|
24
29
  env, expected_status = example
25
30
  100.times { app.call(env) }
26
31
  response = app.call(env)
27
- raise unless response[0] == expected_status
32
+ raise "expected status #{expected_status}, but was #{response[0]}" unless response[0] == expected_status
28
33
  end
29
34
  end
30
35
 
@@ -0,0 +1,3 @@
1
+ wrk.method = "POST"
2
+ wrk.body = "{\"say\":\"hi!\"}"
3
+ wrk.headers["Content-Type"] = "application/json"
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'multi_json'
4
+
5
+ module OpenapiFirst
6
+ class BodyParserMiddleware
7
+ def initialize(app, options = {})
8
+ @app = app
9
+ @raise = options.fetch(:raise_error, false)
10
+ end
11
+
12
+ RACK_INPUT = 'rack.input'
13
+ ROUTER_PARSED_BODY = 'router.parsed_body'
14
+
15
+ def call(env)
16
+ env[ROUTER_PARSED_BODY] = parse_body(env)
17
+ @app.call(env)
18
+ rescue BodyParsingError => e
19
+ raise if @raise
20
+
21
+ err = { title: "Failed to parse body as #{env['CONTENT_TYPE']}", status: '400' }
22
+ err[:detail] = e.cause unless ENV['RACK_ENV'] == 'production'
23
+ errors = [err]
24
+
25
+ Rack::Response.new(
26
+ MultiJson.dump(errors: errors),
27
+ 400,
28
+ Rack::CONTENT_TYPE => 'application/vnd.api+json'
29
+ ).finish
30
+ end
31
+
32
+ private
33
+
34
+ def parse_body(env)
35
+ request = Rack::Request.new(env)
36
+ body = read_body(request)
37
+ return if body.empty?
38
+
39
+ return MultiJson.load(body) if request.media_type =~ (/json/i) && (request.media_type =~ /json/i)
40
+ return request.POST if request.form_data?
41
+
42
+ body
43
+ rescue MultiJson::ParseError => e
44
+ raise BodyParsingError, e
45
+ end
46
+
47
+ def read_body(request)
48
+ body = request.body.read
49
+ request.body.rewind
50
+ body
51
+ end
52
+ end
53
+ end
@@ -10,11 +10,14 @@ module OpenapiFirst
10
10
  end
11
11
 
12
12
  def call(operation)
13
- @handlers[operation.name] ||= find_handler(operation['x-handler'] || operation['operationId'])
13
+ @handlers[operation.name] ||= begin
14
+ id = handler_id(operation)
15
+ find_handler(id) if id
16
+ end
14
17
  end
15
18
 
16
- def find_handler(operation_id)
17
- name = operation_id.match(/:*(.*)/)&.to_a&.at(1)
19
+ def find_handler(id)
20
+ name = id.match(/:*(.*)/)&.to_a&.at(1)
18
21
  return if name.nil?
19
22
 
20
23
  catch :halt do
@@ -27,6 +30,16 @@ module OpenapiFirst
27
30
  @namespace.method(method_name)
28
31
  end
29
32
 
33
+ def handler_id(operation)
34
+ id = operation['x-handler'] || operation['operationId']
35
+ if id.nil?
36
+ raise HandlerNotFoundError,
37
+ "operationId or x-handler is missing in '#{operation.method} #{operation.path}' so I cannot find a handler for this operation." # rubocop:disable Layout/LineLength
38
+ end
39
+
40
+ id
41
+ end
42
+
30
43
  def find_class_method_handler(name)
31
44
  module_name, method_name = name.split('.')
32
45
  klass = find_const(@namespace, module_name)
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ class Error < StandardError; end
5
+
6
+ class NotFoundError < Error; end
7
+
8
+ class HandlerNotFoundError < Error; end
9
+
10
+ class NotImplementedError < Error
11
+ def initialize(message)
12
+ warn 'NotImplementedError is deprecated. Handle HandlerNotFoundError instead'
13
+ super
14
+ end
15
+ end
16
+
17
+ class ResponseInvalid < Error; end
18
+
19
+ class ResponseCodeNotFoundError < ResponseInvalid; end
20
+
21
+ class ResponseContentTypeNotFoundError < ResponseInvalid; end
22
+
23
+ class ResponseBodyInvalidError < ResponseInvalid; end
24
+
25
+ class BodyParsingError < Error; end
26
+
27
+ class RequestInvalidError < Error
28
+ def initialize(serialized_errors)
29
+ message = error_message(serialized_errors)
30
+ super message
31
+ end
32
+
33
+ private
34
+
35
+ def error_message(errors)
36
+ errors.map do |error|
37
+ [human_source(error), human_error(error)].compact.join(' ')
38
+ end.join(', ')
39
+ end
40
+
41
+ def human_source(error)
42
+ return unless error[:source]
43
+
44
+ source_key = error[:source].keys.first
45
+ source = {
46
+ pointer: 'Request body invalid:',
47
+ parameter: 'Query parameter invalid:'
48
+ }.fetch(source_key, source_key)
49
+ name = error[:source].values.first
50
+ source += " #{name}" unless name.nil? || name.empty?
51
+ source
52
+ end
53
+
54
+ def human_error(error)
55
+ error[:title]
56
+ end
57
+ end
58
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiFirst
4
- # An instance of this gets passed to handler functions as first argument.
4
+ # An instance of this gets passed to handler functions in the Responder.
5
5
  class Inbox < Hash
6
6
  attr_reader :env
7
7
 
@@ -47,6 +47,13 @@ module OpenapiFirst
47
47
  end
48
48
  end
49
49
 
50
+ def query_parameters_schema
51
+ @query_parameters_schema ||= begin
52
+ query_parameters_json_schema = build_query_parameters_json_schema
53
+ query_parameters_json_schema && SchemaValidation.new(query_parameters_json_schema)
54
+ end
55
+ end
56
+
50
57
  def content_types_for(status)
51
58
  response_for(status)['content']&.keys
52
59
  end
@@ -88,11 +95,19 @@ module OpenapiFirst
88
95
  "#{method.upcase} #{path} (#{operation_id})"
89
96
  end
90
97
 
98
+ def valid_request_content_type?(request_content_type)
99
+ content = operation_object.dig('requestBody', 'content')
100
+ return unless content
101
+
102
+ !!find_content_for_content_type(content, request_content_type)
103
+ end
104
+
91
105
  private
92
106
 
93
107
  def response_by_code(status)
94
108
  operation_object.dig('responses', status.to_s) ||
95
109
  operation_object.dig('responses', "#{status / 100}XX") ||
110
+ operation_object.dig('responses', "#{status / 100}xx") ||
96
111
  operation_object.dig('responses', 'default')
97
112
  end
98
113
 
@@ -117,6 +132,16 @@ module OpenapiFirst
117
132
  end
118
133
  end
119
134
 
135
+ def build_query_parameters_json_schema
136
+ query_parameters = all_parameters.reject { |field, _value| field['in'] == 'header' }
137
+ return unless query_parameters&.any?
138
+
139
+ query_parameters.each_with_object(new_node) do |parameter, schema|
140
+ params = Rack::Utils.parse_nested_query(parameter['name'])
141
+ generate_schema(schema, params, parameter)
142
+ end
143
+ end
144
+
120
145
  def all_parameters
121
146
  parameters = @path_item_object['parameters']&.dup || []
122
147
  parameters_on_operation = operation_object['parameters']
@@ -1,35 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'rack'
4
- require 'multi_json'
5
- require_relative 'inbox'
6
- require_relative 'default_operation_resolver'
3
+ require_relative 'responder'
7
4
 
8
5
  module OpenapiFirst
9
- class RackResponder
10
- def initialize(namespace: nil, resolver: nil)
11
- @resolver = resolver || DefaultOperationResolver.new(namespace)
12
- @namespace = namespace
13
- end
14
-
6
+ class RackResponder < Responder
15
7
  def call(env)
16
8
  operation = env[OpenapiFirst::OPERATION]
17
9
  find_handler(operation)&.call(env)
18
10
  end
19
-
20
- private
21
-
22
- def find_handler(operation)
23
- handler = @resolver.call(operation)
24
- raise NotImplementedError, "Could not find handler for #{operation.name}" unless handler
25
-
26
- handler
27
- end
28
-
29
- def serialize(result)
30
- return result if result.is_a?(String)
31
-
32
- MultiJson.dump(result)
33
- end
34
11
  end
35
12
  end
@@ -3,12 +3,12 @@
3
3
  require 'rack'
4
4
  require 'multi_json'
5
5
  require_relative 'inbox'
6
- require_relative 'router_required'
6
+ require_relative 'use_router'
7
7
  require_relative 'validation_format'
8
8
 
9
9
  module OpenapiFirst
10
10
  class RequestValidation # rubocop:disable Metrics/ClassLength
11
- prepend RouterRequired
11
+ prepend UseRouter
12
12
 
13
13
  def initialize(app, options = {})
14
14
  @app = app
@@ -16,61 +16,60 @@ module OpenapiFirst
16
16
  end
17
17
 
18
18
  def call(env) # rubocop:disable Metrics/AbcSize
19
- operation = env[OpenapiFirst::OPERATION]
19
+ operation = env[OPERATION]
20
20
  return @app.call(env) unless operation
21
21
 
22
- env[INBOX] = Inbox.new(env)
23
- catch(:halt) do
24
- validate_query_parameters!(env, operation, env[PARAMETERS])
22
+ env[INBOX] = {}
23
+ error = catch(:error) do
24
+ params = validate_query_parameters!(operation, env[PARAMETERS])
25
+ env[INBOX].merge! env[PARAMETERS] = params if params
25
26
  req = Rack::Request.new(env)
26
- content_type = req.content_type
27
27
  return @app.call(env) unless operation.request_body
28
28
 
29
- validate_request_content_type!(content_type, operation)
30
- body = req.body.read
31
- req.body.rewind
32
- parse_and_validate_request_body!(env, content_type, body, operation)
33
- @app.call(env)
29
+ validate_request_content_type!(operation, req.content_type)
30
+ parsed_request_body = parse_and_validate_request_body!(operation, req)
31
+ env[REQUEST_BODY] = parsed_request_body
32
+ env[INBOX].merge! parsed_request_body if parsed_request_body.is_a?(Hash)
33
+ nil
34
34
  end
35
+ if error
36
+ raise RequestInvalidError, error[:errors] if @raise
37
+
38
+ return validation_error_response(error[:status], error[:errors])
39
+ end
40
+ @app.call(env)
35
41
  end
36
42
 
37
43
  private
38
44
 
39
- def halt(response)
40
- throw :halt, response
41
- end
45
+ ROUTER_PARSED_BODY = 'router.parsed_body'
46
+
47
+ def parse_and_validate_request_body!(operation, request)
48
+ env = request.env
49
+
50
+ body = env.delete(ROUTER_PARSED_BODY) if env.key?(ROUTER_PARSED_BODY)
42
51
 
43
- def parse_and_validate_request_body!(env, content_type, body, operation)
44
52
  validate_request_body_presence!(body, operation)
45
- return if body.empty?
53
+ return if body.nil?
46
54
 
47
- schema = operation&.request_body_schema(content_type)
55
+ schema = operation&.request_body_schema(request.content_type)
48
56
  return unless schema
49
57
 
50
- parsed_request_body = parse_request_body!(body)
51
- errors = schema.validate(parsed_request_body)
52
- halt_with_error(400, serialize_request_body_errors(errors)) if errors.any?
53
- env[INBOX].merge! env[REQUEST_BODY] = Utils.deep_symbolize(parsed_request_body)
54
- end
58
+ errors = schema.validate(body)
59
+ throw_error(400, serialize_request_body_errors(errors)) if errors.any?
60
+ return Utils.deep_symbolize(body) if body.is_a?(Hash)
55
61
 
56
- def parse_request_body!(body)
57
- MultiJson.load(body)
58
- rescue MultiJson::ParseError => e
59
- err = { title: 'Failed to parse body as JSON' }
60
- err[:detail] = e.cause unless ENV['RACK_ENV'] == 'production'
61
- halt_with_error(400, [err])
62
+ body
62
63
  end
63
64
 
64
- def validate_request_content_type!(content_type, operation)
65
- return if operation.request_body.dig('content', content_type)
66
-
67
- halt_with_error(415)
65
+ def validate_request_content_type!(operation, content_type)
66
+ operation.valid_request_content_type?(content_type) || throw_error(415)
68
67
  end
69
68
 
70
69
  def validate_request_body_presence!(body, operation)
71
- return unless operation.request_body['required'] && body.empty?
70
+ return unless operation.request_body['required'] && body.nil?
72
71
 
73
- halt_with_error(415, 'Request body is required')
72
+ throw_error(415, 'Request body is required')
74
73
  end
75
74
 
76
75
  def default_error(status, title = Rack::Utils::HTTP_STATUS_CODES[status])
@@ -80,10 +79,15 @@ module OpenapiFirst
80
79
  }
81
80
  end
82
81
 
83
- def halt_with_error(status, errors = [default_error(status)])
84
- raise RequestInvalidError, errors if @raise
82
+ def throw_error(status, errors = [default_error(status)])
83
+ throw :error, {
84
+ status: status,
85
+ errors: errors
86
+ }
87
+ end
85
88
 
86
- halt Rack::Response.new(
89
+ def validation_error_response(status, errors)
90
+ Rack::Response.new(
87
91
  MultiJson.dump(errors: errors),
88
92
  status,
89
93
  Rack::CONTENT_TYPE => 'application/vnd.api+json'
@@ -100,17 +104,15 @@ module OpenapiFirst
100
104
  end
101
105
  end
102
106
 
103
- def validate_query_parameters!(env, operation, params)
104
- schema = operation.parameters_schema
107
+ def validate_query_parameters!(operation, params)
108
+ schema = operation.query_parameters_schema
105
109
  return unless schema
106
110
 
107
111
  params = filtered_params(schema.raw_schema, params)
108
112
  params = Utils.deep_stringify(params)
109
113
  errors = schema.validate(params)
110
- halt_with_error(400, serialize_query_parameter_errors(errors)) if errors.any?
111
- params = Utils.deep_symbolize(params)
112
- env[PARAMETERS] = params
113
- env[INBOX].merge! params
114
+ throw_error(400, serialize_query_parameter_errors(errors)) if errors.any?
115
+ Utils.deep_symbolize(params)
114
116
  end
115
117
 
116
118
  def filtered_params(json_schema, params)
@@ -16,7 +16,7 @@ module OpenapiFirst
16
16
  operation = env[OpenapiFirst::OPERATION]
17
17
  res = Rack::Response.new
18
18
  handler = find_handler(operation)
19
- result = handler.call(env[INBOX], res)
19
+ result = handler.call(inbox(env), res)
20
20
  res.write serialize(result) if result && res.body.empty?
21
21
  res[Rack::CONTENT_TYPE] ||= operation.content_types_for(res.status)&.first
22
22
  res.finish
@@ -24,6 +24,10 @@ module OpenapiFirst
24
24
 
25
25
  private
26
26
 
27
+ def inbox(env)
28
+ Inbox.new(env).tap { |i| i.merge!(env[INBOX]) if env[INBOX] }
29
+ end
30
+
27
31
  def find_handler(operation)
28
32
  handler = @resolver.call(operation)
29
33
  raise NotImplementedError, "Could not find handler for #{operation.name}" unless handler
@@ -37,11 +41,4 @@ module OpenapiFirst
37
41
  MultiJson.dump(result)
38
42
  end
39
43
  end
40
-
41
- class OperationResolver < Responder
42
- def initialize(spec:, namespace:)
43
- warn "#{self.class.name} was renamed to #{OpenapiFirst::Responder.name}"
44
- super
45
- end
46
- end
47
44
  end