HttpDispatcher.php

PHP

Path: src/Dispatcher/HttpDispatcher.php

<?php

namespace mini\Dispatcher;

use mini\Mini;
use mini\Converter\ConverterRegistryInterface;
use mini\Http\ResponseAlreadySentException;
use Psr\Http\Message\{ServerRequestInterface, ResponseInterface, UploadedFileInterface};
use Psr\Http\Server\{RequestHandlerInterface, MiddlewareInterface};
use mini\Http\Message\{ServerRequest, Stream, UploadedFile};

/**
 * HTTP request dispatcher
 *
 * The HttpDispatcher is the entry point for HTTP requests. It:
 * 1. Creates PSR-7 ServerRequest from PHP globals
 * 2. Makes request available via mini\request()
 * 3. Delegates to RequestHandlerInterface (Router)
 * 4. Converts exceptions to HTTP responses
 * 5. Emits response to browser
 *
 * Architecture:
 * HttpDispatcher (request lifecycle) → RequestHandlerInterface (Router) → Controllers
 *
 * Exception handling:
 * Exceptions thrown during request handling are converted to ResponseInterface
 * using a separate exception converter registry. This allows registering specific
 * exception handlers without polluting the main converter registry.
 *
 * Usage:
 * ```php
 * // html/index.php
 * Mini::$mini->get(HttpDispatcher::class)->dispatch();
 * ```
 *
 * Register exception converters:
 * ```php
 * // bootstrap.php
 * $dispatcher = Mini::$mini->get(HttpDispatcher::class);
 * $dispatcher->registerExceptionConverter(function(NotFoundException $e): ResponseInterface {
 *     return new Response(404, ['Content-Type' => 'text/html'], render('404'));
 * });
 * ```
 */
class HttpDispatcher
{
    private ConverterRegistryInterface $exceptionConverters;
    private ?ServerRequestInterface $currentServerRequest = null;

    /** @var array<MiddlewareInterface> Middleware stack (FIFO order) */
    private array $middlewares = [];

    /**
     * Event triggered before processing a request
     *
     * Listeners receive the ServerRequestInterface being processed.
     * Use this for request-scoped initialization.
     *
     * @var \mini\Hooks\Event<ServerRequestInterface>
     */
    public readonly \mini\Hooks\Event $onBeforeRequest;

    /**
     * Event triggered after processing a request (in finally block)
     *
     * Always fires, even if an exception was thrown or response already sent.
     * Use this for cleanup, session saving, logging, etc.
     *
     * Listeners receive: (ServerRequestInterface $request, ?ResponseInterface $response, ?\Throwable $exception)
     * - $response is null if exception was thrown before response was created
     * - $exception is the thrown exception (if any), null on success
     *
     * @var \mini\Hooks\Event<ServerRequestInterface, ?ResponseInterface, ?\Throwable>
     */
    public readonly \mini\Hooks\Event $onAfterRequest;

    public function __construct()
    {
        // Create separate converter registry for exceptions
        // This keeps exception handling separate from content conversion
        $this->exceptionConverters = new \mini\Converter\ConverterRegistry();

        // Initialize request lifecycle hooks
        $this->onBeforeRequest = new \mini\Hooks\Event('http.before-request');
        $this->onAfterRequest = new \mini\Hooks\Event('http.after-request');
    }

    /**
     * Add middleware to the request pipeline
     *
     * Middleware is executed in the order added (FIFO).
     * Can only be called during Bootstrap phase - throws exception if called after Ready phase.
     *
     * Examples:
     * ```php
     * // In bootstrap.php or module functions.php
     * $dispatcher = Mini::$mini->get(HttpDispatcher::class);
     * $dispatcher->addMiddleware(Mini::$mini->get(StaticFiles::class));
     * $dispatcher->addMiddleware(new CorsMiddleware());
     * $dispatcher->addMiddleware(new AuthMiddleware());
     * ```
     *
     * @param MiddlewareInterface $middleware PSR-15 middleware instance
     * @return self For method chaining
     * @throws \RuntimeException If called after Bootstrap phase
     */
    public function addMiddleware(MiddlewareInterface $middleware): self
    {
        // Only allow middleware registration during Bootstrap phase
        $currentPhase = Mini::$mini->phase->getCurrentState();
        if ($currentPhase === \mini\Phase::Ready || $currentPhase === \mini\Phase::Shutdown) {
            throw new \RuntimeException(
                'Cannot add middleware after Bootstrap phase. ' .
                'Middleware must be registered during application bootstrap.'
            );
        }

        $this->middlewares[] = $middleware;
        return $this;
    }

    /**
     * Register an exception converter
     *
     * Exception converters transform exceptions to HTTP responses.
     * They are separate from the main converter registry to keep concerns separated.
     *
     * Examples:
     * ```php
     * // Handle 404 errors
     * $dispatcher->registerExceptionConverter(function(NotFoundException $e): ResponseInterface {
     *     return new Response(404, ['Content-Type' => 'text/html'], render('404'));
     * });
     *
     * // Handle validation errors
     * $dispatcher->registerExceptionConverter(function(ValidationException $e): ResponseInterface {
     *     $json = json_encode(['errors' => $e->errors]);
     *     return new Response(400, ['Content-Type' => 'application/json'], $json);
     * });
     *
     * // Generic error handler
     * $dispatcher->registerExceptionConverter(function(\Throwable $e): ResponseInterface {
     *     $statusCode = 500;
     *     $message = Mini::$mini->debug ? $e->getMessage() : 'Internal Server Error';
     *     return new Response($statusCode, ['Content-Type' => 'text/html'], render('error', compact('message')));
     * });
     * ```
     *
     * @param \Closure $converter Typed closure: function(ExceptionType): ResponseInterface
     * @return void
     */
    public function registerExceptionConverter(\Closure $converter): void
    {
        $this->exceptionConverters->register($converter);
    }


    /**
     * Dispatch the current HTTP request
     *
     * Complete HTTP request lifecycle:
     * 1. Register ServerRequest as Transient service
     * 2. Create PSR-7 ServerRequest from PHP request globals
     * 3. Set as current request
     * 4. Replace $_GET, $_POST, $_COOKIE with proxies (fiber-safe)
     * 5. Declare Ready phase (locks down service registration)
     * 6. Add request replacement callback for Router
     * 7. Build middleware chain and get RequestHandlerInterface (Router)
     * 8. Process request through middleware chain → router → handlers
     * 9. Catch exceptions and convert to responses
     * 10. Emit response to browser
     *
     * @return void
     */
    public function dispatch(): void
    {
        $response = null;
        $exception = null;

        try {
            // 1. Register ServerRequest as Transient service that returns current request
            Mini::$mini->addService(
                ServerRequestInterface::class,
                \mini\Lifetime::Transient,
                fn() => $this->currentServerRequest ?? throw new \RuntimeException(
                    'No ServerRequest available. ServerRequest is only available during request handling.'
                )
            );

            // 2. Create PSR-7 ServerRequest from PHP request globals (SAPI-specific)
            $serverRequest = $this->createServerRequestFromGlobals();

            // 3. Set current request
            $this->currentServerRequest = $serverRequest;

            // 4. Replace request globals with proxies (fiber-safe)
            $this->installRequestGlobalProxies();

            // 5. Declare Ready phase (locks down service registration)
            Mini::$mini->phase->trigger(\mini\Phase::Ready);

            // 6. Add callback to allow Router to replace current request
            //    Router uses this after Redirect/Reroute to update the request
            $serverRequest = $serverRequest->withAttribute(
                'mini.dispatcher.replaceRequest',
                function(ServerRequestInterface $newRequest) {
                    $this->currentServerRequest = $newRequest;
                }
            );

            // 7. Trigger before-request hook
            $this->onBeforeRequest->trigger($serverRequest);

            // 8. Build middleware chain and dispatch into the framework
            try {
                // Get the final handler (Router)
                $handler = Mini::$mini->get(RequestHandlerInterface::class);

                // Wrap handler with middleware stack (reverse order for FIFO execution)
                $handler = $this->buildMiddlewareChain($handler);

                // Process request through middleware chain
                $response = $handler->handle($serverRequest);

            } catch (ResponseAlreadySentException $e) {
                // Response already sent using classical PHP (echo/header)
                // Nothing more to do - but still trigger after-request hook
                return;

            } catch (\Throwable $e) {
                // Convert exception to response
                $response = $this->exceptionConverters->convert($e, ResponseInterface::class);

                if ($response === null) {
                    // No exception converter registered - rethrow
                    $exception = $e;
                    throw $e;
                }
            }

            // 9. Emit response to browser
            $this->emitResponse($response);

        } catch (\Throwable $e) {
            // Last resort error handling
            $exception = $e;
            $this->handleFatalError($e);
        } finally {
            // 10. Always trigger after-request hook for cleanup (session save, logging, etc.)
            if ($this->currentServerRequest !== null) {
                $this->onAfterRequest->trigger($this->currentServerRequest, $response, $exception);
            }
        }
    }

    /**
     * Build middleware chain wrapper around the final handler
     *
     * Wraps the handler (Router) with all registered middleware in reverse order
     * to ensure FIFO execution (first added middleware executes first).
     *
     * @param RequestHandlerInterface $handler Final handler (typically Router)
     * @return RequestHandlerInterface Wrapped handler with middleware chain
     */
    private function buildMiddlewareChain(RequestHandlerInterface $handler): RequestHandlerInterface
    {
        // If no middleware registered, return handler as-is
        if (empty($this->middlewares)) {
            return $handler;
        }

        // Wrap handler with middleware in reverse order (FIFO execution)
        // Last middleware in array wraps the handler first
        for ($i = count($this->middlewares) - 1; $i >= 0; $i--) {
            $middleware = $this->middlewares[$i];
            $handler = new class($middleware, $handler) implements RequestHandlerInterface {
                public function __construct(
                    private MiddlewareInterface $middleware,
                    private RequestHandlerInterface $next
                ) {}

                public function handle(ServerRequestInterface $request): ResponseInterface {
                    return $this->middleware->process($request, $this->next);
                }
            };
        }

        return $handler;
    }

    /**
     * Install request global proxies for fiber-safe request handling
     *
     * Replaces $_GET, $_POST, $_COOKIE with ArrayAccess proxies that delegate
     * to the current ServerRequest. This enables:
     * - Fiber-safe concurrent request handling
     * - Zero code changes (existing $_GET['id'] works)
     * - Works with all SAPIs (FPM, Swoole, ReactPHP, etc.)
     *
     * Called once during HttpDispatcher construction. Idempotent - safe to call multiple times.
     *
     * @return void
     */
    private function installRequestGlobalProxies(): void
    {
        static $installed = false;

        if ($installed) {
            return;
        }

        $_GET = new \mini\Http\RequestGlobalProxy('query');
        $_POST = new \mini\Http\RequestGlobalProxy('post');
        $_COOKIE = new \mini\Http\RequestGlobalProxy('cookie');
        $_SESSION = new \mini\Session\SessionProxy();

        $installed = true;
    }

    /**
     * Emit a PSR-7 response to the browser
     *
     * Sends status code, headers, and body.
     *
     * @param ResponseInterface $response
     * @return void
     */
    private function emitResponse(ResponseInterface $response): void
    {
        // Send status code
        http_response_code($response->getStatusCode());

        // Send headers
        foreach ($response->getHeaders() as $name => $values) {
            foreach ($values as $value) {
                header("$name: $value", false);
            }
        }

        // Send body
        echo $response->getBody();
    }

    /**
     * Handle fatal errors when no exception converter is registered
     *
     * Last resort error handling - renders a detailed error page in debug mode,
     * or a simple error page in production.
     *
     * @param \Throwable $e
     * @return void
     */
    private function handleFatalError(\Throwable $e): void
    {
        // Clean output buffer if present
        while (ob_get_level() > 0) {
            ob_end_clean();
        }

        $statusCode = 500;
        http_response_code($statusCode);

        if (Mini::$mini->debug) {
            // Detailed debug error page
            echo $this->renderDebugErrorPage($e);
        } else {
            // Simple production error page
            echo $this->renderProductionErrorPage($statusCode);
        }
    }

    /**
     * Render detailed error page for debug mode
     *
     * Shows exception type, message, stack trace, and context information.
     *
     * @param \Throwable $e
     * @return string
     */
    private function renderDebugErrorPage(\Throwable $e): string
    {
        $exceptionClass = get_class($e);
        $message = htmlspecialchars($e->getMessage());
        $file = htmlspecialchars($e->getFile());
        $line = $e->getLine();
        $code = $e->getCode();

        // Get stack trace
        $trace = $e->getTraceAsString();
        $traceHtml = htmlspecialchars($trace);

        // Get source code context (5 lines before and after)
        $sourceContext = $this->getSourceContext($e->getFile(), $e->getLine(), 5);

        // Get previous exceptions
        $previousHtml = '';
        $previous = $e->getPrevious();
        if ($previous) {
            $previousList = [];
            while ($previous) {
                $prevClass = htmlspecialchars(get_class($previous));
                $prevMessage = htmlspecialchars($previous->getMessage());
                $prevFile = htmlspecialchars($previous->getFile());
                $prevLine = $previous->getLine();
                $previousList[] = "<li><strong>$prevClass</strong>: $prevMessage<br><small>in $prevFile:$prevLine</small></li>";
                $previous = $previous->getPrevious();
            }
            $previousHtml = '<h2>Previous Exceptions</h2><ul>' . implode('', $previousList) . '</ul>';
        }

        // Request information
        $requestInfo = '';
        try {
            if ($this->currentServerRequest) {
                $method = htmlspecialchars($this->currentServerRequest->getMethod());
                $uri = htmlspecialchars((string)$this->currentServerRequest->getUri());
                $requestInfo = "<h2>Request Information</h2>
                <p><strong>Method:</strong> $method</p>
                <p><strong>URI:</strong> $uri</p>";
            }
        } catch (\Throwable $ignored) {
            // Ignore errors getting request info
        }

        return "<!DOCTYPE html>
<html lang=\"en\">
<head>
    <meta charset=\"UTF-8\">
    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">
    <title>Error - $exceptionClass</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background: #1a1a1a;
            color: #e0e0e0;
            padding: 20px;
            line-height: 1.6;
        }
        .container { max-width: 1200px; margin: 0 auto; }
        h1 {
            color: #ff6b6b;
            font-size: 28px;
            margin-bottom: 10px;
            padding-bottom: 10px;
            border-bottom: 2px solid #333;
        }
        h2 {
            color: #4ecdc4;
            font-size: 20px;
            margin-top: 30px;
            margin-bottom: 15px;
            padding-bottom: 8px;
            border-bottom: 1px solid #333;
        }
        .error-header {
            background: #2d2d2d;
            padding: 20px;
            border-radius: 8px;
            margin-bottom: 20px;
            border-left: 4px solid #ff6b6b;
        }
        .error-type {
            font-size: 18px;
            color: #ff6b6b;
            font-weight: bold;
            margin-bottom: 10px;
        }
        .error-message {
            font-size: 16px;
            margin-bottom: 15px;
            color: #fff;
        }
        .error-location {
            font-family: 'Courier New', monospace;
            font-size: 14px;
            color: #95a5a6;
        }
        .error-code {
            color: #f39c12;
            font-size: 14px;
            margin-top: 5px;
        }
        .section {
            background: #2d2d2d;
            padding: 20px;
            border-radius: 8px;
            margin-bottom: 20px;
        }
        .source-code {
            background: #1e1e1e;
            border-radius: 4px;
            overflow-x: auto;
            margin-top: 10px;
        }
        .source-line {
            font-family: 'Courier New', monospace;
            font-size: 13px;
            padding: 4px 10px;
            border-left: 3px solid transparent;
        }
        .source-line-number {
            display: inline-block;
            width: 50px;
            color: #666;
            text-align: right;
            margin-right: 15px;
            user-select: none;
        }
        .source-line-error {
            background: #3d2020;
            border-left-color: #ff6b6b;
        }
        .source-line-error .source-line-number {
            color: #ff6b6b;
            font-weight: bold;
        }
        .stack-trace {
            background: #1e1e1e;
            padding: 15px;
            border-radius: 4px;
            font-family: 'Courier New', monospace;
            font-size: 13px;
            overflow-x: auto;
            white-space: pre;
            color: #95a5a6;
        }
        ul { list-style: none; }
        li {
            padding: 10px;
            margin: 5px 0;
            background: #1e1e1e;
            border-radius: 4px;
        }
        small { color: #95a5a6; }
        p { margin: 10px 0; }
        strong { color: #4ecdc4; }
        .debug-badge {
            display: inline-block;
            background: #f39c12;
            color: #000;
            padding: 4px 12px;
            border-radius: 4px;
            font-size: 12px;
            font-weight: bold;
            margin-bottom: 20px;
        }
    </style>
</head>
<body>
    <div class=\"container\">
        <div class=\"debug-badge\">🐛 DEBUG MODE</div>

        <div class=\"error-header\">
            <div class=\"error-type\">$exceptionClass</div>
            <div class=\"error-message\">$message</div>
            <div class=\"error-location\">📁 $file:$line</div>
            " . ($code ? "<div class=\"error-code\">Code: $code</div>" : "") . "
        </div>

        $requestInfo

        <div class=\"section\">
            <h2>Source Code Context</h2>
            <div class=\"source-code\">$sourceContext</div>
        </div>

        <div class=\"section\">
            <h2>Stack Trace</h2>
            <div class=\"stack-trace\">$traceHtml</div>
        </div>

        $previousHtml
    </div>
</body>
</html>";
    }

    /**
     * Render simple error page for production
     *
     * @param int $statusCode
     * @return string
     */
    private function renderProductionErrorPage(int $statusCode): string
    {
        return "<!DOCTYPE html>
<html lang=\"en\">
<head>
    <meta charset=\"UTF-8\">
    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">
    <title>$statusCode - Error</title>
    <style>
        body {
            font-family: system-ui, -apple-system, sans-serif;
            max-width: 600px;
            margin: 100px auto;
            padding: 20px;
            text-align: center;
        }
        h1 { color: #dc3545; font-size: 48px; margin-bottom: 20px; }
        p { color: #666; font-size: 18px; }
    </style>
</head>
<body>
    <h1>$statusCode</h1>
    <p>Internal Server Error</p>
</body>
</html>";
    }

    /**
     * Get source code context around the error line
     *
     * @param string $file
     * @param int $errorLine
     * @param int $contextLines Number of lines before and after to show
     * @return string
     */
    private function getSourceContext(string $file, int $errorLine, int $contextLines = 5): string
    {
        if (!is_readable($file)) {
            return '<div class="source-line">Unable to read source file</div>';
        }

        $lines = file($file);
        if ($lines === false) {
            return '<div class="source-line">Unable to read source file</div>';
        }

        $startLine = max(1, $errorLine - $contextLines);
        $endLine = min(count($lines), $errorLine + $contextLines);

        $html = '';
        for ($i = $startLine; $i <= $endLine; $i++) {
            $lineContent = htmlspecialchars(rtrim($lines[$i - 1]));
            $isErrorLine = ($i === $errorLine);
            $class = $isErrorLine ? 'source-line source-line-error' : 'source-line';
            $html .= "<div class=\"$class\"><span class=\"source-line-number\">$i</span>$lineContent</div>";
        }

        return $html;
    }

    /**
     * Create ServerRequest from PHP superglobals
     *
     * SAPI-specific logic for creating ServerRequest from PHP globals.
     * Future FastCGI/fiber-based dispatchers will have their own creation logic.
     */
    private function createServerRequestFromGlobals(): ServerRequestInterface
    {
        $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
        $requestTarget = $_SERVER['REQUEST_URI'] ?? '/';
        $body = Stream::create(fopen('php://input', 'r'));
        $headers = $this->extractHeadersFromServer($_SERVER);
        $protocolVersion = isset($_SERVER['SERVER_PROTOCOL'])
            ? str_replace('HTTP/', '', $_SERVER['SERVER_PROTOCOL'])
            : '1.1';

        $uploadedFiles = $this->normalizeFiles($_FILES);

        return new ServerRequest(
            method: $method,
            requestTarget: $requestTarget,
            body: $body,
            headers: $headers,
            queryParams: null, // Derive from request target
            serverParams: $_SERVER,
            cookieParams: $_COOKIE,
            uploadedFiles: $uploadedFiles,
            parsedBody: $_POST,
            protocolVersion: $protocolVersion
        );
    }

    /**
     * Extract headers from $_SERVER
     */
    private function extractHeadersFromServer(array $server): array
    {
        $headers = [];

        foreach ($server as $key => $value) {
            // HTTP_ prefix headers
            if (str_starts_with($key, 'HTTP_')) {
                $name = str_replace('_', '-', substr($key, 5));
                $headers[$name] = [$value];
                continue;
            }

            // Special case headers without HTTP_ prefix
            if (in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5'], true)) {
                $name = str_replace('_', '-', $key);
                $headers[$name] = [$value];
            }
        }

        return $headers;
    }

    /**
     * Normalize $_FILES array to UploadedFileInterface instances
     */
    private function normalizeFiles(array $files): array
    {
        $normalized = [];

        foreach ($files as $key => $value) {
            if ($value instanceof UploadedFileInterface) {
                $normalized[$key] = $value;
            } elseif (is_array($value) && isset($value['tmp_name'])) {
                $normalized[$key] = $this->createUploadedFileFromSpec($value);
            } elseif (is_array($value)) {
                $normalized[$key] = $this->normalizeFiles($value);
            }
        }

        return $normalized;
    }

    /**
     * Create UploadedFile instance from $_FILES specification
     */
    private function createUploadedFileFromSpec(array $spec): UploadedFileInterface|array
    {
        if (!is_array($spec['tmp_name'])) {
            // Single file
            $stream = Stream::create($spec['tmp_name']);

            return new UploadedFile(
                $stream,
                $spec['size'] ?? null,
                $spec['error'] ?? \UPLOAD_ERR_OK,
                $spec['name'] ?? null,
                $spec['type'] ?? null
            );
        }

        // Multiple files - normalize nested structure
        $files = [];
        foreach (array_keys($spec['tmp_name']) as $key) {
            $files[$key] = $this->createUploadedFileFromSpec([
                'tmp_name' => $spec['tmp_name'][$key],
                'size' => $spec['size'][$key] ?? null,
                'error' => $spec['error'][$key] ?? \UPLOAD_ERR_OK,
                'name' => $spec['name'][$key] ?? null,
                'type' => $spec['type'][$key] ?? null,
            ]);
        }

        return $files;
    }
}