openapi_first 0.11.0.alpha → 0.12.1

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.
@@ -5,7 +5,7 @@ require 'openapi_first'
5
5
 
6
6
  namespace = Module.new do
7
7
  def self.find_thing(params, _res)
8
- { hello: 'world', id: params.fetch('id') }
8
+ { hello: 'world', id: params.fetch(:id) }
9
9
  end
10
10
 
11
11
  def self.find_things(_params, _res)
@@ -13,4 +13,9 @@ module Web
13
13
  end
14
14
 
15
15
  oas_path = File.absolute_path('./openapi.yaml', __dir__)
16
- App = OpenapiFirst.app(oas_path, namespace: Web)
16
+
17
+ App = OpenapiFirst.app(
18
+ oas_path,
19
+ namespace: Web,
20
+ raise_error: OpenapiFirst.env == 'test'
21
+ )
@@ -9,7 +9,7 @@ require 'openapi_first/router'
9
9
  require 'openapi_first/request_validation'
10
10
  require 'openapi_first/response_validator'
11
11
  require 'openapi_first/response_validation'
12
- require 'openapi_first/operation_resolver'
12
+ require 'openapi_first/responder'
13
13
  require 'openapi_first/app'
14
14
 
15
15
  module OpenapiFirst
@@ -19,6 +19,10 @@ module OpenapiFirst
19
19
  INBOX = 'openapi_first.inbox'
20
20
  HANDLER = 'openapi_first.handler'
21
21
 
22
+ def self.env
23
+ ENV['RACK_ENV'] || ENV['HANAMI_ENV'] || ENV['RAILS_ENV']
24
+ end
25
+
22
26
  def self.load(spec_path, only: nil)
23
27
  content = YAML.load_file(spec_path)
24
28
  raw = OasParser::Parser.new(spec_path, content).resolve
@@ -27,14 +31,14 @@ module OpenapiFirst
27
31
  Definition.new(parsed)
28
32
  end
29
33
 
30
- def self.app(spec, namespace:)
34
+ def self.app(spec, namespace:, raise_error: false)
31
35
  spec = OpenapiFirst.load(spec) if spec.is_a?(String)
32
- App.new(nil, spec, namespace: namespace)
36
+ App.new(nil, spec, namespace: namespace, raise_error: raise_error)
33
37
  end
34
38
 
35
- def self.middleware(spec, namespace:)
39
+ def self.middleware(spec, namespace:, raise_error: false)
36
40
  spec = OpenapiFirst.load(spec) if spec.is_a?(String)
37
- AppWithOptions.new(spec, namespace: namespace)
41
+ AppWithOptions.new(spec, namespace: namespace, raise_error: raise_error)
38
42
  end
39
43
 
40
44
  class AppWithOptions
@@ -50,7 +54,41 @@ module OpenapiFirst
50
54
 
51
55
  class Error < StandardError; end
52
56
  class NotFoundError < Error; end
53
- class ResponseCodeNotFoundError < Error; end
54
- class ResponseMediaTypeNotFoundError < Error; end
55
- class ResponseBodyInvalidError < Error; end
57
+ class NotImplementedError < RuntimeError; end
58
+ class ResponseInvalid < Error; end
59
+ class ResponseCodeNotFoundError < ResponseInvalid; end
60
+ class ResponseContentTypeNotFoundError < ResponseInvalid; end
61
+ class ResponseBodyInvalidError < ResponseInvalid; end
62
+
63
+ class RequestInvalidError < Error
64
+ def initialize(serialized_errors)
65
+ message = error_message(serialized_errors)
66
+ super message
67
+ end
68
+
69
+ private
70
+
71
+ def error_message(errors)
72
+ errors.map do |error|
73
+ [human_source(error), human_error(error)].compact.join(' ')
74
+ end.join(', ')
75
+ end
76
+
77
+ def human_source(error)
78
+ return unless error[:source]
79
+
80
+ source_key = error[:source].keys.first
81
+ source = {
82
+ pointer: 'Request body invalid:',
83
+ parameter: 'Query parameter invalid:'
84
+ }.fetch(source_key, source_key)
85
+ name = error[:source].values.first
86
+ source += " #{name}" unless name.nil? || name.empty?
87
+ source
88
+ end
89
+
90
+ def human_error(error)
91
+ error[:title]
92
+ end
93
+ end
56
94
  end
@@ -5,16 +5,13 @@ require 'logger'
5
5
 
6
6
  module OpenapiFirst
7
7
  class App
8
- def initialize(
9
- parent_app,
10
- spec,
11
- namespace:
12
- )
8
+ def initialize(parent_app, spec, namespace:, raise_error:)
13
9
  @stack = Rack::Builder.app do
14
10
  freeze_app
15
- use OpenapiFirst::Router, spec: spec, parent_app: parent_app
16
- use OpenapiFirst::RequestValidation
17
- run OpenapiFirst::OperationResolver.new(
11
+ use OpenapiFirst::Router, spec: spec, raise_error: raise_error, parent_app: parent_app
12
+ use OpenapiFirst::RequestValidation, raise_error: raise_error
13
+ use OpenapiFirst::ResponseValidation if raise_error
14
+ run OpenapiFirst::Responder.new(
18
15
  spec: spec,
19
16
  namespace: namespace
20
17
  )
@@ -14,17 +14,5 @@ module OpenapiFirst
14
14
  def operations
15
15
  @spec.endpoints.map { |e| Operation.new(e) }
16
16
  end
17
-
18
- def find_operation!(request)
19
- @spec
20
- .path_by_path(request.path)
21
- .endpoint_by_method(request.request_method.downcase)
22
- end
23
-
24
- def find_operation(request)
25
- find_operation!(request)
26
- rescue OasParser::PathNotFound, OasParser::MethodNotFound
27
- nil
28
- end
29
17
  end
30
18
  end
@@ -5,14 +5,10 @@ require_relative 'utils'
5
5
  module OpenapiFirst
6
6
  class FindHandler
7
7
  def initialize(spec, namespace)
8
- @spec = spec
9
8
  @namespace = namespace
10
- end
11
-
12
- def all
13
- @spec.operations.each_with_object({}) do |operation, hash|
9
+ @handlers = spec.operations.each_with_object({}) do |operation, hash|
14
10
  operation_id = operation.operation_id
15
- handler = find_by_operation_id(operation_id)
11
+ handler = find_handler(operation_id)
16
12
  if handler.nil?
17
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
18
14
  next
@@ -21,22 +17,17 @@ module OpenapiFirst
21
17
  end
22
18
  end
23
19
 
24
- def find_by_operation_id(operation_id) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
20
+ def [](operation_id)
21
+ @handlers[operation_id]
22
+ end
23
+
24
+ def find_handler(operation_id)
25
25
  name = operation_id.match(/:*(.*)/)&.to_a&.at(1)
26
26
  return if name.nil?
27
27
 
28
- if name.include?('.')
29
- module_name, method_name = name.split('.')
30
- klass = find_const(@namespace, module_name)
31
- return klass&.method(Utils.underscore(method_name))
32
- end
33
- if name.include?('#')
34
- module_name, klass_name = name.split('#')
35
- const = find_const(@namespace, module_name)
36
- klass = find_const(const, klass_name)
37
- return ->(params, res) { klass.new.call(params, res) } if klass.instance_method(:initialize).arity.zero?
38
-
39
- return ->(params, res) { klass.new(params.env).call(params, res) }
28
+ catch :halt do
29
+ return find_class_method_handler(name) if name.include?('.')
30
+ return find_instance_method_handler(name) if name.include?('#')
40
31
  end
41
32
  method_name = Utils.underscore(name)
42
33
  return unless @namespace.respond_to?(method_name)
@@ -44,11 +35,24 @@ module OpenapiFirst
44
35
  @namespace.method(method_name)
45
36
  end
46
37
 
47
- private
38
+ def find_class_method_handler(name)
39
+ module_name, method_name = name.split('.')
40
+ klass = find_const(@namespace, module_name)
41
+ klass.method(Utils.underscore(method_name))
42
+ end
43
+
44
+ def find_instance_method_handler(name)
45
+ module_name, klass_name = name.split('#')
46
+ const = find_const(@namespace, module_name)
47
+ klass = find_const(const, klass_name)
48
+ return ->(params, res) { klass.new.call(params, res) } if klass.instance_method(:initialize).arity.zero?
49
+
50
+ ->(params, res) { klass.new(params.env).call(params, res) }
51
+ end
48
52
 
49
53
  def find_const(parent, name)
50
54
  name = Utils.classify(name)
51
- return unless parent.const_defined?(name, false)
55
+ throw :halt unless parent.const_defined?(name, false)
52
56
 
53
57
  parent.const_get(name, false)
54
58
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'forwardable'
4
+ require 'json_schemer'
4
5
  require_relative 'utils'
5
6
  require_relative 'response_object'
6
7
 
@@ -25,6 +26,10 @@ module OpenapiFirst
25
26
  @parameters_json_schema ||= build_parameters_json_schema
26
27
  end
27
28
 
29
+ def parameters_schema
30
+ @parameters_schema ||= parameters_json_schema && JSONSchemer.schema(parameters_json_schema)
31
+ end
32
+
28
33
  def content_type_for(status)
29
34
  content = response_for(status)['content']
30
35
  content.keys[0] if content
@@ -36,8 +41,8 @@ module OpenapiFirst
36
41
 
37
42
  media_type = content[content_type]
38
43
  unless media_type
39
- message = "Response media type found: '#{content_type}' for '#{operation_name}'"
40
- raise ResponseMediaTypeNotFoundError, message
44
+ message = "Response content type not found '#{content_type}' for '#{name}'"
45
+ raise ResponseContentTypeNotFoundError, message
41
46
  end
42
47
  media_type['schema']
43
48
  end
@@ -45,16 +50,16 @@ module OpenapiFirst
45
50
  def response_for(status)
46
51
  @operation.response_by_code(status.to_s, use_default: true).raw
47
52
  rescue OasParser::ResponseCodeNotFound
48
- message = "Response status code or default not found: #{status} for '#{operation_name}'"
53
+ message = "Response status code or default not found: #{status} for '#{name}'"
49
54
  raise OpenapiFirst::ResponseCodeNotFoundError, message
50
55
  end
51
56
 
52
- private
53
-
54
- def operation_name
55
- "#{method.upcase} #{path}"
57
+ def name
58
+ "#{method.upcase} #{path} (#{operation_id})"
56
59
  end
57
60
 
61
+ private
62
+
58
63
  def build_parameters_json_schema
59
64
  return unless @operation.parameters&.any?
60
65
 
@@ -4,12 +4,16 @@ require 'rack'
4
4
  require 'json_schemer'
5
5
  require 'multi_json'
6
6
  require_relative 'inbox'
7
+ require_relative 'router_required'
7
8
  require_relative 'validation_format'
8
9
 
9
10
  module OpenapiFirst
10
11
  class RequestValidation # rubocop:disable Metrics/ClassLength
11
- def initialize(app, _options = {})
12
+ prepend RouterRequired
13
+
14
+ def initialize(app, raise_error: false)
12
15
  @app = app
16
+ @raise = raise_error
13
17
  end
14
18
 
15
19
  def call(env) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
@@ -46,32 +50,32 @@ module OpenapiFirst
46
50
 
47
51
  parsed_request_body = parse_request_body!(body)
48
52
  errors = validate_json_schema(schema, parsed_request_body)
49
- halt(error_response(400, serialize_request_body_errors(errors))) if errors.any?
53
+ halt_with_error(400, serialize_request_body_errors(errors)) if errors.any?
50
54
  env[INBOX].merge! env[REQUEST_BODY] = parsed_request_body
51
55
  end
52
56
 
53
57
  def parse_request_body!(body)
54
- MultiJson.load(body)
58
+ MultiJson.load(body, symbolize_keys: true)
55
59
  rescue MultiJson::ParseError => e
56
60
  err = { title: 'Failed to parse body as JSON' }
57
61
  err[:detail] = e.cause unless ENV['RACK_ENV'] == 'production'
58
- halt(error_response(400, [err]))
62
+ halt_with_error(400, [err])
59
63
  end
60
64
 
61
65
  def validate_request_content_type!(content_type, operation)
62
66
  return if operation.request_body.content[content_type]
63
67
 
64
- halt(error_response(415))
68
+ halt_with_error(415)
65
69
  end
66
70
 
67
71
  def validate_request_body_presence!(body, operation)
68
72
  return unless operation.request_body.required && body.empty?
69
73
 
70
- halt(error_response(415, 'Request body is required'))
74
+ halt_with_error(415, 'Request body is required')
71
75
  end
72
76
 
73
77
  def validate_json_schema(schema, object)
74
- JSONSchemer.schema(schema).validate(object)
78
+ schema.validate(Utils.deep_stringify(object))
75
79
  end
76
80
 
77
81
  def default_error(status, title = Rack::Utils::HTTP_STATUS_CODES[status])
@@ -81,8 +85,10 @@ module OpenapiFirst
81
85
  }
82
86
  end
83
87
 
84
- def error_response(status, errors = [default_error(status)])
85
- Rack::Response.new(
88
+ def halt_with_error(status, errors = [default_error(status)])
89
+ raise RequestInvalidError, errors if @raise
90
+
91
+ halt Rack::Response.new(
86
92
  MultiJson.dump(errors: errors),
87
93
  status,
88
94
  Rack::CONTENT_TYPE => 'application/vnd.api+json'
@@ -92,7 +98,8 @@ module OpenapiFirst
92
98
  def request_body_schema(content_type, operation)
93
99
  return unless operation
94
100
 
95
- operation.request_body.content[content_type]&.fetch('schema')
101
+ schema = operation.request_body.content[content_type]&.fetch('schema')
102
+ JSONSchemer.schema(schema) if schema
96
103
  end
97
104
 
98
105
  def serialize_request_body_errors(validation_errors)
@@ -110,8 +117,11 @@ module OpenapiFirst
110
117
  return unless json_schema
111
118
 
112
119
  params = filtered_params(json_schema, params)
113
- errors = JSONSchemer.schema(json_schema).validate(params)
114
- halt error_response(400, serialize_query_parameter_errors(errors)) if errors.any?
120
+ errors = validate_json_schema(
121
+ operation.parameters_schema,
122
+ params
123
+ )
124
+ halt_with_error(400, serialize_query_parameter_errors(errors)) if errors.any?
115
125
  env[PARAMETERS] = params
116
126
  env[INBOX].merge! params
117
127
  end
@@ -119,7 +129,8 @@ module OpenapiFirst
119
129
  def filtered_params(json_schema, params)
120
130
  json_schema['properties']
121
131
  .each_with_object({}) do |key_value, result|
122
- parameter_name, schema = key_value
132
+ parameter_name = key_value[0].to_sym
133
+ schema = key_value[1]
123
134
  next unless params.key?(parameter_name)
124
135
 
125
136
  value = params[parameter_name]
@@ -5,16 +5,16 @@ require_relative 'inbox'
5
5
  require_relative 'find_handler'
6
6
 
7
7
  module OpenapiFirst
8
- class OperationResolver
9
- def initialize(spec:, namespace:)
10
- @handlers = FindHandler.new(spec, namespace).all
8
+ class Responder
9
+ def initialize(spec:, namespace:, resolver: FindHandler.new(spec, namespace))
10
+ @resolver = resolver
11
11
  @namespace = namespace
12
12
  end
13
13
 
14
14
  def call(env)
15
15
  operation = env[OpenapiFirst::OPERATION]
16
16
  res = Rack::Response.new
17
- handler = @handlers[operation.operation_id]
17
+ handler = find_handler(operation)
18
18
  result = handler.call(env[INBOX], res)
19
19
  res.write serialize(result) if result && res.body.empty?
20
20
  res[Rack::CONTENT_TYPE] ||= operation.content_type_for(res.status)
@@ -23,10 +23,24 @@ module OpenapiFirst
23
23
 
24
24
  private
25
25
 
26
+ def find_handler(operation)
27
+ handler = @resolver[operation.operation_id]
28
+ raise NotImplementedError, "Could not find handler for #{operation.name}" unless handler
29
+
30
+ handler
31
+ end
32
+
26
33
  def serialize(result)
27
34
  return result if result.is_a?(String)
28
35
 
29
36
  MultiJson.dump(result)
30
37
  end
31
38
  end
39
+
40
+ class OperationResolver < Responder
41
+ def initialize(spec:, namespace:)
42
+ warn "#{self.class.name} was renamed to #{OpenapiFirst::Responder.name}"
43
+ super
44
+ end
45
+ end
32
46
  end
@@ -2,10 +2,13 @@
2
2
 
3
3
  require 'json_schemer'
4
4
  require 'multi_json'
5
+ require_relative 'router_required'
5
6
  require_relative 'validation'
6
7
 
7
8
  module OpenapiFirst
8
9
  class ResponseValidation
10
+ prepend RouterRequired
11
+
9
12
  def initialize(app)
10
13
  @app = app
11
14
  end
@@ -14,103 +17,47 @@ module OpenapiFirst
14
17
  operation = env[OPERATION]
15
18
  return @app.call(env) unless operation
16
19
 
17
- status, headers, body = @app.call(env)
20
+ response = @app.call(env)
21
+ validate(response, operation)
22
+ response
23
+ end
24
+
25
+ def validate(response, operation)
26
+ status, headers, body = response.to_a
27
+ return validate_status_only(operation, status) if status == 204
28
+
18
29
  content_type = headers[Rack::CONTENT_TYPE]
30
+ raise ResponseInvalid, "Response has no content-type for '#{operation.name}'" unless content_type
31
+
19
32
  response_schema = operation.response_schema_for(status, content_type)
20
33
  validate_response_body(response_schema, body) if response_schema
21
-
22
- [status, headers, body]
23
34
  end
24
35
 
25
36
  private
26
37
 
27
- def halt(status, body = '')
28
- throw :halt, [status, {}, body]
29
- end
30
-
31
- def error(message)
32
- { title: message }
33
- end
34
-
35
- def error_response(status, errors)
36
- Rack::Response.new(
37
- MultiJson.dump(errors: errors),
38
- status,
39
- Rack::CONTENT_TYPE => 'application/vnd.api+json'
40
- ).finish
38
+ def validate_status_only(operation, status)
39
+ operation.response_for(status)
41
40
  end
42
41
 
43
42
  def validate_response_body(schema, response)
44
43
  full_body = +''
45
44
  response.each { |chunk| full_body << chunk }
46
- data = full_body.empty? ? {} : MultiJson.load(full_body)
45
+ data = full_body.empty? ? {} : load_json(full_body)
47
46
  errors = JSONSchemer.schema(schema).validate(data).to_a.map do |error|
48
- format_error(error)
47
+ error_message_for(error)
49
48
  end
50
49
  raise ResponseBodyInvalidError, errors.join(', ') if errors.any?
51
50
  end
52
51
 
53
- def format_error(error)
54
- err = ValidationFormat.error_details(error)
55
- [err[:title], 'at', error['data_pointer'], err[:detail]].compact.join(' ')
56
- end
57
- end
58
- end
59
-
60
- # frozen_string_literal: true
61
-
62
- require 'json_schemer'
63
- require 'multi_json'
64
- require_relative 'validation'
65
-
66
- module OpenapiFirst
67
- class ResponseValidator
68
- def initialize(spec)
69
- @spec = spec
70
- end
71
-
72
- def validate(request, response)
73
- errors = validation_errors(request, response)
74
- Validation.new(errors || [])
75
- rescue OasParser::ResponseCodeNotFound, OasParser::MethodNotFound => e
76
- Validation.new([e.message])
52
+ def load_json(string)
53
+ MultiJson.load(string)
54
+ rescue MultiJson::ParseError
55
+ string
77
56
  end
78
57
 
79
- private
80
-
81
- def validation_errors(request, response)
82
- content = response_for(request, response)&.content
83
- return unless content
84
-
85
- content_type = content[response.content_type]
86
- return ["Content type not found: '#{response.content_type}'"] unless content_type
87
-
88
- response_schema = content_type['schema']
89
- return unless response_schema
90
-
91
- response_data = MultiJson.load(response.body)
92
- validate_json_schema(response_schema, response_data)
93
- end
94
-
95
- def validate_json_schema(schema, data)
96
- JSONSchemer.schema(schema).validate(data).to_a.map do |error|
97
- format_error(error)
98
- end
99
- end
100
-
101
- def format_error(error)
102
- ValidationFormat.error_details(error)
103
- .merge!(
104
- data_pointer: error['data_pointer'],
105
- schema_pointer: error['schema_pointer']
106
- ).tap do |formatted|
107
- end
108
- end
109
-
110
- def response_for(request, response)
111
- @spec
112
- .find_operation!(request)
113
- &.response_by_code(response.status.to_s, use_default: true)
58
+ def error_message_for(error)
59
+ err = ValidationFormat.error_details(error)
60
+ [err[:title], error['data_pointer'], err[:detail]].compact.join(' ')
114
61
  end
115
62
  end
116
63
  end