diff --git a/src/Phaseolies/Support/Facades/Route.php b/src/Phaseolies/Support/Facades/Route.php index af4819eb..68ac9d81 100644 --- a/src/Phaseolies/Support/Facades/Route.php +++ b/src/Phaseolies/Support/Facades/Route.php @@ -14,6 +14,14 @@ * @method static \Phaseolies\Support\Router head(string $uri, array|string|callable|null $callback) * @method static \Phaseolies\Support\Router any(string $uri, array|string|callable|null $callback) * @method static void group(array $attributes, \Closure $callback) + * @method static \Phaseolies\Support\Router where(string|array $name, ?string $expression = null) + * @method static \Phaseolies\Support\Router whereNumber(string|array $params) + * @method static \Phaseolies\Support\Router whereAlpha(string|array $params) + * @method static \Phaseolies\Support\Router whereAlphaNumeric(string|array $params) + * @method static \Phaseolies\Support\Router whereUuid(string|array $params) + * @method static \Phaseolies\Support\Router whereUlid(string|array $params) + * @method static \Phaseolies\Support\Router whereIn(string $param, array $values) + * @method static void pattern(string $name, string $expression) * @method static \Phaseolies\Support\Router redirect(string $uri, string $destination, int $status = 302) * @method static void bundle(string $uri, string $controller, array $options = []) * @method static void apiBundle(string $uri, string $controller, array $options = []) diff --git a/src/Phaseolies/Support/Router.php b/src/Phaseolies/Support/Router.php index 8b442104..9600ca93 100644 --- a/src/Phaseolies/Support/Router.php +++ b/src/Phaseolies/Support/Router.php @@ -57,6 +57,19 @@ class Router */ public static array $namedRoutes = []; + /** + * Matches a route parameter, `{id}` or `{id:constraint}`. The constraint may + * use braces for quantifiers (`{id:\d{4}}`) but cannot nest them any deeper. + */ + protected const PARAM_PATTERN = '/\{(\w+)(?::((?:[^{}]|\{[^{}]*\})+))?\}/'; + + /** + * Constraints applied to every route parameter of the given name, see pattern(). + * + * @var array + */ + public static array $patterns = []; + /** * The path of the current route being defined. * @@ -498,9 +511,15 @@ protected function registerAttributeRoute(string $controllerClass, string $metho $rateLimit = $route->rateLimit ?? null; $rateLimitDecay = $route->rateLimitDecay ?? 1; $domain = $route->domain ?? null; + $where = $route->where ?? []; foreach ($httpMethods as $httpMethod) { $this->addRouteNameToAttributesRouting($httpMethod, $path, [$controllerClass, $method], $name, $domain); + + if (!empty($where)) { + $this->where($where); + } + if (!empty($middleware)) { $this->middleware($middleware); } @@ -716,6 +735,9 @@ protected function addRoute(string $method, string $path, $callback, ?string $do : $fullPath; } + $fullPath = $this->applyGlobalPatterns($fullPath); + $this->assertValidInlineConstraints($fullPath); + $entry = $domain ? ['__callback' => $callback, '__domain' => $domain] : $callback; @@ -808,6 +830,274 @@ public function domain(string $domain): self return $this; } + /** + * Constrain a parameter of the last registered route to a regular expression. + * + * @param string|array $name + * @param string|null $expression + * @return self + * @throws \LogicException + */ + public function where(string|array $name, ?string $expression = null): self + { + if ($this->currentRoutePath === null) { + return $this; + } + + $constraints = is_array($name) ? $name : [$name => $expression]; + $path = $this->currentRoutePath; + + foreach ($constraints as $param => $pattern) { + $this->assertValidConstraint((string) $param, (string) $pattern); + + $matched = 0; + $path = preg_replace_callback( + self::PARAM_PATTERN, + function (array $m) use ($param, $pattern, &$matched) { + if ($m[1] !== (string) $param) { + return $m[0]; + } + + $matched++; + + return '{' . $param . ':' . $pattern . '}'; + }, + $path + ); + + if ($matched === 0) { + throw new \LogicException("Route [{$this->currentRoutePath}] has no {{$param}} parameter to constrain."); + } + } + + $this->rekeyCurrentRoute($path); + + return $this; + } + + /** + * Constrain parameters to digits. + * + * @param string|array $params + * @return self + */ + public function whereNumber(string|array $params): self + { + return $this->whereAll($params, '[0-9]+'); + } + + /** + * Constrain parameters to letters. + * + * @param string|array $params + * @return self + */ + public function whereAlpha(string|array $params): self + { + return $this->whereAll($params, '[a-zA-Z]+'); + } + + /** + * Constrain parameters to letters and digits. + * + * @param string|array $params + * @return self + */ + public function whereAlphaNumeric(string|array $params): self + { + return $this->whereAll($params, '[a-zA-Z0-9]+'); + } + + /** + * Constrain parameters to a UUID. + * + * @param string|array $params + * @return self + */ + public function whereUuid(string|array $params): self + { + return $this->whereAll($params, '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'); + } + + /** + * Constrain parameters to a ULID. + * + * @param string|array $params + * @return self + */ + public function whereUlid(string|array $params): self + { + return $this->whereAll($params, '[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}'); + } + + /** + * Constrain a parameter to one of the given values. + * + * @param string $param + * @param array $values + * @return self + */ + public function whereIn(string $param, array $values): self + { + if ($values === []) { + throw new \InvalidArgumentException("whereIn() needs at least one value for {{$param}}."); + } + + return $this->where($param, implode('|', array_map(fn($v) => preg_quote((string) $v, '@'), $values))); + } + + /** + * Constrain every route parameter of this name, on routes registered from now on. + * + * @param string $name + * @param string $expression + * @return void + * @throws \LogicException When the expression is invalid + */ + public function pattern(string $name, string $expression): void + { + $this->assertValidConstraint($name, $expression); + + self::$patterns[$name] = $expression; + } + + /** + * @param string|array $params + * @param string $expression + * @return self + */ + protected function whereAll(string|array $params, string $expression): self + { + return $this->where(array_fill_keys((array) $params, $expression)); + } + + /** + * Embed the global patterns into the parameters that have no constraint of their own. + * + * @param string $path + * @return string + */ + protected function applyGlobalPatterns(string $path): string + { + if (self::$patterns === []) { + return $path; + } + + return preg_replace_callback(self::PARAM_PATTERN, function (array $m) { + if (isset($m[2]) || !isset(self::$patterns[$m[1]])) { + return $m[0]; + } + + return '{' . $m[1] . ':' . self::$patterns[$m[1]] . '}'; + }, $path); + } + + /** + * Fail at definition time, not on the first request, when a route carries a bad constraint. + * + * @param string $path + * @return void + * @throws \LogicException + */ + protected function assertValidInlineConstraints(string $path): void + { + if (!str_contains($path, ':')) { + return; + } + + preg_match_all(self::PARAM_PATTERN, $path, $matches, PREG_SET_ORDER); + + foreach ($matches as $m) { + if (isset($m[2])) { + $this->assertValidConstraint($m[1], $m[2]); + } + } + } + + /** + * @param string $param + * @param string $pattern + * @return void + * @throws \LogicException + */ + protected function assertValidConstraint(string $param, string $pattern): void + { + if ($pattern === '') { + throw new \LogicException("The constraint for {{$param}} cannot be empty."); + } + + // It has to survive being written into the route as {param:pattern} and read back. + $token = '{' . $param . ':' . $pattern . '}'; + + if (!preg_match(self::PARAM_PATTERN, $token, $m) || $m[0] !== $token || ($m[2] ?? null) !== $pattern) { + throw new \LogicException( + "The constraint for {{$param}} is not usable: braces are only allowed as quantifiers, such as {4} or {2,5}." + ); + } + + set_error_handler(static fn() => true); + $valid = preg_match($this->constraintRegex($pattern, true), '') !== false; + restore_error_handler(); + + if (!$valid) { + throw new \LogicException("The constraint for {{$param}} is not a valid regular expression: {$pattern}"); + } + } + + /** + * Wrap a constraint so it can sit inside the router's `@`-delimited expressions. + * + * @param string $pattern + * @param bool $anchored Wrap into a full expression, for validation + * @return string + */ + protected function constraintRegex(string $pattern, bool $anchored = false): string + { + $group = '(?:' . preg_replace('/(?currentRoutePath; + + if ($old === null || $old === $newPath) { + return; + } + + $method = $this->getCurrentRequestMethod(); + + $routes = []; + foreach (self::$routes[$method] ?? [] as $path => $entry) { + $routes[$path === $old ? $newPath : $path] = $entry; + } + self::$routes[$method] = $routes; + + if (isset(self::$routeMiddlewares[$method][$old])) { + self::$routeMiddlewares[$method][$newPath] = array_merge( + self::$routeMiddlewares[$method][$newPath] ?? [], + self::$routeMiddlewares[$method][$old] + ); + unset(self::$routeMiddlewares[$method][$old]); + } + + foreach (self::$namedRoutes as $name => $path) { + if ($path === $old) { + self::$namedRoutes[$name] = $newPath; + } + } + + $this->currentRoutePath = $newPath; + } + /** * Generates a URL for a named route. * @@ -824,7 +1114,7 @@ public function route(string $name, mixed $params = []): ?string $route = self::$namedRoutes[$name]; if (!is_array($params)) { - if (preg_match('/\{(\w+)(:[^}]+)?}/', $route, $matches)) { + if (preg_match(self::PARAM_PATTERN, $route, $matches)) { $params = [$matches[1] => $params]; } else { $params = [$params]; @@ -832,7 +1122,7 @@ public function route(string $name, mixed $params = []): ?string } foreach ($params as $key => $value) { - $route = preg_replace('/\{' . $key . '(:[^}]+)?}/', $value, $route, 1); + $route = preg_replace('/\{' . preg_quote((string) $key, '/') . '(?::(?:[^{}]|\{[^{}]*\})+)?\}/', (string) $value, $route, 1); } return $route; @@ -972,14 +1262,27 @@ protected function matchesDomain(string $domain, $request): bool */ protected function convertRouteToRegex(string $route): string { - $regex = str_replace('/', '\/', $route); + // Lift the parameters out first: a constraint can contain `/` and `*`, + // which must not be touched by the escaping and wildcard steps below. + $params = []; + $route = preg_replace_callback(self::PARAM_PATTERN, function (array $m) use (&$params) { + $params[] = [$m[1], $m[2] ?? null]; + + return "\x00" . (count($params) - 1) . "\x00"; + }, $route); - // Replace {param} with named capture groups - $regex = preg_replace('/\{(\w+)(:[^}]+)?}/', '(?P<$1>[^\/]+)', $regex); + $regex = str_replace('/', '\/', $route); // Replace * with .* for wildcard matching $regex = str_replace('*', '.*', $regex); + // Put the parameters back as named capture groups + $regex = preg_replace_callback('/\x00(\d+)\x00/', function (array $m) use ($params) { + [$name, $constraint] = $params[(int) $m[1]]; + + return '(?P<' . $name . '>' . ($constraint === null ? '[^\/]+' : $this->constraintRegex($constraint)) . ')'; + }, $regex); + return '@^' . $regex . '$@D'; } @@ -993,7 +1296,7 @@ protected function convertRouteToRegex(string $route): string protected function extractRouteParameters(string $route, array $matches): array|false { // Get all named parameters from the route pattern - preg_match_all('/\{(\w+)(:[^}]+)?}/', $route, $paramNames); + preg_match_all(self::PARAM_PATTERN, $route, $paramNames); $params = []; foreach ($paramNames[1] as $name) { @@ -1028,7 +1331,11 @@ public function getCurrentRouteMiddleware($request): ?array $routes = self::$routes[$method] ?? []; foreach ($routes as $route => $callback) { - $routeRegex = "@^" . preg_replace('/\{(\w+)(:[^}]+)?}/', '([^/]+)', $route) . "$@"; + $routeRegex = "@^" . preg_replace_callback( + self::PARAM_PATTERN, + fn(array $m) => '(' . (isset($m[2]) ? $this->constraintRegex($m[2]) : '[^/]+') . ')', + $route + ) . "$@"; if (preg_match($routeRegex, $url)) { return self::$routeMiddlewares[$method][$route] ?? null; } diff --git a/src/Phaseolies/Support/Router/Attributes/Route.php b/src/Phaseolies/Support/Router/Attributes/Route.php index bf8d94b0..d6687dd8 100644 --- a/src/Phaseolies/Support/Router/Attributes/Route.php +++ b/src/Phaseolies/Support/Router/Attributes/Route.php @@ -14,6 +14,7 @@ public function __construct( public array $middleware = [], public ?int $rateLimit = null, public ?int $rateLimitDecay = 1, - public ?string $domain = null + public ?string $domain = null, + public array $where = [] ) {} } diff --git a/tests/Router/RouteConstraintsTest.php b/tests/Router/RouteConstraintsTest.php new file mode 100644 index 00000000..a98d53f3 --- /dev/null +++ b/tests/Router/RouteConstraintsTest.php @@ -0,0 +1,379 @@ +stubMethod; + } + + public function getPath(): string + { + return $this->stubPath; + } + + public function getHost(): string + { + return 'localhost'; + } + + public function getRouteParams(): array + { + return $this->stubRouteParams; + } + + public function setRouteParams(array $params): self + { + $this->stubRouteParams = $params; + + return $this; + } +} + +class ConstraintTestController +{ + public function show() + { + } +} + +class RouteConstraintsTest extends TestCase +{ + private Router $router; + + protected function setUp(): void + { + Container::setInstance(new MockContainer()); + $this->router = new Router(new Gateway()); + $this->resetStatics(); + } + + protected function tearDown(): void + { + $this->resetStatics(); + } + + private function resetStatics(): void + { + $reflection = new \ReflectionClass(Router::class); + + $reflection->getProperty('routes')->setValue(null, []); + $reflection->getProperty('namedRoutes')->setValue(null, []); + $reflection->getProperty('patterns')->setValue(null, []); + $reflection->getProperty('routeMiddlewares')->setValue(null, [ + 'GET' => [], 'POST' => [], 'PUT' => [], 'PATCH' => [], + 'DELETE' => [], 'OPTIONS' => [], 'HEAD' => [], 'ANY' => [], + ]); + } + + private function routes(): array + { + return (new \ReflectionProperty(Router::class, 'routes'))->getValue(); + } + + private function middlewares(): array + { + return (new \ReflectionProperty(Router::class, 'routeMiddlewares'))->getValue(); + } + + /** + * Resolve a GET path to the label the matching route returns, plus the route params + */ + private function dispatch(string $path, string $method = 'GET'): array + { + $request = new ConstraintRequestStub($method, $path); + $callback = $this->router->getCallback($request); + + return [$callback === false ? null : $callback(), $request->getRouteParams()]; + } + + public function testInlineConstraintOnlyMatchesTheDeclaredShape(): void + { + $this->router->get('posts/{id:[0-9]+}', fn() => 'post'); + + $this->assertSame(['post', ['id' => '42']], $this->dispatch('/posts/42')); + $this->assertSame([null, []], $this->dispatch('/posts/abc')); + $this->assertSame([null, []], $this->dispatch('/posts/42abc')); + } + + public function testWhereConstrainsTheLastRoute(): void + { + $this->router->get('posts/{id}', fn() => 'post')->where('id', '[0-9]+'); + + $this->assertSame('post', $this->dispatch('/posts/7')[0]); + $this->assertNull($this->dispatch('/posts/seven')[0]); + $this->assertArrayHasKey('/posts/{id:[0-9]+}', $this->routes()['GET']); + $this->assertArrayNotHasKey('/posts/{id}', $this->routes()['GET']); + } + + public function testWhereAcceptsAMapForSeveralParameters(): void + { + $this->router->get('posts/{id}/{slug}', fn() => 'post')->where(['id' => '[0-9]+', 'slug' => '[a-z-]+']); + + $this->assertSame(['post', ['id' => '3', 'slug' => 'hello-world']], $this->dispatch('/posts/3/hello-world')); + $this->assertNull($this->dispatch('/posts/3/Hello_World')[0]); + $this->assertNull($this->dispatch('/posts/x/hello')[0]); + } + + public function testFailingTheConstraintFallsThroughToTheNextRoute(): void + { + $this->router->get('posts/{id}', fn() => 'by-id')->whereNumber('id'); + $this->router->get('posts/{slug}', fn() => 'by-slug'); + + $this->assertSame(['by-id', ['id' => '12']], $this->dispatch('/posts/12')); + $this->assertSame(['by-slug', ['slug' => 'hello']], $this->dispatch('/posts/hello')); + } + + public function testWhereKeepsTheRouteNameAndMiddlewareInEitherOrder(): void + { + $this->router->get('a/{id}', fn() => 'a')->name('a.show')->middleware('auth')->where('id', '[0-9]+'); + $this->router->get('b/{id}', fn() => 'b')->where('id', '[0-9]+')->name('b.show')->middleware('auth'); + + $this->assertSame('/a/5', $this->router->route('a.show', ['id' => 5])); + $this->assertSame('/b/9', $this->router->route('b.show', 9)); + $this->assertSame(['auth'], $this->middlewares()['GET']['/a/{id:[0-9]+}']); + $this->assertSame(['auth'], $this->middlewares()['GET']['/b/{id:[0-9]+}']); + $this->assertArrayNotHasKey('/a/{id}', $this->middlewares()['GET']); + } + + public function testWhereKeepsTheMatchingOrder(): void + { + $this->router->get('first/{id}', fn() => 'first'); + $this->router->get('second/{id}', fn() => 'second')->whereNumber('id'); + $this->router->get('third/{id}', fn() => 'third'); + + $this->assertSame( + ['/first/{id}', '/second/{id:[0-9]+}', '/third/{id}'], + array_keys($this->routes()['GET']) + ); + } + + public function testCurrentRouteMiddlewareFollowsTheConstraint(): void + { + $this->router->get('items/{id}', fn() => 'by-id')->whereNumber('id')->middleware('numeric'); + $this->router->get('items/{slug}', fn() => 'by-slug')->middleware('named'); + + $this->assertSame(['numeric'], $this->router->getCurrentRouteMiddleware(new ConstraintRequestStub('GET', '/items/12'))); + $this->assertSame(['named'], $this->router->getCurrentRouteMiddleware(new ConstraintRequestStub('GET', '/items/hello'))); + } + + public function testShortcuts(): void + { + $uuid = '123e4567-e89b-12d3-a456-426614174000'; + $ulid = '01ARZ3NDEKTSV4RRFFQ69G5FAV'; + + $this->router->get('n/{v}', fn() => 'n')->whereNumber('v'); + $this->router->get('a/{v}', fn() => 'a')->whereAlpha('v'); + $this->router->get('an/{v}', fn() => 'an')->whereAlphaNumeric('v'); + $this->router->get('u/{v}', fn() => 'u')->whereUuid('v'); + $this->router->get('l/{v}', fn() => 'l')->whereUlid('v'); + $this->router->get('i/{v}', fn() => 'i')->whereIn('v', ['draft', 'published']); + + foreach ( + [ + ['/n/10', 'n'], ['/n/x', null], + ['/a/abc', 'a'], ['/a/ab1', null], + ['/an/ab1', 'an'], ['/an/a-b', null], + ["/u/$uuid", 'u'], ['/u/not-a-uuid', null], + ["/l/$ulid", 'l'], ['/l/short', null], + ['/i/draft', 'i'], ['/i/published', 'i'], ['/i/archived', null], ['/i/draf', null], + ] as [$path, $expected] + ) { + $this->assertSame($expected, $this->dispatch($path)[0], $path); + } + } + + public function testShortcutsAcceptSeveralParameters(): void + { + $this->router->get('x/{a}/{b}', fn() => 'x')->whereNumber(['a', 'b']); + + $this->assertSame('x', $this->dispatch('/x/1/2')[0]); + $this->assertNull($this->dispatch('/x/1/b')[0]); + } + + public function testBracesAreAllowedAsQuantifiers(): void + { + $this->router->get('year/{y:[0-9]{4}}', fn() => 'inline'); + $this->router->get('month/{m}', fn() => 'where')->where('m', '[0-9]{1,2}'); + + $this->assertSame('inline', $this->dispatch('/year/2025')[0]); + $this->assertNull($this->dispatch('/year/25')[0]); + $this->assertSame('where', $this->dispatch('/month/7')[0]); + $this->assertNull($this->dispatch('/month/123')[0]); + } + + public function testConstraintsMayContainSlashesAlternationAtSignsAndStars(): void + { + $this->router->get('files/{path}', fn() => 'files')->where('path', '.+\.(png|jpg)'); + $this->router->get('mail/{who}', fn() => 'mail')->where('who', '[a-z]+@[a-z]+'); + $this->router->get('stars/{v}', fn() => 'stars')->where('v', 'a*b'); + + $this->assertSame('files', $this->dispatch('/files/photo.png')[0]); + $this->assertNull($this->dispatch('/files/photo.gif')[0]); + $this->assertSame('mail', $this->dispatch('/mail/me@host')[0]); + $this->assertNull($this->dispatch('/mail/nobody')[0]); + $this->assertSame('stars', $this->dispatch('/stars/aaab')[0]); + $this->assertNull($this->dispatch('/stars/aaac')[0]); + } + + public function testUnconstrainedAndWildcardRoutesBehaveAsBefore(): void + { + $this->router->get('plain/{id}', fn() => 'plain'); + $this->router->get('docs/*', fn() => 'docs'); + + $this->assertSame(['plain', ['id' => 'anything']], $this->dispatch('/plain/anything')); + $this->assertSame('docs', $this->dispatch('/docs/a/b')[0]); + } + + public function testRegexIsUnchangedForUnconstrainedParameters(): void + { + $method = new \ReflectionMethod(Router::class, 'convertRouteToRegex'); + + $this->assertSame('@^\/users\/(?P[^\/]+)$@D', $method->invoke($this->router, '/users/{id}')); + $this->assertSame('@^\/users\/(?P(?:[0-9]+))$@D', $method->invoke($this->router, '/users/{id:[0-9]+}')); + } + + public function testWhereRejectsUnknownParameters(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('has no {nope} parameter'); + + $this->router->get('posts/{id}', fn() => 'x')->where('nope', '[0-9]+'); + } + + public function testInvalidRegularExpressionsFailAtDefinitionTime(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('not a valid regular expression'); + + $this->router->get('posts/{id}', fn() => 'x')->where('id', '[0-9'); + } + + public function testInvalidInlineConstraintsFailAtDefinitionTime(): void + { + $this->expectException(\LogicException::class); + + $this->router->get('posts/{id:(unclosed}', fn() => 'x'); + } + + public function testNestedBracesAreRejected(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('braces are only allowed as quantifiers'); + + $this->router->get('posts/{id}', fn() => 'x')->where('id', '(?:a{1,{2}})'); + } + + public function testEmptyConstraintIsRejected(): void + { + $this->expectException(\LogicException::class); + + $this->router->get('posts/{id}', fn() => 'x')->where('id', ''); + } + + public function testWhereInNeedsValues(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->router->get('posts/{status}', fn() => 'x')->whereIn('status', []); + } + + public function testGlobalPatternAppliesToRoutesDefinedAfterwards(): void + { + $this->router->get('before/{id}', fn() => 'before'); + + $this->router->pattern('id', '[0-9]+'); + + $this->router->get('after/{id}', fn() => 'after'); + $this->router->get('override/{id:[a-z]+}', fn() => 'override'); + + $this->assertSame('before', $this->dispatch('/before/abc')[0]); + $this->assertSame('after', $this->dispatch('/after/12')[0]); + $this->assertNull($this->dispatch('/after/abc')[0]); + $this->assertSame('override', $this->dispatch('/override/abc')[0], 'a constraint on the route wins'); + $this->assertNull($this->dispatch('/override/12')[0]); + } + + public function testWhereWorksWithDomainInEitherOrderAndInsideGroups(): void + { + $this->router->get('a/{id}', fn() => 'a')->domain('localhost')->whereNumber('id'); + $this->router->get('b/{id}', fn() => 'b')->whereNumber('id')->domain('localhost'); + $this->router->get('c/{id}', fn() => 'c')->whereNumber('id')->domain('other.test'); + + $this->router->group(['prefix' => 'admin'], function () { + $this->router->get('users/{id}', fn() => 'users')->whereNumber('id'); + }); + + $this->assertSame('a', $this->dispatch('/a/1')[0]); + $this->assertNull($this->dispatch('/a/x')[0]); + $this->assertSame('b', $this->dispatch('/b/1')[0]); + $this->assertNull($this->dispatch('/b/x')[0]); + $this->assertNull($this->dispatch('/c/1')[0], 'the domain restriction is kept'); + $this->assertSame('users', $this->dispatch('/admin/users/5')[0]); + $this->assertNull($this->dispatch('/admin/users/x')[0]); + } + + public function testGlobalPatternMustBeValid(): void + { + $this->expectException(\LogicException::class); + + $this->router->pattern('id', '('); + } + + public function testRouteAttributeAcceptsConstraints(): void + { + $register = new \ReflectionMethod(Router::class, 'registerAttributeRoute'); + + $register->invoke($this->router, ConstraintTestController::class, 'show', new RouteAttribute( + uri: 'posts/{id}', + methods: ['GET', 'DELETE'], + name: 'posts.show', + middleware: ['auth'], + where: ['id' => '[0-9]+'] + )); + + $this->assertArrayHasKey('/posts/{id:[0-9]+}', $this->routes()['GET']); + $this->assertArrayHasKey('/posts/{id:[0-9]+}', $this->routes()['DELETE']); + $this->assertSame('/posts/3', $this->router->route('posts.show', 3)); + $this->assertSame(['auth'], $this->middlewares()['GET']['/posts/{id:[0-9]+}']); + $this->assertSame(['auth'], $this->middlewares()['DELETE']['/posts/{id:[0-9]+}']); + } + + public function testGlobalPatternAlsoAppliesToAttributeRoutes(): void + { + $this->router->pattern('id', '[0-9]+'); + + $register = new \ReflectionMethod(Router::class, 'registerAttributeRoute'); + $register->invoke($this->router, ConstraintTestController::class, 'show', new RouteAttribute(uri: 'items/{id}')); + $register->invoke($this->router, ConstraintTestController::class, 'show', new RouteAttribute(uri: 'tags/{id:[a-z]+}')); + + $this->assertArrayHasKey('/items/{id:[0-9]+}', $this->routes()['GET']); + $this->assertArrayHasKey('/tags/{id:[a-z]+}', $this->routes()['GET'], 'a constraint on the route wins'); + } + + public function testConstraintedRouteKeysSurviveTheRouteCache(): void + { + $this->router->get('posts/{id}', [ConstraintTestController::class, 'show'])->where('id', '[0-9]{1,3}|new'); + + $cached = eval('return ' . var_export($this->routes(), true) . ';'); + + $this->assertSame(array_keys($this->routes()['GET']), array_keys($cached['GET'])); + $this->assertArrayHasKey('/posts/{id:[0-9]{1,3}|new}', $cached['GET']); + } +}