409 lines
16 KiB
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;
|
|
}
|
|
}
|