From 9680fda4dd067e633338d59946653e6184ec611e Mon Sep 17 00:00:00 2001 From: Jagepard Date: Fri, 14 Aug 2026 11:23:00 +0300 Subject: [PATCH] feat(router): support optional URL segments and regex parameters --- src/Router.php | 46 +++++---- src/Traits/RouterAnnotationTrait.php | 54 ++++++++-- tests/RouterAnnotationTraitTest.php | 147 +++++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 30 deletions(-) diff --git a/src/Router.php b/src/Router.php index 7762fef..d814154 100755 --- a/src/Router.php +++ b/src/Router.php @@ -98,34 +98,36 @@ private function handleRequestMethod(): void */ private function handlePattern(array $route, array $request): array { - $uri = []; - $params = null; - $subject = explode('/', ltrim($route['url'], '/')); - $count = count($subject); - - for ($i = 0; $i < $count; $i++) { - if (preg_match("/^:[a-zA-Z0-9_-]+$/", $subject[$i]) > 0 && array_key_exists($i, $request)) { - $value = $request[$i]; - $uri[] = $value; - $params[] = $value; + $uri = []; + $params = null; + + foreach (explode('/', ltrim($route['url'], '/')) as $i => $segment) { + if (!str_starts_with($segment, ':')) { + $uri[] = $segment; continue; } - if (preg_match("/^:([\\[\\]\\\\:a-zA-Z0-9_\\-{,}]+)$/", $subject[$i], $matches)) { - if (array_key_exists($i, $request)) { - $pattern = $matches[1]; - if (preg_match("/^$pattern$/", $request[$i])) { - $uri[] = $request[$i]; - $params[] = $request[$i]; - } else { - $uri[] = '!@#$%^&*'; - } + $pattern = substr($segment, 1); + $hasSegment = array_key_exists($i, $request); + + // Simple parameter like :id, :page + if (preg_match('/^[a-zA-Z0-9_-]+$/', $pattern)) { + if ($hasSegment) { + $uri[] = $request[$i]; + $params[] = $request[$i]; + } else { + $uri[] = $segment; } - continue; } - - $uri[] = $subject[$i]; + + // Regex parameter like :[\d]+ or :[a-z]{1,3} + if ($hasSegment && @preg_match("/^$pattern$/", $request[$i]) === 1) { + $uri[] = $request[$i]; + $params[] = $request[$i]; + } else { + $uri[] = $hasSegment ? '!@#$%^&*' : $segment; + } } return [$uri, $params]; diff --git a/src/Traits/RouterAnnotationTrait.php b/src/Traits/RouterAnnotationTrait.php index 5a428db..96c1c17 100755 --- a/src/Traits/RouterAnnotationTrait.php +++ b/src/Traits/RouterAnnotationTrait.php @@ -36,7 +36,7 @@ public function annotationCollector(array $controllers, bool $getter = false, bo } $reflection = new \ReflectionClass($controller); - $methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC); + $methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC); foreach ($methods as $method) { $action = $method->getName(); @@ -63,9 +63,24 @@ public function annotationCollector(array $controllers, bool $getter = false, bo 'method' => 'GET', ]; - $getter - ? $annotations[] = [$route] - : $this->set($route); + // Determine the key used for the URL (supports both 'url' and legacy '0' index) + $urlKey = array_key_exists('url', $route) ? 'url' : (array_key_exists(0, $route) ? 0 : 'url'); + $rawUrls = $route[$urlKey] ?? ''; + $urls = is_array($rawUrls) ? $rawUrls : [$rawUrls]; + $expandedUrls = []; + + foreach ($urls as $url) { + $expandedUrls = array_merge($expandedUrls, $this->expandOptionalSegments((string)$url)); + } + + // Register each expanded URL while preserving all other route parameters (method, middleware, etc.) + foreach (array_unique($expandedUrls) as $expandedUrl) { + $currentRoute = $route; + $currentRoute[$urlKey] = $expandedUrl; + + $getter + ? $annotations[] = [$currentRoute] : $this->set($currentRoute); + } } } } @@ -75,11 +90,34 @@ public function annotationCollector(array $controllers, bool $getter = false, bo } /** - * Processes middleware annotations into a valid middleware format. + * Expands optional route segments enclosed in square brackets. + * Ignores brackets that are part of regex parameters (after ':'). * - * ```#[Middleware(name: "Auth", params: "admin")]``` - * to: - * ```['Auth', 'admin']``` + * Example: 'admin[/item[/page[/:page]]]' expands to 4 routes: + * ['admin', 'admin/item', 'admin/item/page', 'admin/item/page/:page'] + */ + protected function expandOptionalSegments(string $url): array + { + // Find '[' that is NOT part of a regex parameter (not immediately after ':') + if (!preg_match('/(?expandOptionalSegments($prefix . $suffix), + $this->expandOptionalSegments($prefix . $inner . $suffix) + )); + } + + /** + * Processes middleware annotations into a valid middleware format. + * #[Middleware(name: "Auth", params: "admin")] to: ['Auth', 'admin'] */ protected function handleAnnotationMiddleware(array $annotation): array { diff --git a/tests/RouterAnnotationTraitTest.php b/tests/RouterAnnotationTraitTest.php index 7606bc6..c68b918 100644 --- a/tests/RouterAnnotationTraitTest.php +++ b/tests/RouterAnnotationTraitTest.php @@ -16,6 +16,7 @@ use Rudra\Container\Facades\Rudra; use Rudra\Container\Interfaces\RudraInterface; use Rudra\Router\Router; +use Rudra\Router\Attributes\Routing; use Rudra\Router\Tests\Stub\Controllers\AnnotatedController; use Rudra\Router\Tests\Stub\Controllers\MainController; @@ -227,4 +228,150 @@ public function testHandleAnnotationMiddlewareMultiple(): void ['CacheMiddleware', ['3600']], ], $result); } + + /** + * Tests that optional segments in square brackets are expanded into multiple routes. + * Example: 'admin[/item[/page[/:page]]]' should expand to 4 separate routes. + */ + public function testExpandOptionalSegments(): void + { + $controller = new class { + #[Routing(url: 'admin[/item[/page[/:page]]]')] + public function adminPanel() {} + }; + + $router = $this->getRouter(); + $routes = $router->annotationCollector( + [get_class($controller)], + getter: true, + attributes: true + ); + + $this->assertCount(4, $routes); + + $urls = array_column($this->flattenRoutes($routes), 'url'); + sort($urls); + + $this->assertEquals([ + 'admin', + 'admin/item', + 'admin/item/page', + 'admin/item/page/:page', + ], $urls); + } + + /** + * Tests that passing an array of URLs registers multiple routes for the same action. + */ + public function testArrayOfUrls(): void + { + $controller = new class { + #[Routing(url: ['admin', 'admin/item', 'admin/item/page'])] + public function adminPanel() {} + }; + + $router = $this->getRouter(); + $routes = $router->annotationCollector( + [get_class($controller)], + getter: true, + attributes: true + ); + + $this->assertCount(3, $routes); + + $urls = array_column($this->flattenRoutes($routes), 'url'); + sort($urls); + + $this->assertEquals([ + 'admin', + 'admin/item', + 'admin/item/page', + ], $urls); + } + + /** + * Tests that regex parameters like :[a-z]{1,3} are NOT treated as optional segments. + * The brackets inside regex patterns must be ignored by expandOptionalSegments. + */ + public function testRegexParametersIgnoredInExpand(): void + { + $controller = new class { + #[Routing(url: 'lang/:[a-z]{1,3}')] + public function setLanguage() {} + + #[Routing(url: 'page/:[\d]{1,3}')] + public function showPage() {} + }; + + $router = $this->getRouter(); + $routes = $router->annotationCollector( + [get_class($controller)], + getter: true, + attributes: true + ); + + // Each method should produce exactly 1 route (regex brackets are not expanded) + $this->assertCount(2, $routes); + + $flat = $this->flattenRoutes($routes); + $urls = array_column($flat, 'url'); + + $this->assertContains('lang/:[a-z]{1,3}', $urls); + $this->assertContains('page/:[\d]{1,3}', $urls); + } + + /** + * Tests combined usage of optional segments and regex parameters in one URL. + * Example: 'admin[/item]/:[a-z]+' should expand to 2 routes, both keeping regex intact. + */ + public function testExpandWithRegexAndOptionalSegments(): void + { + $controller = new class { + #[Routing(url: 'admin[/item]/:[a-z]+')] + public function mixedRoute() {} + }; + + $router = $this->getRouter(); + $routes = $router->annotationCollector( + [get_class($controller)], + getter: true, + attributes: true + ); + + $this->assertCount(2, $routes); + + $urls = array_column($this->flattenRoutes($routes), 'url'); + sort($urls); + + $this->assertEquals([ + 'admin/:[a-z]+', + 'admin/item/:[a-z]+', + ], $urls); + } + + /** + * Tests that expanded routes preserve HTTP method and other route parameters. + */ + public function testExpandedRoutesPreserveMethod(): void + { + $controller = new class { + #[Routing(url: 'admin[/item[/page]]', method: 'POST')] + public function adminAction() {} + }; + + $router = $this->getRouter(); + $routes = $router->annotationCollector( + [get_class($controller)], + getter: true, + attributes: true + ); + + $flat = $this->flattenRoutes($routes); + + $this->assertCount(3, $flat); + foreach ($flat as $route) { + $this->assertEquals('POST', $route['method']); + $this->assertEquals('adminAction', $route['action']); + } + } }