openapi_first 0.13.2 → 0.14.2

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.
@@ -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
@@ -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,20 +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
- next if handler.nil?
13
-
14
- hash[operation_id] = handler
15
- end
9
+ @handlers = {}
16
10
  end
17
11
 
18
- def [](operation_id)
19
- @handlers[operation_id]
12
+ def call(operation)
13
+ @handlers[operation.name] ||= find_handler(operation['x-handler'] || operation['operationId'])
20
14
  end
21
15
 
22
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']&.dup || []
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
@@ -157,6 +142,8 @@ module OpenapiFirst
157
142
  end
158
143
 
159
144
  def parse_array_parameter(value, schema)
145
+ return value if value.nil? || value.empty?
146
+
160
147
  array = value.is_a?(Array) ? value : value.split(',')
161
148
  return array unless schema['items']
162
149
 
@@ -2,12 +2,12 @@
2
2
 
3
3
  require 'rack'
4
4
  require_relative 'inbox'
5
- require_relative 'find_handler'
5
+ require_relative 'default_operation_resolver'
6
6
 
7
7
  module OpenapiFirst
8
8
  class Responder
9
- def initialize(spec:, namespace:, resolver: FindHandler.new(spec, namespace))
10
- @resolver = resolver
9
+ def initialize(namespace: nil, resolver: nil)
10
+ @resolver = resolver || DefaultOperationResolver.new(namespace)
11
11
  @namespace = namespace
12
12
  end
13
13
 
@@ -24,7 +24,7 @@ module OpenapiFirst
24
24
  private
25
25
 
26
26
  def find_handler(operation)
27
- handler = @resolver[operation.operation_id]
27
+ handler = @resolver.call(operation)
28
28
  raise NotImplementedError, "Could not find handler for #{operation.name}" unless handler
29
29
 
30
30
  handler
@@ -41,7 +41,8 @@ module OpenapiFirst
41
41
  full_body = +''
42
42
  response.each { |chunk| full_body << chunk }
43
43
  data = full_body.empty? ? {} : load_json(full_body)
44
- errors = JSONSchemer.schema(schema).validate(data).to_a.map do |error|
44
+ errors = schema.validate(data)
45
+ errors = errors.to_a.map! do |error|
45
46
  error_message_for(error)
46
47
  end
47
48
  raise ResponseBodyInvalidError, errors.join(', ') if errors.any?
@@ -40,7 +40,10 @@ module OpenapiFirst
40
40
 
41
41
  def raise_error(env)
42
42
  req = Rack::Request.new(env)
43
- msg = "Could not find definition for #{req.request_method} '#{req.path}' in API description #{@filepath}"
43
+ msg =
44
+ "Could not find definition for #{req.request_method} '#{
45
+ req.path
46
+ }' in API description #{@filepath}"
44
47
  raise NotFoundError, msg
45
48
  end
46
49
 
@@ -53,13 +56,12 @@ module OpenapiFirst
53
56
  env[Rack::PATH_INFO] = env.delete(ORIGINAL_PATH) if env[ORIGINAL_PATH]
54
57
  end
55
58
 
56
- def build_router(operations) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
57
- router = Hanami::Router.new {}
59
+ def build_router(operations) # rubocop:disable Metrics/AbcSize
60
+ router = Hanami::Router.new
58
61
  operations.each do |operation|
59
62
  normalized_path = operation.path.gsub('{', ':').gsub('}', '')
60
63
  if operation.operation_id.nil?
61
64
  warn "operationId is missing in '#{operation.method} #{operation.path}'. I am ignoring this operation."
62
- next
63
65
  end
64
66
  router.public_send(
65
67
  operation.method,
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json_schemer'
4
+
5
+ module OpenapiFirst
6
+ class SchemaValidation
7
+ attr_reader :raw_schema
8
+
9
+ def initialize(schema, write: true)
10
+ @raw_schema = schema
11
+ custom_keywords = {}
12
+ custom_keywords['writeOnly'] = proc { |data| !data } unless write
13
+ custom_keywords['readOnly'] = proc { |data| !data } if write
14
+ @schemer = JSONSchemer.schema(
15
+ schema,
16
+ keywords: custom_keywords,
17
+ before_property_validation: proc do |data, property, property_schema, parent|
18
+ convert_nullable(data, property, property_schema, parent)
19
+ end
20
+ )
21
+ end
22
+
23
+ def validate(input)
24
+ @schemer.validate(input)
25
+ end
26
+
27
+ private
28
+
29
+ def convert_nullable(_data, _property, property_schema, _parent)
30
+ return unless property_schema.is_a?(Hash) && property_schema['nullable'] && property_schema['type']
31
+
32
+ property_schema['type'] = [*property_schema['type'], 'null']
33
+ property_schema.delete('nullable')
34
+ end
35
+ end
36
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'hanami/utils/string'
4
+ require 'hanami/utils/hash'
4
5
  require 'deep_merge/core'
5
6
 
6
7
  module OpenapiFirst
@@ -17,20 +18,12 @@ module OpenapiFirst
17
18
  Hanami::Utils::String.classify(string)
18
19
  end
19
20
 
20
- def self.deep_stringify(params) # rubocop:disable Metrics/MethodLength
21
- params.each_with_object({}) do |(key, value), output|
22
- output[key.to_s] =
23
- case value
24
- when ::Hash
25
- deep_stringify(value)
26
- when Array
27
- value.map do |item|
28
- item.is_a?(::Hash) ? deep_stringify(item) : item
29
- end
30
- else
31
- value
32
- end
33
- end
21
+ def self.deep_symbolize(hash)
22
+ Hanami::Utils::Hash.deep_symbolize(hash)
23
+ end
24
+
25
+ def self.deep_stringify(hash)
26
+ Hanami::Utils::Hash.deep_stringify(hash)
34
27
  end
35
28
  end
36
29
  end
@@ -6,17 +6,37 @@ module OpenapiFirst
6
6
 
7
7
  # rubocop:disable Metrics/MethodLength
8
8
  # rubocop:disable Metrics/AbcSize
9
+ # rubocop:disable Metrics/CyclomaticComplexity
10
+ # rubocop:disable Metrics/PerceivedComplexity
9
11
  def self.error_details(error)
10
12
  if error['type'] == 'pattern'
11
13
  {
12
14
  title: 'is not valid',
13
15
  detail: "does not match pattern '#{error['schema']['pattern']}'"
14
16
  }
17
+ elsif error['type'] == 'format'
18
+ {
19
+ title: "has not a valid #{error.dig('schema', 'format')} format",
20
+ detail: "#{error['data'].inspect} is not a valid #{error.dig('schema', 'format')} format"
21
+ }
22
+ elsif error['type'] == 'enum'
23
+ {
24
+ title: "value #{error['data'].inspect} is not defined in enum",
25
+ detail: "value can be one of #{error.dig('schema', 'enum')&.join(', ')}"
26
+ }
15
27
  elsif error['type'] == 'required'
16
28
  missing_keys = error['details']['missing_keys']
17
29
  {
18
30
  title: "is missing required properties: #{missing_keys.join(', ')}"
19
31
  }
32
+ elsif error['type'] == 'readOnly'
33
+ {
34
+ title: 'appears in request, but is read-only'
35
+ }
36
+ elsif error['type'] == 'writeOnly'
37
+ {
38
+ title: 'write-only field appears in response:'
39
+ }
20
40
  elsif SIMPLE_TYPES.include?(error['type'])
21
41
  {
22
42
  title: "should be a #{error['type']}"
@@ -29,5 +49,7 @@ module OpenapiFirst
29
49
  end
30
50
  # rubocop:enable Metrics/MethodLength
31
51
  # rubocop:enable Metrics/AbcSize
52
+ # rubocop:enable Metrics/CyclomaticComplexity
53
+ # rubocop:enable Metrics/PerceivedComplexity
32
54
  end
33
55
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiFirst
4
- VERSION = '0.13.2'
4
+ VERSION = '0.14.2'
5
5
  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
@@ -20,7 +20,7 @@ Gem::Specification.new do |spec|
20
20
  spec.metadata['changelog_uri'] = 'https://github.com/ahx/openapi_first/blob/master/CHANGELOG.md'
21
21
  else
22
22
  raise 'RubyGems 2.0 or newer is required to protect against ' \
23
- 'public gem pushes.'
23
+ 'public gem pushes.'
24
24
  end
25
25
 
26
26
  spec.files = Dir.chdir(File.expand_path(__dir__)) do
@@ -32,10 +32,12 @@ Gem::Specification.new do |spec|
32
32
  spec.bindir = 'exe'
33
33
  spec.require_paths = ['lib']
34
34
 
35
+ spec.required_ruby_version = '>= 2.6.0'
36
+
35
37
  spec.add_runtime_dependency 'deep_merge', '>= 1.2.1'
36
38
  spec.add_runtime_dependency 'hanami-router', '~> 2.0.alpha3'
37
39
  spec.add_runtime_dependency 'hanami-utils', '~> 2.0.alpha1'
38
- spec.add_runtime_dependency 'json_schemer', '~> 0.2'
40
+ spec.add_runtime_dependency 'json_schemer', '~> 0.2.16'
39
41
  spec.add_runtime_dependency 'multi_json', '~> 1.14'
40
42
  spec.add_runtime_dependency 'oas_parser', '~> 0.25.1'
41
43
  spec.add_runtime_dependency 'rack', '~> 2.2'