openapi_first 1.4.3 → 2.0.0

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