openapi_first 0.13.3 → 0.14.3

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