index/src/Http/Routing/HttpRouter.php

237 lines
8 KiB
PHP

<?php
// HttpRouter.php
// Created: 2024-03-28
// Updated: 2024-03-28
namespace Index\Http\Routing;
use stdClass;
use InvalidArgumentException;
use Index\Http\{HttpResponse,HttpResponseBuilder,HttpRequest};
use Index\Http\Content\StringContent;
use Index\Http\ContentHandling\{BencodeContentHandler,IContentHandler,JsonContentHandler,StreamContentHandler};
use Index\Http\ErrorHandling\{HtmlErrorHandler,IErrorHandler,PlainErrorHandler};
class HttpRouter implements IRouter {
use RouterTrait;
private array $middlewares = [];
private array $staticRoutes = [];
private array $dynamicRoutes = [];
private array $contentHandlers = [];
private IErrorHandler $errorHandler;
public function __construct(
IErrorHandler|string $errorHandler = 'html',
bool $registerDefaultContentHandlers = true,
) {
$this->setErrorHandler($errorHandler);
if($registerDefaultContentHandlers)
$this->registerDefaultContentHandlers();
}
public function getErrorHandler(): IErrorHandler {
return $this->errorHandler;
}
public function setErrorHandler(IErrorHandler|string $handler): void {
if($handler instanceof IErrorHandler)
$this->errorHandler = $handler;
elseif($handler === 'html')
$this->setHTMLErrorHandler();
else // plain
$this->setPlainErrorHandler();
}
public function setHTMLErrorHandler(): void {
$this->errorHandler = new HtmlErrorHandler;
}
public function setPlainErrorHandler(): void {
$this->errorHandler = new PlainErrorHandler;
}
public function registerContentHandler(IContentHandler $contentHandler): void {
if(!in_array($contentHandler, $this->contentHandlers))
$this->contentHandlers[] = $contentHandler;
}
public function registerDefaultContentHandlers(): void {
$this->registerContentHandler(new StreamContentHandler);
$this->registerContentHandler(new JsonContentHandler);
$this->registerContentHandler(new BencodeContentHandler);
}
public function scopeTo(string $prefix): IRouter {
return new ScopedRouter($this, $prefix);
}
private static function preparePath(string $path, bool $prefixMatch): string|false {
// this sucks lol
if(!str_contains($path, '(') || !str_contains($path, ')'))
return false;
return sprintf('#^%s%s#su', $path, $prefixMatch ? '' : '$');
}
public function use(string $path, callable $handler): void {
$this->middlewares[] = $mwInfo = new stdClass;
$mwInfo->handler = $handler;
$prepared = self::preparePath($path, true);
$mwInfo->dynamic = $prepared !== false;
if($mwInfo->dynamic)
$mwInfo->match = $prepared;
else
$mwInfo->prefix = $path;
}
public function add(string $method, string $path, callable $handler): void {
if($method === '')
throw new InvalidArgumentException('$method may not be empty');
$method = strtoupper($method);
if(trim($method) !== $method)
throw new InvalidArgumentException('$method may start or end with whitespace');
$prepared = self::preparePath($path, false);
if($prepared === false) {
if(array_key_exists($path, $this->staticRoutes))
$this->staticRoutes[$path][$method] = $handler;
else
$this->staticRoutes[$path] = [$method => $handler];
} else {
if(array_key_exists($prepared, $this->dynamicRoutes))
$this->dynamicRoutes[$prepared][$method] = $handler;
else
$this->dynamicRoutes[$prepared] = [$method => $handler];
}
}
public function resolve(string $method, string $path): ResolvedRouteInfo {
$middlewares = [];
foreach($this->middlewares as $mwInfo) {
if($mwInfo->dynamic) {
if(preg_match($mwInfo->match, $path, $args) !== 1)
continue;
array_shift($args);
} else {
if(!str_starts_with($path, $mwInfo->prefix))
continue;
$args = [];
}
$middlewares[] = [$mwInfo->handler, $args];
}
$methods = [];
$handler = null;
$args = [];
if(array_key_exists($path, $this->staticRoutes)) {
$methods = $this->staticRoutes[$path];
} else {
foreach($this->dynamicRoutes as $rPattern => $rMethods)
if(preg_match($rPattern, $path, $args) === 1) {
$methods = $rMethods;
array_shift($args);
break;
}
}
$method = strtoupper($method);
if(array_key_exists($method, $methods))
$handler = $methods[$method];
return new ResolvedRouteInfo($middlewares, array_keys($methods), $handler, $args);
}
public function dispatch(?HttpRequest $request = null, array $args = []): void {
$request ??= HttpRequest::fromRequest();
$response = new HttpResponseBuilder;
$args = array_merge([$response, $request], $args);
$routeInfo = $this->resolve($request->getMethod(), $request->getPath());
// always run middleware regardless of 404 or 405
$result = $routeInfo->runMiddleware($args);
if($result === null) {
if(!$routeInfo->hasHandler()) {
if($routeInfo->hasOtherMethods()) {
$result = 405;
$response->setHeader('Allow', implode(', ', $routeInfo->getSupportedMethods()));
} else
$result = 404;
} else
$result = $routeInfo->dispatch($args);
}
if(is_int($result)) {
if(!$response->hasStatusCode() && $result >= 100 && $result < 600)
$this->writeErrorPage($response, $request, $result);
else
$response->setContent(new StringContent((string)$result));
} elseif(!$response->hasContent()) {
foreach($this->contentHandlers as $contentHandler)
if($contentHandler->match($result)) {
$contentHandler->handle($response, $result);
break;
}
if(!$response->hasContent() && $result !== null) {
$result = (string)$result;
$response->setContent(new StringContent($result));
if(!$response->hasContentType()) {
$charset = strtolower(mb_preferred_mime_name(mb_detect_encoding($result)));
if(strtolower(substr($result, 0, 14)) === '<!doctype html')
$response->setTypeHTML($charset);
elseif(strtolower(substr($result, 0, 5)) === '<?xml')
$response->setTypeXML($charset);
else
$response->setTypePlain($charset);
}
}
}
self::output($response->toResponse());
}
public function writeErrorPage(HttpResponseBuilder $response, HttpRequest $request, int $statusCode): void {
$response->setStatusCode($statusCode);
$this->errorHandler->handle($response, $request, $response->getStatusCode(), $response->getStatusText());
}
public static function output(HttpResponse $response): void {
$version = $response->getHttpVersion();
header(sprintf(
'HTTP/%d.%d %03d %s',
$version->getMajor(),
$version->getMinor(),
$response->getStatusCode(),
$response->getStatusText()
));
$headers = $response->getHeaders();
foreach($headers as $header) {
$name = (string)$header->getName();
$lines = $header->getLines();
foreach($lines as $line)
header(sprintf('%s: %s', $name, (string)$line));
}
if($response->hasContent())
echo (string)$response->getContent();
}
}