openapi_first 0.13.0 → 0.14.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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
@@ -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.0'
4
+ VERSION = '0.14.1'
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'