openapi_first 0.13.3 → 0.14.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -28,15 +28,13 @@ paths:
28
28
  content:
29
29
  application/json:
30
30
  schema:
31
- type: array
32
- items:
33
- type: object
34
- required: [hello, id]
35
- properties:
36
- hello:
37
- type: string
38
- id:
39
- type: string
31
+ type: object
32
+ required: [hello, id]
33
+ properties:
34
+ hello:
35
+ type: string
36
+ id:
37
+ type: string
40
38
  /hello:
41
39
  get:
42
40
  operationId: find_things
@@ -61,11 +59,13 @@ paths:
61
59
  content:
62
60
  application/json:
63
61
  schema:
64
- type: object
65
- required: [hello]
66
- properties:
67
- hello:
68
- type: string
62
+ type: array
63
+ items:
64
+ type: object
65
+ required: [hello]
66
+ properties:
67
+ hello:
68
+ type: string
69
69
  default:
70
70
  description: Error response
71
71
 
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'multi_json'
4
+ require 'openapi_first'
5
+ require 'hanami/api'
6
+
7
+ app = Class.new(Hanami::API) do
8
+ get '/hello/:id' do
9
+ json(hello: 'world', id: params.fetch(:id))
10
+ end
11
+
12
+ get '/hello' do
13
+ json([{ hello: 'world' }])
14
+ end
15
+
16
+ post '/hello' do
17
+ status 201
18
+ json(hello: 'world')
19
+ end
20
+ end.new
21
+
22
+ oas_path = File.absolute_path('./openapi.yaml', __dir__)
23
+ use OpenapiFirst::Router, spec: OpenapiFirst.load(oas_path)
24
+ use OpenapiFirst::RequestValidation
25
+
26
+ run app
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'multi_json'
4
+ require 'openapi_first'
5
+
6
+ namespace = Module.new do
7
+ def self.find_thing(params, _res)
8
+ { hello: 'world', id: params.fetch(:id) }
9
+ end
10
+
11
+ def self.find_things(_params, _res)
12
+ [{ hello: 'world' }]
13
+ end
14
+
15
+ def self.create_thing(_params, res)
16
+ res.status = 201
17
+ { hello: 'world' }
18
+ end
19
+ end
20
+
21
+ oas_path = File.absolute_path('./openapi.yaml', __dir__)
22
+ run OpenapiFirst.app(oas_path, namespace: namespace, response_validation: true)
@@ -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
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rack'
4
- require 'logger'
5
4
 
6
5
  module OpenapiFirst
7
6
  class App
@@ -11,17 +10,15 @@ module OpenapiFirst
11
10
  namespace:,
12
11
  router_raise_error: false,
13
12
  request_validation_raise_error: false,
14
- response_validation: false
13
+ response_validation: false,
14
+ resolver: nil
15
15
  )
16
16
  @stack = Rack::Builder.app do
17
17
  freeze_app
18
18
  use OpenapiFirst::Router, spec: spec, raise_error: router_raise_error, parent_app: parent_app
19
19
  use OpenapiFirst::RequestValidation, raise_error: request_validation_raise_error
20
20
  use OpenapiFirst::ResponseValidation if response_validation
21
- run OpenapiFirst::Responder.new(
22
- spec: spec,
23
- namespace: namespace
24
- )
21
+ run OpenapiFirst::Responder.new(namespace: namespace, resolver: resolver)
25
22
  end
26
23
  end
27
24
 
@@ -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)
@@ -6,9 +6,14 @@ module OpenapiFirst
6
6
  class Definition
7
7
  attr_reader :filepath, :operations
8
8
 
9
- def initialize(parsed)
10
- @filepath = parsed.path
11
- @operations = parsed.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
12
17
  end
13
18
  end
14
19
  end
@@ -1,29 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'forwardable'
4
- require 'json_schemer'
5
4
  require_relative 'schema_validation'
6
5
  require_relative 'utils'
7
6
  require_relative 'response_object'
8
7
 
9
8
  module OpenapiFirst
10
- class Operation
9
+ class Operation # rubocop:disable Metrics/ClassLength
11
10
  extend Forwardable
12
- def_delegators :@operation,
13
- :parameters,
14
- :method,
15
- :request_body,
16
- :operation_id
11
+ def_delegators :operation_object,
12
+ :[],
13
+ :dig
17
14
 
18
15
  WRITE_METHODS = Set.new(%w[post put patch delete]).freeze
19
16
  private_constant :WRITE_METHODS
20
17
 
21
- def initialize(parsed)
22
- @operation = parsed
18
+ attr_reader :path, :method
19
+
20
+ def initialize(path, request_method, path_item_object)
21
+ @path = path
22
+ @method = request_method
23
+ @path_item_object = path_item_object
23
24
  end
24
25
 
25
- def path
26
- @operation.path.path
26
+ def operation_id
27
+ operation_object['operationId']
27
28
  end
28
29
 
29
30
  def read?
@@ -34,6 +35,10 @@ module OpenapiFirst
34
35
  WRITE_METHODS.include?(method)
35
36
  end
36
37
 
38
+ def request_body
39
+ operation_object['requestBody']
40
+ end
41
+
37
42
  def parameters_schema
38
43
  @parameters_schema ||= begin
39
44
  parameters_json_schema = build_parameters_json_schema
@@ -53,6 +58,7 @@ module OpenapiFirst
53
58
  raise ResponseInvalid, "Response has no content-type for '#{name}'" unless content_type
54
59
 
55
60
  media_type = find_content_for_content_type(content, content_type)
61
+
56
62
  unless media_type
57
63
  message = "Response content type not found '#{content_type}' for '#{name}'"
58
64
  raise ResponseContentTypeNotFoundError, message
@@ -62,7 +68,7 @@ module OpenapiFirst
62
68
  end
63
69
 
64
70
  def request_body_schema(request_content_type)
65
- content = @operation.request_body.content
71
+ content = operation_object.dig('requestBody', 'content')
66
72
  media_type = find_content_for_content_type(content, request_content_type)
67
73
  schema = media_type&.fetch('schema', nil)
68
74
  return unless schema
@@ -71,8 +77,9 @@ module OpenapiFirst
71
77
  end
72
78
 
73
79
  def response_for(status)
74
- @operation.response_by_code(status.to_s, use_default: true).raw
75
- rescue OasParser::ResponseCodeNotFound
80
+ response_content = response_by_code(status)
81
+ return response_content if response_content
82
+
76
83
  message = "Response status code or default not found: #{status} for '#{name}'"
77
84
  raise OpenapiFirst::ResponseCodeNotFoundError, message
78
85
  end
@@ -83,6 +90,15 @@ module OpenapiFirst
83
90
 
84
91
  private
85
92
 
93
+ def response_by_code(status)
94
+ operation_object.dig('responses', status.to_s) ||
95
+ operation_object.dig('responses', 'default')
96
+ end
97
+
98
+ def operation_object
99
+ @path_item_object[method]
100
+ end
101
+
86
102
  def find_content_for_content_type(content, request_content_type)
87
103
  content.fetch(request_content_type) do |_|
88
104
  type = request_content_type.split(';')[0]
@@ -91,24 +107,32 @@ module OpenapiFirst
91
107
  end
92
108
 
93
109
  def build_parameters_json_schema
94
- return unless @operation.parameters&.any?
110
+ parameters = all_parameters
111
+ return unless parameters&.any?
95
112
 
96
- @operation.parameters.each_with_object(new_node) do |parameter, schema|
97
- params = Rack::Utils.parse_nested_query(parameter.name)
113
+ parameters.each_with_object(new_node) do |parameter, schema|
114
+ params = Rack::Utils.parse_nested_query(parameter['name'])
98
115
  generate_schema(schema, params, parameter)
99
116
  end
100
117
  end
101
118
 
119
+ def all_parameters
120
+ parameters = @path_item_object['parameters']&.dup || []
121
+ parameters_on_operation = operation_object['parameters']
122
+ parameters.concat(parameters_on_operation) if parameters_on_operation
123
+ parameters
124
+ end
125
+
102
126
  def generate_schema(schema, params, parameter)
103
127
  required = Set.new(schema['required'])
104
128
  params.each do |key, value|
105
- required << key if parameter.required
129
+ required << key if parameter['required']
106
130
  if value.is_a? Hash
107
131
  property_schema = new_node
108
132
  generate_schema(property_schema, value, parameter)
109
133
  Utils.deep_merge!(schema['properties'], { key => property_schema })
110
134
  else
111
- schema['properties'][key] = parameter.schema
135
+ schema['properties'][key] = parameter['schema']
112
136
  end
113
137
  end
114
138
  schema['required'] = required.to_a
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rack'
4
- require 'json_schemer'
5
4
  require 'multi_json'
6
5
  require_relative 'inbox'
7
6
  require_relative 'router_required'
@@ -63,13 +62,13 @@ module OpenapiFirst
63
62
  end
64
63
 
65
64
  def validate_request_content_type!(content_type, operation)
66
- return if operation.request_body.content[content_type]
65
+ return if operation.request_body.dig('content', content_type)
67
66
 
68
67
  halt_with_error(415)
69
68
  end
70
69
 
71
70
  def validate_request_body_presence!(body, operation)
72
- return unless operation.request_body.required && body.empty?
71
+ return unless operation.request_body['required'] && body.empty?
73
72
 
74
73
  halt_with_error(415, 'Request body is required')
75
74
  end
@@ -142,6 +141,8 @@ module OpenapiFirst
142
141
  end
143
142
 
144
143
  def parse_array_parameter(value, schema)
144
+ return value if value.nil? || value.empty?
145
+
145
146
  array = value.is_a?(Array) ? value : value.split(',')
146
147
  return array unless schema['items']
147
148
 
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rack'
4
+ require 'multi_json'
4
5
  require_relative 'inbox'
5
- require_relative 'find_handler'
6
+ require_relative 'default_operation_resolver'
6
7
 
7
8
  module OpenapiFirst
8
9
  class Responder
9
- def initialize(spec:, namespace:, resolver: FindHandler.new(spec, namespace))
10
- @resolver = resolver
10
+ def initialize(namespace: nil, resolver: nil)
11
+ @resolver = resolver || DefaultOperationResolver.new(namespace)
11
12
  @namespace = namespace
12
13
  end
13
14
 
@@ -24,7 +25,7 @@ module OpenapiFirst
24
25
  private
25
26
 
26
27
  def find_handler(operation)
27
- handler = @resolver[operation.operation_id]
28
+ handler = @resolver.call(operation)
28
29
  raise NotImplementedError, "Could not find handler for #{operation.name}" unless handler
29
30
 
30
31
  handler
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'forwardable'
4
- require_relative 'utils'
5
4
 
6
5
  module OpenapiFirst
7
6
  # Represents an OpenAPI Response Object
@@ -1,9 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json_schemer'
4
3
  require 'multi_json'
5
4
  require_relative 'router_required'
6
- require_relative 'validation'
5
+ require_relative 'validation_format'
7
6
 
8
7
  module OpenapiFirst
9
8
  class ResponseValidation
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json_schemer'
4
- require 'multi_json'
5
- require_relative 'validation'
3
+ require_relative 'response_validation'
6
4
  require_relative 'router'
7
5
 
8
6
  module OpenapiFirst
@@ -2,7 +2,6 @@
2
2
 
3
3
  require 'rack'
4
4
  require 'hanami/router'
5
- require_relative 'utils'
6
5
 
7
6
  module OpenapiFirst
8
7
  class Router
@@ -40,7 +39,10 @@ module OpenapiFirst
40
39
 
41
40
  def raise_error(env)
42
41
  req = Rack::Request.new(env)
43
- msg = "Could not find definition for #{req.request_method} '#{req.path}' in API description #{@filepath}"
42
+ msg =
43
+ "Could not find definition for #{req.request_method} '#{
44
+ req.path
45
+ }' in API description #{@filepath}"
44
46
  raise NotFoundError, msg
45
47
  end
46
48
 
@@ -54,7 +56,7 @@ module OpenapiFirst
54
56
  end
55
57
 
56
58
  def build_router(operations) # rubocop:disable Metrics/AbcSize
57
- router = Hanami::Router.new {}
59
+ router = Hanami::Router.new
58
60
  operations.each do |operation|
59
61
  normalized_path = operation.path.gsub('{', ':').gsub('}', '')
60
62
  if operation.operation_id.nil?
@@ -19,6 +19,11 @@ module OpenapiFirst
19
19
  title: "has not a valid #{error.dig('schema', 'format')} format",
20
20
  detail: "#{error['data'].inspect} is not a valid #{error.dig('schema', 'format')} format"
21
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
+ }
22
27
  elsif error['type'] == 'required'
23
28
  missing_keys = error['details']['missing_keys']
24
29
  {
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiFirst
4
- VERSION = '0.13.3'
4
+ VERSION = '0.14.3'
5
5
  end
data/lib/openapi_first.rb CHANGED
@@ -1,16 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'yaml'
4
- require 'oas_parser'
5
- require 'openapi_first/definition'
6
- require 'openapi_first/version'
7
- require 'openapi_first/inbox'
8
- require 'openapi_first/router'
9
- require 'openapi_first/request_validation'
10
- require 'openapi_first/response_validator'
11
- require 'openapi_first/response_validation'
12
- require 'openapi_first/responder'
13
- require 'openapi_first/app'
4
+ require 'json_refs'
5
+ require_relative 'openapi_first/definition'
6
+ require_relative 'openapi_first/version'
7
+ require_relative 'openapi_first/inbox'
8
+ require_relative 'openapi_first/router'
9
+ require_relative 'openapi_first/request_validation'
10
+ require_relative 'openapi_first/response_validator'
11
+ require_relative 'openapi_first/response_validation'
12
+ require_relative 'openapi_first/responder'
13
+ require_relative 'openapi_first/app'
14
14
 
15
15
  module OpenapiFirst
16
16
  OPERATION = 'openapi_first.operation'
@@ -24,11 +24,12 @@ module OpenapiFirst
24
24
  end
25
25
 
26
26
  def self.load(spec_path, only: nil)
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)
27
+ resolved = Dir.chdir(File.dirname(spec_path)) do
28
+ content = YAML.load_file(File.basename(spec_path))
29
+ JsonRefs.call(content, resolve_local_ref: true, resolve_file_ref: true)
30
+ end
31
+ resolved['paths'].filter!(&->(key, _) { only.call(key) }) if only
32
+ Definition.new(resolved, spec_path)
32
33
  end
33
34
 
34
35
  def self.app(
@@ -78,11 +79,17 @@ module OpenapiFirst
78
79
  end
79
80
 
80
81
  class Error < StandardError; end
82
+
81
83
  class NotFoundError < Error; end
84
+
82
85
  class NotImplementedError < RuntimeError; end
86
+
83
87
  class ResponseInvalid < Error; end
88
+
84
89
  class ResponseCodeNotFoundError < ResponseInvalid; end
90
+
85
91
  class ResponseContentTypeNotFoundError < ResponseInvalid; end
92
+
86
93
  class ResponseBodyInvalidError < ResponseInvalid; end
87
94
 
88
95
  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
@@ -35,15 +35,18 @@ Gem::Specification.new do |spec|
35
35
  spec.required_ruby_version = '>= 2.6.0'
36
36
 
37
37
  spec.add_runtime_dependency 'deep_merge', '>= 1.2.1'
38
- spec.add_runtime_dependency 'hanami-router', '~> 2.0.alpha3'
38
+ spec.add_runtime_dependency 'hanami-router', '~> 2.0.alpha4'
39
39
  spec.add_runtime_dependency 'hanami-utils', '~> 2.0.alpha1'
40
+ spec.add_runtime_dependency 'json_refs', '>= 0.1.7'
40
41
  spec.add_runtime_dependency 'json_schemer', '~> 0.2.16'
41
42
  spec.add_runtime_dependency 'multi_json', '~> 1.14'
42
- spec.add_runtime_dependency 'oas_parser', '~> 0.25.1'
43
43
  spec.add_runtime_dependency 'rack', '~> 2.2'
44
44
 
45
45
  spec.add_development_dependency 'bundler', '~> 2'
46
46
  spec.add_development_dependency 'rack-test', '~> 1'
47
47
  spec.add_development_dependency 'rake', '~> 13'
48
48
  spec.add_development_dependency 'rspec', '~> 3'
49
+ spec.metadata = {
50
+ 'rubygems_mfa_required' => 'true'
51
+ }
49
52
  end