Router.php

13.01 KB
04/08/2025 05:23
PHP
<?php

namespace App\Core;

/**
 * Flexible Router Class
 * Supports various deployment configurations (subdomain, subfolder, domain)
 * Handles middleware pipeline and parameter extraction
 */
class Router
{
    private array $routes = [];
    private string $basePath = '';
    private array $globalMiddleware = [];
    private array $routeGroups = [];
    private ?string $currentGroupPrefix = null;
    private array $currentGroupMiddleware = [];

    /**
     * @param string $basePath
     */
    public function __construct(string $basePath = '')
    {
        $this->basePath = rtrim($basePath, '/');
        $this->detectBasePath();
    }

    /**
     * Auto-detect base path based on deployment configuration
     */
    private function detectBasePath(): void
    {
        $config = $this->getConfig();

        switch ($config['deployment_type']) {
            case 'subdomain':
                // For subdomain deployment (api.domain.com)
                $this->basePath = '';
                break;

            case 'subfolder':
                // For subfolder deployment (domain.com/store/api)
                $this->basePath = rtrim($config['api_base_path'], '/');
                break;

            case 'domain':
                // For dedicated domain (api-domain.com)
                $this->basePath = '';
                break;

            default:
                // Default to subfolder if not specified
                $this->basePath = rtrim($config['api_base_path'], '/');
        }
    }

    /**
     * Add global middleware that runs on all routes
     */
    public function addGlobalMiddleware(string $middleware): void
    {
        $this->globalMiddleware[] = $middleware;
    }

    /**
     * Create a route group with shared attributes
     */
    public function group(array $attributes, callable $callback): void
    {
        $previousPrefix = $this->currentGroupPrefix;
        $previousMiddleware = $this->currentGroupMiddleware;

        // Set group attributes
        $this->currentGroupPrefix = ($this->currentGroupPrefix ?? '').($attributes['prefix'] ?? '');
        $this->currentGroupMiddleware = array_merge(
            $this->currentGroupMiddleware,
            $attributes['middleware'] ?? []
        );

        // Execute the group callback
        $callback($this);

        // Restore previous group state
        $this->currentGroupPrefix = $previousPrefix;
        $this->currentGroupMiddleware = $previousMiddleware;
    }

    /**
     * Add a route with specified method
     */
    public function addRoute(string $method, string $path, $handler, array $middleware = []): void
    {
        // Apply group prefix
        $fullPath = ($this->currentGroupPrefix ?? '').$path;

        // Combine group middleware with route middleware
        $allMiddleware = array_merge(
            $this->globalMiddleware,
            $this->currentGroupMiddleware,
            $middleware
        );

        $pattern = $this->convertToRegex($fullPath);

        $this->routes[] = [
            'method' => strtoupper($method),
            'pattern' => $pattern,
            'handler' => $handler,
            'middleware' => $allMiddleware,
            'path' => $fullPath,
            'original_path' => $path
        ];
    }

    /**
     * HTTP Method shortcuts
     */
    public function get(string $path, $handler, array $middleware = []): void
    {
        $this->addRoute('GET', $path, $handler, $middleware);
    }

    /**
     * @param string $path
     * @param $handler
     * @param array $middleware
     */
    public function post(string $path, $handler, array $middleware = []): void
    {
        $this->addRoute('POST', $path, $handler, $middleware);
    }

    /**
     * @param string $path
     * @param $handler
     * @param array $middleware
     */
    public function put(string $path, $handler, array $middleware = []): void
    {
        $this->addRoute('PUT', $path, $handler, $middleware);
    }

    /**
     * @param string $path
     * @param $handler
     * @param array $middleware
     */
    public function patch(string $path, $handler, array $middleware = []): void
    {
        $this->addRoute('PATCH', $path, $handler, $middleware);
    }

    /**
     * @param string $path
     * @param $handler
     * @param array $middleware
     */
    public function delete(string $path, $handler, array $middleware = []): void
    {
        $this->addRoute('DELETE', $path, $handler, $middleware);
    }

    /**
     * @param string $path
     * @param $handler
     * @param array $middleware
     */
    public function options(string $path, $handler, array $middleware = []): void
    {
        $this->addRoute('OPTIONS', $path, $handler, $middleware);
    }

    /**
     * Handle preflight OPTIONS requests for CORS
     */
    public function handleCors(): void
    {
        if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
            $config = $this->getConfig();

            if ($config['cors_enabled']) {
                $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
                $allowedOrigins = $config['cors_origins'];

                if (in_array('*', $allowedOrigins) || in_array($origin, $allowedOrigins)) {
                    header('Access-Control-Allow-Origin: '.($origin ?: '*'));
                    header('Access-Control-Allow-Methods: '.implode(', ', $config['cors_methods']));
                    header('Access-Control-Allow-Headers: '.implode(', ', $config['cors_headers']));

                    if ($config['cors_credentials']) {
                        header('Access-Control-Allow-Credentials: true');
                    }

                    header('Access-Control-Max-Age: 86400'); // 24 hours
                }
            }

            http_response_code(200);
            exit;
        }
    }

    /**
     * Get request method with _method override support
     */
    private function getRequestMethod(): string
    {
        $method = $_SERVER['REQUEST_METHOD'];

        // Support method override for POST requests only
        if ($method === 'POST' && isset($_POST['_method'])) {
            $overrideMethod = strtoupper($_POST['_method']);

            // Only allow safe method overrides to simulate REST methods
            if (in_array($overrideMethod, ['PUT', 'PATCH', 'DELETE'])) {
                return $overrideMethod;
            }
        }

        return $method;
    }

    /**
     * Main dispatch method
     */
    public function dispatch(): void
    {
        try {
            // Handle CORS preflight requests
            $this->handleCors();

            $method = $this->getRequestMethod();
            $uri = $this->getCurrentUri();

            // Find matching route
            $matchedRoute = $this->findRoute($method, $uri);

            if (!$matchedRoute) {
                $this->handleNotFound();
                return;
            }

            // Execute middleware pipeline
            $this->executeMiddleware($matchedRoute['middleware']);

            // Execute route handler
            $this->executeHandler($matchedRoute);

        } catch (\Exception $e) {
            $this->handleException($e);
        }
    }

    /**
     * Get current URI with base path removed
     */
    private function getCurrentUri(): string
    {
        $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

        // Remove base path if present
        if ($this->basePath && strpos($uri, $this->basePath) === 0) {
            $uri = substr($uri, strlen($this->basePath));
        }

        // Ensure URI starts with /
        return '/'.ltrim($uri, '/');
    }

    /**
     * Find matching route
     */
    private function findRoute(string $method, string $uri): ?array
    {
        foreach ($this->routes as $route) {
            if ($route['method'] === $method && preg_match($route['pattern'], $uri, $matches)) {
                // Remove full match from parameters
                array_shift($matches);
                $route['parameters'] = $matches;
                return $route;
            }
        }

        return null;
    }

    /**
     * Execute middleware pipeline
     */
    private function executeMiddleware(array $middleware): void
    {
        foreach ($middleware as $middlewareClass) {
            if (class_exists($middlewareClass)) {
                $middlewareInstance = new $middlewareClass();

                if (method_exists($middlewareInstance, 'handle')) {
                    $result = $middlewareInstance->handle();

                    // If middleware returns false, stop execution
                    if ($result === false) {
                        return;
                    }
                }
            }
        }
    }

    /**
     * Execute route handler
     */
    private function executeHandler(array $route): void
    {
        $handler = $route['handler'];
        $parameters = $route['parameters'] ?? [];

        if (is_callable($handler)) {
            // Direct callable
            $result = call_user_func_array($handler, $parameters);
        } elseif (is_array($handler) && count($handler) === 2) {
            // Controller@method format
            [$controllerClass, $method] = $handler;

            if (class_exists($controllerClass)) {
                $controller = new $controllerClass();

                if (method_exists($controller, $method)) {
                    $result = call_user_func_array([$controller, $method], $parameters);
                } else {
                    throw new \Exception("Method {$method} not found in {$controllerClass}");
                }
            } else {
                throw new \Exception("Controller {$controllerClass} not found");
            }
        } else {
            throw new \Exception("Invalid route handler");
        }

        // Handle response
        $this->handleResponse($result);
    }

    /**
     * Handle response output
     */
    private function handleResponse($result): void
    {
        if ($result === null) {
            return;
        }

        if (is_array($result) || is_object($result)) {
            header('Content-Type: application/json');
            echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
        } else {
            echo $result;
        }
    }

    /**
     * Convert route path to regex pattern
     */
    private function convertToRegex(string $path): string
    {
        // Escape forward slashes
        $pattern = str_replace('/', '\/', $path);

        // Convert {param} to named capture groups
        $pattern = preg_replace('/\{([^}]+)\}/', '([^\/]+)', $pattern);

        // Convert {param?} to optional parameters
        $pattern = preg_replace('/\{([^}]+)\?\}/', '([^\/]*)', $pattern);

        return '/^'.$pattern.'$/';
    }

    /**
     * Handle 404 Not Found
     */
    private function handleNotFound(): void
    {
        http_response_code(404);
        header('Content-Type: application/json');
        echo json_encode([
            'error' => 'Not Found',
            'message' => 'The requested resource was not found',
            'code' => 404
        ]);
    }

    /**
     * Handle exceptions
     */
    private function handleException(\Exception $e): void
    {
        $config = $this->getConfig();

        http_response_code(500);
        header('Content-Type: application/json');

        if ($config['debug']) {
            echo json_encode([
                'error' => 'Internal Server Error',
                'message' => $e->getMessage(),
                'file' => $e->getFile(),
                'line' => $e->getLine(),
                'trace' => $e->getTrace()
            ], JSON_PRETTY_PRINT);
        } else {
            echo json_encode([
                'error' => 'Internal Server Error',
                'message' => 'An unexpected error occurred',
                'code' => 500
            ]);
        }

        // Log the error
        error_log("Router Exception: ".$e->getMessage()." in ".$e->getFile().":".$e->getLine());
    }

    /**
     * Get application configuration
     */
    private function getConfig(): array
    {
        /**
         * @var mixed
         */
        static $config = null;

        if ($config === null) {
            // Try store-specific config first, fallback to default
            $storeConfigFile = __DIR__.'/../../config/app-store.php';
            $defaultConfigFile = __DIR__.'/../../config/app.php';

            if (file_exists($storeConfigFile)) {
                $config = require $storeConfigFile;
            } elseif (file_exists($defaultConfigFile)) {
                $config = require $defaultConfigFile;
            } else {
                $config = [];
            }
        }

        return $config;
    }

    /**
     * Get all registered routes (for debugging)
     */
    public function getRoutes(): array
    {
        return $this->routes;
    }

    /**
     * Generate URL for named route
     */
    public function url(string $name, array $parameters = []): string
    {
        // This would be implemented if we add named routes
        // For now, return a basic URL construction
        $config = $this->getConfig();
        $baseUrl = $config['app_url'].$this->basePath;

        return $baseUrl.$name;
    }
}