openapi_first 1.4.3 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -1
  3. data/README.md +105 -28
  4. data/lib/openapi_first/body_parser.rb +8 -11
  5. data/lib/openapi_first/builder.rb +81 -0
  6. data/lib/openapi_first/configuration.rb +24 -3
  7. data/lib/openapi_first/definition.rb +44 -100
  8. data/lib/openapi_first/error_response.rb +2 -2
  9. data/lib/openapi_first/error_responses/default.rb +73 -0
  10. data/lib/openapi_first/error_responses/jsonapi.rb +59 -0
  11. data/lib/openapi_first/errors.rb +26 -4
  12. data/lib/openapi_first/failure.rb +29 -26
  13. data/lib/openapi_first/json_refs.rb +1 -3
  14. data/lib/openapi_first/middlewares/request_validation.rb +2 -2
  15. data/lib/openapi_first/middlewares/response_validation.rb +4 -3
  16. data/lib/openapi_first/request.rb +92 -0
  17. data/lib/openapi_first/request_parser.rb +35 -0
  18. data/lib/openapi_first/request_validator.rb +25 -0
  19. data/lib/openapi_first/response.rb +57 -0
  20. data/lib/openapi_first/response_parser.rb +49 -0
  21. data/lib/openapi_first/response_validator.rb +27 -0
  22. data/lib/openapi_first/router/find_content.rb +17 -0
  23. data/lib/openapi_first/router/find_response.rb +45 -0
  24. data/lib/openapi_first/{definition → router}/path_template.rb +9 -1
  25. data/lib/openapi_first/router.rb +100 -0
  26. data/lib/openapi_first/schema/validation_error.rb +16 -10
  27. data/lib/openapi_first/schema/validation_result.rb +8 -6
  28. data/lib/openapi_first/schema.rb +4 -8
  29. data/lib/openapi_first/test/methods.rb +21 -0
  30. data/lib/openapi_first/test.rb +19 -0
  31. data/lib/openapi_first/validated_request.rb +81 -0
  32. data/lib/openapi_first/validated_response.rb +33 -0
  33. data/lib/openapi_first/validators/request_body.rb +39 -0
  34. data/lib/openapi_first/validators/request_parameters.rb +61 -0
  35. data/lib/openapi_first/validators/response_body.rb +30 -0
  36. data/lib/openapi_first/validators/response_headers.rb +25 -0
  37. data/lib/openapi_first/version.rb +1 -1
  38. data/lib/openapi_first.rb +40 -21
  39. metadata +25 -20
  40. data/lib/openapi_first/definition/operation.rb +0 -197
  41. data/lib/openapi_first/definition/path_item.rb +0 -40
  42. data/lib/openapi_first/definition/request_body.rb +0 -46
  43. data/lib/openapi_first/definition/response.rb +0 -32
  44. data/lib/openapi_first/definition/responses.rb +0 -87
  45. data/lib/openapi_first/plugins/default/error_response.rb +0 -74
  46. data/lib/openapi_first/plugins/default.rb +0 -11
  47. data/lib/openapi_first/plugins/jsonapi/error_response.rb +0 -60
  48. data/lib/openapi_first/plugins/jsonapi.rb +0 -11
  49. data/lib/openapi_first/plugins.rb +0 -25
  50. data/lib/openapi_first/request_validation/request_body_validator.rb +0 -41
  51. data/lib/openapi_first/request_validation/validator.rb +0 -82
  52. data/lib/openapi_first/response_validation/validator.rb +0 -98
  53. data/lib/openapi_first/runtime_request.rb +0 -166
  54. data/lib/openapi_first/runtime_response.rb +0 -124
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module ErrorResponses
5
+ # An error reponse that returns application/problem+json with a list of "errors"
6
+ # See also https://www.rfc-editor.org/rfc/rfc9457.html
7
+ class Default
8
+ include OpenapiFirst::ErrorResponse
9
+ OpenapiFirst.register_error_response(:default, self)
10
+
11
+ TITLES = {
12
+ not_found: 'Not Found',
13
+ method_not_allowed: 'Request Method Not Allowed',
14
+ unsupported_media_type: 'Unsupported Media Type',
15
+ invalid_body: 'Bad Request Body',
16
+ invalid_query: 'Bad Query Parameter',
17
+ invalid_header: 'Bad Request Header',
18
+ invalid_path: 'Bad Request Path',
19
+ invalid_cookie: 'Bad Request Cookie'
20
+ }.freeze
21
+ private_constant :TITLES
22
+
23
+ def body
24
+ result = {
25
+ title:,
26
+ status:
27
+ }
28
+ result[:errors] = errors if failure.errors
29
+ MultiJson.dump(result)
30
+ end
31
+
32
+ def type = failure.type
33
+
34
+ def title
35
+ TITLES.fetch(type)
36
+ end
37
+
38
+ def content_type
39
+ 'application/problem+json'
40
+ end
41
+
42
+ def errors
43
+ key = pointer_key
44
+ failure.errors.map do |error|
45
+ {
46
+ message: error.message,
47
+ key => pointer(error.data_pointer),
48
+ code: error.type
49
+ }
50
+ end
51
+ end
52
+
53
+ def pointer_key
54
+ case type
55
+ when :invalid_body
56
+ :pointer
57
+ when :invalid_query, :invalid_path
58
+ :parameter
59
+ when :invalid_header
60
+ :header
61
+ when :invalid_cookie
62
+ :cookie
63
+ end
64
+ end
65
+
66
+ def pointer(data_pointer)
67
+ return data_pointer if type == :invalid_body
68
+
69
+ data_pointer.delete_prefix('/')
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ module ErrorResponses
5
+ # A JSON:API conform error response. See https://jsonapi.org/.
6
+ class Jsonapi
7
+ include OpenapiFirst::ErrorResponse
8
+ OpenapiFirst.register_error_response(:jsonapi, self)
9
+
10
+ def body
11
+ MultiJson.dump({ errors: serialized_errors })
12
+ end
13
+
14
+ def content_type
15
+ 'application/vnd.api+json'
16
+ end
17
+
18
+ def serialized_errors
19
+ return default_errors unless failure.errors
20
+
21
+ key = pointer_key
22
+ failure.errors.map do |error|
23
+ {
24
+ status: status.to_s,
25
+ source: { key => pointer(error.data_pointer) },
26
+ title: error.message,
27
+ code: error.type
28
+ }
29
+ end
30
+ end
31
+
32
+ def default_errors
33
+ [{
34
+ status: status.to_s,
35
+ title: Rack::Utils::HTTP_STATUS_CODES[status]
36
+ }]
37
+ end
38
+
39
+ def pointer_key
40
+ case failure.type
41
+ when :invalid_body
42
+ :pointer
43
+ when :invalid_query, :invalid_path
44
+ :parameter
45
+ when :invalid_header
46
+ :header
47
+ when :invalid_cookie
48
+ :cookie
49
+ end
50
+ end
51
+
52
+ def pointer(data_pointer)
53
+ return data_pointer if failure.type == :invalid_body
54
+
55
+ data_pointer.delete_prefix('/')
56
+ end
57
+ end
58
+ end
59
+ end
@@ -4,13 +4,35 @@ module OpenapiFirst
4
4
  # @!visibility private
5
5
  class Error < StandardError; end
6
6
  # @!visibility private
7
+ class FileNotFoundError < Error; end
8
+ # @!visibility private
7
9
  class ParseError < Error; end
10
+
8
11
  # @!visibility private
9
- class NotFoundError < Error; end
12
+ class RequestInvalidError < Error
13
+ def initialize(message, validated_request)
14
+ super(message)
15
+ @request = validated_request
16
+ end
17
+
18
+ # @attr_reader [OpenapiFirst::ValidatedRequest] request The validated request
19
+ attr_reader :request
20
+ end
21
+
10
22
  # @!visibility private
11
- class RequestInvalidError < Error; end
23
+ class NotFoundError < RequestInvalidError; end
24
+
12
25
  # @!visibility private
13
- class ResponseNotFoundError < Error; end
26
+ class ResponseInvalidError < Error
27
+ def initialize(message, validated_response)
28
+ super(message)
29
+ @response = validated_response
30
+ end
31
+
32
+ # @attr_reader [OpenapiFirst::ValidatedResponse] request The validated response
33
+ attr_reader :response
34
+ end
35
+
14
36
  # @!visibility private
15
- class ResponseInvalidError < Error; end
37
+ class ResponseNotFoundError < ResponseInvalidError; end
16
38
  end
@@ -1,10 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenapiFirst
4
- # A failure object returned when validation of request or response has failed.
4
+ # A failure object returned when validation or parsing of a request or response has failed.
5
+ # This returned in ValidatedRequest#error and ValidatedResponse#error.
5
6
  class Failure
6
- FAILURE = :openapi_first_validation_failure
7
-
8
7
  TYPES = {
9
8
  not_found: [NotFoundError, 'Request path is not defined.'],
10
9
  method_not_allowed: [RequestInvalidError, 'Request method is not defined.'],
@@ -14,64 +13,68 @@ module OpenapiFirst
14
13
  invalid_header: [RequestInvalidError, 'Request header is invalid:'],
15
14
  invalid_path: [RequestInvalidError, 'Path segment is invalid:'],
16
15
  invalid_cookie: [RequestInvalidError, 'Cookie value is invalid:'],
17
- response_not_found: [ResponseNotFoundError, 'Response is not defined.'],
16
+ response_not_found: [ResponseNotFoundError],
18
17
  invalid_response_body: [ResponseInvalidError, 'Response body is invalid:'],
19
18
  invalid_response_header: [ResponseInvalidError, 'Response header is invalid:']
20
19
  }.freeze
21
20
  private_constant :TYPES
22
21
 
23
- # @param error_type [Symbol] See Failure::TYPES.keys
22
+ # @param type [Symbol] See Failure::TYPES.keys
24
23
  # @param errors [Array<OpenapiFirst::Schema::ValidationError>]
25
- def self.fail!(error_type, message: nil, errors: nil)
24
+ def self.fail!(type, message: nil, errors: nil)
26
25
  throw FAILURE, new(
27
- error_type,
26
+ type,
28
27
  message:,
29
28
  errors:
30
29
  )
31
30
  end
32
31
 
33
- # @param error_type [Symbol] See TYPES.keys
32
+ # @param type [Symbol] See TYPES.keys
34
33
  # @param message [String] A generic error message
35
34
  # @param errors [Array<OpenapiFirst::Schema::ValidationError>]
36
- def initialize(error_type, message: nil, errors: nil)
37
- unless TYPES.key?(error_type)
35
+ def initialize(type, message: nil, errors: nil)
36
+ unless TYPES.key?(type)
38
37
  raise ArgumentError,
39
- "error_type must be one of #{TYPES.keys} but was #{error_type.inspect}"
38
+ "type must be one of #{TYPES.keys} but was #{type.inspect}"
40
39
  end
41
40
 
42
- @error_type = error_type
41
+ @type = type
43
42
  @message = message
44
43
  @errors = errors
45
44
  end
46
45
 
47
- # @attr_reader [Symbol] error_type The type of the failure. See TYPES.keys.
48
- # @alias type error_type
46
+ # @attr_reader [Symbol] type The type of the failure. See TYPES.keys.
49
47
  # Example: :invalid_body
50
- attr_reader :error_type
51
- alias type error_type
52
-
53
- # @attr_reader [String] message A generic error message
54
- attr_reader :message
48
+ attr_reader :type
55
49
 
56
50
  # @attr_reader [Array<OpenapiFirst::Schema::ValidationError>] errors Schema validation errors
57
51
  attr_reader :errors
58
52
 
59
- # Raise an exception that fits the failure.
60
- def raise!
61
- exception, = TYPES.fetch(error_type)
62
- raise exception, exception_message
53
+ # A generic error message
54
+ def message
55
+ @message ||= exception_message
56
+ end
57
+
58
+ def exception(context = nil)
59
+ TYPES.fetch(type).first.new(exception_message, context)
63
60
  end
64
61
 
65
62
  def exception_message
66
- _, message_prefix = TYPES.fetch(error_type)
63
+ _, message_prefix = TYPES.fetch(type)
64
+
65
+ [message_prefix, @message || generate_message].compact.join(' ')
66
+ end
67
67
 
68
- "#{message_prefix} #{@message || generate_message}"
68
+ # @deprecated Please use {#type} instead
69
+ def error_type
70
+ warn 'OpenapiFirst::Failure#error_type is deprecated. Use #type instead.'
71
+ type
69
72
  end
70
73
 
71
74
  private
72
75
 
73
76
  def generate_message
74
- messages = errors&.take(3)&.map(&:error)
77
+ messages = errors&.take(3)&.map(&:message)
75
78
  messages << "... (#{errors.size} errors total)" if errors && errors.size > 3
76
79
  messages&.join('. ')
77
80
  end
@@ -33,9 +33,7 @@ require 'json'
33
33
  require 'yaml'
34
34
 
35
35
  module OpenapiFirst
36
- class FileNotFoundError < StandardError; end
37
-
38
- module JsonRefs
36
+ module JsonRefs # :nodoc:
39
37
  class << self
40
38
  def dereference(doc)
41
39
  file_cache = {}
@@ -27,7 +27,7 @@ module OpenapiFirst
27
27
 
28
28
  def call(env)
29
29
  validated = @definition.validate_request(Rack::Request.new(env), raise_error: @raise)
30
- env[REQUEST] ||= validated
30
+ env[REQUEST] = validated
31
31
  failure = validated.error
32
32
  return @error_response_class.new(failure:).render if failure
33
33
 
@@ -37,7 +37,7 @@ module OpenapiFirst
37
37
  private
38
38
 
39
39
  def error_response(mod)
40
- return OpenapiFirst.find_plugin(mod)::ErrorResponse if mod.is_a?(Symbol)
40
+ return OpenapiFirst.find_error_response(mod) if mod.is_a?(Symbol)
41
41
 
42
42
  mod || OpenapiFirst.configuration.request_validation_error_response
43
43
  end
@@ -8,9 +8,11 @@ module OpenapiFirst
8
8
  class ResponseValidation
9
9
  # @param app The parent Rack application
10
10
  # @param options Hash
11
- # :spec Path to the OpenAPI file or an instance of Definition
11
+ # :spec [String, OpenapiFirst::Definition] Path to the OpenAPI file or an instance of Definition
12
+ # :raise_error [Boolean] Whether to raise an error if validation fails. default: true
12
13
  def initialize(app, options = {})
13
14
  @app = app
15
+ @raise = options.fetch(:raise_error, OpenapiFirst.configuration.response_validation_raise_error)
14
16
 
15
17
  spec = options.fetch(:spec)
16
18
  raise "You have to pass spec: when initializing #{self.class}" unless spec
@@ -24,8 +26,7 @@ module OpenapiFirst
24
26
  def call(env)
25
27
  status, headers, body = @app.call(env)
26
28
  body = read_body(body)
27
- @definition.validate_response(Rack::Request.new(env), Rack::Response[status, headers, body], raise_error: true)
28
- env[REQUEST] ||= @definition.request(Rack::Request.new(env))
29
+ @definition.validate_response(Rack::Request.new(env), Rack::Response[status, headers, body], raise_error: @raise)
29
30
  [status, headers, body]
30
31
  end
31
32
 
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'request_parser'
4
+ require_relative 'request_validator'
5
+ require_relative 'validated_request'
6
+
7
+ module OpenapiFirst
8
+ # Represents one request definition of an OpenAPI description.
9
+ # Note that this is not the same as an OpenAPI 3.x Operation.
10
+ # An 3.x Operation object can accept multiple requests, because it can handle multiple content-types.
11
+ # This class represents one of those requests.
12
+ class Request
13
+ def initialize(path:, request_method:, operation_object:,
14
+ parameters:, content_type:, content_schema:, required_body:,
15
+ hooks:, openapi_version:)
16
+ @path = path
17
+ @request_method = request_method
18
+ @content_type = content_type
19
+ @content_schema = content_schema
20
+ @required_request_body = required_body == true
21
+ @operation = operation_object
22
+ @parameters = build_parameters(parameters)
23
+ @request_parser = RequestParser.new(
24
+ query_parameters: @parameters[:query],
25
+ path_parameters: @parameters[:path],
26
+ header_parameters: @parameters[:header],
27
+ cookie_parameters: @parameters[:cookie],
28
+ content_type:
29
+ )
30
+ @validator = RequestValidator.new(self, hooks:, openapi_version:)
31
+ end
32
+
33
+ attr_reader :content_type, :content_schema, :operation, :request_method, :path
34
+
35
+ def validate(request, route_params:)
36
+ parsed_values = {}
37
+ error = catch FAILURE do
38
+ parsed_values = @request_parser.parse(request, route_params:)
39
+ @validator.call(parsed_values)
40
+ nil
41
+ end
42
+ ValidatedRequest.new(request, parsed_values:, error:, request_definition: self)
43
+ end
44
+
45
+ # These return a Schema instance for each type of parameters
46
+ %i[path query header cookie].each do |location|
47
+ define_method(:"#{location}_schema") do
48
+ build_parameters_schema(@parameters[location])
49
+ end
50
+ end
51
+
52
+ def required_request_body?
53
+ @required_request_body
54
+ end
55
+
56
+ def operation_id
57
+ @operation['operationId']
58
+ end
59
+
60
+ private
61
+
62
+ IGNORED_HEADERS = Set['Content-Type', 'Accept', 'Authorization'].freeze
63
+ private_constant :IGNORED_HEADERS
64
+
65
+ def build_parameters(parameter_definitions)
66
+ result = {}
67
+ parameter_definitions&.each do |parameter|
68
+ (result[parameter['in'].to_sym] ||= []) << parameter
69
+ end
70
+ result[:header]&.reject! { IGNORED_HEADERS.include?(_1['name']) }
71
+ result
72
+ end
73
+
74
+ def build_parameters_schema(parameters)
75
+ return unless parameters
76
+
77
+ properties = {}
78
+ required = []
79
+ parameters.each do |parameter|
80
+ schema = parameter['schema']
81
+ name = parameter['name']
82
+ properties[name] = schema if schema
83
+ required << name if parameter['required']
84
+ end
85
+
86
+ {
87
+ 'properties' => properties,
88
+ 'required' => required
89
+ }
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openapi_parameters'
4
+ require_relative 'body_parser'
5
+
6
+ module OpenapiFirst
7
+ # Parse a request
8
+ class RequestParser
9
+ def initialize(
10
+ query_parameters:,
11
+ path_parameters:,
12
+ header_parameters:,
13
+ cookie_parameters:,
14
+ content_type:
15
+ )
16
+ @query_parser = OpenapiParameters::Query.new(query_parameters) if query_parameters
17
+ @path_parser = OpenapiParameters::Path.new(path_parameters) if path_parameters
18
+ @headers_parser = OpenapiParameters::Header.new(header_parameters) if header_parameters
19
+ @cookies_parser = OpenapiParameters::Cookie.new(cookie_parameters) if cookie_parameters
20
+ @body_parser = BodyParser.new(content_type) if content_type
21
+ end
22
+
23
+ attr_reader :query, :path, :headers, :cookies
24
+
25
+ def parse(request, route_params:)
26
+ result = {}
27
+ result[:path] = @path_parser.unpack(route_params) if @path_parser
28
+ result[:query] = @query_parser.unpack(request.env[Rack::QUERY_STRING]) if @query_parser
29
+ result[:headers] = @headers_parser.unpack_env(request.env) if @headers_parser
30
+ result[:cookies] = @cookies_parser.unpack(request.env[Rack::HTTP_COOKIE]) if @cookies_parser
31
+ result[:body] = @body_parser.parse(request) if @body_parser
32
+ result
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'failure'
4
+ require_relative 'validators/request_parameters'
5
+ require_relative 'validators/request_body'
6
+
7
+ module OpenapiFirst
8
+ # Validates a Request against a request definition.
9
+ class RequestValidator
10
+ VALIDATORS = [
11
+ Validators::RequestParameters,
12
+ Validators::RequestBody
13
+ ].freeze
14
+
15
+ def initialize(request_definition, openapi_version:, hooks: {})
16
+ @validators = VALIDATORS.filter_map do |klass|
17
+ klass.for(request_definition, hooks:, openapi_version:)
18
+ end
19
+ end
20
+
21
+ def call(parsed_request)
22
+ @validators.each { |v| v.call(parsed_request) }
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'response_parser'
4
+ require_relative 'response_validator'
5
+ require_relative 'validated_response'
6
+
7
+ module OpenapiFirst
8
+ # Represents a response definition in the OpenAPI document.
9
+ # This is not a direct reflecton of the OpenAPI 3.X response definition, but a combination of
10
+ # status, content type and content schema.
11
+ class Response
12
+ def initialize(status:, headers:, content_type:, content_schema:, openapi_version:)
13
+ @status = status
14
+ @content_type = content_type
15
+ @content_schema = content_schema
16
+ @headers = headers
17
+ @headers_schema = build_headers_schema(headers)
18
+ @parser = ResponseParser.new(headers:, content_type:)
19
+ @validator = ResponseValidator.new(self, openapi_version:)
20
+ end
21
+
22
+ # @attr_reader [Integer] status The HTTP status code of the response definition.
23
+ # @attr_reader [String, nil] content_type Content type of this response.
24
+ # @attr_reader [Schema, nil] content_schema the Schema of the response body.
25
+ attr_reader :status, :content_type, :content_schema, :headers, :headers_schema
26
+
27
+ def validate(response)
28
+ parsed_values = @parser.parse(response)
29
+ error = @validator.call(parsed_values)
30
+ ValidatedResponse.new(response, parsed_values:, error:, response_definition: self)
31
+ end
32
+
33
+ private
34
+
35
+ def parse(request)
36
+ @parser.parse(request)
37
+ end
38
+
39
+ def build_headers_schema(headers_object)
40
+ return unless headers_object&.any?
41
+
42
+ properties = {}
43
+ required = []
44
+ headers_object.each do |name, header|
45
+ schema = header['schema']
46
+ next if name.casecmp('content-type').zero?
47
+
48
+ properties[name] = schema if schema
49
+ required << name if header['required']
50
+ end
51
+ {
52
+ 'properties' => properties,
53
+ 'required' => required
54
+ }
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ ParsedResponse = Data.define(:body, :headers)
5
+
6
+ # Parse a response
7
+ class ResponseParser
8
+ def initialize(headers:, content_type:)
9
+ @headers = headers
10
+ @content_type = content_type
11
+ end
12
+
13
+ attr_reader :headers, :content_type
14
+
15
+ def parse(rack_response)
16
+ ParsedResponse.new(
17
+ body: parse_body(rack_response),
18
+ headers: parse_headers(rack_response)
19
+ )
20
+ end
21
+
22
+ private
23
+
24
+ def parse_body(rack_response)
25
+ MultiJson.load(read_body(rack_response)) if /json/i.match?(content_type)
26
+ rescue MultiJson::ParseError
27
+ Failure.fail!(:invalid_response_body, message: 'Response body is invalid: Failed to parse response body as JSON')
28
+ end
29
+
30
+ def read_body(rack_response)
31
+ buffered_body = +''
32
+ if rack_response.body.respond_to?(:each)
33
+ rack_response.body.each { |chunk| buffered_body.to_s << chunk }
34
+ return buffered_body
35
+ end
36
+ rack_response.body
37
+ end
38
+
39
+ def parse_headers(rack_response)
40
+ return {} if headers.nil?
41
+
42
+ # TODO: memoize unpacker
43
+ headers_as_parameters = headers.map do |name, definition|
44
+ definition.merge('name' => name, 'in' => 'header')
45
+ end
46
+ OpenapiParameters::Header.new(headers_as_parameters).unpack(rack_response.headers)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'validators/response_headers'
4
+ require_relative 'validators/response_body'
5
+
6
+ module OpenapiFirst
7
+ # Entry point for response validators
8
+ class ResponseValidator
9
+ VALIDATORS = [
10
+ Validators::ResponseHeaders,
11
+ Validators::ResponseBody
12
+ ].freeze
13
+
14
+ def initialize(response_definition, openapi_version:)
15
+ @validators = VALIDATORS.filter_map do |klass|
16
+ klass.for(response_definition, openapi_version:)
17
+ end
18
+ end
19
+
20
+ def call(parsed_response)
21
+ catch FAILURE do
22
+ @validators.each { |v| v.call(parsed_response) }
23
+ nil
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenapiFirst
4
+ class Router
5
+ # @visibility private
6
+ module FindContent
7
+ def self.call(contents, content_type)
8
+ return contents[nil] if content_type.nil? || content_type.empty?
9
+
10
+ contents.fetch(content_type) do
11
+ type = content_type.split(';')[0]
12
+ contents[type] || contents["#{type.split('/')[0]}/*"] || contents['*/*'] || contents[nil]
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'find_content'
4
+
5
+ module OpenapiFirst
6
+ class Router
7
+ # @visibility private
8
+ module FindResponse
9
+ Match = Data.define(:response, :error)
10
+
11
+ def self.call(responses, status, content_type, request_method:, path:)
12
+ contents = find_status(responses, status)
13
+ if contents.nil?
14
+ message = "Status #{status} is not defined for #{request_method} #{path}. " \
15
+ "Defined statuses are: #{responses.keys.join(', ')}."
16
+ return Match.new(error: Failure.new(:response_not_found, message:), response: nil)
17
+ end
18
+ response = FindContent.call(contents, content_type)
19
+ if response.nil?
20
+ message = "#{content_error(content_type, request_method:,
21
+ path:)} Content-Type should be #{contents.keys.join(' or ')}."
22
+ return Match.new(error: Failure.new(:response_not_found, message:), response: nil)
23
+ end
24
+
25
+ Match.new(response:, error: nil)
26
+ end
27
+
28
+ def self.content_error(content_type, request_method:, path:)
29
+ return 'Response Content-Type must not be empty.' if content_type.nil? || content_type.empty?
30
+
31
+ "Response Content-Type #{content_type} is not defined for #{request_method} #{path}."
32
+ end
33
+
34
+ def self.find_status(responses, status)
35
+ # According to OAS status has to be a string,
36
+ # but there are a few API descriptions out there that use integers because of YAML.
37
+
38
+ responses[status] || responses[status.to_s] ||
39
+ responses["#{status / 100}XX"] ||
40
+ responses["#{status / 100}xx"] ||
41
+ responses['default']
42
+ end
43
+ end
44
+ end
45
+ end