index/src/Http/Routing/Router.php

409 lines
16 KiB
PHP

<?php
// Router.php
// Created: 2024-03-28
// Updated: 2025-03-19
namespace Index\Http\Routing;
use InvalidArgumentException;
use JsonSerializable;
use RuntimeException;
use Stringable;
use Index\XArray;
use Index\Bencode\{Bencode,BencodeSerializable};
use Index\Http\{HttpRequest,HttpUri};
use Index\Http\Routing\AccessControl\{AccessControl,AccessControlHandler,SimpleAccessControlHandler};
use Index\Http\Routing\ErrorHandling\{ErrorHandler,HtmlErrorHandler,PlainErrorHandler};
use Index\Http\Routing\Filters\FilterInfo;
use Index\Http\Routing\Processors\{ProcessorInfo,ProcessorTarget};
use Index\Http\Routing\Routes\RouteInfo;
use Index\Http\Streams\Stream;
use Psr\Http\Message\{ResponseInterface,ServerRequestInterface};
use Psr\Http\Server\RequestHandlerInterface;
/**
* Implements a router for HTTP requests.
*
* There are four different kinds of handler you can register.
* - Filter: Always runs on a given path or prefix regardless of a route being matched.
* - Preprocessors: Must be explicitly imported by a route handler. Performs checks and can transform details of the pipeline.
* - Route: An actual proper handler for a request.
* - Postprocessors: Must be explicitly imported by a route handler. Performs transformations on the output.
*
* [HTTP Request] -> [Run Filters] -> [Run Preprocessors] -> [Run route handler] -> [Run Postprocessors] -> [HTTP Response]
*
* Preprocessors has the same abilities as filters do, but as mentioned are included explicitly.
*/
class Router implements RequestHandlerInterface {
/** @var FilterInfo[] */
private array $filters = [];
/** @var RouteInfo[] */
private array $routes = [];
/** @var array<string, ProcessorInfo> */
private array $preprocs = [];
/** @var array<string, ProcessorInfo> */
private array $postprocs = [];
/**
* Access control handler instance.
*/
public private(set) AccessControlHandler $accessControlHandler;
/**
* Error handler instance.
*/
public private(set) ErrorHandler $errorHandler;
/**
* @param ErrorHandler|'html'|'plain' $errorHandler Error handling to use for error responses with an empty body. 'html' for the default HTML implementation, 'plain' for the plaintext implementation.
* @param ?AccessControlHandler $accessControlHandler Handler for CORS requests.
* @param int $accessControlTtl Default Access-Control-Max-Age value.
* @param bool $registerDefaultProcessors Whether the default processors should be registered.
*/
public function __construct(
ErrorHandler|string $errorHandler = 'html',
?AccessControlHandler $accessControlHandler = null,
public private(set) int $accessControlTtl = 300,
bool $registerDefaultProcessors = true
) {
$this->setErrorHandler($errorHandler);
$this->accessControlHandler = $accessControlHandler ?? new SimpleAccessControlHandler;
if($registerDefaultProcessors)
$this->register(new RouterProcessors);
}
/**
* Sets an error handler.
*
* @param ErrorHandler|'html'|'plain' $handler
*/
public function setErrorHandler(ErrorHandler|string $handler): void {
if($handler instanceof ErrorHandler)
$this->errorHandler = $handler;
elseif($handler === 'html')
$this->errorHandler = new HtmlErrorHandler;
elseif($handler === 'plain')
$this->errorHandler = new PlainErrorHandler;
else
throw new InvalidArgumentException('$handler must be an instance of ErrorHandler or "html" or "plain"');
}
/**
* Set the error handler to the basic HTML one.
*/
public function setHtmlErrorHandler(): void {
$this->setErrorHandler('html');
}
/**
* Set the error handler to the plain text one.
*/
public function setPlainErrorHandler(): void {
$this->setErrorHandler('plain');
}
/**
* Registers a filter.
*
* @param FilterInfo $filter FilterInfo instance to register.
*/
public function filter(FilterInfo $filter): void {
$this->filters[] = $filter;
}
/**
* Registers a route.
*
* @param RouteInfo $route RouteInfo instance to register.
*/
public function route(RouteInfo $route): void {
$this->routes[] = $route;
}
/**
* Registers a processor.
*
* @param ProcessorInfo $processor ProcessorInfo instance to register.
* @throws InvalidArgumentException if the processor target is not supported.
* @throws RuntimeException if the processor has already been registered.
*/
public function processor(ProcessorInfo $processor): void {
if($processor->target === ProcessorTarget::Pre)
$procs = &$this->preprocs;
elseif($processor->target === ProcessorTarget::Post)
$procs = &$this->postprocs;
else
throw new InvalidArgumentException('$processor target is not supported');
if(array_key_exists($processor->name, $procs))
throw new RuntimeException('$processor has already been registered');
$procs[$processor->name] = $processor;
}
/**
* Registers a pre-processor.
*
* @param ProcessorInfo $processor ProcessorInfo instance to register.
* @throws InvalidArgumentException if the processor target is not a preprocessor.
* @throws RuntimeException if the processor has already been registered.
*/
public function preprocessor(ProcessorInfo $processor): void {
if($processor->target !== ProcessorTarget::Pre)
throw new InvalidArgumentException('$processor is not a preprocessor');
$this->processor($processor);
}
/**
* Registers a post-processor.
*
* @param ProcessorInfo $processor ProcessorInfo instance to register.
* @throws InvalidArgumentException if the processor target is not a postprocessor.
*/
public function postprocessor(ProcessorInfo $processor): void {
if($processor->target !== ProcessorTarget::Post)
throw new InvalidArgumentException('$processor is not a postprocessor');
$this->processor($processor);
}
/**
* Registers a route handler class instance.
*
* Calls $handler->registerRoutes($this), go-to implementation is available in RouteHandlerCommon.
*
* @param RouteHandler $handler Handler to register.
*/
public function register(RouteHandler $handler): void {
$handler->registerRoutes($this);
}
/**
* Default steps that get run at the end of the pipeline, including default return value handling.
*
* @param HandlerContext $context Route pipeline context instance.
* @param mixed $result The last return value.
*/
public function defaultResultHandler(HandlerContext $context, mixed $result): void {
if($result !== null) {
if(is_int($result)) {
if($result < 100 || $result > 599) {
$context->response->statusCode = 500;
$context->response->reasonPhrase = 'Unsupported Status Code';
} else {
$context->response->statusCode = $result;
$context->response->reasonPhrase = '';
}
$this->errorHandler->handle($context);
} elseif($context->response->needsBody) {
// this is a slight step back, but should be remedied by using postprocessors
if($result instanceof JsonSerializable) {
if(!$context->response->hasContentType())
$context->response->setTypeJson();
$result = json_encode($result, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
} elseif($result instanceof BencodeSerializable) {
if(!$context->response->hasContentType())
$context->response->setTypeBencode();
$result = Bencode::encode($result);
} elseif($result instanceof Stringable) {
$result = (string)$result;
} elseif(is_object($result) || is_array($result)) {
if(!$context->response->hasContentType())
$context->response->setTypeJson();
$result = json_encode($result, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
} elseif(is_scalar($result)) {
$result = (string)$result;
} else {
$result = '';
}
if(!$context->response->hasContentType()) {
if(mb_stripos($result, '<!doctype html') === 0)
$context->response->setTypeHtml();
elseif(mb_stripos($result, '<?xml') === 0)
$context->response->setTypeXml();
else
$context->response->setTypePlain();
}
$context->response->body = Stream::createStream($result);
}
}
if(!$context->response->hasContentLength()) {
$bytes = $context->response->body?->getSize();
if($bytes !== null)
$context->response->setContentLength($bytes);
}
if($context->request->method === 'HEAD')
$context->response->body = null;
}
public function handle(ServerRequestInterface $request): ResponseInterface {
$context = new HandlerContext($request);
foreach($this->filters as $filterInfo) {
$match = $filterInfo->matcher->match($context->request->uri);
if($match !== false) {
$result = $context->call($filterInfo->handler, is_array($match) ? $match : null);
if($result !== null || $context->stopped) {
$this->defaultResultHandler($context, $result);
return $context->response->toResponse();
}
}
}
/** @var array<string, object{info: RouteInfo, matches: ?array<int|string, mixed>}> */
$methods = [];
foreach($this->routes as $routeInfo) {
$match = $routeInfo->matcher->match($context->request->uri);
if($match !== false) {
$routeInfo->readAccessControl();
$methods[$routeInfo->method] = (object)[
'info' => $routeInfo,
'matches' => is_array($match) ? $match : null,
];
}
}
if(empty($methods)) {
$context->response->statusCode = 404;
$context->response->reasonPhrase = '';
$this->errorHandler->handle($context);
$this->defaultResultHandler($context, null);
return $context->response->toResponse();
}
if(!array_key_exists($context->request->method, $methods)) {
if($context->request->method === 'HEAD' && array_key_exists('GET', $methods)) {
$methods['HEAD'] = $methods['GET'];
} else {
$allow = array_keys($methods);
$allow[] = 'OPTIONS';
if(in_array('GET', $allow))
$allow[] = 'HEAD';
$context->response->setAllow($allow);
$context->response->reasonPhrase = '';
if($context->request->method === 'OPTIONS') {
$context->response->statusCode = 200;
$context->response->setHeader('Content-Length', '0');
if($context->request->hasHeader('Origin')) {
$requestMethod = strtoupper($context->request->getHeaderLine('Access-Control-Request-Method'));
$routeInfos = array_map(fn($item) => $item->info, $methods);
$accessControl = AccessControl::aggregate($routeInfos, $requestMethod);
if($accessControl->allow) {
if($context->request->hasHeader('Access-Control-Request-Headers')) {
$requestHeaders = trim($context->request->getHeaderLine('Access-Control-Request-Headers'));
$requestHeaders = $requestHeaders === '' ? [] : XArray::select(
explode(',', $requestHeaders),
fn($part) => trim($part)
);
} else
$requestHeaders = null;
if($requestHeaders === null || $accessControl->verifyHeadersAllowed($requestHeaders))
$this->accessControlHandler->handlePreflight(
$context,
$accessControl,
$routeInfos,
HttpUri::createUri($context->request->getHeaderLine('Origin')),
$requestMethod,
$requestHeaders,
)->applyPreflight($context->response, $this->accessControlTtl);
}
}
} else {
$context->response->statusCode = 405;
$this->errorHandler->handle($context);
}
$this->defaultResultHandler($context, null);
return $context->response->toResponse();
}
}
$route = $methods[$context->request->method];
// if you're overriding OPTIONS you're on your own
if($context->request->method !== 'OPTIONS'
&& $context->request->hasHeader('Origin')
&& $route->info->accessControl?->allow === true)
$this->accessControlHandler->handleRequest(
$context,
$route->info,
$route->info->accessControl,
HttpUri::createUri($context->request->getHeaderLine('Origin')),
)->applyResult($context->response);
$route->info->readProcessors();
foreach($route->info->preprocs as $name => $args) {
if(!array_key_exists($name, $this->preprocs))
throw new RuntimeException(sprintf('preprocessor "%s" was not found', $name));
$result = $context->call($this->preprocs[$name]->handler, $args);
if($result !== null || $context->stopped) {
$this->defaultResultHandler($context, $result);
return $context->response->toResponse();
}
}
$context->result = $context->call($route->info->handler, $route->matches);
if(!$context->stopped)
foreach($route->info->postprocs as $name => $args) {
if(!array_key_exists($name, $this->postprocs))
throw new RuntimeException(sprintf('postprocessor "%s" was not found', $name));
$result = $context->call($this->postprocs[$name]->handler, $args);
if($result !== null || $context->stopped) // @phpstan-ignore booleanOr.rightAlwaysFalse
break;
}
$this->defaultResultHandler($context, $context->result);
return $context->response->toResponse();
}
/**
* Dispatches a route based on a given HTTP request message with additional prefix arguments and output to stdout.
*
* @param ?HttpRequest $request HTTP request message to handle, null to use the current request.
*/
public function dispatch(?HttpRequest $request = null): void {
self::output($this->handle($request ?? HttpRequest::fromRequest()));
}
/**
* Outputs a HTTP response message to stdout.
*
* @param ResponseInterface $response HTTP response message to output.
*/
public static function output(ResponseInterface $response): void {
header(sprintf(
'HTTP/%s %03d %s',
$response->getProtocolVersion(),
$response->getStatusCode(),
$response->getReasonPhrase(),
));
foreach($response->getHeaders() as $name => $lines)
foreach($lines as $line)
header(sprintf('%s: %s', $name, $line));
$stream = $response->getBody();
if($stream->isReadable())
echo (string)$stream;
}
}