Changes to route and filter registration flow.

This commit is contained in:
flash 2025-03-07 00:25:00 +00:00
parent bdb66bc1ba
commit 2b481fee4d
Signed by: flash
GPG key ID: 2C9C2C574D47FE3E
22 changed files with 555 additions and 284 deletions

View file

@ -1 +1 @@
0.2503.20208
0.2503.70022

26
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "2082a243d3444ca54afbd0acb80e700d",
"content-hash": "2bf36789b391bbd2d2d287646ce382a9",
"packages": [
{
"name": "psr/http-message",
@ -976,16 +976,16 @@
},
{
"name": "phpstan/phpstan",
"version": "2.1.6",
"version": "2.1.7",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan.git",
"reference": "6eaec7c6c9e90dcfe46ad1e1ffa5171e2dab641c"
"reference": "12567f91a74036d56ba0af6d56c8e73ac0e8d850"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/6eaec7c6c9e90dcfe46ad1e1ffa5171e2dab641c",
"reference": "6eaec7c6c9e90dcfe46ad1e1ffa5171e2dab641c",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/12567f91a74036d56ba0af6d56c8e73ac0e8d850",
"reference": "12567f91a74036d56ba0af6d56c8e73ac0e8d850",
"shasum": ""
},
"require": {
@ -1030,7 +1030,7 @@
"type": "github"
}
],
"time": "2025-02-19T15:46:42+00:00"
"time": "2025-03-05T13:43:55+00:00"
},
{
"name": "phpunit/php-code-coverage",
@ -1356,16 +1356,16 @@
},
{
"name": "phpunit/phpunit",
"version": "12.0.5",
"version": "12.0.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "0f177d7316ba155d36337c3811b11993b54dae32"
"reference": "a1c7e1e0466b6774de8edd72d91bc82400f7dfc0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0f177d7316ba155d36337c3811b11993b54dae32",
"reference": "0f177d7316ba155d36337c3811b11993b54dae32",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a1c7e1e0466b6774de8edd72d91bc82400f7dfc0",
"reference": "a1c7e1e0466b6774de8edd72d91bc82400f7dfc0",
"shasum": ""
},
"require": {
@ -1379,7 +1379,7 @@
"phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1",
"php": ">=8.3",
"phpunit/php-code-coverage": "^12.0.3",
"phpunit/php-code-coverage": "^12.0.4",
"phpunit/php-file-iterator": "^6.0.0",
"phpunit/php-invoker": "^6.0.0",
"phpunit/php-text-template": "^5.0.0",
@ -1433,7 +1433,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.0.5"
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.0.6"
},
"funding": [
{
@ -1449,7 +1449,7 @@
"type": "tidelift"
}
],
"time": "2025-02-25T06:13:04+00:00"
"time": "2025-03-05T07:38:55+00:00"
},
{
"name": "sebastian/cli-parser",

View file

@ -1,7 +1,7 @@
<?php
// HttpRequestBuilder.php
// Created: 2022-02-08
// Updated: 2025-02-28
// Updated: 2025-03-07
namespace Index\Http;
@ -10,7 +10,7 @@ use InvalidArgumentException;
/**
* Represents a HTTP request message builder.
*/
class HttpRequestBuilder extends HttpMessageBuilder {
final class HttpRequestBuilder extends HttpMessageBuilder {
/**
* Origin remote address.
*/

View file

@ -1,7 +1,7 @@
<?php
// HttpResponseBuilder.php
// Created: 2022-02-08
// Updated: 2025-02-28
// Updated: 2025-03-07
namespace Index\Http;
@ -12,7 +12,7 @@ use Index\Performance\Timings;
/**
* Represents a HTTP response message builder.
*/
class HttpResponseBuilder extends HttpMessageBuilder {
final class HttpResponseBuilder extends HttpMessageBuilder {
/** @var string[] */
private array $vary = [];

View file

@ -0,0 +1,23 @@
<?php
// ExactPathUriMatcher.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing;
use Psr\Http\Message\UriInterface;
/**
* Provides a matcher for a static path.
*/
class ExactPathUriMatcher implements UriMatcher {
/**
* @param string $path Path to match against.
*/
public function __construct(
private string $path
) {}
public function match(UriInterface $uri): array|bool {
return $uri->getPath() === $this->path;
}
}

View file

@ -0,0 +1,28 @@
<?php
// ExactRoute.php
// Created: 2024-03-28
// Updated: 2025-03-07
namespace Index\Http\Routing;
use Attribute;
use Closure;
/**
* Marks a method as a prefix filter.
*/
#[Attribute(RouteAttribute::FLAGS)]
class ExactRoute extends RouteAttribute {
/**
* @param string $method HTTP Method.
* @param string $path URI path.
*/
public function __construct(
private string $method,
private string $path,
) {}
public function createInstance(Closure $handler): RouteInfo {
return RouteInfo::exact($this->method, $this->path, $handler);
}
}

View file

@ -1,14 +0,0 @@
<?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

@ -0,0 +1,27 @@
<?php
// FilterAttribute.php
// Created: 2024-03-28
// Updated: 2025-03-07
namespace Index\Http\Routing;
use Attribute;
use Closure;
/**
* Provides an attribute for marking methods in a class as a filter.
*/
abstract class FilterAttribute extends RouterAttribute {
/**
* Flags to set for the Attribute attribute.
*/
public const int FLAGS = Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE;
/**
* Creates a FilterInfo instance.
*
* @param Closure $handler Handler method.
* @return FilterInfo
*/
abstract public function createInstance(Closure $handler): FilterInfo;
}

View file

@ -0,0 +1,43 @@
<?php
// FilterInfo.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing;
use Closure;
/**
* Information of a filter.
*/
class FilterInfo {
/**
* @param UriMatcher $matcher URI matcher to use.
* @param Closure $handler Handler for this filter.
*/
public function __construct(
public private(set) UriMatcher $matcher,
public private(set) Closure $handler,
) {}
/**
* Creates FilterInfo instance using PrefixPathUriMatcher.
*
* @param string $path Path to match against.
* @param Closure $handler Handler for this route.
* @return FilterInfo
*/
public static function prefix(string $path, Closure $handler): FilterInfo {
return new FilterInfo(new PrefixPathUriMatcher($path), $handler);
}
/**
* Creates FilterInfo instance using PatternPathUriMatcher.
*
* @param string $pattern Path regex pattern to match against.
* @param Closure $handler Handler for this route.
* @return FilterInfo
*/
public static function pattern(string $pattern, Closure $handler): FilterInfo {
return new FilterInfo(new PatternPathUriMatcher($pattern), $handler);
}
}

View file

@ -0,0 +1,34 @@
<?php
// PatternFilter.php
// Created: 2024-03-28
// Updated: 2025-03-07
namespace Index\Http\Routing;
use Attribute;
use Closure;
/**
* Marks a method as a pattern filter.
*/
#[Attribute(FilterAttribute::FLAGS)]
class PatternFilter extends FilterAttribute {
private string $pattern;
/**
* @param string $pattern URI path pattern.
* @param bool $raw If true, $pattern is taken as is, if false, $pattern is prefixed with #^ and suffixed with #u or $#uD
* @param bool $prefix Only has effect if $raw is false. If true is #u appended to $pattern, otherwise $#uD.
*/
public function __construct(
string $pattern,
bool $raw = false,
bool $prefix = true,
) {
$this->pattern = $raw ? $pattern : sprintf('#^%s%s', $pattern, $prefix ? '#u' : '$#uD');
}
public function createInstance(Closure $handler): FilterInfo {
return FilterInfo::pattern($this->pattern, $handler);
}
}

View file

@ -0,0 +1,40 @@
<?php
// PatternPathUriMatcher.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing;
use Psr\Http\Message\UriInterface;
/**
* Provides a matcher for a regular expression path.
*/
class PatternPathUriMatcher implements UriMatcher {
/**
* @param string $pattern Path pattern to match against.
*/
public function __construct(
private string $pattern
) {}
public function match(UriInterface $uri): array|bool {
$result = preg_match($this->pattern, $uri->getPath(), $matches, PREG_UNMATCHED_AS_NULL);
if($result !== 1)
return false;
$numbered = [];
$named = [];
$keys = array_keys($matches);
// discard first entry
array_shift($keys);
foreach($keys as $key)
if(is_int($key))
$numbered[] = $matches[$key];
else
$named[$key] = $matches[$key];
return array_merge($numbered, $named);
}
}

View file

@ -0,0 +1,34 @@
<?php
// PatternRoute.php
// Created: 2024-03-28
// Updated: 2025-03-07
namespace Index\Http\Routing;
use Attribute;
use Closure;
/**
* Marks a method as a pattern route.
*/
#[Attribute(RouteAttribute::FLAGS)]
class PatternRoute extends RouteAttribute {
private string $pattern;
/**
* @param string $method HTTP Method.
* @param string $pattern URI path pattern.
* @param bool $raw If true, $pattern is taken as is, if false, $pattern is prefixed with #^ and suffixed with $#uD
*/
public function __construct(
private string $method,
string $pattern,
bool $raw = false
) {
$this->pattern = $raw ? $pattern : sprintf('#^%s$#uD', $pattern);
}
public function createInstance(Closure $handler): RouteInfo {
return RouteInfo::pattern($this->method, $this->pattern, $handler);
}
}

View file

@ -0,0 +1,26 @@
<?php
// PrefixFilter.php
// Created: 2024-03-28
// Updated: 2025-03-07
namespace Index\Http\Routing;
use Attribute;
use Closure;
/**
* Provides an attribute for marking methods in a class as a route.
*/
#[Attribute(FilterAttribute::FLAGS)]
class PrefixFilter extends FilterAttribute {
/**
* @param string $prefix URI prefix.
*/
public function __construct(
private string $prefix
) {}
public function createInstance(Closure $handler): FilterInfo {
return FilterInfo::prefix($this->prefix, $handler);
}
}

View file

@ -0,0 +1,28 @@
<?php
// PrefixPathUriMatcher.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing;
use Psr\Http\Message\UriInterface;
/**
* Provides a matcher for a prefix path.
*/
class PrefixPathUriMatcher implements UriMatcher {
private string $prefix;
private string $exact;
/**
* @param string $prefix Prefix to match against.
*/
public function __construct(string $prefix) {
$this->exact = rtrim($prefix, '/');
$this->prefix = $this->exact . '/';
}
public function match(UriInterface $uri): array|bool {
$path = $uri->getPath();
return $this->exact === $path || str_starts_with($path, $this->prefix);
}
}

View file

@ -1,25 +0,0 @@
<?php
// Route.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 route.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class Route extends HandlerAttribute {
/**
* @param string $method
* @param string $path
*/
public function __construct(
public private(set) string $method,
string $path
) {
parent::__construct($path);
}
}

View file

@ -0,0 +1,27 @@
<?php
// RouteAttribute.php
// Created: 2024-03-28
// Updated: 2025-03-07
namespace Index\Http\Routing;
use Attribute;
use Closure;
/**
* Provides an attribute for marking methods in a class as a route.
*/
abstract class RouteAttribute extends RouterAttribute {
/**
* Flags to set for the Attribute attribute.
*/
public const int FLAGS = Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE;
/**
* Creates a RouteInfo instance.
*
* @param Closure $handler Handler method.
* @return RouteInfo
*/
abstract public function createInstance(Closure $handler): RouteInfo;
}

View file

@ -1,16 +1,16 @@
<?php
// RouteHandlerCommon.php
// Created: 2024-03-28
// Updated: 2025-01-18
// Updated: 2025-03-07
namespace Index\Http\Routing;
/**
* Provides an implementation of RouteHandler::registerRoutes that uses the attributes.
* For more advanced use, everything can be use'd separately and HandlerAttribute::register called manually.
* For more advanced use, everything can be use'd separately and RouterAttribute::register called manually.
*/
trait RouteHandlerCommon {
public function registerRoutes(Router $router): void {
HandlerAttribute::register($router, $this);
RouterAttribute::register($router, $this);
}
}

View file

@ -1,18 +1,17 @@
<?php
// RouteInfo.php
// Created: 2025-03-02
// Updated: 2025-03-02
// Updated: 2025-03-07
namespace Index\Http\Routing;
use Closure;
use InvalidArgumentException;
use RuntimeException;
use Psr\Http\Message\UriInterface;
/**
* Information of a route.
*/
abstract class RouteInfo {
class RouteInfo {
/** HTTP method this route serves. */
public private(set) string $method;
@ -25,15 +24,19 @@ abstract class RouteInfo {
/**
* @param string $method HTTP method this route serves.
* @param callable(): void $handler Handler for this route.
* @throws InvalidArgumentException If $handler is not a callable.
* @param UriMatcher $matcher URI matcher to use.
* @param Closure $handler Handler for this route.
* @throws InvalidArgumentException If $method is empty or starts/ends with whitespace.
*/
public function __construct(
string $method,
public private(set) $handler,
public private(set) UriMatcher $matcher,
public private(set) Closure $handler,
) {
if(!is_callable($handler))
throw new InvalidArgumentException('$handler must be callable');
if($method === '')
throw new InvalidArgumentException('$method may not be empty');
if(trim($method) !== $method)
throw new InvalidArgumentException('$method may start or end with whitespace');
$this->method = strtoupper($method);
}
@ -53,10 +56,26 @@ abstract class RouteInfo {
}
/**
* Matches a URI to this route.
* Creates RouteInfo instance using ExactPathUriMatcher.
*
* @param UriInterface $uri URI to match against.
* @return bool
* @param string $method HTTP method this route serves.
* @param string $path Path to match against.
* @param Closure $handler Handler for this route.
* @return RouteInfo
*/
abstract public function match(UriInterface $uri): bool;
public static function exact(string $method, string $path, Closure $handler): RouteInfo {
return new RouteInfo($method, new ExactPathUriMatcher($path), $handler);
}
/**
* Creates RouteInfo instance using PatternPathUriMatcher.
*
* @param string $method HTTP method this route serves.
* @param string $pattern Path regex pattern to match against.
* @param Closure $handler Handler for this route.
* @return RouteInfo
*/
public static function pattern(string $method, string $pattern, Closure $handler): RouteInfo {
return new RouteInfo($method, new PatternPathUriMatcher($pattern), $handler);
}
}

View file

@ -1,7 +1,7 @@
<?php
// Router.php
// Created: 2024-03-28
// Updated: 2025-03-02
// Updated: 2025-03-07
namespace Index\Http\Routing;
@ -11,18 +11,15 @@ use Index\Http\{
HtmlHttpErrorHandler,HttpContentHandler,HttpErrorHandler,HttpResponse,
HttpResponseBuilder,HttpRequest,HttpStream,PlainHttpErrorHandler,StringHttpContent
};
use Psr\Http\Message\{ResponseInterface,ServerRequestInterface};
use Psr\Http\Message\{ResponseInterface,ServerRequestInterface,UriInterface};
use Psr\Http\Server\RequestHandlerInterface;
class Router implements RequestHandlerInterface {
/** @var array{handler: callable, match?: string, prefix?: string}[] */
/** @var FilterInfo[] */
private array $filters = [];
/** @var array<string, array<string, callable>> */
private array $staticRoutes = [];
/** @var array<string, array<string, callable>> */
private array $dynamicRoutes = [];
/** @var RouteInfo[] */
private array $routes = [];
private string $charSetValue;
@ -88,105 +85,91 @@ class Router implements RequestHandlerInterface {
$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 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);
$filter['prefix'] = $path;
} else
$filter['match'] = $prepared;
/**
* Registers a filter.
*
* @param FilterInfo $filter FilterInfo instance to register.
*/
public function filter(FilterInfo $filter): void {
$this->filters[] = $filter;
}
public function route(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];
}
/**
* Registers a route.
*
* @param RouteInfo $route RouteInfo instance to register.
*/
public function route(RouteInfo $route): void {
$this->routes[] = $route;
}
/**
* 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);
}
public function resolve(string $method, string $path): ResolvedRouteInfo {
public function handle(ServerRequestInterface $request): ResponseInterface {
$request = HttpRequest::castRequest($request);
$builder = new HttpResponseBuilder;
$args = [$builder, $request];
$path = $request->uri->getPath();
if(str_ends_with($path, '/'))
$path = substr($path, 0, -1);
$filters = [];
foreach($this->filters as $filter) {
if(array_key_exists('match', $filter)) {
if(preg_match($filter['match'], $path, $args) !== 1)
continue;
foreach($this->filters as $filterInfo) {
$match = $filterInfo->matcher->match($request->uri);
if($match !== false) {
$info = ['info' => $filterInfo];
if(is_array($match))
$info['matches'] = $match;
//$filters[] = $info;
array_shift($args);
} elseif(array_key_exists('prefix', $filter)) {
if($filter['prefix'] !== '' && !str_starts_with($path, $filter['prefix']))
continue;
$args = [];
} else continue;
$filters[] = [$filter['handler'], $args];
$filters[] = [$filterInfo->handler, is_array($match) ? $match : []];
}
}
$methods = [];
$methods = []; // legacy
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)];
$routes = [];
foreach($this->routes as $routeInfo) {
$match = $routeInfo->matcher->match($request->uri);
if($match !== false) {
$info = ['info' => $routeInfo];
if(is_array($match))
$info['matches'] = $match;
$routes[] = $info;
$methods[$routeInfo->method] = [$routeInfo->handler, is_array($match) ? $match : []];
}
}
$method = strtoupper($method);
$method = strtoupper($request->method);
if(array_key_exists($method, $methods)) {
[$handler, $args] = $methods[$method];
} elseif($method === 'OPTIONS') {
// this should be implemented differently and also handle the CORS headers
$supportedMethods = array_keys($methods);
$supportedMethods[] = 'OPTIONS';
if(in_array('GET', $supportedMethods))
$supportedMethods[] = 'HEAD';
$supportedMethods = array_unique($supportedMethods);
sort($supportedMethods);
$args[] = $supportedMethods;
$handler = function(HttpResponseBuilder $response, HttpRequest $request, array $methods) {
$response->statusCode = 204;
$response->setHeader('Allow', implode(', ', $methods));
};
} elseif($method === 'HEAD' && array_key_exists('GET', $methods)) {
[$handler, $args] = $methods['GET'];
} else {
@ -194,15 +177,7 @@ class Router implements RequestHandlerInterface {
$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->method, $request->uri->path);
$routeInfo = new ResolvedRouteInfo($filters, array_keys($methods), $handler, $args);
// always run filters regardless of 404 or 405
$result = $routeInfo->runFilters($args);
@ -212,7 +187,7 @@ class Router implements RequestHandlerInterface {
$result = 404;
} else {
$result = 405;
$response->setHeader('Allow', implode(', ', $routeInfo->supportedMethods));
$builder->setHeader('Allow', implode(', ', $routeInfo->supportedMethods));
}
} else
$result = $routeInfo->dispatch($args);
@ -220,15 +195,15 @@ class Router implements RequestHandlerInterface {
if(is_int($result)) {
if($result >= 100 && $result < 600)
$this->writeErrorPage($response, $request, $result);
} elseif(empty($response->content)) {
$this->writeErrorPage($builder, $request, $result);
} elseif($builder->body === null) {
if(is_scalar($result)) {
$result = (string)$result;
$response->body = HttpStream::createStream($result);
$builder->body = HttpStream::createStream($result);
if(!$response->hasContentType()) {
if(!$builder->hasContentType()) {
if(strtolower(substr($result, 0, 14)) === '<!doctype html')
$response->setTypeHtml($this->charSet);
$builder->setTypeHtml($this->charSet);
else {
$charset = mb_detect_encoding($result);
if($charset !== false)
@ -236,15 +211,18 @@ class Router implements RequestHandlerInterface {
$charset = $charset === false ? 'utf-8' : strtolower($charset);
if(strtolower(substr($result, 0, 5)) === '<?xml')
$response->setTypeXml($charset);
$builder->setTypeXml($charset);
else
$response->setTypePlain($charset);
$builder->setTypePlain($charset);
}
}
}
}
return $response->toResponse();
if($request->method === 'HEAD' && $builder->body !== null)
$builder->body = null;
return $builder->toResponse();
}
/**
@ -253,8 +231,7 @@ class Router implements RequestHandlerInterface {
* @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');
self::output($this->handle($request ?? HttpRequest::fromRequest()));
}
/**
@ -277,9 +254,8 @@ class Router implements RequestHandlerInterface {
* 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 {
public static function output(ResponseInterface $response): void {
header(sprintf(
'HTTP/%s %03d %s',
$response->getProtocolVersion(),
@ -291,10 +267,8 @@ class Router implements RequestHandlerInterface {
foreach($lines as $line)
header(sprintf('%s: %s', $name, $line));
if($includeBody) {
$stream = $response->getBody();
if($stream->isReadable())
echo (string)$stream;
}
$stream = $response->getBody();
if($stream->isReadable())
echo (string)$stream;
}
}

View file

@ -1,7 +1,7 @@
<?php
// HandlerAttribute.php
// RouterAttribute.php
// Created: 2024-03-28
// Updated: 2025-03-02
// Updated: 2025-03-07
namespace Index\Http\Routing;
@ -11,14 +11,7 @@ use ReflectionObject;
/**
* Provides base for attributes that mark methods in a class as handlers.
*/
abstract class HandlerAttribute {
/**
* @param string $path Target path.
*/
public function __construct(
public private(set) string $path
) {}
abstract class RouterAttribute {
/**
* Reads attributes from methods in a RouteHandler instance and registers them to a given Router instance.
*
@ -30,16 +23,16 @@ abstract class HandlerAttribute {
$methodInfos = $objectInfo->getMethods();
foreach($methodInfos as $methodInfo) {
$attrInfos = $methodInfo->getAttributes(HandlerAttribute::class, ReflectionAttribute::IS_INSTANCEOF);
$attrInfos = $methodInfo->getAttributes(RouterAttribute::class, ReflectionAttribute::IS_INSTANCEOF);
foreach($attrInfos as $attrInfo) {
$handlerInfo = $attrInfo->newInstance();
$closure = $methodInfo->getClosure($methodInfo->isStatic() ? null : $handler);
if($handlerInfo instanceof Route)
$router->route($handlerInfo->method, $handlerInfo->path, $closure);
else
$router->filter($handlerInfo->path, $closure);
if($handlerInfo instanceof RouteAttribute)
$router->route($handlerInfo->createInstance($closure));
elseif($handlerInfo instanceof FilterAttribute)
$router->filter($handlerInfo->createInstance($closure));
}
}
}

View file

@ -0,0 +1,20 @@
<?php
// UriMatcher.php
// Created: 2025-03-07
// Updated: 2025-03-07
namespace Index\Http\Routing;
use Psr\Http\Message\UriInterface;
/**
* Provides a common interface for URI matching.
*/
interface UriMatcher {
/**
* Match a URI.
*
* @param UriInterface $uri URI to match.
* @return array<int|string, mixed>|bool false if not a math, true or array with matched parameters if match.
*/
public function match(UriInterface $uri): array|bool;
}

View file

@ -1,75 +1,67 @@
<?php
// RouterTest.php
// Created: 2022-01-20
// Updated: 2025-03-02
// Updated: 2025-03-07
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use Index\Http\{HttpHeaders,HttpRequest,HttpUri,NullStream};
use Index\Http\Routing\{Filter,Route,Router,RouteHandler,RouteHandlerCommon};
use Index\Http\{HttpHeaders,HttpResponseBuilder,HttpRequest,HttpUri,NullStream};
use Index\Http\Routing\{
ExactRoute,
FilterInfo,
PatternRoute,PrefixFilter,
RouteInfo,RouteHandler,RouteHandlerCommon,Router
};
/**
* 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(Filter::class)]
#[CoversClass(Route::class)]
#[CoversClass(Router::class)]
#[CoversClass(RouteInfo::class)]
#[CoversClass(PrefixFilter::class)]
#[CoversClass(ExactRoute::class)]
#[CoversClass(RouteHandler::class)]
#[CoversClass(RouteHandlerCommon::class)]
#[CoversClass(PatternRoute::class)]
#[CoversClass(FilterInfo::class)]
final class RouterTest extends TestCase {
public function testRouter(): void {
$router1 = new Router;
$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');
$router1->route(RouteInfo::exact('GET', '/', fn() => 'get'));
$router1->route(RouteInfo::exact('POST', '/', fn() => 'post'));
$router1->route(RouteInfo::exact('DELETE', '/', fn() => 'delete'));
$router1->route(RouteInfo::exact('PATCH', '/', fn() => 'patch'));
$router1->route(RouteInfo::exact('PUT', '/', fn() => 'put'));
$router1->route(RouteInfo::exact('CUSTOM', '/', fn() => 'wacky'));
$this->assertEquals('get', $router1->resolve('GET', '/')->dispatch([]));
$this->assertEquals('wacky', $router1->resolve('CUSTOM', '/')->dispatch([]));
$this->assertEquals('get', (string)$router1->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/'), [], []))->getBody());
$this->assertEquals('wacky', (string)$router1->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'CUSTOM', HttpUri::createUri('/'), [], []))->getBody());
$router1->filter('/', function() { /* this one intentionally does nothing */ });
$router1->filter(FilterInfo::prefix('/', function() { /* this one intentionally does nothing */ }));
// registration order should matter
$router1->filter('/deep', fn() => 'deep');
$router1->filter(FilterInfo::prefix('/deep', fn() => 'deep'));
$postRoot = $router1->resolve('POST', '/');
$this->assertNull($postRoot->runFilters([]));
$this->assertEquals('post', $postRoot->dispatch([]));
$router1->filter(FilterInfo::pattern('#^/user/([A-Za-z0-9]+)/below#u', fn(string $user) => 'warioware below ' . $user));
$this->assertEquals('deep', $router1->resolve('GET', '/deep/nothing')->runFilters([]));
$router1->route(RouteInfo::exact('GET', '/user/static', fn() => 'the static one'));
$router1->route(RouteInfo::exact('GET', '/user/static/below', fn() => 'below the static one'));
$router1->route(RouteInfo::pattern('GET', '#^/user/([A-Za-z0-9]+)$#uD', fn(string $user) => $user));
$router1->route(RouteInfo::pattern('GET', '#^/user/([A-Za-z0-9]+)/below$#uD', fn(string $user) => 'below ' . $user));
$router1->filter('/user/([A-Za-z0-9]+)/below', fn(string $user) => 'warioware 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->runFilters([]));
$this->assertEquals('below flashwave', $getWariowareBelowFlashwave->dispatch([]));
$this->assertEquals('warioware below static', (string)$router1->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/user/static/below'), [], []))->getBody());
$router2 = new Router;
$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');
$router2->filter(FilterInfo::prefix('/', fn() => 'meow'));
$router2->route(RouteInfo::exact('GET', '/rules', fn() => 'rules page'));
$router2->route(RouteInfo::exact('GET', '/contact', fn() => 'contact page'));
$router2->route(RouteInfo::exact('GET', '/25252', fn() => 'numeric test'));
$getRules = $router2->resolve('GET', '/rules');
$this->assertEquals('meow', $getRules->runFilters([]));
$this->assertEquals('rules page', $getRules->dispatch([]));
$get25252 = $router2->resolve('GET', '/25252');
$this->assertEquals('meow', $get25252->runFilters([]));
$this->assertEquals('numeric test', $get25252->dispatch([]));
$this->assertEquals('meow', (string)$router2->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/rules'), [], []))->getBody());
}
public function testAttribute(): void {
@ -77,90 +69,92 @@ final class RouterTest extends TestCase {
$handler = new class implements RouteHandler {
use RouteHandlerCommon;
#[Route('GET', '/')]
#[ExactRoute('GET', '/')]
public function getIndex(): string {
return 'index';
}
#[Route('POST', '/avatar')]
#[ExactRoute('POST', '/avatar')]
public function postAvatar(): string {
return 'avatar';
}
#[Route('PUT', '/static')]
#[ExactRoute('PUT', '/static')]
public static function putStatic(): string {
return 'static';
}
#[Route('GET', '/meow')]
#[Route('POST', '/meow')]
#[ExactRoute('GET', '/meow')]
#[ExactRoute('POST', '/meow')]
public function multiple(): string {
return 'meow';
}
#[Filter('/filter')]
#[PrefixFilter('/filter')]
public function useFilter(): string {
return 'this intercepts';
}
#[Route('GET', '/filter')]
#[ExactRoute('GET', '/filter')]
public function getFilter(): string {
return 'this is intercepted';
}
#[PatternRoute('GET', '/profile/([A-Za-z0-9]+)')]
public function getPattern(string $beans): string {
return sprintf('profile of %s', $beans);
}
#[PatternRoute('GET', '#^/profile-but-raw/([A-Za-z0-9]+)$#uD', raw: true)]
public function getPatternRaw(string $beans): string {
return sprintf('still the profile of %s', $beans);
}
public function hasNoAttr(): string {
return 'not a route';
}
};
$router->register($handler);
$this->assertFalse($router->resolve('GET', '/soap')->hasHandler());
$patchAvatar = $router->resolve('PATCH', '/avatar');
$this->assertFalse($patchAvatar->hasHandler());
$this->assertTrue(!empty($patchAvatar->supportedMethods));
$this->assertEquals(['POST'], $patchAvatar->supportedMethods);
$this->assertEquals('index', $router->resolve('GET', '/')->dispatch([]));
$this->assertEquals('avatar', $router->resolve('POST', '/avatar')->dispatch([]));
$this->assertEquals('static', $router->resolve('PUT', '/static')->dispatch([]));
$this->assertEquals('meow', $router->resolve('GET', '/meow')->dispatch([]));
$this->assertEquals('meow', $router->resolve('POST', '/meow')->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([]));
$this->assertEquals('index', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/'), [], []))->getBody());
$this->assertEquals('avatar', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'POST', HttpUri::createUri('/avatar'), [], []))->getBody());
$this->assertEquals('static', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'PUT', HttpUri::createUri('/static'), [], []))->getBody());
$this->assertEquals('meow', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/meow'), [], []))->getBody());
$this->assertEquals('meow', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'POST', HttpUri::createUri('/meow'), [], []))->getBody());
$this->assertEquals('profile of Cool134', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/profile/Cool134'), [], []))->getBody());
$this->assertEquals('still the profile of Cool134', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/profile-but-raw/Cool134'), [], []))->getBody());
}
public function testEEPROMSituation(): void {
$router = new Router;
$router->route(RouteInfo::pattern('GET', '#^/uploads/([A-Za-z0-9\-_]+)(?:\.(t|json))?$#uD', fn(string $id) => "Get {$id}"));
$router->route(RouteInfo::pattern('DELETE', '#^/uploads/([A-Za-z0-9\-_]+)$#uD', fn(string $id) => "Delete {$id}"));
$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);
// make sure both GET and DELETE are able to execute with a different pattern
$this->assertEquals('Get AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/uploads/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'), [], []))->getBody());
$this->assertEquals('Delete BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'DELETE', HttpUri::createUri('/uploads/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'), [], []))->getBody());
}
public function testFilterInterceptionOnRoot(): void {
$router = new Router;
$router->filter('/', fn() => 'expected');
$router->route('GET', '/', fn() => 'unexpected');
$router->route('GET', '/test', fn() => 'also unexpected');
$router->filter(FilterInfo::prefix('/', fn() => 'expected'));
$router->route(RouteInfo::exact('GET', '/', fn() => 'unexpected'));
$router->route(RouteInfo::exact('GET', '/test', fn() => 'also unexpected'));
ob_start();
$router->dispatch(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/'), [], []));
$this->assertEquals('expected', ob_get_clean());
$this->assertEquals('expected', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/'), [], []))->getBody());
$this->assertEquals('expected', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/test'), [], []))->getBody());
$this->assertEquals('expected', (string)$router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/error'), [], []))->getBody());
}
ob_start();
$router->dispatch(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/test'), [], []));
$this->assertEquals('expected', ob_get_clean());
public function testDefaultOptionsImplementation(): void {
$router = new Router;
$router->route(RouteInfo::exact('GET', '/test', fn() => 'get'));
$router->route(RouteInfo::exact('POST', '/test', fn() => 'post'));
$router->route(RouteInfo::exact('PATCH', '/test', fn() => 'patch'));
ob_start();
$router->dispatch(new HttpRequest('1.1', [], NullStream::instance(), [], 'GET', HttpUri::createUri('/error'), [], []));
$this->assertEquals('expected', ob_get_clean());
$response = $router->handle(new HttpRequest('1.1', [], NullStream::instance(), [], 'OPTIONS', HttpUri::createUri('/test'), [], []));
$this->assertEquals(204, $response->getStatusCode());
$this->assertEquals('GET, HEAD, OPTIONS, PATCH, POST', $response->getHeaderLine('Allow'));
}
}