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