263 lines
9.1 KiB
PHP
263 lines
9.1 KiB
PHP
<?php
|
|
// HttpRouter.php
|
|
// Created: 2024-03-28
|
|
// Updated: 2024-04-02
|
|
|
|
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 string $defaultCharSet;
|
|
|
|
private IErrorHandler $errorHandler;
|
|
|
|
public function __construct(
|
|
string $charSet = '',
|
|
IErrorHandler|string $errorHandler = 'html',
|
|
bool $registerDefaultContentHandlers = true,
|
|
) {
|
|
$this->defaultCharSet = $charSet;
|
|
$this->setErrorHandler($errorHandler);
|
|
|
|
if($registerDefaultContentHandlers)
|
|
$this->registerDefaultContentHandlers();
|
|
}
|
|
|
|
public function getCharSet(): string {
|
|
if($this->defaultCharSet === '')
|
|
return strtolower(mb_preferred_mime_name(mb_internal_encoding()));
|
|
return $this->defaultCharSet;
|
|
}
|
|
|
|
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;
|
|
|
|
// make trailing slash optional
|
|
if(!$prefixMatch && str_ends_with($path, '/'))
|
|
$path .= '?';
|
|
|
|
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(str_ends_with($path, '/'))
|
|
$path = substr($path, 0, -1);
|
|
|
|
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 {
|
|
if(str_ends_with($path, '/'))
|
|
$path = substr($path, 0, -1);
|
|
|
|
$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 = [];
|
|
|
|
if(array_key_exists($path, $this->staticRoutes)) {
|
|
foreach($this->staticRoutes[$path] as $sMethodName => $sMethodHandler)
|
|
$methods[$sMethodName] = [$sMethodHandler, []];
|
|
} else {
|
|
foreach($this->dynamicRoutes as $rPattern => $rMethods)
|
|
if(preg_match($rPattern, $path, $args) === 1)
|
|
foreach($rMethods as $rMethodName => $rMethodHandler)
|
|
if(!array_key_exists($rMethodName, $methods))
|
|
$methods[$rMethodName] = [$rMethodHandler, array_slice($args, 1)];
|
|
}
|
|
|
|
$method = strtoupper($method);
|
|
if(array_key_exists($method, $methods)) {
|
|
[$handler, $args] = $methods[$method];
|
|
} elseif($method === 'HEAD' && array_key_exists('GET', $methods)) {
|
|
[$handler, $args] = $methods['GET'];
|
|
} else {
|
|
$handler = null;
|
|
$args = [];
|
|
}
|
|
|
|
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()) {
|
|
if(strtolower(substr($result, 0, 14)) === '<!doctype html')
|
|
$response->setTypeHTML($this->getCharSet());
|
|
else {
|
|
$charset = strtolower(mb_preferred_mime_name(mb_detect_encoding($result)));
|
|
|
|
if(strtolower(substr($result, 0, 5)) === '<?xml')
|
|
$response->setTypeXML($charset);
|
|
else
|
|
$response->setTypePlain($charset);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
self::output($response->toResponse(), $request->getMethod() !== 'HEAD');
|
|
}
|
|
|
|
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, bool $includeBody): 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($includeBody && $response->hasContent())
|
|
echo (string)$response->getContent();
|
|
}
|
|
}
|