openapi_first 0.11.0.alpha → 0.12.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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