Changes to route and filter registration flow.
This commit is contained in:
parent
bdb66bc1ba
commit
2b481fee4d
22 changed files with 555 additions and 284 deletions
2
VERSION
2
VERSION
|
@ -1 +1 @@
|
|||
0.2503.20208
|
||||
0.2503.70022
|
||||
|
|
26
composer.lock
generated
26
composer.lock
generated
|
@ -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",
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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 = [];
|
||||
|
||||
|
|
23
src/Http/Routing/ExactPathUriMatcher.php
Normal file
23
src/Http/Routing/ExactPathUriMatcher.php
Normal 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;
|
||||
}
|
||||
}
|
28
src/Http/Routing/ExactRoute.php
Normal file
28
src/Http/Routing/ExactRoute.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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 {}
|
27
src/Http/Routing/FilterAttribute.php
Normal file
27
src/Http/Routing/FilterAttribute.php
Normal 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;
|
||||
}
|
43
src/Http/Routing/FilterInfo.php
Normal file
43
src/Http/Routing/FilterInfo.php
Normal 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);
|
||||
}
|
||||
}
|
34
src/Http/Routing/PatternFilter.php
Normal file
34
src/Http/Routing/PatternFilter.php
Normal 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);
|
||||
}
|
||||
}
|
40
src/Http/Routing/PatternPathUriMatcher.php
Normal file
40
src/Http/Routing/PatternPathUriMatcher.php
Normal 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);
|
||||
}
|
||||
}
|
34
src/Http/Routing/PatternRoute.php
Normal file
34
src/Http/Routing/PatternRoute.php
Normal 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);
|
||||
}
|
||||
}
|
26
src/Http/Routing/PrefixFilter.php
Normal file
26
src/Http/Routing/PrefixFilter.php
Normal 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);
|
||||
}
|
||||
}
|
28
src/Http/Routing/PrefixPathUriMatcher.php
Normal file
28
src/Http/Routing/PrefixPathUriMatcher.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
27
src/Http/Routing/RouteAttribute.php
Normal file
27
src/Http/Routing/RouteAttribute.php
Normal 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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
20
src/Http/Routing/UriMatcher.php
Normal file
20
src/Http/Routing/UriMatcher.php
Normal 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;
|
||||
}
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue