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
// HttpRequest.php
// Created: 2022-02-08
// Updated: 2025-02-28
// Updated: 2025-03-02
namespace Index\Http;
@ -29,7 +29,7 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface {
* @param HttpUri $uri HTTP request URI.
* @param array<string, string[]> $params HTTP request query parameters.
* @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.
*/
final public function __construct(
@ -343,6 +343,24 @@ class HttpRequest extends HttpMessage implements ServerRequestInterface {
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.
*

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
// HandlerAttribute.php
// Created: 2024-03-28
// Updated: 2025-01-18
// Updated: 2025-03-02
namespace Index\Http\Routing;
@ -36,10 +36,10 @@ abstract class HandlerAttribute {
$handlerInfo = $attrInfo->newInstance();
$closure = $methodInfo->getClosure($methodInfo->isStatic() ? null : $handler);
if($handlerInfo instanceof HttpRoute)
$router->add($handlerInfo->method, $handlerInfo->path, $closure);
if($handlerInfo instanceof Route)
$router->route($handlerInfo->method, $handlerInfo->path, $closure);
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
// ResolvedRouteInfo.php
// Created: 2024-03-28
// Updated: 2025-01-18
// Updated: 2025-03-02
namespace Index\Http\Routing;
@ -10,27 +10,27 @@ namespace Index\Http\Routing;
*/
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 (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(
private array $middlewares,
private array $filters,
public private(set) array $supportedMethods,
private mixed $handler,
private array $args,
) {}
/**
* Run middleware handlers.
* Run filter handlers.
*
* @param mixed[] $args Additional arguments to pass to the middleware handlers.
* @return mixed Return value from the first middleware to return anything non-null, otherwise null.
* @param mixed[] $args Additional arguments to pass to the filter handlers.
* @return mixed Return value from the first filter to return anything non-null, otherwise null.
*/
public function runMiddleware(array $args): mixed {
foreach($this->middlewares as $middleware) {
$result = $middleware[0](...array_merge($args, $middleware[1]));
public function runFilters(array $args): mixed {
foreach($this->filters as $filter) {
$result = $filter[0](...array_merge($args, $filter[1]));
if($result !== null)
return $result;
}

View file

@ -1,7 +1,7 @@
<?php
// HttpRoute.php
// Route.php
// Created: 2024-03-28
// Updated: 2025-01-18
// Updated: 2025-03-02
namespace Index\Http\Routing;
@ -11,7 +11,7 @@ use Attribute;
* Provides an attribute for marking methods in a class as a route.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class HttpRoute extends HandlerAttribute {
class Route extends HandlerAttribute {
/**
* @param string $method
* @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
// Router.php
// Created: 2024-03-28
// Updated: 2025-02-28
// Updated: 2025-03-02
namespace Index\Http\Routing;
@ -16,7 +16,7 @@ use Psr\Http\Server\RequestHandlerInterface;
class Router implements RequestHandlerInterface {
/** @var array{handler: callable, match?: string, prefix?: string}[] */
private array $middlewares = [];
private array $filters = [];
/** @var array<string, array<string, callable>> */
private array $staticRoutes = [];
@ -100,23 +100,27 @@ class Router implements RequestHandlerInterface {
return sprintf('#^%s%s#su', $path, $prefixMatch ? '' : '$');
}
public function use(string $path, callable $handler): void {
$mwInfo = [];
$mwInfo['handler'] = $handler;
public function filter(string $path, callable $handler): void {
//
$filter = [];
$filter['handler'] = $handler;
$prepared = self::preparePath($path, true);
if($prepared === false) {
if(str_ends_with($path, '/'))
$path = substr($path, 0, -1);
$mwInfo['prefix'] = $path;
$filter['prefix'] = $path;
} 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 === '')
throw new InvalidArgumentException('$method may not be empty');
@ -149,22 +153,22 @@ class Router implements RequestHandlerInterface {
if(str_ends_with($path, '/'))
$path = substr($path, 0, -1);
$middlewares = [];
$filters = [];
foreach($this->middlewares as $mwInfo) {
if(array_key_exists('match', $mwInfo)) {
if(preg_match($mwInfo['match'], $path, $args) !== 1)
foreach($this->filters as $filter) {
if(array_key_exists('match', $filter)) {
if(preg_match($filter['match'], $path, $args) !== 1)
continue;
array_shift($args);
} elseif(array_key_exists('prefix', $mwInfo)) {
if($mwInfo['prefix'] !== '' && !str_starts_with($path, $mwInfo['prefix']))
} elseif(array_key_exists('prefix', $filter)) {
if($filter['prefix'] !== '' && !str_starts_with($path, $filter['prefix']))
continue;
$args = [];
} else continue;
$middlewares[] = [$mwInfo['handler'], $args];
$filters[] = [$filter['handler'], $args];
}
$methods = [];
@ -190,17 +194,18 @@ class Router implements RequestHandlerInterface {
$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 {
$request = HttpRequest::castRequest($request);
$response = new HttpResponseBuilder;
$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
$result = $routeInfo->runMiddleware($args);
// always run filters regardless of 404 or 405
$result = $routeInfo->runFilters($args);
if($result === null) {
if(!$routeInfo->hasHandler()) {
if(empty($routeInfo->supportedMethods)) {

View file

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