openapi_first 1.4.3 → 2.0.2
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +49 -1
- data/README.md +105 -28
- data/lib/openapi_first/body_parser.rb +8 -11
- data/lib/openapi_first/builder.rb +81 -0
- data/lib/openapi_first/configuration.rb +25 -4
- data/lib/openapi_first/definition.rb +44 -100
- data/lib/openapi_first/error_response.rb +2 -2
- data/lib/openapi_first/error_responses/default.rb +73 -0
- data/lib/openapi_first/error_responses/jsonapi.rb +59 -0
- data/lib/openapi_first/errors.rb +26 -4
- data/lib/openapi_first/failure.rb +29 -26
- data/lib/openapi_first/json_refs.rb +1 -3
- data/lib/openapi_first/middlewares/request_validation.rb +2 -2
- data/lib/openapi_first/middlewares/response_validation.rb +4 -3
- data/lib/openapi_first/request.rb +92 -0
- data/lib/openapi_first/request_parser.rb +35 -0
- data/lib/openapi_first/request_validator.rb +25 -0
- data/lib/openapi_first/response.rb +57 -0
- data/lib/openapi_first/response_parser.rb +49 -0
- data/lib/openapi_first/response_validator.rb +27 -0
- data/lib/openapi_first/router/find_content.rb +17 -0
- data/lib/openapi_first/router/find_response.rb +45 -0
- data/lib/openapi_first/{definition → router}/path_template.rb +9 -1
- data/lib/openapi_first/router.rb +100 -0
- data/lib/openapi_first/schema/validation_error.rb +16 -10
- data/lib/openapi_first/schema/validation_result.rb +8 -6
- data/lib/openapi_first/schema.rb +4 -8
- data/lib/openapi_first/test/methods.rb +21 -0
- data/lib/openapi_first/test.rb +27 -0
- data/lib/openapi_first/validated_request.rb +81 -0
- data/lib/openapi_first/validated_response.rb +33 -0
- data/lib/openapi_first/validators/request_body.rb +39 -0
- data/lib/openapi_first/validators/request_parameters.rb +61 -0
- data/lib/openapi_first/validators/response_body.rb +30 -0
- data/lib/openapi_first/validators/response_headers.rb +25 -0
- data/lib/openapi_first/version.rb +1 -1
- data/lib/openapi_first.rb +40 -21
- metadata +25 -20
- data/lib/openapi_first/definition/operation.rb +0 -197
- data/lib/openapi_first/definition/path_item.rb +0 -40
- data/lib/openapi_first/definition/request_body.rb +0 -46
- data/lib/openapi_first/definition/response.rb +0 -32
- data/lib/openapi_first/definition/responses.rb +0 -87
- data/lib/openapi_first/plugins/default/error_response.rb +0 -74
- data/lib/openapi_first/plugins/default.rb +0 -11
- data/lib/openapi_first/plugins/jsonapi/error_response.rb +0 -60
- data/lib/openapi_first/plugins/jsonapi.rb +0 -11
- data/lib/openapi_first/plugins.rb +0 -25
- data/lib/openapi_first/request_validation/request_body_validator.rb +0 -41
- data/lib/openapi_first/request_validation/validator.rb +0 -82
- data/lib/openapi_first/response_validation/validator.rb +0 -98
- data/lib/openapi_first/runtime_request.rb +0 -166
- 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
         | 
    
        data/lib/openapi_first/errors.rb
    CHANGED
    
    | @@ -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  | 
| 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  | 
| 23 | 
            +
              class NotFoundError < RequestInvalidError; end
         | 
| 24 | 
            +
             | 
| 12 25 | 
             
              # @!visibility private
         | 
| 13 | 
            -
              class  | 
| 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  | 
| 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 | 
| 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  | 
| 22 | 
            +
                # @param type [Symbol] See Failure::TYPES.keys
         | 
| 24 23 | 
             
                # @param errors [Array<OpenapiFirst::Schema::ValidationError>]
         | 
| 25 | 
            -
                def self.fail!( | 
| 24 | 
            +
                def self.fail!(type, message: nil, errors: nil)
         | 
| 26 25 | 
             
                  throw FAILURE, new(
         | 
| 27 | 
            -
                     | 
| 26 | 
            +
                    type,
         | 
| 28 27 | 
             
                    message:,
         | 
| 29 28 | 
             
                    errors:
         | 
| 30 29 | 
             
                  )
         | 
| 31 30 | 
             
                end
         | 
| 32 31 |  | 
| 33 | 
            -
                # @param  | 
| 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( | 
| 37 | 
            -
                  unless TYPES.key?( | 
| 35 | 
            +
                def initialize(type, message: nil, errors: nil)
         | 
| 36 | 
            +
                  unless TYPES.key?(type)
         | 
| 38 37 | 
             
                    raise ArgumentError,
         | 
| 39 | 
            -
                          " | 
| 38 | 
            +
                          "type must be one of #{TYPES.keys} but was #{type.inspect}"
         | 
| 40 39 | 
             
                  end
         | 
| 41 40 |  | 
| 42 | 
            -
                  @ | 
| 41 | 
            +
                  @type = type
         | 
| 43 42 | 
             
                  @message = message
         | 
| 44 43 | 
             
                  @errors = errors
         | 
| 45 44 | 
             
                end
         | 
| 46 45 |  | 
| 47 | 
            -
                # @attr_reader [Symbol]  | 
| 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 : | 
| 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 | 
            -
                #  | 
| 60 | 
            -
                def  | 
| 61 | 
            -
                   | 
| 62 | 
            -
             | 
| 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( | 
| 63 | 
            +
                  _, message_prefix = TYPES.fetch(type)
         | 
| 64 | 
            +
             | 
| 65 | 
            +
                  [message_prefix, @message || generate_message].compact.join(' ')
         | 
| 66 | 
            +
                end
         | 
| 67 67 |  | 
| 68 | 
            -
             | 
| 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(&: | 
| 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
         | 
| @@ -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]  | 
| 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. | 
| 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 | 
| 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:  | 
| 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
         |