openapi_first 0.12.5 → 0.14.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,30 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'committee'
4
- require 'syro'
5
3
  require 'multi_json'
4
+ require 'committee'
5
+ require 'hanami/api'
6
6
 
7
- app = Syro.new do
8
- on 'hello' do
9
- on :id do
10
- get do
11
- res.json MultiJson.dump(hello: 'world', id: inbox[:id])
12
- end
13
- end
7
+ app = Class.new(Hanami::API) do
8
+ get '/hello/:id' do
9
+ json(hello: 'world', id: params.fetch(:id))
10
+ end
14
11
 
15
- get do
16
- res.json [MultiJson.dump(hello: 'world')]
17
- end
12
+ get '/hello' do
13
+ json([{ hello: 'world' }])
14
+ end
18
15
 
19
- post do
20
- res.status = 201
21
- res.json MultiJson.dump(hello: 'world')
22
- end
16
+ post '/hello' do
17
+ status 201
18
+ json(hello: 'world')
23
19
  end
24
- end
20
+ end.new
25
21
 
26
22
  use Committee::Middleware::RequestValidation,
27
23
  schema_path: './apps/openapi.yaml',
28
- coerce_date_times: false
24
+ parse_response_by_content_type: true
29
25
 
30
26
  run app
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'multi_json'
4
+ require 'hanami/api'
5
+
6
+ app = Class.new(Hanami::API) do
7
+ get '/hello/:id' do
8
+ json(hello: 'world', id: params.fetch(:id))
9
+ end
10
+
11
+ get '/hello' do
12
+ json([{ hello: 'world' }])
13
+ end
14
+
15
+ post '/hello' do
16
+ status 201
17
+ json(hello: 'world')
18
+ end
19
+ end.new
20
+
21
+ run app
@@ -4,7 +4,7 @@ require 'hanami/router'
4
4
  require 'multi_json'
5
5
 
6
6
  app = Hanami::Router.new do
7
- get '/hello', to: ->(_env) { [200, {}, [MultiJson.dump(hello: 'world')]] }
7
+ get '/hello', to: ->(_env) { [200, {}, [MultiJson.dump([{ hello: 'world' }])]] }
8
8
  get '/hello/:id', to: lambda { |env|
9
9
  [200, {}, [MultiJson.dump(hello: 'world', id: env['router.params'][:id])]]
10
10
  }
@@ -13,7 +13,7 @@ class SinatraExample < Sinatra::Base
13
13
 
14
14
  get '/hello' do
15
15
  content_type :json
16
- [MultiJson.dump(hello: 'world')]
16
+ MultiJson.dump([{ hello: 'world' }])
17
17
  end
18
18
 
19
19
  post '/hello' do
@@ -7,17 +7,17 @@ app = Syro.new do
7
7
  on 'hello' do
8
8
  on :id do
9
9
  get do
10
- res.json MultiJson.dump(hello: 'world', id: inbox[:id])
10
+ res.json({ hello: 'world', id: inbox[:id] })
11
11
  end
12
12
  end
13
13
 
14
14
  get do
15
- res.json [MultiJson.dump(hello: 'world')]
15
+ res.json([{ hello: 'world' }])
16
16
  end
17
17
 
18
18
  post do
19
19
  res.status = 201
20
- res.json MultiJson.dump(hello: 'world')
20
+ res.json({ hello: 'world' })
21
21
  end
22
22
  end
23
23
  end
@@ -19,28 +19,25 @@ apps = Dir['./apps/*.ru'].each_with_object({}) do |config, hash|
19
19
  end
20
20
  apps.freeze
21
21
 
22
+ bench = lambda do |app|
23
+ examples.each do |example|
24
+ env, expected_status = example
25
+ 100.times { app.call(env) }
26
+ response = app.call(env)
27
+ raise unless response[0] == expected_status
28
+ end
29
+ end
30
+
22
31
  Benchmark.ips do |x|
23
32
  apps.each do |config, app|
24
- x.report(config) do
25
- examples.each do |example|
26
- env, expected_status = example
27
- response = app.call(env)
28
- raise unless response[0] == expected_status
29
- end
30
- end
33
+ x.report(config) { bench.call(app) }
31
34
  end
32
35
  x.compare!
33
36
  end
34
37
 
35
38
  Benchmark.memory do |x|
36
39
  apps.each do |config, app|
37
- x.report(config) do
38
- examples.each do |example|
39
- env, expected_status = example
40
- response = app.call(env)
41
- raise unless response[0] == expected_status
42
- end
43
- end
40
+ x.report(config) { bench.call(app) }
44
41
  end
45
42
  x.compare!
46
43
  end
data/lib/openapi_first.rb CHANGED
@@ -25,10 +25,9 @@ module OpenapiFirst
25
25
 
26
26
  def self.load(spec_path, only: nil)
27
27
  content = YAML.load_file(spec_path)
28
- raw = OasParser::Parser.new(spec_path, content).resolve
29
- raw['paths'].filter!(&->(key, _) { only.call(key) }) if only
30
- parsed = OasParser::Definition.new(raw, spec_path)
31
- Definition.new(parsed)
28
+ resolved = OasParser::Parser.new(spec_path, content).resolve
29
+ resolved['paths'].filter!(&->(key, _) { only.call(key) }) if only
30
+ Definition.new(resolved, spec_path)
32
31
  end
33
32
 
34
33
  def self.app(
@@ -78,11 +77,17 @@ module OpenapiFirst
78
77
  end
79
78
 
80
79
  class Error < StandardError; end
80
+
81
81
  class NotFoundError < Error; end
82
+
82
83
  class NotImplementedError < RuntimeError; end
84
+
83
85
  class ResponseInvalid < Error; end
86
+
84
87
  class ResponseCodeNotFoundError < ResponseInvalid; end
88
+
85
89
  class ResponseContentTypeNotFoundError < ResponseInvalid; end
90
+
86
91
  class ResponseBodyInvalidError < ResponseInvalid; end
87
92
 
88
93
  class RequestInvalidError < Error
@@ -11,17 +11,15 @@ module OpenapiFirst
11
11
  namespace:,
12
12
  router_raise_error: false,
13
13
  request_validation_raise_error: false,
14
- response_validation: false
14
+ response_validation: false,
15
+ resolver: nil
15
16
  )
16
17
  @stack = Rack::Builder.app do
17
18
  freeze_app
18
19
  use OpenapiFirst::Router, spec: spec, raise_error: router_raise_error, parent_app: parent_app
19
20
  use OpenapiFirst::RequestValidation, raise_error: request_validation_raise_error
20
21
  use OpenapiFirst::ResponseValidation if response_validation
21
- run OpenapiFirst::Responder.new(
22
- spec: spec,
23
- namespace: namespace
24
- )
22
+ run OpenapiFirst::Responder.new(namespace: namespace, resolver: resolver)
25
23
  end
26
24
  end
27
25
 
@@ -3,22 +3,14 @@
3
3
  require_relative 'utils'
4
4
 
5
5
  module OpenapiFirst
6
- class FindHandler
7
- def initialize(spec, namespace)
6
+ class DefaultOperationResolver
7
+ def initialize(namespace)
8
8
  @namespace = namespace
9
- @handlers = spec.operations.each_with_object({}) do |operation, hash|
10
- operation_id = operation.operation_id
11
- handler = find_handler(operation_id)
12
- if handler.nil?
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
14
- next
15
- end
16
- hash[operation_id] = handler
17
- end
9
+ @handlers = {}
18
10
  end
19
11
 
20
- def [](operation_id)
21
- @handlers[operation_id]
12
+ def call(operation)
13
+ @handlers[operation.name] ||= find_handler(operation['x-handler'] || operation['operationId'])
22
14
  end
23
15
 
24
16
  def find_handler(operation_id)
@@ -4,15 +4,16 @@ require_relative 'operation'
4
4
 
5
5
  module OpenapiFirst
6
6
  class Definition
7
- attr_reader :filepath
7
+ attr_reader :filepath, :operations
8
8
 
9
- def initialize(parsed)
10
- @filepath = parsed.path
11
- @spec = parsed
12
- end
13
-
14
- def operations
15
- @spec.endpoints.map { |e| Operation.new(e) }
9
+ def initialize(resolved, filepath)
10
+ @filepath = filepath
11
+ methods = %w[get head post put patch delete trace options]
12
+ @operations = resolved['paths'].flat_map do |path, path_item|
13
+ path_item.slice(*methods).map do |request_method, _operation_object|
14
+ Operation.new(path, request_method, path_item)
15
+ end
16
+ end
16
17
  end
17
18
  end
18
19
  end
@@ -2,32 +2,49 @@
2
2
 
3
3
  require 'forwardable'
4
4
  require 'json_schemer'
5
+ require_relative 'schema_validation'
5
6
  require_relative 'utils'
6
7
  require_relative 'response_object'
7
8
 
8
9
  module OpenapiFirst
9
- class Operation
10
+ class Operation # rubocop:disable Metrics/ClassLength
10
11
  extend Forwardable
11
- def_delegators :@operation,
12
- :parameters,
13
- :method,
14
- :request_body,
15
- :operation_id
12
+ def_delegators :operation_object,
13
+ :[],
14
+ :dig
16
15
 
17
- def initialize(parsed)
18
- @operation = parsed
16
+ WRITE_METHODS = Set.new(%w[post put patch delete]).freeze
17
+ private_constant :WRITE_METHODS
18
+
19
+ attr_reader :path, :method
20
+
21
+ def initialize(path, request_method, path_item_object)
22
+ @path = path
23
+ @method = request_method
24
+ @path_item_object = path_item_object
25
+ end
26
+
27
+ def operation_id
28
+ operation_object['operationId']
19
29
  end
20
30
 
21
- def path
22
- @operation.path.path
31
+ def read?
32
+ !write?
23
33
  end
24
34
 
25
- def parameters_json_schema
26
- @parameters_json_schema ||= build_parameters_json_schema
35
+ def write?
36
+ WRITE_METHODS.include?(method)
37
+ end
38
+
39
+ def request_body
40
+ operation_object['requestBody']
27
41
  end
28
42
 
29
43
  def parameters_schema
30
- @parameters_schema ||= parameters_json_schema && JSONSchemer.schema(parameters_json_schema)
44
+ @parameters_schema ||= begin
45
+ parameters_json_schema = build_parameters_json_schema
46
+ parameters_json_schema && SchemaValidation.new(parameters_json_schema)
47
+ end
31
48
  end
32
49
 
33
50
  def content_type_for(status)
@@ -42,22 +59,28 @@ module OpenapiFirst
42
59
  raise ResponseInvalid, "Response has no content-type for '#{name}'" unless content_type
43
60
 
44
61
  media_type = find_content_for_content_type(content, content_type)
62
+
45
63
  unless media_type
46
64
  message = "Response content type not found '#{content_type}' for '#{name}'"
47
65
  raise ResponseContentTypeNotFoundError, message
48
66
  end
49
- media_type['schema']
67
+ schema = media_type['schema']
68
+ SchemaValidation.new(schema, write: false) if schema
50
69
  end
51
70
 
52
- def request_body_schema_for(request_content_type)
53
- content = @operation.request_body.content
71
+ def request_body_schema(request_content_type)
72
+ content = operation_object.dig('requestBody', 'content')
54
73
  media_type = find_content_for_content_type(content, request_content_type)
55
- media_type&.fetch('schema', nil)
74
+ schema = media_type&.fetch('schema', nil)
75
+ return unless schema
76
+
77
+ SchemaValidation.new(schema, write: write?)
56
78
  end
57
79
 
58
80
  def response_for(status)
59
- @operation.response_by_code(status.to_s, use_default: true).raw
60
- rescue OasParser::ResponseCodeNotFound
81
+ response_content = response_by_code(status)
82
+ return response_content if response_content
83
+
61
84
  message = "Response status code or default not found: #{status} for '#{name}'"
62
85
  raise OpenapiFirst::ResponseCodeNotFoundError, message
63
86
  end
@@ -68,6 +91,15 @@ module OpenapiFirst
68
91
 
69
92
  private
70
93
 
94
+ def response_by_code(status)
95
+ operation_object.dig('responses', status.to_s) ||
96
+ operation_object.dig('responses', 'default')
97
+ end
98
+
99
+ def operation_object
100
+ @path_item_object[method]
101
+ end
102
+
71
103
  def find_content_for_content_type(content, request_content_type)
72
104
  content.fetch(request_content_type) do |_|
73
105
  type = request_content_type.split(';')[0]
@@ -76,24 +108,32 @@ module OpenapiFirst
76
108
  end
77
109
 
78
110
  def build_parameters_json_schema
79
- return unless @operation.parameters&.any?
111
+ parameters = all_parameters
112
+ return unless parameters&.any?
80
113
 
81
- @operation.parameters.each_with_object(new_node) do |parameter, schema|
82
- params = Rack::Utils.parse_nested_query(parameter.name)
114
+ parameters.each_with_object(new_node) do |parameter, schema|
115
+ params = Rack::Utils.parse_nested_query(parameter['name'])
83
116
  generate_schema(schema, params, parameter)
84
117
  end
85
118
  end
86
119
 
87
- def generate_schema(schema, params, parameter) # rubocop:disable Metrics/MethodLength
120
+ def all_parameters
121
+ parameters = @path_item_object['parameters'] || []
122
+ parameters_on_operation = operation_object['parameters']
123
+ parameters.concat(parameters_on_operation) if parameters_on_operation
124
+ parameters
125
+ end
126
+
127
+ def generate_schema(schema, params, parameter)
88
128
  required = Set.new(schema['required'])
89
129
  params.each do |key, value|
90
- required << key if parameter.required
130
+ required << key if parameter['required']
91
131
  if value.is_a? Hash
92
132
  property_schema = new_node
93
133
  generate_schema(property_schema, value, parameter)
94
134
  Utils.deep_merge!(schema['properties'], { key => property_schema })
95
135
  else
96
- schema['properties'][key] = parameter.schema
136
+ schema['properties'][key] = parameter['schema']
97
137
  end
98
138
  end
99
139
  schema['required'] = required.to_a
@@ -16,7 +16,7 @@ module OpenapiFirst
16
16
  @raise = raise_error
17
17
  end
18
18
 
19
- def call(env) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
19
+ def call(env) # rubocop:disable Metrics/AbcSize
20
20
  operation = env[OpenapiFirst::OPERATION]
21
21
  return @app.call(env) unless operation
22
22
 
@@ -45,17 +45,17 @@ module OpenapiFirst
45
45
  validate_request_body_presence!(body, operation)
46
46
  return if body.empty?
47
47
 
48
- schema = request_body_schema(content_type, operation)
48
+ schema = operation&.request_body_schema(content_type)
49
49
  return unless schema
50
50
 
51
51
  parsed_request_body = parse_request_body!(body)
52
- errors = validate_json_schema(schema, parsed_request_body)
52
+ errors = schema.validate(parsed_request_body)
53
53
  halt_with_error(400, serialize_request_body_errors(errors)) if errors.any?
54
- env[INBOX].merge! env[REQUEST_BODY] = parsed_request_body
54
+ env[INBOX].merge! env[REQUEST_BODY] = Utils.deep_symbolize(parsed_request_body)
55
55
  end
56
56
 
57
57
  def parse_request_body!(body)
58
- MultiJson.load(body, symbolize_keys: true)
58
+ MultiJson.load(body)
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'
@@ -63,21 +63,17 @@ module OpenapiFirst
63
63
  end
64
64
 
65
65
  def validate_request_content_type!(content_type, operation)
66
- return if operation.request_body.content[content_type]
66
+ return if operation.request_body.dig('content', content_type)
67
67
 
68
68
  halt_with_error(415)
69
69
  end
70
70
 
71
71
  def validate_request_body_presence!(body, operation)
72
- return unless operation.request_body.required && body.empty?
72
+ return unless operation.request_body['required'] && body.empty?
73
73
 
74
74
  halt_with_error(415, 'Request body is required')
75
75
  end
76
76
 
77
- def validate_json_schema(schema, object)
78
- schema.validate(Utils.deep_stringify(object))
79
- end
80
-
81
77
  def default_error(status, title = Rack::Utils::HTTP_STATUS_CODES[status])
82
78
  {
83
79
  status: status.to_s,
@@ -95,14 +91,6 @@ module OpenapiFirst
95
91
  ).finish
96
92
  end
97
93
 
98
- def request_body_schema(content_type, operation)
99
- return unless operation
100
-
101
- schema = operation.request_body_schema_for(content_type)
102
-
103
- JSONSchemer.schema(schema) if schema
104
- end
105
-
106
94
  def serialize_request_body_errors(validation_errors)
107
95
  validation_errors.map do |error|
108
96
  {
@@ -114,14 +102,11 @@ module OpenapiFirst
114
102
  end
115
103
 
116
104
  def validate_query_parameters!(env, operation, params)
117
- json_schema = operation.parameters_json_schema
118
- return unless json_schema
119
-
120
- params = filtered_params(json_schema, params)
121
- errors = validate_json_schema(
122
- operation.parameters_schema,
123
- params
124
- )
105
+ schema = operation.parameters_schema
106
+ return unless schema
107
+
108
+ params = filtered_params(schema.raw_schema, params)
109
+ errors = schema.validate(Utils.deep_stringify(params))
125
110
  halt_with_error(400, serialize_query_parameter_errors(errors)) if errors.any?
126
111
  env[PARAMETERS] = params
127
112
  env[INBOX].merge! params
@@ -141,8 +126,9 @@ module OpenapiFirst
141
126
 
142
127
  def serialize_query_parameter_errors(validation_errors)
143
128
  validation_errors.map do |error|
129
+ pointer = error['data_pointer'][1..].to_s
144
130
  {
145
- source: { parameter: File.basename(error['data_pointer']) }
131
+ source: { parameter: pointer }
146
132
  }.update(ValidationFormat.error_details(error))
147
133
  end
148
134
  end
@@ -150,14 +136,27 @@ module OpenapiFirst
150
136
  def parse_parameter(value, schema)
151
137
  return filtered_params(schema, value) if schema['properties']
152
138
 
139
+ return parse_array_parameter(value, schema) if schema['type'] == 'array'
140
+
141
+ parse_simple_value(value, schema)
142
+ end
143
+
144
+ def parse_array_parameter(value, schema)
145
+ array = value.is_a?(Array) ? value : value.split(',')
146
+ return array unless schema['items']
147
+
148
+ array.map! { |e| parse_simple_value(e, schema['items']) }
149
+ end
150
+
151
+ def parse_simple_value(value, schema)
152
+ return to_boolean(value) if schema['type'] == 'boolean'
153
+
153
154
  begin
154
155
  return Integer(value, 10) if schema['type'] == 'integer'
155
156
  return Float(value) if schema['type'] == 'number'
156
157
  rescue ArgumentError
157
158
  value
158
159
  end
159
- return to_boolean(value) if schema['type'] == 'boolean'
160
-
161
160
  value
162
161
  end
163
162