user = $user; $this->logger = new Logger("Router", $user->getSQL()); $this->routes = []; $this->statusCodeRoutes = []; } public function run(string $url): string { // TODO: do we want a global try cache and return status page 500 on any error? // or do we want to have a global status page function here? $url = strtok($url, "?"); foreach ($this->routes as $route) { $pathParams = $route->match($url); if ($pathParams !== false) { return $route->call($this, $pathParams); } } return $this->returnStatusCode(404); } public function returnStatusCode(int $code, array $params = []): string { http_response_code($code); $params["status_code"] = $code; $params["status_description"] = HTTP_STATUS_DESCRIPTIONS[$code] ?? "Unknown Error"; $route = $this->statusCodeRoutes[strval($code)] ?? null; if ($route) { return $route->call($this, $params); } else { $req = new \Api\Template\Render($this->user); $res = $req->execute(["file" => "error_document.twig", "parameters" => $params]); if ($res) { return $req->getResult()["html"]; } else { var_dump($req->getLastError()); $description = htmlspecialchars($params["status_description"]); return "$code - $description"; } } } public function addRoute(AbstractRoute $route) { if (preg_match("/^\d+$/", $route->getPattern())) { $this->statusCodeRoutes[$route->getPattern()] = $route; } else { $this->routes[] = $route; } } public function writeCache(string $file): bool { $routes = ""; foreach ($this->routes as $route) { $constructor = $route->generateCache(); $routes .= "\n \$this->addRoute($constructor);"; } $date = (new \DateTime())->format("Y/m/d H:i:s"); $code = "logger->severe("Could not write Router cache file: $file"); return false; } return true; } public function getUser(): User { return $this->user; } public function getLogger(): Logger { return $this->logger; } public static function cleanURL(string $url, bool $cleanGET = true): string { // strip GET parameters if ($cleanGET) { if (($index = strpos($url, "?")) !== false) { $url = substr($url, 0, $index); } } // strip document reference part if (($index = strpos($url, "#")) !== false) { $url = substr($url, 0, $index); } // strip leading slash return preg_replace("/^\/+/", "", $url); } } } namespace Objects\Router { use Api\Parameter\Parameter; use Elements\Document; use Objects\Router; use PHPUnit\TextUI\ReflectionException; abstract class AbstractRoute { private string $pattern; private bool $exact; public function __construct(string $pattern, bool $exact = true) { $this->pattern = $pattern; $this->exact = $exact; } private static function parseParamType(?string $type): ?int { if ($type === null || trim($type) === "") { return null; } $type = strtolower(trim($type)); if (in_array($type, ["int", "integer"])) { return Parameter::TYPE_INT; } else if (in_array($type, ["float", "double"])) { return Parameter::TYPE_FLOAT; } else if (in_array($type, ["bool", "boolean"])) { return Parameter::TYPE_BOOLEAN; } else { return Parameter::TYPE_STRING; } } public function getPattern(): string { return $this->pattern; } public abstract function call(Router $router, array $params): string; protected function getArgs(): array { return [$this->pattern, $this->exact]; } public function generateCache(): string { $reflection = new \ReflectionClass($this); $className = $reflection->getName(); $args = implode(", ", array_map(function ($arg) { return var_export($arg, true); }, $this->getArgs())); return "new \\$className($args)"; } public function match(string $url) { # /test/{abc}/{param:?}/{xyz:int}/{aaa:int?} $patternParts = explode("/", Router::cleanURL($this->pattern, false)); $countPattern = count($patternParts); $patternOffset = 0; # /test/param/optional/123 $urlParts = explode("/", $url); $countUrl = count($urlParts); $urlOffset = 0; $params = []; for (; $patternOffset < $countPattern; $patternOffset++) { if (!preg_match("/^{.*}$/", $patternParts[$patternOffset])) { // not a parameter? check if it matches if ($urlOffset >= $countUrl || $urlParts[$urlOffset] !== $patternParts[$patternOffset]) { return false; } $urlOffset++; } else { // we got a parameter here $paramDefinition = explode(":", substr($patternParts[$patternOffset], 1, -1)); $paramName = array_shift($paramDefinition); $paramType = array_shift($paramDefinition); $paramOptional = endsWith($paramType, "?"); if ($paramOptional) { $paramType = substr($paramType, 0, -1); } $paramType = self::parseParamType($paramType); if ($urlOffset >= $countUrl || $urlParts[$urlOffset] === "") { if ($paramOptional) { $param = $urlParts[$urlOffset] ?? null; if ($param !== null && $paramType !== null && Parameter::parseType($param) !== $paramType) { return false; } $params[$paramName] = $param; if ($urlOffset < $countUrl) { $urlOffset++; } } else { return false; } } else { $param = $urlParts[$urlOffset]; if ($paramType !== null && Parameter::parseType($param) !== $paramType) { return false; } $params[$paramName] = $param; $urlOffset++; } } } if ($urlOffset !== $countUrl && $this->exact) { return false; } return $params; } } class EmptyRoute extends AbstractRoute { public function __construct(string $pattern, bool $exact = true) { parent::__construct($pattern, $exact); } public function call(Router $router, array $params): string { return ""; } } class StaticFileRoute extends AbstractRoute { private string $path; private int $code; public function __construct(string $pattern, bool $exact, string $path, int $code = 200) { parent::__construct($pattern, $exact); $this->path = $path; $this->code = $code; } public function call(Router $router, array $params): string { http_response_code($this->code); return serveStatic(WEBROOT, $this->path); } protected function getArgs(): array { return array_merge(parent::getArgs(), [$this->path, $this->code]); } } class StaticRoute extends AbstractRoute { private string $data; private int $code; public function __construct(string $pattern, bool $exact, string $data, int $code = 200) { parent::__construct($pattern, $exact); $this->data = $data; $this->code = $code; } public function call(Router $router, array $params): string { http_response_code($this->code); return $this->data; } protected function getArgs(): array { return array_merge(parent::getArgs(), [$this->data, $this->code]); } } class RedirectRoute extends AbstractRoute { private string $destination; private int $code; public function __construct(string $pattern, bool $exact, string $destination, int $code = 307) { parent::__construct($pattern, $exact); $this->destination = $destination; $this->code = $code; } public function call(Router $router, array $params): string { header("Location: $this->destination"); http_response_code($this->code); return ""; } protected function getArgs(): array { return array_merge(parent::getArgs(), [$this->destination, $this->code]); } } class DocumentRoute extends AbstractRoute { private string $className; private array $args; private ?\ReflectionClass $reflectionClass; public function __construct(string $pattern, bool $exact, string $className, ...$args) { parent::__construct($pattern, $exact); $this->className = $className; $this->args = $args; $this->reflectionClass = null; } private function loadClass(): bool { if ($this->reflectionClass === null) { try { $file = getClassPath($this->className); if (file_exists($file)) { $this->reflectionClass = new \ReflectionClass($this->className); if ($this->reflectionClass->isSubclassOf(Document::class)) { return true; } } } catch (ReflectionException $exception) { $this->reflectionClass = null; return false; } $this->reflectionClass = null; return false; } return true; } public function match(string $url) { $match = parent::match($url); if ($match === false || !$this->loadClass()) { return false; } return $match; } protected function getArgs(): array { return array_merge(parent::getArgs(), [$this->className], $this->args); } public function call(Router $router, array $params): string { if (!$this->loadClass()) { return $router->returnStatusCode(500, [ "message" => "Error loading class: $this->className"]); } try { $args = array_merge([$router->getUser()], $this->args); $document = $this->reflectionClass->newInstanceArgs($args); return $document->getCode($params); } catch (\ReflectionException $e) { return $router->returnStatusCode(500, [ "message" => "Error loading class $this->className: " . $e->getMessage()]); } } } }