Removed scoping from router (will be replaced by something else).

This commit is contained in:
flash 2025-02-28 22:44:56 +00:00
parent 42f46cca78
commit ee540d8137
Signed by: flash
GPG key ID: 2C9C2C574D47FE3E
5 changed files with 278 additions and 411 deletions

View file

@ -1 +1 @@
0.2502.282232
0.2502.282243

View file

@ -1,298 +0,0 @@
<?php
// HttpRouter.php
// Created: 2024-03-28
// Updated: 2025-02-28
namespace Index\Http\Routing;
use stdClass;
use InvalidArgumentException;
use Index\Http\{
HtmlHttpErrorHandler,HttpContentHandler,HttpErrorHandler,HttpResponse,
HttpResponseBuilder,HttpRequest,HttpStream,PlainHttpErrorHandler,StringHttpContent
};
use Psr\Http\Message\{ResponseInterface,ServerRequestInterface};
class HttpRouter implements Router {
/** @var array{handler: callable, match?: string, prefix?: string}[] */
private array $middlewares = [];
/** @var array<string, array<string, callable>> */
private array $staticRoutes = [];
/** @var array<string, array<string, callable>> */
private array $dynamicRoutes = [];
private string $charSetValue;
/**
* @param string $charSet Default character set to specify when none is present.
* @param HttpErrorHandler|string $errorHandler Error handling to use for error responses with an empty body. 'html' for the default HTML implementation, 'plain' for the plaintext implementation.
*/
public function __construct(
string $charSet = '',
HttpErrorHandler|string $errorHandler = 'html'
) {
$this->charSetValue = $charSet;
$this->errorHandler = $errorHandler;
}
/**
* Retrieves the normalised name of the preferred character set.
*
* @return string Normalised character set name.
*/
public string $charSet {
get {
if($this->charSetValue === '') {
$charSet = mb_preferred_mime_name(mb_internal_encoding());
if($charSet === false)
$charSet = 'UTF-8';
return strtolower($charSet);
}
return $this->charSetValue;
}
}
/**
* Error handler instance.
*
* @var HttpErrorHandler
*/
public HttpErrorHandler $errorHandler {
get => $this->errorHandler;
set(HttpErrorHandler|string $handler) {
if($handler instanceof HttpErrorHandler)
$this->errorHandler = $handler;
elseif($handler === 'html')
$this->setHtmlErrorHandler();
else // plain
$this->setPlainErrorHandler();
}
}
/**
* Set the error handler to the basic HTML one.
*/
public function setHtmlErrorHandler(): void {
$this->errorHandler = new HtmlHttpErrorHandler;
}
/**
* Set the error handler to the plain text one.
*/
public function setPlainErrorHandler(): void {
$this->errorHandler = new PlainHttpErrorHandler;
}
public function scopeTo(string $prefix): Router {
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 {
$mwInfo = [];
$mwInfo['handler'] = $handler;
$prepared = self::preparePath($path, true);
if($prepared === false) {
if(str_ends_with($path, '/'))
$path = substr($path, 0, -1);
$mwInfo['prefix'] = $path;
} else
$mwInfo['match'] = $prepared;
$this->middlewares[] = $mwInfo;
}
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 register(RouteHandler $handler): void {
$handler->registerRoutes($this);
}
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(array_key_exists('match', $mwInfo)) {
if(preg_match($mwInfo['match'], $path, $args) !== 1)
continue;
array_shift($args);
} elseif(array_key_exists('prefix', $mwInfo)) {
if($mwInfo['prefix'] !== '' && !str_starts_with($path, $mwInfo['prefix']))
continue;
$args = [];
} else continue;
$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 handle(ServerRequestInterface $request): ResponseInterface {
$response = new HttpResponseBuilder;
$args = [$response, $request];
$routeInfo = $this->resolve($request->getMethod(), $request->getUri()->getPath());
// always run middleware regardless of 404 or 405
$result = $routeInfo->runMiddleware($args);
if($result === null) {
if(!$routeInfo->hasHandler()) {
if(empty($routeInfo->supportedMethods)) {
$result = 404;
} else {
$result = 405;
$response->setHeader('Allow', implode(', ', $routeInfo->supportedMethods));
}
} else
$result = $routeInfo->dispatch($args);
}
if(is_int($result)) {
if($result >= 100 && $result < 600)
$this->writeErrorPage($response, $request, $result);
} elseif(empty($response->content)) {
if(is_scalar($result)) {
$result = (string)$result;
$response->body = HttpStream::createStream($result);
if(!$response->hasContentType()) {
if(strtolower(substr($result, 0, 14)) === '<!doctype html')
$response->setTypeHtml($this->charSet);
else {
$charset = mb_detect_encoding($result);
if($charset !== false)
$charset = mb_preferred_mime_name($charset);
$charset = $charset === false ? 'utf-8' : strtolower($charset);
if(strtolower(substr($result, 0, 5)) === '<?xml')
$response->setTypeXml($charset);
else
$response->setTypePlain($charset);
}
}
}
}
return $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 {
$request ??= HttpRequest::fromRequest();
self::output($this->handle($request), $request->method !== 'HEAD');
}
/**
* Writes an error page to a given HTTP response builder.
*
* @param HttpResponseBuilder $response HTTP response builder to apply the error page to.
* @param ServerRequestInterface $request HTTP request that triggered this error.
* @param int $statusCode HTTP status code for this error page.
*/
public function writeErrorPage(HttpResponseBuilder $response, ServerRequestInterface $request, int $statusCode): void {
$this->errorHandler->handle(
$response,
$request,
$response->statusCode = $statusCode,
$response->reasonPhrase
);
}
/**
* Outputs a HTTP response message to stdout.
*
* @param ResponseInterface $response HTTP response message to output.
* @param bool $includeBody true to include the response message body, false to omit it for HEAD requests.
*/
public static function output(ResponseInterface $response, bool $includeBody): 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));
if($includeBody) {
$stream = $response->getBody();
if($stream->isReadable())
echo (string)$stream;
}
}
}

View file

@ -5,55 +5,291 @@
namespace Index\Http\Routing;
use stdClass;
use InvalidArgumentException;
use RuntimeException;
use Index\Http\HttpRequest;
use Index\Http\{
HtmlHttpErrorHandler,HttpContentHandler,HttpErrorHandler,HttpResponse,
HttpResponseBuilder,HttpRequest,HttpStream,PlainHttpErrorHandler,StringHttpContent
};
use Psr\Http\Message\{ResponseInterface,ServerRequestInterface};
use Psr\Http\Server\RequestHandlerInterface;
/**
* Provides an interface for defining HTTP routers.
*/
interface Router extends RequestHandlerInterface {
/**
* Retrieve a scoped router to a given path prefix.
*
* @param string $prefix Prefix to apply to paths within the returned router.
* @return Router Scoped router proxy.
*/
public function scopeTo(string $prefix): Router;
class Router implements RequestHandlerInterface {
/** @var array{handler: callable, match?: string, prefix?: string}[] */
private array $middlewares = [];
/** @var array<string, array<string, callable>> */
private array $staticRoutes = [];
/** @var array<string, array<string, callable>> */
private array $dynamicRoutes = [];
private string $charSetValue;
/**
* Registers a middleware handler.
*
* @param string $path Path prefix or regex to apply this middleware on.
* @param callable $handler Middleware handler.
* @param string $charSet Default character set to specify when none is present.
* @param HttpErrorHandler|string $errorHandler Error handling to use for error responses with an empty body. 'html' for the default HTML implementation, 'plain' for the plaintext implementation.
*/
public function use(string $path, callable $handler): void;
public function __construct(
string $charSet = '',
HttpErrorHandler|string $errorHandler = 'html'
) {
$this->charSetValue = $charSet;
$this->errorHandler = $errorHandler;
}
/**
* Registers a route handler for a given method and path.
* Retrieves the normalised name of the preferred character set.
*
* @param string $method Method to use this handler for.
* @param string $path Path or regex to use this handler with.
* @param callable $handler Handler to use for this method/path combination.
* @throws InvalidArgumentException If $method is empty.
* @throws InvalidArgumentException If $method starts or ends with spaces.
* @return string Normalised character set name.
*/
public function add(string $method, string $path, callable $handler): void;
public string $charSet {
get {
if($this->charSetValue === '') {
$charSet = mb_preferred_mime_name(mb_internal_encoding());
if($charSet === false)
$charSet = 'UTF-8';
return strtolower($charSet);
}
return $this->charSetValue;
}
}
/**
* Resolves middlewares and a route handler for a given method and path.
* Error handler instance.
*
* @param string $method Method to resolve for.
* @param string $path Path to resolve for.
* @return ResolvedRouteInfo Resolved route information.
* @var HttpErrorHandler
*/
public function resolve(string $method, string $path): ResolvedRouteInfo;
public HttpErrorHandler $errorHandler {
get => $this->errorHandler;
set(HttpErrorHandler|string $handler) {
if($handler instanceof HttpErrorHandler)
$this->errorHandler = $handler;
elseif($handler === 'html')
$this->setHtmlErrorHandler();
else // plain
$this->setPlainErrorHandler();
}
}
/**
* Registers routes in an RouteHandler implementation.
*
* @param RouteHandler $handler Routes handler.
* Set the error handler to the basic HTML one.
*/
public function register(RouteHandler $handler): void;
public function setHtmlErrorHandler(): void {
$this->errorHandler = new HtmlHttpErrorHandler;
}
/**
* Set the error handler to the plain text one.
*/
public function setPlainErrorHandler(): void {
$this->errorHandler = new PlainHttpErrorHandler;
}
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 {
$mwInfo = [];
$mwInfo['handler'] = $handler;
$prepared = self::preparePath($path, true);
if($prepared === false) {
if(str_ends_with($path, '/'))
$path = substr($path, 0, -1);
$mwInfo['prefix'] = $path;
} else
$mwInfo['match'] = $prepared;
$this->middlewares[] = $mwInfo;
}
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 register(RouteHandler $handler): void {
$handler->registerRoutes($this);
}
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(array_key_exists('match', $mwInfo)) {
if(preg_match($mwInfo['match'], $path, $args) !== 1)
continue;
array_shift($args);
} elseif(array_key_exists('prefix', $mwInfo)) {
if($mwInfo['prefix'] !== '' && !str_starts_with($path, $mwInfo['prefix']))
continue;
$args = [];
} else continue;
$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 handle(ServerRequestInterface $request): ResponseInterface {
$response = new HttpResponseBuilder;
$args = [$response, $request];
$routeInfo = $this->resolve($request->getMethod(), $request->getUri()->getPath());
// always run middleware regardless of 404 or 405
$result = $routeInfo->runMiddleware($args);
if($result === null) {
if(!$routeInfo->hasHandler()) {
if(empty($routeInfo->supportedMethods)) {
$result = 404;
} else {
$result = 405;
$response->setHeader('Allow', implode(', ', $routeInfo->supportedMethods));
}
} else
$result = $routeInfo->dispatch($args);
}
if(is_int($result)) {
if($result >= 100 && $result < 600)
$this->writeErrorPage($response, $request, $result);
} elseif(empty($response->content)) {
if(is_scalar($result)) {
$result = (string)$result;
$response->body = HttpStream::createStream($result);
if(!$response->hasContentType()) {
if(strtolower(substr($result, 0, 14)) === '<!doctype html')
$response->setTypeHtml($this->charSet);
else {
$charset = mb_detect_encoding($result);
if($charset !== false)
$charset = mb_preferred_mime_name($charset);
$charset = $charset === false ? 'utf-8' : strtolower($charset);
if(strtolower(substr($result, 0, 5)) === '<?xml')
$response->setTypeXml($charset);
else
$response->setTypePlain($charset);
}
}
}
}
return $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 {
$request ??= HttpRequest::fromRequest();
self::output($this->handle($request), $request->method !== 'HEAD');
}
/**
* Writes an error page to a given HTTP response builder.
*
* @param HttpResponseBuilder $response HTTP response builder to apply the error page to.
* @param ServerRequestInterface $request HTTP request that triggered this error.
* @param int $statusCode HTTP status code for this error page.
*/
public function writeErrorPage(HttpResponseBuilder $response, ServerRequestInterface $request, int $statusCode): void {
$this->errorHandler->handle(
$response,
$request,
$response->statusCode = $statusCode,
$response->reasonPhrase
);
}
/**
* Outputs a HTTP response message to stdout.
*
* @param ResponseInterface $response HTTP response message to output.
* @param bool $includeBody true to include the response message body, false to omit it for HEAD requests.
*/
public static function output(ResponseInterface $response, bool $includeBody): 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));
if($includeBody) {
$stream = $response->getBody();
if($stream->isReadable())
echo (string)$stream;
}
}
}

View file

@ -1,51 +0,0 @@
<?php
// ScopedRouter.php
// Created: 2024-03-28
// Updated: 2025-02-28
namespace Index\Http\Routing;
use Psr\Http\Message\{ResponseInterface,ServerRequestInterface};
/**
* Provides a scoped router interface, automatically adds a prefix to any routes added.
*/
class ScopedRouter implements Router {
/**
* @param Router $router Underlying router.
* @param string $prefix Base path to use as a prefix.
*/
public function __construct(
private Router $router,
private string $prefix
) {
if($router instanceof ScopedRouter)
$this->router = $router->router;
// TODO: cleanup prefix
}
public function scopeTo(string $prefix): Router {
return $this->router->scopeTo($this->prefix . $prefix);
}
public function use(string $path, callable $handler): void {
$this->router->use($this->prefix . $path, $handler);
}
public function add(string $method, string $path, callable $handler): void {
$this->router->add($method, $this->prefix . $path, $handler);
}
public function register(RouteHandler $handler): void {
$handler->registerRoutes($this);
}
public function resolve(string $method, string $path): ResolvedRouteInfo {
return $this->router->resolve($method, $this->prefix . $path);
}
public function handle(ServerRequestInterface $request): ResponseInterface {
return $this->router->handle($request->withRequestTarget($this->prefix . $request->getRequestTarget()));
}
}

View file

@ -8,7 +8,7 @@ declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use Index\Http\{HttpHeaders,HttpRequest,HttpUri,NullStream};
use Index\Http\Routing\{HttpMiddleware,HttpRoute,HttpRouter,RouteHandler,RouteHandlerCommon};
use Index\Http\Routing\{HttpMiddleware,HttpRoute,Router,RouteHandler,RouteHandlerCommon};
/**
* This test isn't super representative of the current functionality
@ -16,12 +16,12 @@ use Index\Http\Routing\{HttpMiddleware,HttpRoute,HttpRouter,RouteHandler,RouteHa
*/
#[CoversClass(HttpMiddleware::class)]
#[CoversClass(HttpRoute::class)]
#[CoversClass(HttpRouter::class)]
#[CoversClass(Router::class)]
#[CoversClass(RouteHandler::class)]
#[CoversClass(RouteHandlerCommon::class)]
final class RouterTest extends TestCase {
public function testRouter(): void {
$router1 = new HttpRouter;
$router1 = new Router;
$router1->add('GET', '/', fn() => 'get');
$router1->add('POST', '/', fn() => 'post');
@ -57,7 +57,7 @@ final class RouterTest extends TestCase {
$this->assertEquals('warioware below flashwave', $getWariowareBelowFlashwave->runMiddleware([]));
$this->assertEquals('below flashwave', $getWariowareBelowFlashwave->dispatch([]));
$router2 = new HttpRouter;
$router2 = new Router;
$router2->use('/', fn() => 'meow');
$router2->add('GET', '/rules', fn() => 'rules page');
$router2->add('GET', '/contact', fn() => 'contact page');
@ -70,21 +70,10 @@ final class RouterTest extends TestCase {
$get25252 = $router2->resolve('GET', '/25252');
$this->assertEquals('meow', $get25252->runMiddleware([]));
$this->assertEquals('numeric test', $get25252->dispatch([]));
$router3 = $router1->scopeTo('/scoped');
$router3->add('GET', '/static', fn() => 'wrong');
$router1->add('GET', '/scoped/static/0', fn() => 'correct');
$router3->add('GET', '/variable', fn() => 'wrong');
$router3->add('GET', '/variable/([0-9]+)', fn(string $num) => $num === '0' ? 'correct' : 'VERY wrong');
$router3->add('GET', '/variable/([a-z]+)', fn(string $char) => $char === 'a' ? 'correct' : 'VERY wrong');
$this->assertEquals('correct', $router3->resolve('GET', '/static/0')->dispatch([]));
$this->assertEquals('correct', $router1->resolve('GET', '/scoped/variable/0')->dispatch([]));
$this->assertEquals('correct', $router3->resolve('GET', '/variable/a')->dispatch([]));
}
public function testAttribute(): void {
$router = new HttpRouter;
$router = new Router;
$handler = new class implements RouteHandler {
use RouteHandlerCommon;
@ -142,19 +131,10 @@ final class RouterTest extends TestCase {
$getMw = $router->resolve('GET', '/mw');
$this->assertEquals('this intercepts', $getMw->runMiddleware([]));
$this->assertEquals('this is intercepted', $getMw->dispatch([]));
$scoped = $router->scopeTo('/scoped');
$scoped->register($handler);
$this->assertEquals('index', $scoped->resolve('GET', '/')->dispatch([]));
$this->assertEquals('avatar', $router->resolve('POST', '/scoped/avatar')->dispatch([]));
$this->assertEquals('static', $scoped->resolve('PUT', '/static')->dispatch([]));
$this->assertEquals('meow', $router->resolve('GET', '/scoped/meow')->dispatch([]));
$this->assertEquals('meow', $scoped->resolve('POST', '/meow')->dispatch([]));
}
public function testEEPROMSituation(): void {
$router = new HttpRouter;
$router = new Router;
$router->add('OPTIONS', '/uploads/([A-Za-z0-9\-_]+)(?:\.(t|json))?', function() {});
$router->add('GET', '/uploads/([A-Za-z0-9\-_]+)(?:\.(t|json))?', function() {});
@ -166,7 +146,7 @@ final class RouterTest extends TestCase {
}
public function testMiddlewareInterceptionOnRoot(): void {
$router = new HttpRouter;
$router = new Router;
$router->use('/', fn() => 'expected');
$router->add('GET', '/', fn() => 'unexpected');
$router->add('GET', '/test', fn() => 'also unexpected');