Renamed existing concept of middleware to filters.

This commit is contained in:
flash 2025-03-02 02:08:45 +00:00
parent ee540d8137
commit bdb66bc1ba
Signed by: flash
GPG key ID: 2C9C2C574D47FE3E
Notes: flash 2025-03-02 02:10:42 +00:00
oopsie RouteInfo was not supposed to be in here
10 changed files with 185 additions and 100 deletions

View file

@ -1 +1 @@
0.2502.282243 0.2503.20208

View file

@ -1,7 +1,7 @@
<?php <?php
// HttpRequest.php // HttpRequest.php
// Created: 2022-02-08 // Created: 2022-02-08
// Updated: 2025-02-28 // Updated: 2025-03-02
namespace Index\Http; namespace Index\Http;
@ -29,7 +29,7 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface {
* @param HttpUri $uri HTTP request URI. * @param HttpUri $uri HTTP request URI.
* @param array<string, string[]> $params HTTP request query parameters. * @param array<string, string[]> $params HTTP request query parameters.
* @param array<string, string> $cookies HTTP request cookies. * @param array<string, string> $cookies HTTP request cookies.
* @param null|array<string, string[]|object[]>|object $parsedBody Parsed body contents. * @param array<string, array<object|string>>|object|null $parsedBody Parsed body contents.
* @param ?array<string, HttpUploadedFile[]> $uploadedFiles Parsed files. * @param ?array<string, HttpUploadedFile[]> $uploadedFiles Parsed files.
*/ */
final public function __construct( final public function __construct(
@ -343,6 +343,24 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface {
return $this->with(parsedBody: $data); return $this->with(parsedBody: $data);
} }
public static function castRequest(ServerRequestInterface $request): HttpRequest {
if($request instanceof HttpRequest)
return $request;
return new HttpRequest(
$request->getProtocolVersion(),
$request->getHeaders(), // @phpstan-ignore-line: dont care
$request->getBody(),
[],
$request->getMethod(),
HttpUri::castUri($request->getUri()),
$request->getQueryParams(), // @phpstan-ignore-line: dont care
$request->getCookieParams(), // @phpstan-ignore-line: dont care
$request->getParsedBody(), // @phpstan-ignore-line: dont care
$request->getUploadedFiles(), // @phpstan-ignore-line: dont care
);
}
/** /**
* Creates an HttpRequest instance from the current request. * Creates an HttpRequest instance from the current request.
* *

View file

@ -0,0 +1,14 @@
<?php
// Filter.php
// Created: 2024-03-28
// Updated: 2025-03-02
namespace Index\Http\Routing;
use Attribute;
/**
* Provides an attribute for marking methods in a class as a filter.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class Filter extends HandlerAttribute {}

View file

@ -1,7 +1,7 @@
<?php <?php
// HandlerAttribute.php // HandlerAttribute.php
// Created: 2024-03-28 // Created: 2024-03-28
// Updated: 2025-01-18 // Updated: 2025-03-02
namespace Index\Http\Routing; namespace Index\Http\Routing;
@ -36,10 +36,10 @@ abstract class HandlerAttribute {
$handlerInfo = $attrInfo->newInstance(); $handlerInfo = $attrInfo->newInstance();
$closure = $methodInfo->getClosure($methodInfo->isStatic() ? null : $handler); $closure = $methodInfo->getClosure($methodInfo->isStatic() ? null : $handler);
if($handlerInfo instanceof HttpRoute) if($handlerInfo instanceof Route)
$router->add($handlerInfo->method, $handlerInfo->path, $closure); $router->route($handlerInfo->method, $handlerInfo->path, $closure);
else else
$router->use($handlerInfo->path, $closure); $router->filter($handlerInfo->path, $closure);
} }
} }
} }

View file

@ -1,14 +0,0 @@
<?php
// HttpMiddleware.php
// Created: 2024-03-28
// Updated: 2024-03-28
namespace Index\Http\Routing;
use Attribute;
/**
* Provides an attribute for marking methods in a class as middleware.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class HttpMiddleware extends HandlerAttribute {}

View file

@ -1,7 +1,7 @@
<?php <?php
// ResolvedRouteInfo.php // ResolvedRouteInfo.php
// Created: 2024-03-28 // Created: 2024-03-28
// Updated: 2025-01-18 // Updated: 2025-03-02
namespace Index\Http\Routing; namespace Index\Http\Routing;
@ -10,27 +10,27 @@ namespace Index\Http\Routing;
*/ */
class ResolvedRouteInfo { class ResolvedRouteInfo {
/** /**
* @param array{callable, mixed[]}[] $middlewares Middlewares that should be run prior to the route handler. * @param array{callable, mixed[]}[] $filters Filters that should be run prior to the route handler.
* @param string[] $supportedMethods HTTP methods that this route accepts. * @param string[] $supportedMethods HTTP methods that this route accepts.
* @param (callable(): mixed)|null $handler Route handler. * @param (callable(): mixed)|null $handler Route handler.
* @param mixed[] $args Argument list to pass to the middleware and route handlers. * @param mixed[] $args Argument list to pass to the filter and route handlers.
*/ */
public function __construct( public function __construct(
private array $middlewares, private array $filters,
public private(set) array $supportedMethods, public private(set) array $supportedMethods,
private mixed $handler, private mixed $handler,
private array $args, private array $args,
) {} ) {}
/** /**
* Run middleware handlers. * Run filter handlers.
* *
* @param mixed[] $args Additional arguments to pass to the middleware handlers. * @param mixed[] $args Additional arguments to pass to the filter handlers.
* @return mixed Return value from the first middleware to return anything non-null, otherwise null. * @return mixed Return value from the first filter to return anything non-null, otherwise null.
*/ */
public function runMiddleware(array $args): mixed { public function runFilters(array $args): mixed {
foreach($this->middlewares as $middleware) { foreach($this->filters as $filter) {
$result = $middleware[0](...array_merge($args, $middleware[1])); $result = $filter[0](...array_merge($args, $filter[1]));
if($result !== null) if($result !== null)
return $result; return $result;
} }

View file

@ -1,7 +1,7 @@
<?php <?php
// HttpRoute.php // Route.php
// Created: 2024-03-28 // Created: 2024-03-28
// Updated: 2025-01-18 // Updated: 2025-03-02
namespace Index\Http\Routing; namespace Index\Http\Routing;
@ -11,7 +11,7 @@ use Attribute;
* Provides an attribute for marking methods in a class as a route. * Provides an attribute for marking methods in a class as a route.
*/ */
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class HttpRoute extends HandlerAttribute { class Route extends HandlerAttribute {
/** /**
* @param string $method * @param string $method
* @param string $path * @param string $path

View file

@ -0,0 +1,62 @@
<?php
// RouteInfo.php
// Created: 2025-03-02
// Updated: 2025-03-02
namespace Index\Http\Routing;
use InvalidArgumentException;
use RuntimeException;
use Psr\Http\Message\UriInterface;
/**
* Information of a route.
*/
abstract class RouteInfo {
/** HTTP method this route serves. */
public private(set) string $method;
/**
* Names of middleware to run before calling the method handler. Executed in order of registration.
*
* @var array<string, array<int|string, mixed>>
*/
public private(set) array $middlewares = [];
/**
* @param string $method HTTP method this route serves.
* @param callable(): void $handler Handler for this route.
* @throws InvalidArgumentException If $handler is not a callable.
*/
public function __construct(
string $method,
public private(set) $handler,
) {
if(!is_callable($handler))
throw new InvalidArgumentException('$handler must be callable');
$this->method = strtoupper($method);
}
/**
* Adds middleware to the pipeline prior execution of the handler. Executed in order of registration/
*
* @param string $name Name of the middleware, implementation registered in the router.
* @param array<int|string, mixed> $args Additional arguments to call the middleware with.
* @throws InvalidArgumentException If $name has already been registered.
*/
public function require(string $name, array $args = []): void {
if(array_key_exists($name, $this->middlewares))
throw new InvalidArgumentException('middleware in $name has been registered for this route');
$this->middlewares[$name] = $args;
}
/**
* Matches a URI to this route.
*
* @param UriInterface $uri URI to match against.
* @return bool
*/
abstract public function match(UriInterface $uri): bool;
}

View file

@ -1,7 +1,7 @@
<?php <?php
// Router.php // Router.php
// Created: 2024-03-28 // Created: 2024-03-28
// Updated: 2025-02-28 // Updated: 2025-03-02
namespace Index\Http\Routing; namespace Index\Http\Routing;
@ -16,7 +16,7 @@ use Psr\Http\Server\RequestHandlerInterface;
class Router implements RequestHandlerInterface { class Router implements RequestHandlerInterface {
/** @var array{handler: callable, match?: string, prefix?: string}[] */ /** @var array{handler: callable, match?: string, prefix?: string}[] */
private array $middlewares = []; private array $filters = [];
/** @var array<string, array<string, callable>> */ /** @var array<string, array<string, callable>> */
private array $staticRoutes = []; private array $staticRoutes = [];
@ -100,23 +100,27 @@ class Router implements RequestHandlerInterface {
return sprintf('#^%s%s#su', $path, $prefixMatch ? '' : '$'); return sprintf('#^%s%s#su', $path, $prefixMatch ? '' : '$');
} }
public function use(string $path, callable $handler): void { public function filter(string $path, callable $handler): void {
$mwInfo = []; //
$mwInfo['handler'] = $handler;
$filter = [];
$filter['handler'] = $handler;
$prepared = self::preparePath($path, true); $prepared = self::preparePath($path, true);
if($prepared === false) { if($prepared === false) {
if(str_ends_with($path, '/')) if(str_ends_with($path, '/'))
$path = substr($path, 0, -1); $path = substr($path, 0, -1);
$mwInfo['prefix'] = $path; $filter['prefix'] = $path;
} else } else
$mwInfo['match'] = $prepared; $filter['match'] = $prepared;
$this->middlewares[] = $mwInfo; $this->filters[] = $filter;
} }
public function add(string $method, string $path, callable $handler): void { public function route(string $method, string $path, callable $handler): void {
//
if($method === '') if($method === '')
throw new InvalidArgumentException('$method may not be empty'); throw new InvalidArgumentException('$method may not be empty');
@ -149,22 +153,22 @@ class Router implements RequestHandlerInterface {
if(str_ends_with($path, '/')) if(str_ends_with($path, '/'))
$path = substr($path, 0, -1); $path = substr($path, 0, -1);
$middlewares = []; $filters = [];
foreach($this->middlewares as $mwInfo) { foreach($this->filters as $filter) {
if(array_key_exists('match', $mwInfo)) { if(array_key_exists('match', $filter)) {
if(preg_match($mwInfo['match'], $path, $args) !== 1) if(preg_match($filter['match'], $path, $args) !== 1)
continue; continue;
array_shift($args); array_shift($args);
} elseif(array_key_exists('prefix', $mwInfo)) { } elseif(array_key_exists('prefix', $filter)) {
if($mwInfo['prefix'] !== '' && !str_starts_with($path, $mwInfo['prefix'])) if($filter['prefix'] !== '' && !str_starts_with($path, $filter['prefix']))
continue; continue;
$args = []; $args = [];
} else continue; } else continue;
$middlewares[] = [$mwInfo['handler'], $args]; $filters[] = [$filter['handler'], $args];
} }
$methods = []; $methods = [];
@ -190,17 +194,18 @@ class Router implements RequestHandlerInterface {
$args = []; $args = [];
} }
return new ResolvedRouteInfo($middlewares, array_keys($methods), $handler, $args); return new ResolvedRouteInfo($filters, array_keys($methods), $handler, $args);
} }
public function handle(ServerRequestInterface $request): ResponseInterface { public function handle(ServerRequestInterface $request): ResponseInterface {
$request = HttpRequest::castRequest($request);
$response = new HttpResponseBuilder; $response = new HttpResponseBuilder;
$args = [$response, $request]; $args = [$response, $request];
$routeInfo = $this->resolve($request->getMethod(), $request->getUri()->getPath()); $routeInfo = $this->resolve($request->method, $request->uri->path);
// always run middleware regardless of 404 or 405 // always run filters regardless of 404 or 405
$result = $routeInfo->runMiddleware($args); $result = $routeInfo->runFilters($args);
if($result === null) { if($result === null) {
if(!$routeInfo->hasHandler()) { if(!$routeInfo->hasHandler()) {
if(empty($routeInfo->supportedMethods)) { if(empty($routeInfo->supportedMethods)) {

View file

@ -1,21 +1,21 @@
<?php <?php
// RouterTest.php // RouterTest.php
// Created: 2022-01-20 // Created: 2022-01-20
// Updated: 2025-02-28 // Updated: 2025-03-02
declare(strict_types=1); declare(strict_types=1);
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Index\Http\{HttpHeaders,HttpRequest,HttpUri,NullStream}; use Index\Http\{HttpHeaders,HttpRequest,HttpUri,NullStream};
use Index\Http\Routing\{HttpMiddleware,HttpRoute,Router,RouteHandler,RouteHandlerCommon}; use Index\Http\Routing\{Filter,Route,Router,RouteHandler,RouteHandlerCommon};
/** /**
* This test isn't super representative of the current functionality * This test isn't super representative of the current functionality
* it mostly just does the same tests that were done against the previous implementation * it mostly just does the same tests that were done against the previous implementation
*/ */
#[CoversClass(HttpMiddleware::class)] #[CoversClass(Filter::class)]
#[CoversClass(HttpRoute::class)] #[CoversClass(Route::class)]
#[CoversClass(Router::class)] #[CoversClass(Router::class)]
#[CoversClass(RouteHandler::class)] #[CoversClass(RouteHandler::class)]
#[CoversClass(RouteHandlerCommon::class)] #[CoversClass(RouteHandlerCommon::class)]
@ -23,52 +23,52 @@ final class RouterTest extends TestCase {
public function testRouter(): void { public function testRouter(): void {
$router1 = new Router; $router1 = new Router;
$router1->add('GET', '/', fn() => 'get'); $router1->route('GET', '/', fn() => 'get');
$router1->add('POST', '/', fn() => 'post'); $router1->route('POST', '/', fn() => 'post');
$router1->add('DELETE', '/', fn() => 'delete'); $router1->route('DELETE', '/', fn() => 'delete');
$router1->add('PATCH', '/', fn() => 'patch'); $router1->route('PATCH', '/', fn() => 'patch');
$router1->add('PUT', '/', fn() => 'put'); $router1->route('PUT', '/', fn() => 'put');
$router1->add('CUSTOM', '/', fn() => 'wacky'); $router1->route('CUSTOM', '/', fn() => 'wacky');
$this->assertEquals('get', $router1->resolve('GET', '/')->dispatch([])); $this->assertEquals('get', $router1->resolve('GET', '/')->dispatch([]));
$this->assertEquals('wacky', $router1->resolve('CUSTOM', '/')->dispatch([])); $this->assertEquals('wacky', $router1->resolve('CUSTOM', '/')->dispatch([]));
$router1->use('/', function() { /* this one intentionally does nothing */ }); $router1->filter('/', function() { /* this one intentionally does nothing */ });
// registration order should matter // registration order should matter
$router1->use('/deep', fn() => 'deep'); $router1->filter('/deep', fn() => 'deep');
$postRoot = $router1->resolve('POST', '/'); $postRoot = $router1->resolve('POST', '/');
$this->assertNull($postRoot->runMiddleware([])); $this->assertNull($postRoot->runFilters([]));
$this->assertEquals('post', $postRoot->dispatch([])); $this->assertEquals('post', $postRoot->dispatch([]));
$this->assertEquals('deep', $router1->resolve('GET', '/deep/nothing')->runMiddleware([])); $this->assertEquals('deep', $router1->resolve('GET', '/deep/nothing')->runFilters([]));
$router1->use('/user/([A-Za-z0-9]+)/below', fn(string $user) => 'warioware below ' . $user); $router1->filter('/user/([A-Za-z0-9]+)/below', fn(string $user) => 'warioware below ' . $user);
$router1->add('GET', '/user/static', fn() => 'the static one'); $router1->route('GET', '/user/static', fn() => 'the static one');
$router1->add('GET', '/user/static/below', fn() => 'below the static one'); $router1->route('GET', '/user/static/below', fn() => 'below the static one');
$router1->add('GET', '/user/([A-Za-z0-9]+)', fn(string $user) => $user); $router1->route('GET', '/user/([A-Za-z0-9]+)', fn(string $user) => $user);
$router1->add('GET', '/user/([A-Za-z0-9]+)/below', fn(string $user) => 'below ' . $user); $router1->route('GET', '/user/([A-Za-z0-9]+)/below', fn(string $user) => 'below ' . $user);
$this->assertEquals('below the static one', $router1->resolve('GET', '/user/static/below')->dispatch([])); $this->assertEquals('below the static one', $router1->resolve('GET', '/user/static/below')->dispatch([]));
$getWariowareBelowFlashwave = $router1->resolve('GET', '/user/flashwave/below'); $getWariowareBelowFlashwave = $router1->resolve('GET', '/user/flashwave/below');
$this->assertEquals('warioware below flashwave', $getWariowareBelowFlashwave->runMiddleware([])); $this->assertEquals('warioware below flashwave', $getWariowareBelowFlashwave->runFilters([]));
$this->assertEquals('below flashwave', $getWariowareBelowFlashwave->dispatch([])); $this->assertEquals('below flashwave', $getWariowareBelowFlashwave->dispatch([]));
$router2 = new Router; $router2 = new Router;
$router2->use('/', fn() => 'meow'); $router2->filter('/', fn() => 'meow');
$router2->add('GET', '/rules', fn() => 'rules page'); $router2->route('GET', '/rules', fn() => 'rules page');
$router2->add('GET', '/contact', fn() => 'contact page'); $router2->route('GET', '/contact', fn() => 'contact page');
$router2->add('GET', '/25252', fn() => 'numeric test'); $router2->route('GET', '/25252', fn() => 'numeric test');
$getRules = $router2->resolve('GET', '/rules'); $getRules = $router2->resolve('GET', '/rules');
$this->assertEquals('meow', $getRules->runMiddleware([])); $this->assertEquals('meow', $getRules->runFilters([]));
$this->assertEquals('rules page', $getRules->dispatch([])); $this->assertEquals('rules page', $getRules->dispatch([]));
$get25252 = $router2->resolve('GET', '/25252'); $get25252 = $router2->resolve('GET', '/25252');
$this->assertEquals('meow', $get25252->runMiddleware([])); $this->assertEquals('meow', $get25252->runFilters([]));
$this->assertEquals('numeric test', $get25252->dispatch([])); $this->assertEquals('numeric test', $get25252->dispatch([]));
} }
@ -77,34 +77,34 @@ final class RouterTest extends TestCase {
$handler = new class implements RouteHandler { $handler = new class implements RouteHandler {
use RouteHandlerCommon; use RouteHandlerCommon;
#[HttpRoute('GET', '/')] #[Route('GET', '/')]
public function getIndex(): string { public function getIndex(): string {
return 'index'; return 'index';
} }
#[HttpRoute('POST', '/avatar')] #[Route('POST', '/avatar')]
public function postAvatar(): string { public function postAvatar(): string {
return 'avatar'; return 'avatar';
} }
#[HttpRoute('PUT', '/static')] #[Route('PUT', '/static')]
public static function putStatic(): string { public static function putStatic(): string {
return 'static'; return 'static';
} }
#[HttpRoute('GET', '/meow')] #[Route('GET', '/meow')]
#[HttpRoute('POST', '/meow')] #[Route('POST', '/meow')]
public function multiple(): string { public function multiple(): string {
return 'meow'; return 'meow';
} }
#[HttpMiddleware('/mw')] #[Filter('/filter')]
public function useMw(): string { public function useFilter(): string {
return 'this intercepts'; return 'this intercepts';
} }
#[HttpRoute('GET', '/mw')] #[Route('GET', '/filter')]
public function getMw(): string { public function getFilter(): string {
return 'this is intercepted'; return 'this is intercepted';
} }
@ -127,29 +127,29 @@ final class RouterTest extends TestCase {
$this->assertEquals('meow', $router->resolve('GET', '/meow')->dispatch([])); $this->assertEquals('meow', $router->resolve('GET', '/meow')->dispatch([]));
$this->assertEquals('meow', $router->resolve('POST', '/meow')->dispatch([])); $this->assertEquals('meow', $router->resolve('POST', '/meow')->dispatch([]));
// stopping on middleware is the dispatcher's job // stopping on filter is the dispatcher's job
$getMw = $router->resolve('GET', '/mw'); $getFilter = $router->resolve('GET', '/filter');
$this->assertEquals('this intercepts', $getMw->runMiddleware([])); $this->assertEquals('this intercepts', $getFilter->runFilters([]));
$this->assertEquals('this is intercepted', $getMw->dispatch([])); $this->assertEquals('this is intercepted', $getFilter->dispatch([]));
} }
public function testEEPROMSituation(): void { public function testEEPROMSituation(): void {
$router = new Router; $router = new Router;
$router->add('OPTIONS', '/uploads/([A-Za-z0-9\-_]+)(?:\.(t|json))?', function() {}); $router->route('OPTIONS', '/uploads/([A-Za-z0-9\-_]+)(?:\.(t|json))?', function() {});
$router->add('GET', '/uploads/([A-Za-z0-9\-_]+)(?:\.(t|json))?', function() {}); $router->route('GET', '/uploads/([A-Za-z0-9\-_]+)(?:\.(t|json))?', function() {});
$router->add('DELETE', '/uploads/([A-Za-z0-9\-_]+)', function() {}); $router->route('DELETE', '/uploads/([A-Za-z0-9\-_]+)', function() {});
$resolved = $router->resolve('DELETE', '/uploads/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); $resolved = $router->resolve('DELETE', '/uploads/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA');
$this->assertEquals(['OPTIONS', 'GET', 'DELETE'], $resolved->supportedMethods); $this->assertEquals(['OPTIONS', 'GET', 'DELETE'], $resolved->supportedMethods);
} }
public function testMiddlewareInterceptionOnRoot(): void { public function testFilterInterceptionOnRoot(): void {
$router = new Router; $router = new Router;
$router->use('/', fn() => 'expected'); $router->filter('/', fn() => 'expected');
$router->add('GET', '/', fn() => 'unexpected'); $router->route('GET', '/', fn() => 'unexpected');
$router->add('GET', '/test', fn() => 'also unexpected'); $router->route('GET', '/test', fn() => 'also unexpected');
ob_start(); ob_start();
$router->dispatch(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/'), [], [])); $router->dispatch(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/'), [], []));