openapi_first 0.12.5 → 0.14.0

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