lib/protocol/rack/adapter/generic.rb
# frozen_string_literal: true # Released under the MIT License. # Copyright, 2022-2025, by Samuel Williams. require "console" require_relative "../constants" require_relative "../input" require_relative "../response" require_relative "../rewindable" module Protocol module Rack module Adapter # The base adapter class that provides common functionality for all Rack adapters. # It handles the conversion between {Protocol::HTTP} and Rack environments. class Generic # Creates a new adapter instance for the given Rack application. # Wraps the adapter in a {Rewindable} instance to ensure request body can be read multiple times, which is required for Rack < 3. # # @parameter app [Interface(:call)] A Rack application. # @returns [Rewindable] A rewindable adapter instance. def self.wrap(app) Rewindable.new(self.new(app)) end # Parses a Rackup file and returns the application. # # @parameter path [String] The path to the Rackup file. # @returns [Interface(:call)] The Rack application. def self.parse_file(...) # This is the old interface, which was changed in Rack 3. ::Rack::Builder.parse_file(...).first end # Initialize the rack adaptor middleware. # # @parameter app [Interface(:call)] The rack middleware. # @raises [ArgumentError] If the app does not respond to `call`. def initialize(app) @app = app raise ArgumentError, "App must be callable!" unless @app.respond_to?(:call) end # The logger to use for this adapter. # # @returns [Console] The console logger. def logger Console end # Unwrap HTTP headers into the CGI-style expected by Rack middleware, and add them to the rack `env`. # # e.g. `accept-encoding` becomes `HTTP_ACCEPT_ENCODING`. # # Headers keys with underscores will generate the same CGI-style header key as headers with dashes. # # e.g `accept_encoding` becomes `HTTP_ACCEPT_ENCODING` too. # # You should not implicitly trust the `HTTP_` headers for security purposes, as they are generated by the client. # # Multiple headers are combined with a comma, with one exception: `HTTP_COOKIE` headers are combined with a semicolon. # # @parameter headers [Protocol::HTTP::Headers] The raw HTTP request headers. # @parameter env [Hash] The rack request `env`. def unwrap_headers(headers, env) headers.each do |key, value| http_key = "HTTP_#{key.upcase.tr('-', '_')}" if current_value = env[http_key] if http_key == CGI::HTTP_COOKIE env[http_key] = "#{current_value};#{value}" else env[http_key] = "#{current_value},#{value}" end else env[http_key] = value end end end # Process the incoming request into a valid rack `env`. # # - Set the `env['CONTENT_TYPE']` and `env['CONTENT_LENGTH']` based on the incoming request body. # - Set the `env['HTTP_HOST']` header to the request authority. # - Set the `env['HTTP_X_FORWARDED_PROTO']` header to the request scheme. # - Set `env['REMOTE_ADDR']` to the request remote adress. # # @parameter request [Protocol::HTTP::Request] The incoming request. # @parameter env [Hash] The rack `env`. def unwrap_request(request, env) # The request protocol, either from the upgrade header or the HTTP/2 pseudo header of the same name. if protocol = request.protocol env[RACK_PROTOCOL] = protocol end if content_type = request.headers.delete("content-type") env[CGI::CONTENT_TYPE] = content_type end # In some situations we don't know the content length, e.g. when using chunked encoding, or when decompressing the body. if body = request.body and length = body.length env[CGI::CONTENT_LENGTH] = length.to_s end self.unwrap_headers(request.headers, env) # For the sake of compatibility, we set the `HTTP_UPGRADE` header to the requested protocol. if protocol = request.protocol and request.version.start_with?("HTTP/1") env[CGI::HTTP_UPGRADE] = Array(protocol).join(",") end if request.respond_to?(:hijack?) and request.hijack? env[RACK_IS_HIJACK] = true env[RACK_HIJACK] = proc{request.hijack!.io.dup} end # HTTP/2 prefers `:authority` over `host`, so we do this for backwards compatibility. env[CGI::HTTP_HOST] ||= request.authority if peer = request.peer env[CGI::REMOTE_ADDR] = peer.ip_address end end # Create a base environment hash for the request. # # @parameter request [Protocol::HTTP::Request] The incoming request. # @returns [Hash] The base environment hash. def make_environment(request) { request: request } end # Build a rack `env` from the incoming request and apply it to the rack middleware. # # @parameter request [Protocol::HTTP::Request] The incoming request. # @returns [Protocol::HTTP::Response] The HTTP response. # @raises [ArgumentError] If the status is not an integer or headers are nil. def call(request) env = self.make_environment(request) status, headers, body = @app.call(env) # The status must always be an integer. unless status.is_a?(Integer) raise ArgumentError, "Status must be an integer!" end # Headers must always be a hash or equivalent. unless headers raise ArgumentError, "Headers must not be nil!" end headers, meta = self.wrap_headers(headers) return Response.wrap(env, status, headers, meta, body, request) rescue => exception Console.error(self, exception) body&.close if body.respond_to?(:close) env&.[](RACK_RESPONSE_FINISHED)&.each do |callback| callback.call(env, status, headers, exception) end return failure_response(exception) end # Generate a suitable response for the given exception. # # @parameter exception [Exception] The exception that occurred. # @returns [Protocol::HTTP::Response] A response representing the error. def failure_response(exception) Protocol::HTTP::Response.for_exception(exception) end # Extract protocol information from the environment and response. # # @parameter env [Hash] The rack environment. # @parameter response [Protocol::HTTP::Response] The HTTP response. # @parameter headers [Hash] The response headers to modify. def self.extract_protocol(env, response, headers) if protocol = response.protocol # This is the newer mechanism for protocol upgrade: if env["rack.protocol"] headers["rack.protocol"] = protocol # Older mechanism for protocol upgrade: elsif env[CGI::HTTP_UPGRADE] headers["upgrade"] = protocol headers["connection"] = "upgrade" end end end end end end end