From 02971c6683242afdf22f4363c85b64b0c7ec8d1b Mon Sep 17 00:00:00 2001 From: Rustam Date: Mon, 6 Nov 2023 09:27:26 +0500 Subject: [PATCH 01/32] Allow to create route instance directly --- src/Group.php | 50 +++++++++++++++++++++++++--- src/Route.php | 80 +++++++++++++++++++++++++++++++++++++++------ tests/GroupTest.php | 17 ++++++++++ tests/RouteTest.php | 39 ++++++++++++++++++++++ 4 files changed, 172 insertions(+), 14 deletions(-) diff --git a/src/Group.php b/src/Group.php index 0099cb11..e3c414d9 100644 --- a/src/Group.php +++ b/src/Group.php @@ -27,10 +27,8 @@ final class Group * @var string[] */ private array $hosts = []; - private ?string $namePrefix = null; private bool $routesAdded = false; private bool $middlewareAdded = false; - private array $disabledMiddlewares = []; /** * @psalm-var list|null @@ -42,9 +40,24 @@ final class Group */ private $corsMiddleware = null; - private function __construct( - private ?string $prefix = null + /** + * @param array $disabledMiddlewares Excludes middleware from being invoked when action is handled. + * It is useful to avoid invoking one of the parent group middleware for + * a certain route. + */ + public function __construct( + private ?string $prefix = null, + array $middlewares = [], + array $hosts = [], + private ?string $namePrefix = null, + private array $disabledMiddlewares = [], + array|callable|string|null $corsMiddleware = null ) { + $this->assertMiddlewares($middlewares); + $this->assertHosts($hosts); + $this->middlewares = $middlewares; + $this->hosts = $hosts; + $this->corsMiddleware = $corsMiddleware; } /** @@ -215,4 +228,33 @@ private function getEnabledMiddlewares(): array return $this->enabledMiddlewaresCache; } + + /** + * @psalm-assert array $hosts + */ + private function assertHosts(array $hosts): void + { + foreach ($hosts as $host) { + if (!is_string($host)) { + throw new \InvalidArgumentException('Invalid $hosts provided, list of string expected.'); + } + } + } + + /** + * @psalm-assert list $middlewares + */ + private function assertMiddlewares(array $middlewares): void + { + /** @var mixed $middleware */ + foreach ($middlewares as $middleware) { + if (is_string($middleware) || is_callable($middleware) || is_array($middleware)) { + continue; + } + + throw new \InvalidArgumentException( + 'Invalid $middlewares provided, list of string or array or callable expected.' + ); + } + } } diff --git a/src/Route.php b/src/Route.php index d5940701..27fc0425 100644 --- a/src/Route.php +++ b/src/Route.php @@ -17,13 +17,15 @@ */ final class Route implements Stringable { - private ?string $name = null; + /** + * @var string[] + */ + private array $methods = []; /** * @var string[] */ private array $hosts = []; - private bool $override = false; private bool $actionAdded = false; /** @@ -32,25 +34,50 @@ final class Route implements Stringable */ private array $middlewares = []; - private array $disabledMiddlewares = []; - /** * @psalm-var list|null */ private ?array $enabledMiddlewaresCache = null; /** - * @var array + * @var array */ private array $defaults = []; /** - * @param string[] $methods + * @param array|callable|string|null $action Action handler. It is a primary middleware definition that + * should be invoked last for a matched route. + * @param array $defaults Parameter default values indexed by parameter names. + * @param bool $override Marks route as override. When added it will replace existing route with the same name. + * @param array $disabledMiddlewares Excludes middleware from being invoked when action is handled. + * It is useful to avoid invoking one of the parent group middleware for + * a certain route. */ - private function __construct( - private array $methods, + public function __construct( + array $methods, private string $pattern, + private ?string $name = null, + array|callable|string $action = null, + array $middlewares = [], + array $defaults = [], + array $hosts = [], + private bool $override = false, + private array $disabledMiddlewares = [], ) { + if (empty($methods)) { + throw new InvalidArgumentException('$methods cannot be empty.'); + } + $this->assertListOfStrings($methods, 'methods'); + $this->assertMiddlewares($middlewares); + $this->assertListOfStrings($hosts, 'hosts'); + $this->methods = $methods; + $this->middlewares = $middlewares; + $this->hosts = $hosts; + $this->defaults = array_map('\strval', $defaults); + if (!empty($action)) { + $this->middlewares[] = $action; + $this->actionAdded = true; + } } public static function get(string $pattern): self @@ -93,7 +120,7 @@ public static function options(string $pattern): self */ public static function methods(array $methods, string $pattern): self { - return new self($methods, $pattern); + return new self(methods: $methods, pattern: $pattern); } public function name(string $name): self @@ -271,7 +298,7 @@ public function __toString(): string $result .= implode(',', $this->methods) . ' '; } - if ($this->hosts) { + if (!empty($this->hosts)) { $quoted = array_map(static fn ($host) => preg_quote($host, '/'), $this->hosts); if (!preg_match('/' . implode('|', $quoted) . '/', $this->pattern)) { @@ -314,4 +341,37 @@ private function getEnabledMiddlewares(): array return $this->enabledMiddlewaresCache; } + + /** + * @psalm-assert array $items + */ + private function assertListOfStrings(array $items, string $argument): void + { + foreach ($items as $item) { + if (!is_string($item)) { + throw new \InvalidArgumentException('Invalid $' . $argument . ' provided, list of string expected.'); + } + } + } + + /** + * @psalm-assert list $middlewares + */ + private function assertMiddlewares(array $middlewares): void + { + /** @var mixed $middleware */ + foreach ($middlewares as $middleware) { + if (is_string($middleware)) { + continue; + } + + if (is_callable($middleware) || is_array($middleware)) { + continue; + } + + throw new \InvalidArgumentException( + 'Invalid $middlewares provided, list of string or array or callable expected.' + ); + } + } } diff --git a/tests/GroupTest.php b/tests/GroupTest.php index c74eaf99..c95b7cf4 100644 --- a/tests/GroupTest.php +++ b/tests/GroupTest.php @@ -243,6 +243,15 @@ public function testGroupMiddlewareStackInterrupted(): void $this->assertSame(403, $response->getStatusCode()); } + public function testInvalidMiddlewares(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid $middlewares provided, list of string or array or callable expected.'); + + $middleware = static fn () => new Response(); + $group = new Group('/api', [$middleware, new \stdClass()]); + } + public function testAddGroup(): void { $logoutRoute = Route::post('/logout'); @@ -304,6 +313,14 @@ public function testHosts(): void $this->assertSame(['https://yiiframework.com', 'https://yiiframework.ru'], $group->getData('hosts')); } + public function testInvalidHosts(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid $hosts provided, list of string expected.'); + + $group = new Group(hosts: ['https://yiiframework.com/', 123]); + } + public function testName(): void { $group = Group::create()->namePrefix('api'); diff --git a/tests/RouteTest.php b/tests/RouteTest.php index 92b158b0..cf701bcc 100644 --- a/tests/RouteTest.php +++ b/tests/RouteTest.php @@ -28,6 +28,29 @@ final class RouteTest extends TestCase { use AssertTrait; + public function testSimpleInstance(): void + { + $route = new Route( + methods: [Method::GET], + pattern: '/', + action: [TestController::class, 'index'], + middlewares: [TestMiddleware1::class], + override: true, + ); + + $this->assertInstanceOf(Route::class, $route); + $this->assertCount(2, $route->getData('enabledMiddlewares')); + $this->assertTrue($route->getData('override')); + } + + public function testEmptyMethods(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('$methods cannot be empty.'); + + new Route([], ''); + } + public function testName(): void { $route = Route::get('/')->name('test.route'); @@ -371,6 +394,14 @@ public function testMiddlewaresWithKeys(): void ); } + public function testInvalidMiddlewares(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid $middlewares provided, list of string or array or callable expected.'); + + $route = new Route([Method::GET], '/', middlewares: [static fn () => new Response(), (object) ['test' => 1]]); + } + public function testDebugInfo(): void { $route = Route::get('/') @@ -438,6 +469,14 @@ public function testDuplicateHosts(): void $this->assertSame(['a.com', 'b.com'], $route->getData('hosts')); } + public function testInvalidHosts(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid $hosts provided, list of string expected.'); + + $route = new Route([Method::GET], '/', hosts: ['b.com', 123]); + } + public function testImmutability(): void { $route = Route::get('/'); From 7848f1471a43a6019a58e21fb37fb32bfb074130 Mon Sep 17 00:00:00 2001 From: Rustam Date: Wed, 8 Nov 2023 15:20:43 +0500 Subject: [PATCH 02/32] Adjust Group --- src/Group.php | 28 +++++++++++++++++++++++++++- tests/GroupTest.php | 2 +- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/Group.php b/src/Group.php index e3c414d9..ce55426d 100644 --- a/src/Group.php +++ b/src/Group.php @@ -47,17 +47,26 @@ final class Group */ public function __construct( private ?string $prefix = null, + private ?string $namePrefix = null, + array $routes = [], array $middlewares = [], array $hosts = [], - private ?string $namePrefix = null, private array $disabledMiddlewares = [], array|callable|string|null $corsMiddleware = null ) { + $this->assertRoutes($routes); $this->assertMiddlewares($middlewares); $this->assertHosts($hosts); + $this->routes = $routes; $this->middlewares = $middlewares; $this->hosts = $hosts; $this->corsMiddleware = $corsMiddleware; + if (!empty($routes)) { + $this->routesAdded = true; + } + if (!empty($middlewares)) { + $this->middlewareAdded = true; + } } /** @@ -257,4 +266,21 @@ private function assertMiddlewares(array $middlewares): void ); } } + + /** + * @psalm-assert array $routes + */ + private function assertRoutes(array $routes): void + { + /** @var mixed $route */ + foreach ($routes as $route) { + if ($route instanceof Route || $route instanceof self) { + continue; + } + + throw new \InvalidArgumentException( + 'Invalid $routes provided, array of `Route` or `Group` expected.' + ); + } + } } diff --git a/tests/GroupTest.php b/tests/GroupTest.php index c95b7cf4..ecc850d9 100644 --- a/tests/GroupTest.php +++ b/tests/GroupTest.php @@ -249,7 +249,7 @@ public function testInvalidMiddlewares(): void $this->expectExceptionMessage('Invalid $middlewares provided, list of string or array or callable expected.'); $middleware = static fn () => new Response(); - $group = new Group('/api', [$middleware, new \stdClass()]); + $group = new Group('/api', middlewares: [$middleware, new \stdClass()]); } public function testAddGroup(): void From 39f05ff76d078dd2091aaf2b4e9c6708275a9faf Mon Sep 17 00:00:00 2001 From: Rustam Date: Thu, 9 Nov 2023 12:19:34 +0500 Subject: [PATCH 03/32] Simplify route classes & add route builders --- src/Builder/GroupBuilder.php | 166 ++++++++++ src/Builder/RouteBuilder.php | 218 +++++++++++++ src/CurrentRoute.php | 14 +- src/Debug/RouterCollector.php | 22 +- src/Group.php | 195 ++++-------- src/Middleware/Router.php | 2 +- src/RoutableInterface.php | 10 + src/Route.php | 274 ++++++----------- src/RouteCollection.php | 90 +++--- src/RouteCollector.php | 22 +- src/RouteCollectorInterface.php | 12 +- tests/Builder/GroupBuilderTest.php | 439 ++++++++++++++++++++++++++ tests/Builder/RouteBuilderTest.php | 392 ++++++++++++++++++++++++ tests/ConfigTest.php | 4 +- tests/CurrentRouteTest.php | 33 +- tests/Debug/RouterCollectorTest.php | 7 +- tests/GroupTest.php | 459 ++-------------------------- tests/MatchingResultTest.php | 2 +- tests/Middleware/RouterTest.php | 8 +- tests/RouteCollectionTest.php | 32 +- tests/RouteCollectorTest.php | 20 +- tests/RouteTest.php | 395 ++++-------------------- 22 files changed, 1607 insertions(+), 1209 deletions(-) create mode 100644 src/Builder/GroupBuilder.php create mode 100644 src/Builder/RouteBuilder.php create mode 100644 src/RoutableInterface.php create mode 100644 tests/Builder/GroupBuilderTest.php create mode 100644 tests/Builder/RouteBuilderTest.php diff --git a/src/Builder/GroupBuilder.php b/src/Builder/GroupBuilder.php new file mode 100644 index 00000000..9268b806 --- /dev/null +++ b/src/Builder/GroupBuilder.php @@ -0,0 +1,166 @@ + + */ + private array $middlewares = []; + + private array $disabledMiddlewares = []; + + /** + * @var string[] + */ + private array $hosts = []; + private bool $routesAdded = false; + private bool $middlewareAdded = false; + + /** + * @var array|callable|string|null Middleware definition for CORS requests. + */ + private $corsMiddleware = null; + + private function __construct( + private ?string $prefix = null, + private ?string $namePrefix = null, + ) { + } + + /** + * Create a new group instance. + * + * @param string|null $prefix URL prefix to prepend to all routes of the group. + */ + public static function create(?string $prefix = null, ?string $namePrefix = null): self + { + return new self($prefix, $namePrefix); + } + + public function routes(Group|Route|RoutableInterface ...$routes): self + { + if ($this->middlewareAdded) { + throw new RuntimeException('routes() can not be used after prependMiddleware().'); + } + + $new = clone $this; + $new->routes = $routes; + $new->routesAdded = true; + + return $new; + } + + /** + * Adds a middleware definition that handles CORS requests. + * If set, routes for {@see Method::OPTIONS} request will be added automatically. + * + * @param array|callable|string|null $middlewareDefinition Middleware definition for CORS requests. + */ + public function withCors(array|callable|string|null $middlewareDefinition): self + { + $group = clone $this; + $group->corsMiddleware = $middlewareDefinition; + + return $group; + } + + /** + * Appends a handler middleware definition that should be invoked for a matched route. + * First added handler will be executed first. + */ + public function middleware(array|callable|string ...$definition): self + { + if ($this->routesAdded) { + throw new RuntimeException('middleware() can not be used after routes().'); + } + + $new = clone $this; + array_push( + $new->middlewares, + ...array_values($definition) + ); + + return $new; + } + + /** + * Prepends a handler middleware definition that should be invoked for a matched route. + * First added handler will be executed last. + */ + public function prependMiddleware(array|callable|string ...$definition): self + { + $new = clone $this; + array_unshift( + $new->middlewares, + ...array_values($definition) + ); + + $new->middlewareAdded = true; + + return $new; + } + + public function namePrefix(string $namePrefix): self + { + $new = clone $this; + $new->namePrefix = $namePrefix; + return $new; + } + + public function host(string $host): self + { + return $this->hosts($host); + } + + public function hosts(string ...$hosts): self + { + $new = clone $this; + $new->hosts = array_values($hosts); + + return $new; + } + + /** + * Excludes middleware from being invoked when action is handled. + * It is useful to avoid invoking one of the parent group middleware for + * a certain route. + */ + public function disableMiddleware(mixed ...$definition): self + { + $new = clone $this; + array_push( + $new->disabledMiddlewares, + ...array_values($definition), + ); + + return $new; + } + + public function toRoute(): Group|Route + { + return new Group( + prefix: $this->prefix, + namePrefix: $this->namePrefix, + routes: $this->routes, + middlewares: $this->middlewares, + hosts: $this->hosts, + disabledMiddlewares: $this->disabledMiddlewares, + corsMiddleware: $this->corsMiddleware + ); + } +} diff --git a/src/Builder/RouteBuilder.php b/src/Builder/RouteBuilder.php new file mode 100644 index 00000000..f00607a4 --- /dev/null +++ b/src/Builder/RouteBuilder.php @@ -0,0 +1,218 @@ + + */ + private array $middlewares = []; + + /** + * @var array + */ + private array $defaults = []; + + /** + * @param string[] $methods + */ + private function __construct( + private array $methods, + private string $pattern, + ) { + } + + public static function get(string $pattern): self + { + return self::methods([Method::GET], $pattern); + } + + public static function post(string $pattern): self + { + return self::methods([Method::POST], $pattern); + } + + public static function put(string $pattern): self + { + return self::methods([Method::PUT], $pattern); + } + + public static function delete(string $pattern): self + { + return self::methods([Method::DELETE], $pattern); + } + + public static function patch(string $pattern): self + { + return self::methods([Method::PATCH], $pattern); + } + + public static function head(string $pattern): self + { + return self::methods([Method::HEAD], $pattern); + } + + public static function options(string $pattern): self + { + return self::methods([Method::OPTIONS], $pattern); + } + + /** + * @param string[] $methods + */ + public static function methods(array $methods, string $pattern): self + { + return new self(methods: $methods, pattern: $pattern); + } + + public function name(string $name): self + { + $route = clone $this; + $route->name = $name; + return $route; + } + + public function pattern(string $pattern): self + { + $new = clone $this; + $new->pattern = $pattern; + return $new; + } + + public function host(string $host): self + { + return $this->hosts($host); + } + + public function hosts(string ...$hosts): self + { + $route = clone $this; + $route->hosts = array_values($hosts); + + return $route; + } + + /** + * Marks route as override. When added it will replace existing route with the same name. + */ + public function override(): self + { + $route = clone $this; + $route->override = true; + return $route; + } + + /** + * Parameter default values indexed by parameter names. + * + * @psalm-param array $defaults + */ + public function defaults(array $defaults): self + { + $route = clone $this; + $route->defaults = $defaults; + return $route; + } + + /** + * Appends a handler middleware definition that should be invoked for a matched route. + * First added handler will be executed first. + */ + public function middleware(array|callable|string ...$definition): self + { + $route = clone $this; + array_push( + $route->middlewares, + ...array_values($definition) + ); + + return $route; + } + + /** + * Prepends a handler middleware definition that should be invoked for a matched route. + * Last added handler will be executed first. + */ + public function prependMiddleware(array|callable|string ...$definition): self + { + $route = clone $this; + array_unshift( + $route->middlewares, + ...array_values($definition) + ); + + return $route; + } + + /** + * Appends action handler. It is a primary middleware definition that should be invoked last for a matched route. + */ + public function action(array|callable|string $middlewareDefinition): self + { + $route = clone $this; + $route->action = $middlewareDefinition; + return $route; + } + + /** + * Excludes middleware from being invoked when action is handled. + * It is useful to avoid invoking one of the parent group middleware for + * a certain route. + */ + public function disableMiddleware(mixed ...$definition): self + { + $route = clone $this; + array_push( + $route->disabledMiddlewares, + ...array_values($definition) + ); + + return $route; + } + + public function toRoute(): Group|Route + { + return new Route( + methods: $this->methods, + pattern: $this->pattern, + name: $this->name, + action: $this->action, + middlewares: $this->middlewares, + defaults: $this->defaults, + hosts: $this->hosts, + override: $this->override, + disabledMiddlewares: $this->disabledMiddlewares + ); + } +} diff --git a/src/CurrentRoute.php b/src/CurrentRoute.php index d3eb9a21..9b9a4ff4 100644 --- a/src/CurrentRoute.php +++ b/src/CurrentRoute.php @@ -36,17 +36,17 @@ final class CurrentRoute */ public function getName(): ?string { - return $this->route?->getData('name'); + return $this->route?->getName(); } /** - * Returns the current route host. + * Returns the current route hosts. * - * @return string|null The current route host. + * @return array|null The current route hosts. */ - public function getHost(): ?string + public function getHosts(): ?array { - return $this->route?->getData('host'); + return $this->route?->getHosts(); } /** @@ -56,7 +56,7 @@ public function getHost(): ?string */ public function getPattern(): ?string { - return $this->route?->getData('pattern'); + return $this->route?->getPattern(); } /** @@ -66,7 +66,7 @@ public function getPattern(): ?string */ public function getMethods(): ?array { - return $this->route?->getData('methods'); + return $this->route?->getMethods(); } /** diff --git a/src/Debug/RouterCollector.php b/src/Debug/RouterCollector.php index e9dfc597..ff7e0388 100644 --- a/src/Debug/RouterCollector.php +++ b/src/Debug/RouterCollector.php @@ -55,10 +55,10 @@ public function getCollected(): array if ($currentRoute !== null && $route !== null) { $result['currentRoute'] = [ 'matchTime' => $this->matchTime, - 'name' => $route->getData('name'), - 'pattern' => $route->getData('pattern'), + 'name' => $route->getName(), + 'pattern' => $route->getPattern(), 'arguments' => $currentRoute->getArguments(), - 'host' => $route->getData('host'), + 'hosts' => implode(', ', $route->getHosts()), 'uri' => (string) $currentRoute->getUri(), 'action' => $action, 'middlewares' => $middlewares, @@ -91,10 +91,10 @@ public function getSummary(): array return [ 'router' => [ 'matchTime' => $this->matchTime, - 'name' => $route->getData('name'), - 'pattern' => $route->getData('pattern'), + 'name' => $route->getName(), + 'pattern' => $route->getPattern(), 'arguments' => $currentRoute->getArguments(), - 'host' => $route->getData('host'), + 'hosts' => implode(', ', $route->getHosts()), 'uri' => (string) $currentRoute->getUri(), 'action' => $action, 'middlewares' => $middlewares, @@ -134,15 +134,7 @@ private function getMiddlewaresAndAction(?Route $route): array if ($route === null) { return [[], null]; } - $reflection = new ReflectionObject($route); - $reflectionProperty = $reflection->getProperty('middlewareDefinitions'); - $reflectionProperty->setAccessible(true); - /** - * @var array[]|callable[]|string[] $middlewareDefinitions - */ - $middlewareDefinitions = $reflectionProperty->getValue($route); - $action = array_pop($middlewareDefinitions); - return [$middlewareDefinitions, $action]; + return [$route->getMiddlewares(), $route->getAction()]; } } diff --git a/src/Group.php b/src/Group.php index ce55426d..f1b2783c 100644 --- a/src/Group.php +++ b/src/Group.php @@ -4,16 +4,12 @@ namespace Yiisoft\Router; -use InvalidArgumentException; -use RuntimeException; use Yiisoft\Router\Internal\MiddlewareFilter; -use function in_array; - final class Group { /** - * @var Group[]|Route[] + * @var Group[]|Route[]|RoutableInterface[] */ private array $routes = []; @@ -27,8 +23,6 @@ final class Group * @var string[] */ private array $hosts = []; - private bool $routesAdded = false; - private bool $middlewareAdded = false; /** * @psalm-var list|null @@ -54,180 +48,109 @@ public function __construct( private array $disabledMiddlewares = [], array|callable|string|null $corsMiddleware = null ) { - $this->assertRoutes($routes); - $this->assertMiddlewares($middlewares); - $this->assertHosts($hosts); - $this->routes = $routes; - $this->middlewares = $middlewares; - $this->hosts = $hosts; + $this->setRoutes($routes); + $this->setMiddlewares($middlewares); + $this->setHosts($hosts); $this->corsMiddleware = $corsMiddleware; - if (!empty($routes)) { - $this->routesAdded = true; - } - if (!empty($middlewares)) { - $this->middlewareAdded = true; - } } /** - * Create a new group instance. - * - * @param string|null $prefix URL prefix to prepend to all routes of the group. + * @return Group[]|RoutableInterface[]|Route[] */ - public static function create(?string $prefix = null): self + public function getRoutes(): array { - return new self($prefix); + return $this->routes; } - public function routes(self|Route ...$routes): self + public function getMiddlewares(): array { - if ($this->middlewareAdded) { - throw new RuntimeException('routes() can not be used after prependMiddleware().'); - } - - $new = clone $this; - $new->routes = $routes; - $new->routesAdded = true; - - return $new; + return $this->middlewares; } - /** - * Adds a middleware definition that handles CORS requests. - * If set, routes for {@see Method::OPTIONS} request will be added automatically. - * - * @param array|callable|string|null $middlewareDefinition Middleware definition for CORS requests. - */ - public function withCors(array|callable|string|null $middlewareDefinition): self + public function getHosts(): array { - $group = clone $this; - $group->corsMiddleware = $middlewareDefinition; - - return $group; + return $this->hosts; } - /** - * Appends a handler middleware definition that should be invoked for a matched route. - * First added handler will be executed first. - */ - public function middleware(array|callable|string ...$definition): self + public function getCorsMiddleware(): callable|array|string|null { - if ($this->routesAdded) { - throw new RuntimeException('middleware() can not be used after routes().'); - } - - $new = clone $this; - array_push( - $new->middlewares, - ...array_values($definition) - ); - - $new->enabledMiddlewaresCache = null; - - return $new; + return $this->corsMiddleware; } - /** - * Prepends a handler middleware definition that should be invoked for a matched route. - * First added handler will be executed last. - */ - public function prependMiddleware(array|callable|string ...$definition): self + public function getPrefix(): ?string { - $new = clone $this; - array_unshift( - $new->middlewares, - ...array_values($definition) - ); - - $new->middlewareAdded = true; - $new->enabledMiddlewaresCache = null; + return $this->prefix; + } - return $new; + public function getNamePrefix(): ?string + { + return $this->namePrefix; } - public function namePrefix(string $namePrefix): self + public function getDisabledMiddlewares(): array { - $new = clone $this; - $new->namePrefix = $namePrefix; - return $new; + return $this->disabledMiddlewares; } - public function host(string $host): self + public function setRoutes(array $routes): self { - return $this->hosts($host); + $this->assertRoutes($routes); + $this->routes = $routes; + return $this; } - public function hosts(string ...$hosts): self + public function setMiddlewares(array $middlewares): self { - $new = clone $this; + $this->assertMiddlewares($middlewares); + $this->middlewares = $middlewares; + $this->enabledMiddlewaresCache = null; + return $this; + } + public function setHosts(array $hosts): self + { + $this->assertHosts($hosts); foreach ($hosts as $host) { $host = rtrim($host, '/'); - if ($host !== '' && !in_array($host, $new->hosts, true)) { - $new->hosts[] = $host; + if ($host !== '' && !in_array($host, $this->hosts, true)) { + $this->hosts[] = $host; } } - return $new; + return $this; } - /** - * Excludes middleware from being invoked when action is handled. - * It is useful to avoid invoking one of the parent group middleware for - * a certain route. - */ - public function disableMiddleware(mixed ...$definition): self + public function setCorsMiddleware(callable|array|string|null $corsMiddleware): self { - $new = clone $this; - array_push( - $new->disabledMiddlewares, - ...array_values($definition), - ); + $this->corsMiddleware = $corsMiddleware; + return $this; + } - $new->enabledMiddlewaresCache = null; + public function setPrefix(?string $prefix): self + { + $this->prefix = $prefix; + return $this; + } - return $new; + public function setNamePrefix(?string $namePrefix): self + { + $this->namePrefix = $namePrefix; + return $this; } - /** - * @psalm-template T as string - * - * @psalm-param T $key - * - * @psalm-return ( - * T is ('prefix'|'namePrefix'|'host') ? string|null : - * (T is 'routes' ? Group[]|Route[] : - * (T is 'hosts' ? array : - * (T is ('hasCorsMiddleware') ? bool : - * (T is 'enabledMiddlewares' ? list : - * (T is 'corsMiddleware' ? array|callable|string|null : mixed) - * ) - * ) - * ) - * ) - * ) - */ - public function getData(string $key): mixed + public function setDisabledMiddlewares(array $disabledMiddlewares): self { - return match ($key) { - 'prefix' => $this->prefix, - 'namePrefix' => $this->namePrefix, - 'host' => $this->hosts[0] ?? null, - 'hosts' => $this->hosts, - 'corsMiddleware' => $this->corsMiddleware, - 'routes' => $this->routes, - 'hasCorsMiddleware' => $this->corsMiddleware !== null, - 'enabledMiddlewares' => $this->getEnabledMiddlewares(), - default => throw new InvalidArgumentException('Unknown data key: ' . $key), - }; + $this->disabledMiddlewares = $disabledMiddlewares; + $this->enabledMiddlewaresCache = null; + return $this; } /** * @return array[]|callable[]|string[] * @psalm-return list */ - private function getEnabledMiddlewares(): array + public function getEnabledMiddlewares(): array { if ($this->enabledMiddlewaresCache !== null) { return $this->enabledMiddlewaresCache; @@ -268,18 +191,18 @@ private function assertMiddlewares(array $middlewares): void } /** - * @psalm-assert array $routes + * @psalm-assert array $routes */ private function assertRoutes(array $routes): void { - /** @var mixed $route */ + /** @var Route|Group|RoutableInterface $route */ foreach ($routes as $route) { - if ($route instanceof Route || $route instanceof self) { + if ($route instanceof Route || $route instanceof self || $route instanceof RoutableInterface) { continue; } throw new \InvalidArgumentException( - 'Invalid $routes provided, array of `Route` or `Group` expected.' + 'Invalid $routes provided, array of `Route` or `Group` or `RoutableInterface` instance expected.' ); } } diff --git a/src/Middleware/Router.php b/src/Middleware/Router.php index 644bc8ce..3f0c10b0 100644 --- a/src/Middleware/Router.php +++ b/src/Middleware/Router.php @@ -55,7 +55,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $this->currentRoute->setRouteWithArguments($result->route(), $result->arguments()); return $this->dispatcher - ->withMiddlewares($result->route()->getData('enabledMiddlewares')) + ->withMiddlewares($result->route()->getEnabledMiddlewares()) ->dispatch($request, $handler); } } diff --git a/src/RoutableInterface.php b/src/RoutableInterface.php new file mode 100644 index 00000000..8c3082fe --- /dev/null +++ b/src/RoutableInterface.php @@ -0,0 +1,10 @@ + */ private array $methods = []; @@ -26,7 +23,11 @@ final class Route implements Stringable * @var string[] */ private array $hosts = []; - private bool $actionAdded = false; + + /** + * @var array|callable|string|null + */ + private $action = null; /** * @var array[]|callable[]|string[] @@ -40,14 +41,14 @@ final class Route implements Stringable private ?array $enabledMiddlewaresCache = null; /** - * @var array + * @var array */ private array $defaults = []; /** * @param array|callable|string|null $action Action handler. It is a primary middleware definition that * should be invoked last for a matched route. - * @param array $defaults Parameter default values indexed by parameter names. + * @param array $defaults Parameter default values indexed by parameter names. * @param bool $override Marks route as override. When added it will replace existing route with the same name. * @param array $disabledMiddlewares Excludes middleware from being invoked when action is handled. * It is useful to avoid invoking one of the parent group middleware for @@ -67,225 +68,155 @@ public function __construct( if (empty($methods)) { throw new InvalidArgumentException('$methods cannot be empty.'); } - $this->assertListOfStrings($methods, 'methods'); - $this->assertMiddlewares($middlewares); - $this->assertListOfStrings($hosts, 'hosts'); - $this->methods = $methods; - $this->middlewares = $middlewares; - $this->hosts = $hosts; - $this->defaults = array_map('\strval', $defaults); - if (!empty($action)) { - $this->middlewares[] = $action; - $this->actionAdded = true; - } + $this->setMethods($methods); + $this->action = $action; + $this->setMiddlewares($middlewares); + $this->setHosts($hosts); + $this->setDefaults($defaults); } - public static function get(string $pattern): self + /** + * @return string[] + */ + public function getMethods(): array { - return self::methods([Method::GET], $pattern); + return $this->methods; } - public static function post(string $pattern): self + public function getAction(): array|callable|string|null { - return self::methods([Method::POST], $pattern); + return $this->action; } - public static function put(string $pattern): self + public function getMiddlewares(): array { - return self::methods([Method::PUT], $pattern); + return $this->middlewares; } - public static function delete(string $pattern): self + /** + * @return string[] + */ + public function getHosts(): array { - return self::methods([Method::DELETE], $pattern); + return $this->hosts; } - public static function patch(string $pattern): self + public function getDefaults(): array { - return self::methods([Method::PATCH], $pattern); + return $this->defaults; } - public static function head(string $pattern): self + public function getPattern(): string { - return self::methods([Method::HEAD], $pattern); + return $this->pattern; } - public static function options(string $pattern): self + public function getName(): string { - return self::methods([Method::OPTIONS], $pattern); + return $this->name ??= (implode(', ', $this->methods) . ' ' . implode('|', $this->hosts) . $this->pattern); } - /** - * @param string[] $methods - */ - public static function methods(array $methods, string $pattern): self + public function isOverride(): bool { - return new self(methods: $methods, pattern: $pattern); + return $this->override; } - public function name(string $name): self + public function getDisabledMiddlewares(): array { - $route = clone $this; - $route->name = $name; - return $route; + return $this->disabledMiddlewares; } - public function pattern(string $pattern): self + /** + * @return array[]|callable[]|string[] + * @psalm-return list + */ + public function getEnabledMiddlewares(): array { - $new = clone $this; - $new->pattern = $pattern; - return $new; + if ($this->enabledMiddlewaresCache !== null) { + return $this->enabledMiddlewaresCache; + } + + $this->enabledMiddlewaresCache = MiddlewareFilter::filter($this->middlewares, $this->disabledMiddlewares); + if ($this->action !== null) { + $this->enabledMiddlewaresCache[] = $this->action; + } + + return $this->enabledMiddlewaresCache; } - public function host(string $host): self + public function setMethods(array $methods): self { - return $this->hosts($host); + $this->assertListOfStrings($methods, 'methods'); + $this->methods = $methods; + return $this; } - public function hosts(string ...$hosts): self + public function setHosts(array $hosts): self { - $route = clone $this; - $route->hosts = []; - + $this->assertListOfStrings($hosts, 'hosts'); + $this->hosts = []; foreach ($hosts as $host) { $host = rtrim($host, '/'); - if ($host !== '' && !in_array($host, $route->hosts, true)) { - $route->hosts[] = $host; + if ($host !== '' && !in_array($host, $this->hosts, true)) { + $this->hosts[] = $host; } } - return $route; + return $this; } - /** - * Marks route as override. When added it will replace existing route with the same name. - */ - public function override(): self + public function setAction(callable|array|string|null $action): self { - $route = clone $this; - $route->override = true; - return $route; + $this->action = $action; + return $this; } - /** - * Parameter default values indexed by parameter names. - * - * @psalm-param array $defaults - */ - public function defaults(array $defaults): self + public function setMiddlewares(array $middlewares): self { - $route = clone $this; - $route->defaults = array_map('\strval', $defaults); - return $route; + $this->assertMiddlewares($middlewares); + $this->middlewares = $middlewares; + $this->enabledMiddlewaresCache = null; + return $this; } - /** - * Appends a handler middleware definition that should be invoked for a matched route. - * First added handler will be executed first. - */ - public function middleware(array|callable|string ...$definition): self + public function setDefaults(array $defaults): self { - if ($this->actionAdded) { - throw new RuntimeException('middleware() can not be used after action().'); + /** @var mixed $value */ + foreach ($defaults as $key => $value) { + if (!is_scalar($value) && !($value instanceof Stringable)) { + throw new \InvalidArgumentException( + 'Invalid $defaults provided, list of scalar or `Stringable` instance expected.' + ); + } + $this->defaults[$key] = (string) $value; } - - $route = clone $this; - array_push( - $route->middlewares, - ...array_values($definition) - ); - - $route->enabledMiddlewaresCache = null; - - return $route; + return $this; } - /** - * Prepends a handler middleware definition that should be invoked for a matched route. - * Last added handler will be executed first. - */ - public function prependMiddleware(array|callable|string ...$definition): self + public function setPattern(string $pattern): self { - if (!$this->actionAdded) { - throw new RuntimeException('prependMiddleware() can not be used before action().'); - } - - $route = clone $this; - array_unshift( - $route->middlewares, - ...array_values($definition) - ); - - $route->enabledMiddlewaresCache = null; - - return $route; + $this->pattern = $pattern; + return $this; } - /** - * Appends action handler. It is a primary middleware definition that should be invoked last for a matched route. - */ - public function action(array|callable|string $middlewareDefinition): self + public function setName(?string $name): self { - $route = clone $this; - $route->middlewares[] = $middlewareDefinition; - $route->actionAdded = true; - return $route; + $this->name = $name; + return $this; } - /** - * Excludes middleware from being invoked when action is handled. - * It is useful to avoid invoking one of the parent group middleware for - * a certain route. - */ - public function disableMiddleware(mixed ...$definition): self + public function setOverride(bool $override): self { - $route = clone $this; - array_push( - $route->disabledMiddlewares, - ...array_values($definition) - ); - - $route->enabledMiddlewaresCache = null; - - return $route; + $this->override = $override; + return $this; } - /** - * @psalm-template T as string - * - * @psalm-param T $key - * - * @psalm-return ( - * T is ('name'|'pattern') ? string : - * (T is 'host' ? string|null : - * (T is 'hosts' ? array : - * (T is 'methods' ? array : - * (T is 'defaults' ? array : - * (T is ('override'|'hasMiddlewares') ? bool : - * (T is 'enabledMiddlewares' ? array : mixed) - * ) - * ) - * ) - * ) - * ) - * ) - */ - public function getData(string $key): mixed + public function setDisabledMiddlewares(array $disabledMiddlewares): self { - return match ($key) { - 'name' => $this->name ?? - (implode(', ', $this->methods) . ' ' . implode('|', $this->hosts) . $this->pattern), - 'pattern' => $this->pattern, - 'host' => $this->hosts[0] ?? null, - 'hosts' => $this->hosts, - 'methods' => $this->methods, - 'defaults' => $this->defaults, - 'override' => $this->override, - 'hasMiddlewares' => $this->middlewares !== [], - 'enabledMiddlewares' => $this->getEnabledMiddlewares(), - default => throw new InvalidArgumentException('Unknown data key: ' . $key), - }; + $this->disabledMiddlewares = $disabledMiddlewares; + $this->enabledMiddlewaresCache = null; + return $this; } public function __toString(): string @@ -317,10 +248,10 @@ public function __debugInfo() 'name' => $this->name, 'methods' => $this->methods, 'pattern' => $this->pattern, + 'action' => $this->action, 'hosts' => $this->hosts, 'defaults' => $this->defaults, 'override' => $this->override, - 'actionAdded' => $this->actionAdded, 'middlewares' => $this->middlewares, 'disabledMiddlewares' => $this->disabledMiddlewares, 'enabledMiddlewares' => $this->getEnabledMiddlewares(), @@ -328,22 +259,7 @@ public function __debugInfo() } /** - * @return array[]|callable[]|string[] - * @psalm-return list - */ - private function getEnabledMiddlewares(): array - { - if ($this->enabledMiddlewaresCache !== null) { - return $this->enabledMiddlewaresCache; - } - - $this->enabledMiddlewaresCache = MiddlewareFilter::filter($this->middlewares, $this->disabledMiddlewares); - - return $this->enabledMiddlewaresCache; - } - - /** - * @psalm-assert array $items + * @psalm-assert array $items */ private function assertListOfStrings(array $items, string $argument): void { diff --git a/src/RouteCollection.php b/src/RouteCollection.php index 01be0868..0b167ff6 100644 --- a/src/RouteCollection.php +++ b/src/RouteCollection.php @@ -8,6 +8,8 @@ use Psr\Http\Message\ResponseFactoryInterface; use Yiisoft\Http\Method; +use Yiisoft\Router\Builder\RouteBuilder; + use function array_key_exists; use function in_array; use function is_array; @@ -65,13 +67,16 @@ private function ensureItemsInjected(): void /** * Build routes array. * - * @param Group[]|Route[] $items + * @param Group[]|Route[]|RoutableInterface[] $items */ private function injectItems(array $items): void { foreach ($items as $item) { + if ($item instanceof RoutableInterface) { + $item = $item->toRoute(); + } if (!$this->isStaticRoute($item)) { - $item = $item->prependMiddleware(...$this->collector->getMiddlewareDefinitions()); + $item->setMiddlewares(array_merge($this->collector->getMiddlewares(), $item->getMiddlewares())); } $this->injectItem($item); } @@ -87,9 +92,9 @@ private function injectItem(Group|Route $route): void return; } - $routeName = $route->getData('name'); + $routeName = $route->getName(); $this->items[] = $routeName; - if (isset($this->routes[$routeName]) && !$route->getData('override')) { + if (isset($this->routes[$routeName]) && !$route->isOverride()) { throw new InvalidArgumentException("A route with name '$routeName' already exists."); } $this->routes[$routeName] = $route; @@ -102,57 +107,61 @@ private function injectItem(Group|Route $route): void */ private function injectGroup(Group $group, array &$tree, string $prefix = '', string $namePrefix = ''): void { - $prefix .= (string) $group->getData('prefix'); - $namePrefix .= (string) $group->getData('namePrefix'); - $items = $group->getData('routes'); + $prefix .= (string) $group->getPrefix(); + $namePrefix .= (string) $group->getNamePrefix(); + $items = $group->getRoutes(); $pattern = null; $hosts = []; foreach ($items as $item) { + if ($item instanceof RoutableInterface) { + $item = $item->toRoute(); + } if (!$this->isStaticRoute($item)) { - $item = $item->prependMiddleware(...$group->getData('enabledMiddlewares')); + $item = $item->setMiddlewares(array_merge($group->getEnabledMiddlewares(), $item->getMiddlewares())); } - if (!empty($group->getData('hosts')) && empty($item->getData('hosts'))) { - $item = $item->hosts(...$group->getData('hosts')); + if (!empty($group->getHosts()) && empty($item->getHosts())) { + $item->setHosts($group->getHosts()); } if ($item instanceof Group) { - if ($group->getData('hasCorsMiddleware')) { - $item = $item->withCors($group->getData('corsMiddleware')); + if ($group->getCorsMiddleware() !== null) { + $item->setCorsMiddleware($group->getCorsMiddleware()); } - if (empty($item->getData('prefix'))) { + if (empty($item->getPrefix())) { $this->injectGroup($item, $tree, $prefix, $namePrefix); continue; } /** @psalm-suppress PossiblyNullArrayOffset Checked group prefix on not empty above. */ - if (!isset($tree[$item->getData('prefix')])) { - $tree[$item->getData('prefix')] = []; + if (!isset($tree[$item->getPrefix()])) { + $tree[$item->getPrefix()] = []; } /** * @psalm-suppress MixedArgumentTypeCoercion * @psalm-suppress MixedArgument,PossiblyNullArrayOffset * Checked group prefix on not empty above. */ - $this->injectGroup($item, $tree[$item->getData('prefix')], $prefix, $namePrefix); + $this->injectGroup($item, $tree[$item->getPrefix()], $prefix, $namePrefix); continue; } - $modifiedItem = $item->pattern($prefix . $item->getData('pattern')); + /** @var Route $item */ + $item->setPattern($prefix . $item->getPattern()); - if (!str_contains($modifiedItem->getData('name'), implode(', ', $modifiedItem->getData('methods')))) { - $modifiedItem = $modifiedItem->name($namePrefix . $modifiedItem->getData('name')); + if (!str_contains($item->getName(), implode(', ', $item->getMethods()))) { + $item->setName($namePrefix . $item->getName()); } - if ($group->getData('hasCorsMiddleware')) { - $this->processCors($group, $hosts, $pattern, $modifiedItem, $tree); + if ($group->getCorsMiddleware() !== null) { + $this->processCors($group, $hosts, $pattern, $item, $tree); } - $routeName = $modifiedItem->getData('name'); + $routeName = $item->getName(); $tree[] = $routeName; - if (isset($this->routes[$routeName]) && !$modifiedItem->getData('override')) { + if (isset($this->routes[$routeName]) && !$item->isOverride()) { throw new InvalidArgumentException("A route with name '$routeName' already exists."); } - $this->routes[$routeName] = $modifiedItem; + $this->routes[$routeName] = $item; } } @@ -163,30 +172,31 @@ private function processCors( Group $group, array &$hosts, ?string &$pattern, - Route &$modifiedItem, + Route $modifiedItem, array &$tree ): void { /** @var array|callable|string $middleware */ - $middleware = $group->getData('corsMiddleware'); - $isNotDuplicate = !in_array(Method::OPTIONS, $modifiedItem->getData('methods'), true) - && ($pattern !== $modifiedItem->getData('pattern') || $hosts !== $modifiedItem->getData('hosts')); + $middleware = $group->getCorsMiddleware(); + $isNotDuplicate = !in_array(Method::OPTIONS, $modifiedItem->getMethods(), true) + && ($pattern !== $modifiedItem->getPattern() || $hosts !== $modifiedItem->getHosts()); - $pattern = $modifiedItem->getData('pattern'); - $hosts = $modifiedItem->getData('hosts'); - $optionsRoute = Route::options($pattern); + $pattern = $modifiedItem->getPattern(); + $hosts = $modifiedItem->getHosts(); + $optionsRoute = new Route([Method::OPTIONS], $pattern); if (!empty($hosts)) { - $optionsRoute = $optionsRoute->hosts(...$hosts); + $optionsRoute->setHosts($hosts); } if ($isNotDuplicate) { - $optionsRoute = $optionsRoute->middleware($middleware); - - $routeName = $optionsRoute->getData('name'); - $tree[] = $routeName; - $this->routes[$routeName] = $optionsRoute->action( + $optionsRoute->setMiddlewares([$middleware]); + $optionsRoute->setAction( static fn (ResponseFactoryInterface $responseFactory) => $responseFactory->createResponse(204) ); + + $routeName = $optionsRoute->getName(); + $tree[] = $routeName; + $this->routes[$routeName] = $optionsRoute; } - $modifiedItem = $modifiedItem->prependMiddleware($middleware); + $modifiedItem->setMiddlewares(array_merge([$middleware], $modifiedItem->getMiddlewares())); } /** @@ -208,8 +218,8 @@ private function buildTree(array $items, bool $routeAsString): array return $tree; } - private function isStaticRoute(Group|Route $item): bool + private function isStaticRoute(Group|Route|RoutableInterface $item): bool { - return $item instanceof Route && !$item->getData('hasMiddlewares'); + return $item instanceof Route && empty($item->getMiddlewares()) && $item->getAction() === null; } } diff --git a/src/RouteCollector.php b/src/RouteCollector.php index 03fb12bc..52c9d0f9 100644 --- a/src/RouteCollector.php +++ b/src/RouteCollector.php @@ -7,16 +7,16 @@ final class RouteCollector implements RouteCollectorInterface { /** - * @var Group[]|Route[] + * @var Group[]|Route[]|RoutableInterface[] */ private array $items = []; /** * @var array[]|callable[]|string[] */ - private array $middlewareDefinitions = []; + private array $middlewares = []; - public function addRoute(Route|Group ...$routes): RouteCollectorInterface + public function addRoute(Route|Group|RoutableInterface ...$routes): RouteCollectorInterface { array_push( $this->items, @@ -25,20 +25,20 @@ public function addRoute(Route|Group ...$routes): RouteCollectorInterface return $this; } - public function middleware(array|callable|string ...$middlewareDefinition): RouteCollectorInterface + public function middleware(array|callable|string ...$definition): RouteCollectorInterface { array_push( - $this->middlewareDefinitions, - ...array_values($middlewareDefinition) + $this->middlewares, + ...array_values($definition) ); return $this; } - public function prependMiddleware(array|callable|string ...$middlewareDefinition): RouteCollectorInterface + public function prependMiddleware(array|callable|string ...$definition): RouteCollectorInterface { array_unshift( - $this->middlewareDefinitions, - ...array_values($middlewareDefinition) + $this->middlewares, + ...array_values($definition) ); return $this; } @@ -48,8 +48,8 @@ public function getItems(): array return $this->items; } - public function getMiddlewareDefinitions(): array + public function getMiddlewares(): array { - return $this->middlewareDefinitions; + return $this->middlewares; } } diff --git a/src/RouteCollectorInterface.php b/src/RouteCollectorInterface.php index 38c5861c..8cf56aad 100644 --- a/src/RouteCollectorInterface.php +++ b/src/RouteCollectorInterface.php @@ -9,27 +9,29 @@ interface RouteCollectorInterface /** * Add a route or a group of routes. */ - public function addRoute(Route|Group ...$routes): self; + public function addRoute(Route|Group|RoutableInterface ...$routes): self; /** * Appends a handler middleware definition that should be invoked for a matched route. * First added handler will be executed first. */ - public function middleware(array|callable|string ...$middlewareDefinition): self; + public function middleware(array|callable|string ...$definition): self; /** * Prepends a handler middleware definition that should be invoked for a matched route. * First added handler will be executed last. */ - public function prependMiddleware(array|callable|string ...$middlewareDefinition): self; + public function prependMiddleware(array|callable|string ...$definition): self; /** - * @return Group[]|Route[] + * @return Group[]|Route[]|RoutableInterface[] */ public function getItems(): array; /** + * Returns middleware definitions. + * * @return array[]|callable[]|string[] */ - public function getMiddlewareDefinitions(): array; + public function getMiddlewares(): array; } diff --git a/tests/Builder/GroupBuilderTest.php b/tests/Builder/GroupBuilderTest.php new file mode 100644 index 00000000..4541ddc6 --- /dev/null +++ b/tests/Builder/GroupBuilderTest.php @@ -0,0 +1,439 @@ + new Response(); + $middleware2 = static fn () => new Response(); + + $group = $group + ->middleware($middleware1) + ->middleware($middleware2); + $groupRoute = $group->toRoute(); + + $this->assertCount(2, $groupRoute->getEnabledMiddlewares()); + $this->assertSame($middleware1, $groupRoute->getEnabledMiddlewares()[0]); + $this->assertSame($middleware2, $groupRoute->getEnabledMiddlewares()[1]); + } + + public function testMiddlewaresWithKeys(): void + { + $group = Group::create() + ->middleware(m3: TestMiddleware3::class) + ->prependMiddleware(m1: TestMiddleware1::class, m2: TestMiddleware2::class) + ->disableMiddleware(m1: TestMiddleware1::class); + $groupRoute = $group->toRoute(); + + $this->assertSame( + [TestMiddleware2::class, TestMiddleware3::class], + $groupRoute->getEnabledMiddlewares() + ); + } + + public function testNamedArgumentsInMiddlewareMethods(): void + { + $group = Group::create() + ->middleware(TestMiddleware3::class) + ->prependMiddleware(TestMiddleware1::class, TestMiddleware2::class) + ->disableMiddleware(TestMiddleware1::class, TestMiddleware3::class); + $groupRoute = $group->toRoute(); + + $this->assertCount(1, $groupRoute->getEnabledMiddlewares()); + $this->assertSame(TestMiddleware2::class, $groupRoute->getEnabledMiddlewares()[0]); + } + + public function testRoutesAfterMiddleware(): void + { + $group = Group::create(); + + $middleware1 = static fn () => new Response(); + + $group = $group->prependMiddleware($middleware1); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('routes() can not be used after prependMiddleware().'); + + $group->routes(Route::get('/')->toRoute()); + } + + public function testAddNestedMiddleware(): void + { + $request = new ServerRequest('GET', '/outergroup/innergroup/test1'); + + $action = static fn (ServerRequestInterface $request) => new Response( + 200, + [], + null, + '1.1', + implode('', $request->getAttributes()) + ); + + $middleware1 = static function (ServerRequestInterface $request, RequestHandlerInterface $handler) { + $request = $request->withAttribute('middleware', 'middleware1'); + return $handler->handle($request); + }; + + $middleware2 = static function (ServerRequestInterface $request, RequestHandlerInterface $handler) { + $request = $request->withAttribute('middleware', 'middleware2'); + return $handler->handle($request); + }; + + $group = Group::create('/outergroup') + ->middleware($middleware1) + ->routes( + Group::create('/innergroup') + ->middleware($middleware2) + ->routes( + Route::get('/test1') + ->action($action) + ->name('request1'), + ) + ); + + $collector = new RouteCollector(); + $collector->addRoute($group); + + $routeCollection = new RouteCollection($collector); + $route = $routeCollection->getRoute('request1'); + $response = $this->getDispatcher() + ->withMiddlewares($route->getEnabledMiddlewares()) + ->dispatch($request, $this->getRequestHandler()); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('middleware2', $response->getReasonPhrase()); + } + + public function testGroupMiddlewareFullStackCalled(): void + { + $request = new ServerRequest('GET', '/group/test1'); + + $action = static fn (ServerRequestInterface $request) => new Response( + 200, + [], + null, + '1.1', + implode('', $request->getAttributes()) + ); + $middleware1 = function (ServerRequestInterface $request, RequestHandlerInterface $handler) { + $request = $request->withAttribute('middleware', 'middleware1'); + return $handler->handle($request); + }; + $middleware2 = function (ServerRequestInterface $request, RequestHandlerInterface $handler) { + $request = $request->withAttribute('middleware', 'middleware2'); + return $handler->handle($request); + }; + + $group = Group::create('/group') + ->middleware($middleware1) + ->middleware($middleware2) + ->routes( + Route::get('/test1') + ->action($action) + ->name('request1'), + ); + + $collector = new RouteCollector(); + $collector->addRoute($group); + + $routeCollection = new RouteCollection($collector); + $route = $routeCollection->getRoute('request1'); + + $response = $this->getDispatcher() + ->withMiddlewares($route->getEnabledMiddlewares()) + ->dispatch($request, $this->getRequestHandler()); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('middleware2', $response->getReasonPhrase()); + } + + public function testGroupMiddlewareStackInterrupted(): void + { + $request = new ServerRequest('GET', '/group/test1'); + + $action = static fn () => new Response(200); + $middleware1 = fn () => new Response(403); + $middleware2 = fn () => new Response(405); + + $group = Group::create('/group') + ->middleware($middleware1) + ->middleware($middleware2) + ->routes( + Route::get('/test1') + ->action($action) + ->name('request1') + ); + + $collector = new RouteCollector(); + $collector->addRoute($group); + + $routeCollection = new RouteCollection($collector); + $route = $routeCollection->getRoute('request1'); + + $response = $this->getDispatcher() + ->withMiddlewares($route->getEnabledMiddlewares()) + ->dispatch($request, $this->getRequestHandler()); + + $this->assertSame(403, $response->getStatusCode()); + } + + public function testAddGroup(): void + { + $logoutRoute = Route::post('/logout'); + $listRoute = Route::get('/'); + $viewRoute = Route::get('/{id}'); + + $middleware1 = static fn () => new Response(); + $middleware2 = static fn () => new Response(); + + $root = Group::create() + ->routes( + Group::create('/api') + ->middleware($middleware1) + ->middleware($middleware2) + ->routes( + $logoutRoute, + Group::create('/post') + ->routes( + $listRoute, + $viewRoute + ) + ), + ); + $rootGroup = $root->toRoute(); + + $this->assertCount(1, $rootGroup->getRoutes()); + + /** @var Group $api */ + $api = $rootGroup->getRoutes()[0]; + $apiRoute = $api->toRoute(); + + $this->assertSame('/api', $apiRoute->getPrefix()); + $this->assertCount(2, $apiRoute->getRoutes()); + $this->assertSame($logoutRoute, $apiRoute->getRoutes()[0]); + + /** @var Group $postGroup */ + $postGroup = $apiRoute->getRoutes()[1]; + $postGroup = $postGroup->toRoute(); + + $this->assertInstanceOf(\Yiisoft\Router\Group::class, $postGroup); + $this->assertCount(2, $apiRoute->getEnabledMiddlewares()); + $this->assertSame($middleware1, $apiRoute->getEnabledMiddlewares()[0]); + $this->assertSame($middleware2, $apiRoute->getEnabledMiddlewares()[1]); + + $this->assertSame('/post', $postGroup->getPrefix()); + $this->assertCount(2, $postGroup->getRoutes()); + $this->assertSame($listRoute, $postGroup->getRoutes()[0]); + $this->assertSame($viewRoute, $postGroup->getRoutes()[1]); + $this->assertEmpty($postGroup->getEnabledMiddlewares()); + } + + public function testHost(): void + { + $group = Group::create()->host('https://yiiframework.com/'); + + $this->assertSame('https://yiiframework.com', $group->toRoute()->getHosts()[0]); + } + + public function testHosts(): void + { + $group = Group::create()->hosts('https://yiiframework.com/', 'https://yiiframework.ru/'); + + $this->assertSame(['https://yiiframework.com', 'https://yiiframework.ru'], $group->toRoute()->getHosts()); + } + + public function testName(): void + { + $group = Group::create()->namePrefix('api'); + + $this->assertSame('api', $group->toRoute()->getNamePrefix()); + } + + + public function testWithCors(): void + { + $group = Group::create() + ->routes( + Route::get('/info')->action(static fn () => 'info'), + Route::post('/info')->action(static fn () => 'info'), + ) + ->withCors( + static fn () => new Response(204) + ); + + $collector = new RouteCollector(); + $collector->addRoute($group); + $routeCollection = new RouteCollection($collector); + + $this->assertCount(3, $routeCollection->getRoutes()); + } + + public function testWithCorsWithHostRoutes(): void + { + $group = Group::create() + ->routes( + Route::get('/info') + ->action(static fn () => 'info') + ->host('yii.dev'), + Route::get('/info') + ->action(static fn () => 'info') + ->host('yii.test'), + ) + ->withCors( + static fn () => new Response(204) + ); + + $collector = new RouteCollector(); + $collector->addRoute($group); + $routeCollection = new RouteCollection($collector); + + $this->assertCount(4, $routeCollection->getRoutes()); + } + + public function testWithCorsDoesntDuplicateRoutes(): void + { + $group = Group::create() + ->routes( + Route::get('/info') + ->action(static fn () => 'info') + ->host('yii.dev'), + Route::post('/info') + ->action(static fn () => 'info') + ->host('yii.dev'), + Route::put('/info') + ->action(static fn () => 'info') + ->host('yii.test'), + ) + ->withCors( + static fn () => new Response(204) + ); + + $collector = new RouteCollector(); + $collector->addRoute($group); + $routeCollection = new RouteCollection($collector); + + $this->assertCount(5, $routeCollection->getRoutes()); + } + + public function testWithCorsWithNestedGroups(): void + { + $group = Group::create()->routes( + Route::get('/info')->action(static fn () => 'info'), + Route::post('/info')->action(static fn () => 'info'), + Group::create('/v1') + ->routes( + Route::get('/post')->action(static fn () => 'post'), + Route::post('/post')->action(static fn () => 'post'), + Route::options('/options')->action(static fn () => 'options'), + ) + ->withCors( + static fn () => new Response(201) + ) + )->withCors( + static fn () => new Response(204) + ); + + $collector = new RouteCollector(); + $collector->addRoute($group); + + $routeCollection = new RouteCollection($collector); + $this->assertCount(7, $routeCollection->getRoutes()); + $this->assertInstanceOf(\Yiisoft\Router\Route::class, $routeCollection->getRoute('OPTIONS /v1/post')); + } + + public function testWithCorsWithNestedGroups2(): void + { + $group = Group::create()->routes( + Route::get('/info')->action(static fn () => 'info'), + Route::post('/info')->action(static fn () => 'info'), + Route::get('/v1/post')->action(static fn () => 'post'), + Group::create('/v1')->routes( + Route::post('/post')->action(static fn () => 'post'), + Route::options('/options')->action(static fn () => 'options'), + ), + Group::create('/v1')->routes( + Route::put('/post')->action(static fn () => 'post'), + ) + )->withCors( + static fn () => new Response(204) + ); + $collector = new RouteCollector(); + $collector->addRoute($group); + + $routeCollection = new RouteCollection($collector); + $this->assertCount(8, $routeCollection->getRoutes()); + $this->assertInstanceOf(\Yiisoft\Router\Route::class, $routeCollection->getRoute('OPTIONS /v1/post')); + } + + public function testMiddlewareAfterRoutes(): void + { + $group = Group::create()->routes(Route::get('/info')->action(static fn () => 'info')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('middleware() can not be used after routes().'); + $group->middleware(static fn () => new Response()); + } + + public function testDuplicateHosts(): void + { + $route = Group::create()->hosts('a.com', 'b.com', 'a.com'); + + $this->assertSame(['a.com', 'b.com'], $route->toRoute()->getHosts()); + } + + public function testImmutability(): void + { + $group = Group::create(); + + $this->assertNotSame($group, $group->routes()); + $this->assertNotSame($group, $group->withCors(null)); + $this->assertNotSame($group, $group->middleware()); + $this->assertNotSame($group, $group->prependMiddleware()); + $this->assertNotSame($group, $group->namePrefix('')); + $this->assertNotSame($group, $group->hosts()); + $this->assertNotSame($group, $group->disableMiddleware()); + } + + private function getRequestHandler(): RequestHandlerInterface + { + return new class () implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + return new Response(404); + } + }; + } + + private function getDispatcher(): MiddlewareDispatcher + { + $container = new Container([]); + return new MiddlewareDispatcher( + new MiddlewareFactory($container), + $this->createMock(EventDispatcherInterface::class) + ); + } +} diff --git a/tests/Builder/RouteBuilderTest.php b/tests/Builder/RouteBuilderTest.php new file mode 100644 index 00000000..68cdca57 --- /dev/null +++ b/tests/Builder/RouteBuilderTest.php @@ -0,0 +1,392 @@ +name('test.route'); + + $this->assertSame('test.route', $route->toRoute()->getName()); + } + + public function testNameDefault(): void + { + $route = Route::get('/'); + + $this->assertSame('GET /', $route->toRoute()->getName()); + } + + public function testNameDefaultWithHosts(): void + { + $route = Route::get('/')->hosts('a.com', 'b.com'); + + $this->assertSame('GET a.com|b.com/', $route->toRoute()->getName()); + } + + public function testMethods(): void + { + $route = Route::methods([Method::POST, Method::HEAD], '/'); + + $this->assertSame([Method::POST, Method::HEAD], $route->toRoute()->getMethods()); + } + + public function testGetMethod(): void + { + $route = Route::get('/'); + + $this->assertSame([Method::GET], $route->toRoute()->getMethods()); + } + + public function testPostMethod(): void + { + $route = Route::post('/'); + + $this->assertSame([Method::POST], $route->toRoute()->getMethods()); + } + + public function testPutMethod(): void + { + $route = Route::put('/'); + + $this->assertSame([Method::PUT], $route->toRoute()->getMethods()); + } + + public function testDeleteMethod(): void + { + $route = Route::delete('/'); + + $this->assertSame([Method::DELETE], $route->toRoute()->getMethods()); + } + + public function testPatchMethod(): void + { + $route = Route::patch('/'); + + $this->assertSame([Method::PATCH], $route->toRoute()->getMethods()); + } + + public function testHeadMethod(): void + { + $route = Route::head('/'); + + $this->assertSame([Method::HEAD], $route->toRoute()->getMethods()); + } + + public function testOptionsMethod(): void + { + $route = Route::options('/'); + + $this->assertSame([Method::OPTIONS], $route->toRoute()->getMethods()); + } + + public function testPattern(): void + { + $route = Route::get('/test')->pattern('/test2'); + + $this->assertSame('/test2', $route->toRoute()->getPattern()); + } + + public function testHost(): void + { + $route = Route::get('/')->host('https://yiiframework.com/'); + + $this->assertSame('https://yiiframework.com', $route->toRoute()->getHosts()[0]); + } + + public function testHosts(): void + { + $route = Route::get('/') + ->hosts( + 'https://yiiframework.com/', + 'yf.com', + 'yii.com', + 'yf.ru' + ); + + $this->assertSame( + [ + 'https://yiiframework.com', + 'yf.com', + 'yii.com', + 'yf.ru', + ], + $route->toRoute()->getHosts() + ); + } + + public function testMultipleHosts(): void + { + $route = Route::get('/') + ->host('https://yiiframework.com/'); + $multipleRoute = Route::get('/') + ->hosts( + 'https://yiiframework.com/', + 'https://yiiframework.ru/' + ); + + $this->assertCount(1, $route->toRoute()->getHosts()); + $this->assertCount(2, $multipleRoute->toRoute()->getHosts()); + } + + public function testDefaults(): void + { + $route = Route::get('/{language}')->defaults([ + 'language' => 'en', + 'age' => 42, + ]); + + $this->assertSame([ + 'language' => 'en', + 'age' => '42', + ], $route->toRoute()->getDefaults()); + } + + public function testOverride(): void + { + $route = Route::get('/')->override(); + + $this->assertTrue($route->toRoute()->isOverride()); + } + + public function dataToString(): array + { + return [ + ['yiiframework.com/', '/'], + ['yiiframework.com/yiiframeworkXcom', '/yiiframeworkXcom'], + ]; + } + + /** + * @dataProvider dataToString + */ + public function testToString(string $expected, string $pattern): void + { + $route = Route::methods([Method::GET, Method::POST], $pattern) + ->name('test.route') + ->host('yiiframework.com'); + + $this->assertSame('[test.route] GET,POST ' . $expected, (string)$route->toRoute()); + } + + public function testToStringSimple(): void + { + $route = Route::get('/'); + + $this->assertSame('GET /', (string)$route->toRoute()); + } + + public function testDispatcherInjecting(): void + { + $request = new ServerRequest('GET', '/'); + $container = $this->getContainer( + [ + TestController::class => new TestController(), + ] + ); + + $route = Route::get('/')->action([TestController::class, 'index']); + + $response = $this + ->getDispatcher($container) + ->withMiddlewares($route->toRoute()->getEnabledMiddlewares()) + ->dispatch($request, $this->getRequestHandler()); + + $this->assertSame(200, $response->getStatusCode()); + } + + public function testDisabledMiddlewareDefinitions(): void + { + $request = new ServerRequest('GET', '/'); + + $route = Route::get('/') + ->middleware(TestMiddleware1::class, TestMiddleware2::class, TestMiddleware3::class) + ->action([TestController::class, 'index']) + ->disableMiddleware(TestMiddleware1::class, TestMiddleware3::class); + + $dispatcher = $this + ->getDispatcher( + $this->getContainer([ + TestMiddleware1::class => new TestMiddleware1(), + TestMiddleware2::class => new TestMiddleware2(), + TestMiddleware3::class => new TestMiddleware3(), + TestController::class => new TestController(), + ]) + ) + ->withMiddlewares($route->toRoute()->getEnabledMiddlewares()); + + $response = $dispatcher->dispatch($request, $this->getRequestHandler()); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('2', (string) $response->getBody()); + } + + public function testPrependMiddlewareDefinitions(): void + { + $request = new ServerRequest('GET', '/'); + + $route = Route::get('/') + ->middleware(TestMiddleware3::class) + ->action([TestController::class, 'index']) + ->prependMiddleware(TestMiddleware1::class, TestMiddleware2::class); + + $response = $this + ->getDispatcher( + $this->getContainer([ + TestMiddleware1::class => new TestMiddleware1(), + TestMiddleware2::class => new TestMiddleware2(), + TestMiddleware3::class => new TestMiddleware3(), + TestController::class => new TestController(), + ]) + ) + ->withMiddlewares($route->toRoute()->getEnabledMiddlewares()) + ->dispatch($request, $this->getRequestHandler()); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('123', (string) $response->getBody()); + } + + public function testPrependMiddlewaresAfterGetEnabledMiddlewares(): void + { + $route = Route::get('/') + ->middleware(TestMiddleware3::class) + ->disableMiddleware(TestMiddleware1::class) + ->action([TestController::class, 'index']); + + $route->toRoute()->getEnabledMiddlewares(); + + $route = $route->prependMiddleware(TestMiddleware1::class, TestMiddleware2::class); + + $this->assertSame( + [TestMiddleware2::class, TestMiddleware3::class, [TestController::class, 'index']], + $route->toRoute()->getEnabledMiddlewares() + ); + } + + public function testAddMiddlewareAfterGetEnabledMiddlewares(): void + { + $route = Route::get('/') + ->middleware(TestMiddleware3::class); + + $route->toRoute()->getEnabledMiddlewares(); + + $route = $route->middleware(TestMiddleware1::class, TestMiddleware2::class); + + $this->assertSame( + [TestMiddleware3::class, TestMiddleware1::class, TestMiddleware2::class], + $route->toRoute()->getEnabledMiddlewares() + ); + } + + public function testDisableMiddlewareAfterGetEnabledMiddlewares(): void + { + $route = Route::get('/') + ->middleware(TestMiddleware1::class, TestMiddleware2::class, TestMiddleware3::class); + + $route->toRoute()->getEnabledMiddlewares(); + + $route = $route->disableMiddleware(TestMiddleware1::class, TestMiddleware2::class); + + $this->assertSame( + [TestMiddleware3::class], + $route->toRoute()->getEnabledMiddlewares() + ); + } + + public function testGetEnabledMiddlewaresTwice(): void + { + $route = Route::get('/') + ->middleware(TestMiddleware1::class, TestMiddleware2::class); + + $result1 = $route->toRoute()->getEnabledMiddlewares(); + $result2 = $route->toRoute()->getEnabledMiddlewares(); + + $this->assertSame([TestMiddleware1::class, TestMiddleware2::class], $result1); + $this->assertSame($result1, $result2); + } + + public function testMiddlewaresWithKeys(): void + { + $route = Route::get('/') + ->middleware(m3: TestMiddleware3::class) + ->action([TestController::class, 'index']) + ->prependMiddleware(m1: TestMiddleware1::class, m2: TestMiddleware2::class) + ->disableMiddleware(m1: TestMiddleware1::class); + + $this->assertSame( + [TestMiddleware2::class, TestMiddleware3::class, [TestController::class, 'index']], + $route->toRoute()->getEnabledMiddlewares() + ); + } + + public function testImmutability(): void + { + $route = Route::get('/'); + $routeWithAction = $route->action(''); + + $this->assertNotSame($route, $route->name('')); + $this->assertNotSame($route, $route->pattern('')); + $this->assertNotSame($route, $route->host('')); + $this->assertNotSame($route, $route->hosts('')); + $this->assertNotSame($route, $route->override()); + $this->assertNotSame($route, $route->defaults([])); + $this->assertNotSame($route, $route->middleware()); + $this->assertNotSame($route, $route->action('')); + $this->assertNotSame($routeWithAction, $routeWithAction->prependMiddleware()); + $this->assertNotSame($route, $route->disableMiddleware('')); + } + + private function getRequestHandler(): RequestHandlerInterface + { + return new class () implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + return new Response(404); + } + }; + } + + private function getDispatcher(ContainerInterface $container = null): MiddlewareDispatcher + { + if ($container === null) { + return new MiddlewareDispatcher( + new MiddlewareFactory($this->getContainer()), + $this->createMock(EventDispatcherInterface::class) + ); + } + + return new MiddlewareDispatcher( + new MiddlewareFactory($container), + $this->createMock(EventDispatcherInterface::class) + ); + } + + private function getContainer(array $instances = []): ContainerInterface + { + return new Container($instances); + } +} diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index a7a85f60..a902e093 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -10,7 +10,7 @@ use Yiisoft\Di\ContainerConfig; use Yiisoft\Di\StateResetter; use Yiisoft\Router\CurrentRoute; -use Yiisoft\Router\Route; +use Yiisoft\Router\Builder\RouteBuilder as Route; use Yiisoft\Router\RouteCollector; use Yiisoft\Router\RouteCollectorInterface; @@ -29,7 +29,7 @@ public function testCurrentRoute(): void $container = $this->createContainer(); $currentRoute = $container->get(CurrentRoute::class); - $currentRoute->setRouteWithArguments(Route::get('/main'), ['name' => 'hello']); + $currentRoute->setRouteWithArguments(Route::get('/main')->toRoute(), ['name' => 'hello']); $currentRoute->setUri(new Uri('http://example.com/')); $container diff --git a/tests/CurrentRouteTest.php b/tests/CurrentRouteTest.php index 8afd10a7..42701866 100644 --- a/tests/CurrentRouteTest.php +++ b/tests/CurrentRouteTest.php @@ -7,6 +7,7 @@ use LogicException; use Nyholm\Psr7\Uri; use PHPUnit\Framework\TestCase; +use Yiisoft\Http\Method; use Yiisoft\Router\CurrentRoute; use Yiisoft\Router\Route; @@ -14,38 +15,38 @@ class CurrentRouteTest extends TestCase { public function testGetName(): void { - $route = Route::get('')->name('test'); + $route = new Route([Method::GET], '', 'test'); $currentRoute = new CurrentRoute(); $currentRoute->setRouteWithArguments($route, []); - $this->assertSame($route->getData('name'), $currentRoute->getName()); + $this->assertSame($route->getName(), $currentRoute->getName()); } public function testGetHost(): void { - $route = Route::get('')->host('test.com'); + $route = new Route([Method::GET], '', hosts: ['test.com']); $currentRoute = new CurrentRoute(); $currentRoute->setRouteWithArguments($route, []); - $this->assertSame($route->getData('host'), $currentRoute->getHost()); + $this->assertSame($route->getHosts(), $currentRoute->getHosts()); } public function testGetPattern(): void { - $route = Route::get('/home'); + $route = new Route([Method::GET], '/home'); $currentRoute = new CurrentRoute(); $currentRoute->setRouteWithArguments($route, []); - $this->assertSame($route->getData('pattern'), $currentRoute->getPattern()); + $this->assertSame($route->getPattern(), $currentRoute->getPattern()); } public function testGetMethods(): void { - $route = Route::get(''); + $route = new Route([Method::GET], ''); $currentRoute = new CurrentRoute(); $currentRoute->setRouteWithArguments($route, []); - $this->assertSame($route->getData('methods'), $currentRoute->getMethods()); + $this->assertSame($route->getMethods(), $currentRoute->getMethods()); } public function testGetCurrentUri(): void @@ -64,7 +65,7 @@ public function testGetArguments(): void 'foo' => 'bar', ]; $currentRoute = new CurrentRoute(); - $currentRoute->setRouteWithArguments(Route::get(''), $parameters); + $currentRoute->setRouteWithArguments(new Route([Method::GET], ''), $parameters); $this->assertSame($parameters, $currentRoute->getArguments()); } @@ -76,7 +77,7 @@ public function testGetArgument(): void 'foo' => 'bar', ]; $currentRoute = new CurrentRoute(); - $currentRoute->setRouteWithArguments(Route::get(''), $parameters); + $currentRoute->setRouteWithArguments(new Route([Method::GET], ''), $parameters); $this->assertSame('bar', $currentRoute->getArgument('foo')); } @@ -84,7 +85,7 @@ public function testGetArgument(): void public function testGetArgumentWithDefault(): void { $currentRoute = new CurrentRoute(); - $currentRoute->setRouteWithArguments(Route::get(''), ['test' => 1]); + $currentRoute->setRouteWithArguments(new Route([Method::GET], ''), ['test' => 1]); $this->assertSame('bar', $currentRoute->getArgument('foo', 'bar')); } @@ -92,7 +93,7 @@ public function testGetArgumentWithDefault(): void public function testGetArgumentWithNonExist(): void { $currentRoute = new CurrentRoute(); - $currentRoute->setRouteWithArguments(Route::get(''), ['test' => 1]); + $currentRoute->setRouteWithArguments(new Route([Method::GET], ''), ['test' => 1]); $this->assertNull($currentRoute->getArgument('foo')); } @@ -103,8 +104,8 @@ public function testSetRouteTwice(): void $this->expectExceptionMessage('Can not set route/arguments since it was already set.'); $currentRoute = new CurrentRoute(); - $currentRoute->setRouteWithArguments(Route::get('')->name('test'), []); - $currentRoute->setRouteWithArguments(Route::get('/home')->name('home'), []); + $currentRoute->setRouteWithArguments(new Route([Method::GET], '', 'test'), []); + $currentRoute->setRouteWithArguments(new Route([Method::GET], '/home', 'home'), []); } public function testSetUriTwice(): void @@ -123,7 +124,7 @@ public function testSetArgumentsTwice(): void $this->expectExceptionMessage('Can not set route/arguments since it was already set.'); $currentRoute = new CurrentRoute(); - $currentRoute->setRouteWithArguments(Route::get(''), ['foo' => 'bar']); - $currentRoute->setRouteWithArguments(Route::get(''), ['id' => 1]); + $currentRoute->setRouteWithArguments(new Route([Method::GET], ''), ['foo' => 'bar']); + $currentRoute->setRouteWithArguments(new Route([Method::GET], ''), ['id' => 1]); } } diff --git a/tests/Debug/RouterCollectorTest.php b/tests/Debug/RouterCollectorTest.php index e3e5731d..4c375b4a 100644 --- a/tests/Debug/RouterCollectorTest.php +++ b/tests/Debug/RouterCollectorTest.php @@ -7,6 +7,9 @@ use PHPUnit\Framework\MockObject\MockObject; use Yiisoft\Di\Container; use Yiisoft\Di\ContainerConfig; +use Yiisoft\Http\Method; +use Yiisoft\Router\Builder\GroupBuilder; +use Yiisoft\Router\Builder\RouteBuilder; use Yiisoft\Router\Debug\RouterCollector; use Yiisoft\Router\Group; use Yiisoft\Router\Route; @@ -76,8 +79,8 @@ protected function checkCollectedData(array $data): void private function createRoutes(): array { return [ - Route::get('/'), - Group::create('/api')->routes(Route::get('/v1')), + new Route([Method::GET], '/'), + GroupBuilder::create('/api')->routes(RouteBuilder::get('/v1')), ]; } } diff --git a/tests/GroupTest.php b/tests/GroupTest.php index ecc850d9..aa82bb64 100644 --- a/tests/GroupTest.php +++ b/tests/GroupTest.php @@ -6,243 +6,63 @@ use InvalidArgumentException; use Nyholm\Psr7\Response; -use Nyholm\Psr7\ServerRequest; use PHPUnit\Framework\TestCase; -use Psr\EventDispatcher\EventDispatcherInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; -use RuntimeException; -use Yiisoft\Middleware\Dispatcher\MiddlewareDispatcher; -use Yiisoft\Middleware\Dispatcher\MiddlewareFactory; use Yiisoft\Router\Group; -use Yiisoft\Router\Route; -use Yiisoft\Router\RouteCollection; -use Yiisoft\Router\RouteCollector; -use Yiisoft\Router\Tests\Support\Container; use Yiisoft\Router\Tests\Support\TestMiddleware1; use Yiisoft\Router\Tests\Support\TestMiddleware2; use Yiisoft\Router\Tests\Support\TestMiddleware3; final class GroupTest extends TestCase { - public function testAddMiddleware(): void - { - $group = Group::create(); - - $middleware1 = static fn () => new Response(); - $middleware2 = static fn () => new Response(); - - $group = $group - ->middleware($middleware1) - ->middleware($middleware2); - $this->assertCount(2, $group->getData('enabledMiddlewares')); - $this->assertSame($middleware1, $group->getData('enabledMiddlewares')[0]); - $this->assertSame($middleware2, $group->getData('enabledMiddlewares')[1]); - } - public function testDisabledMiddlewareDefinitions(): void { - $group = Group::create() - ->middleware(TestMiddleware3::class) - ->prependMiddleware(TestMiddleware1::class, TestMiddleware2::class) - ->disableMiddleware(TestMiddleware1::class, TestMiddleware3::class); + $group = (new Group()) + ->setDisabledMiddlewares([TestMiddleware1::class, TestMiddleware3::class]); - $this->assertCount(1, $group->getData('enabledMiddlewares')); - $this->assertSame(TestMiddleware2::class, $group->getData('enabledMiddlewares')[0]); + $this->assertCount(2, $group->getDisabledMiddlewares()); } - public function testPrependMiddlewaresAfterGetEnabledMiddlewares(): void + public function testEnabledMiddlewares(): void { - $group = Group::create() - ->middleware(TestMiddleware3::class) - ->disableMiddleware(TestMiddleware1::class); - - $group->getData('enabledMiddlewares'); + $group = (new Group()) + ->setMiddlewares([TestMiddleware1::class, TestMiddleware2::class, TestMiddleware3::class]) + ->setDisabledMiddlewares([TestMiddleware1::class, TestMiddleware3::class]); - $group = $group->prependMiddleware(TestMiddleware1::class, TestMiddleware2::class); - - $this->assertSame( - [TestMiddleware2::class, TestMiddleware3::class], - $group->getData('enabledMiddlewares') - ); + $this->assertCount(1, $group->getEnabledMiddlewares()); + $this->assertSame(TestMiddleware2::class, $group->getEnabledMiddlewares()[0]); } - public function testAddMiddlewareAfterGetEnabledMiddlewares(): void + public function testSetMiddlewaresAfterGetEnabledMiddlewares(): void { - $group = Group::create() - ->middleware(TestMiddleware3::class); + $group = (new Group()) + ->setMiddlewares([TestMiddleware3::class]) + ->setDisabledMiddlewares([TestMiddleware1::class]); - $group->getData('enabledMiddlewares'); + $group->getEnabledMiddlewares(); - $group = $group->middleware(TestMiddleware1::class, TestMiddleware2::class); + $group->setMiddlewares([TestMiddleware1::class, TestMiddleware2::class, ...$group->getMiddlewares()]); $this->assertSame( - [TestMiddleware3::class, TestMiddleware1::class, TestMiddleware2::class], - $group->getData('enabledMiddlewares') + [TestMiddleware2::class, TestMiddleware3::class], + $group->getEnabledMiddlewares() ); } public function testDisableMiddlewareAfterGetEnabledMiddlewares(): void { - $group = Group::create() - ->middleware(TestMiddleware1::class, TestMiddleware2::class, TestMiddleware3::class); + $group = (new Group) + ->setMiddlewares([TestMiddleware1::class, TestMiddleware2::class, TestMiddleware3::class]); - $group->getData('enabledMiddlewares'); + $group->getEnabledMiddlewares(); - $group = $group->disableMiddleware(TestMiddleware1::class, TestMiddleware2::class); + $group->setDisabledMiddlewares([TestMiddleware1::class, TestMiddleware2::class]); $this->assertSame( [TestMiddleware3::class], - $group->getData('enabledMiddlewares') + $group->getEnabledMiddlewares() ); } - public function testMiddlewaresWithKeys(): void - { - $group = Group::create() - ->middleware(m3: TestMiddleware3::class) - ->prependMiddleware(m1: TestMiddleware1::class, m2: TestMiddleware2::class) - ->disableMiddleware(m1: TestMiddleware1::class); - - $this->assertSame( - [TestMiddleware2::class, TestMiddleware3::class], - $group->getData('enabledMiddlewares') - ); - } - - public function testNamedArgumentsInMiddlewareMethods(): void - { - $group = Group::create() - ->middleware(TestMiddleware3::class) - ->prependMiddleware(TestMiddleware1::class, TestMiddleware2::class) - ->disableMiddleware(TestMiddleware1::class, TestMiddleware3::class); - - $this->assertCount(1, $group->getData('enabledMiddlewares')); - $this->assertSame(TestMiddleware2::class, $group->getData('enabledMiddlewares')[0]); - } - - public function testRoutesAfterMiddleware(): void - { - $group = Group::create(); - - $middleware1 = static fn () => new Response(); - - $group = $group->prependMiddleware($middleware1); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('routes() can not be used after prependMiddleware().'); - - $group->routes(Route::get('/')); - } - - public function testAddNestedMiddleware(): void - { - $request = new ServerRequest('GET', '/outergroup/innergroup/test1'); - - $action = static fn (ServerRequestInterface $request) => new Response(200, [], null, '1.1', implode('', $request->getAttributes())); - - $middleware1 = static function (ServerRequestInterface $request, RequestHandlerInterface $handler) { - $request = $request->withAttribute('middleware', 'middleware1'); - return $handler->handle($request); - }; - - $middleware2 = static function (ServerRequestInterface $request, RequestHandlerInterface $handler) { - $request = $request->withAttribute('middleware', 'middleware2'); - return $handler->handle($request); - }; - - $group = Group::create('/outergroup') - ->middleware($middleware1) - ->routes( - Group::create('/innergroup') - ->middleware($middleware2) - ->routes( - Route::get('/test1') - ->action($action) - ->name('request1'), - ) - ); - - $collector = new RouteCollector(); - $collector->addRoute($group); - - $routeCollection = new RouteCollection($collector); - $route = $routeCollection->getRoute('request1'); - $response = $this->getDispatcher() - ->withMiddlewares($route->getData('enabledMiddlewares')) - ->dispatch($request, $this->getRequestHandler()); - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('middleware2', $response->getReasonPhrase()); - } - - public function testGroupMiddlewareFullStackCalled(): void - { - $request = new ServerRequest('GET', '/group/test1'); - - $action = static fn (ServerRequestInterface $request) => new Response(200, [], null, '1.1', implode('', $request->getAttributes())); - $middleware1 = function (ServerRequestInterface $request, RequestHandlerInterface $handler) { - $request = $request->withAttribute('middleware', 'middleware1'); - return $handler->handle($request); - }; - $middleware2 = function (ServerRequestInterface $request, RequestHandlerInterface $handler) { - $request = $request->withAttribute('middleware', 'middleware2'); - return $handler->handle($request); - }; - - $group = Group::create('/group') - ->middleware($middleware1) - ->middleware($middleware2) - ->routes( - Route::get('/test1') - ->action($action) - ->name('request1'), - ); - - $collector = new RouteCollector(); - $collector->addRoute($group); - - $routeCollection = new RouteCollection($collector); - $route = $routeCollection->getRoute('request1'); - - $response = $this->getDispatcher() - ->withMiddlewares($route->getData('enabledMiddlewares')) - ->dispatch($request, $this->getRequestHandler()); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('middleware2', $response->getReasonPhrase()); - } - - public function testGroupMiddlewareStackInterrupted(): void - { - $request = new ServerRequest('GET', '/group/test1'); - - $action = static fn () => new Response(200); - $middleware1 = fn () => new Response(403); - $middleware2 = fn () => new Response(405); - - $group = Group::create('/group') - ->middleware($middleware1) - ->middleware($middleware2) - ->routes( - Route::get('/test1') - ->action($action) - ->name('request1') - ); - - $collector = new RouteCollector(); - $collector->addRoute($group); - - $routeCollection = new RouteCollection($collector); - $route = $routeCollection->getRoute('request1'); - - $response = $this->getDispatcher() - ->withMiddlewares($route->getData('enabledMiddlewares')) - ->dispatch($request, $this->getRequestHandler()); - - $this->assertSame(403, $response->getStatusCode()); - } - public function testInvalidMiddlewares(): void { $this->expectException(InvalidArgumentException::class); @@ -252,65 +72,11 @@ public function testInvalidMiddlewares(): void $group = new Group('/api', middlewares: [$middleware, new \stdClass()]); } - public function testAddGroup(): void - { - $logoutRoute = Route::post('/logout'); - $listRoute = Route::get('/'); - $viewRoute = Route::get('/{id}'); - - $middleware1 = static fn () => new Response(); - $middleware2 = static fn () => new Response(); - - $root = Group::create() - ->routes( - Group::create('/api') - ->middleware($middleware1) - ->middleware($middleware2) - ->routes( - $logoutRoute, - Group::create('/post') - ->routes( - $listRoute, - $viewRoute - ) - ), - ); - - $this->assertCount(1, $root->getData('routes')); - - /** @var Group $api */ - $api = $root->getData('routes')[0]; - - $this->assertSame('/api', $api->getData('prefix')); - $this->assertCount(2, $api->getData('routes')); - $this->assertSame($logoutRoute, $api->getData('routes')[0]); - - /** @var Group $postGroup */ - $postGroup = $api->getData('routes')[1]; - $this->assertInstanceOf(Group::class, $postGroup); - $this->assertCount(2, $api->getData('enabledMiddlewares')); - $this->assertSame($middleware1, $api->getData('enabledMiddlewares')[0]); - $this->assertSame($middleware2, $api->getData('enabledMiddlewares')[1]); - - $this->assertSame('/post', $postGroup->getData('prefix')); - $this->assertCount(2, $postGroup->getData('routes')); - $this->assertSame($listRoute, $postGroup->getData('routes')[0]); - $this->assertSame($viewRoute, $postGroup->getData('routes')[1]); - $this->assertEmpty($postGroup->getData('enabledMiddlewares')); - } - - public function testHost(): void - { - $group = Group::create()->host('https://yiiframework.com/'); - - $this->assertSame('https://yiiframework.com', $group->getData('host')); - } - public function testHosts(): void { - $group = Group::create()->hosts('https://yiiframework.com/', 'https://yiiframework.ru/'); + $group = (new Group())->setHosts(['https://yiiframework.com/']); - $this->assertSame(['https://yiiframework.com', 'https://yiiframework.ru'], $group->getData('hosts')); + $this->assertSame(['https://yiiframework.com'], $group->getHosts()); } public function testInvalidHosts(): void @@ -321,183 +87,24 @@ public function testInvalidHosts(): void $group = new Group(hosts: ['https://yiiframework.com/', 123]); } - public function testName(): void + public function testPrefix(): void { - $group = Group::create()->namePrefix('api'); - - $this->assertSame('api', $group->getData('namePrefix')); - } - - public function testGetDataWithWrongKey(): void - { - $group = Group::create(); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Unknown data key: wrong'); - - $group->getData('wrong'); - } - - public function testWithCors(): void - { - $group = Group::create() - ->routes( - Route::get('/info')->action(static fn () => 'info'), - Route::post('/info')->action(static fn () => 'info'), - ) - ->withCors( - static fn () => new Response(204) - ); - - $collector = new RouteCollector(); - $collector->addRoute($group); - $routeCollection = new RouteCollection($collector); - - $this->assertCount(3, $routeCollection->getRoutes()); - } - - public function testWithCorsWithHostRoutes(): void - { - $group = Group::create() - ->routes( - Route::get('/info') - ->action(static fn () => 'info') - ->host('yii.dev'), - Route::get('/info') - ->action(static fn () => 'info') - ->host('yii.test'), - ) - ->withCors( - static fn () => new Response(204) - ); - - $collector = new RouteCollector(); - $collector->addRoute($group); - $routeCollection = new RouteCollection($collector); + $group = (new Group())->setPrefix('/api'); - $this->assertCount(4, $routeCollection->getRoutes()); + $this->assertSame('/api', $group->getPrefix()); } - public function testWithCorsDoesntDuplicateRoutes(): void - { - $group = Group::create() - ->routes( - Route::get('/info') - ->action(static fn () => 'info') - ->host('yii.dev'), - Route::post('/info') - ->action(static fn () => 'info') - ->host('yii.dev'), - Route::put('/info') - ->action(static fn () => 'info') - ->host('yii.test'), - ) - ->withCors( - static fn () => new Response(204) - ); - - $collector = new RouteCollector(); - $collector->addRoute($group); - $routeCollection = new RouteCollection($collector); - - $this->assertCount(5, $routeCollection->getRoutes()); - } - - public function testWithCorsWithNestedGroups(): void - { - $group = Group::create()->routes( - Route::get('/info')->action(static fn () => 'info'), - Route::post('/info')->action(static fn () => 'info'), - Group::create('/v1') - ->routes( - Route::get('/post')->action(static fn () => 'post'), - Route::post('/post')->action(static fn () => 'post'), - Route::options('/options')->action(static fn () => 'options'), - ) - ->withCors( - static fn () => new Response(201) - ) - )->withCors( - static fn () => new Response(204) - ); - - $collector = new RouteCollector(); - $collector->addRoute($group); - - $routeCollection = new RouteCollection($collector); - $this->assertCount(7, $routeCollection->getRoutes()); - $this->assertInstanceOf(Route::class, $routeCollection->getRoute('OPTIONS /v1/post')); - } - - public function testWithCorsWithNestedGroups2(): void - { - $group = Group::create()->routes( - Route::get('/info')->action(static fn () => 'info'), - Route::post('/info')->action(static fn () => 'info'), - Route::get('/v1/post')->action(static fn () => 'post'), - Group::create('/v1')->routes( - Route::post('/post')->action(static fn () => 'post'), - Route::options('/options')->action(static fn () => 'options'), - ), - Group::create('/v1')->routes( - Route::put('/post')->action(static fn () => 'post'), - ) - )->withCors( - static fn () => new Response(204) - ); - $collector = new RouteCollector(); - $collector->addRoute($group); - - $routeCollection = new RouteCollection($collector); - $this->assertCount(8, $routeCollection->getRoutes()); - $this->assertInstanceOf(Route::class, $routeCollection->getRoute('OPTIONS /v1/post')); - } - - public function testMiddlewareAfterRoutes(): void - { - $group = Group::create()->routes(Route::get('/info')->action(static fn () => 'info')); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('middleware() can not be used after routes().'); - $group->middleware(static fn () => new Response()); - } - - public function testDuplicateHosts(): void - { - $route = Group::create()->hosts('a.com', 'b.com', 'a.com'); - - $this->assertSame(['a.com', 'b.com'], $route->getData('hosts')); - } - - public function testImmutability(): void + public function testName(): void { - $group = Group::create(); + $group = (new Group())->setNamePrefix('api'); - $this->assertNotSame($group, $group->routes()); - $this->assertNotSame($group, $group->withCors(null)); - $this->assertNotSame($group, $group->middleware()); - $this->assertNotSame($group, $group->prependMiddleware()); - $this->assertNotSame($group, $group->namePrefix('')); - $this->assertNotSame($group, $group->hosts()); - $this->assertNotSame($group, $group->disableMiddleware()); + $this->assertSame('api', $group->getNamePrefix()); } - private function getRequestHandler(): RequestHandlerInterface + public function testCors(): void { - return new class () implements RequestHandlerInterface { - public function handle(ServerRequestInterface $request): ResponseInterface - { - return new Response(404); - } - }; - } + $group = (new Group())->setCorsMiddleware($cors = static fn () => new Response()); - private function getDispatcher(): MiddlewareDispatcher - { - $container = new Container([]); - return new MiddlewareDispatcher( - new MiddlewareFactory($container), - $this->createMock(EventDispatcherInterface::class) - ); + $this->assertSame($cors, $group->getCorsMiddleware()); } } diff --git a/tests/MatchingResultTest.php b/tests/MatchingResultTest.php index 10d67b87..e3cb650e 100644 --- a/tests/MatchingResultTest.php +++ b/tests/MatchingResultTest.php @@ -14,7 +14,7 @@ final class MatchingResultTest extends TestCase { public function testFromSuccess(): void { - $route = Route::get('/{name}'); + $route = new Route([Method::GET], '/{name}'); $result = MatchingResult::fromSuccess($route, ['name' => 'Mehdi']); $this->assertTrue($result->isSuccess()); diff --git a/tests/Middleware/RouterTest.php b/tests/Middleware/RouterTest.php index 72c5ead1..8b3545c5 100644 --- a/tests/Middleware/RouterTest.php +++ b/tests/Middleware/RouterTest.php @@ -15,10 +15,10 @@ use Yiisoft\Http\Method; use Yiisoft\Middleware\Dispatcher\MiddlewareFactory; use Yiisoft\Router\CurrentRoute; -use Yiisoft\Router\Group; +use Yiisoft\Router\Builder\GroupBuilder as Group; use Yiisoft\Router\MatchingResult; use Yiisoft\Router\Middleware\Router; -use Yiisoft\Router\Route; +use Yiisoft\Router\Builder\RouteBuilder as Route; use Yiisoft\Router\RouteCollection; use Yiisoft\Router\RouteCollectionInterface; use Yiisoft\Router\RouteCollector; @@ -232,7 +232,7 @@ public function match(ServerRequestInterface $request): MatchingResult ->getUri() ->getPath() === '/options') { $route = Route::options('/options')->middleware($this->middleware); - return MatchingResult::fromSuccess($route, ['method' => 'options']); + return MatchingResult::fromSuccess($route->toRoute(), ['method' => 'options']); } if ($request @@ -243,7 +243,7 @@ public function match(ServerRequestInterface $request): MatchingResult if ($request->getMethod() === Method::GET) { $route = Route::get('/')->middleware($this->middleware); - return MatchingResult::fromSuccess($route, ['parameter' => 'value']); + return MatchingResult::fromSuccess($route->toRoute(), ['parameter' => 'value']); } return MatchingResult::fromFailure([Method::GET, Method::HEAD]); diff --git a/tests/RouteCollectionTest.php b/tests/RouteCollectionTest.php index 72d71b91..ab47f9e4 100644 --- a/tests/RouteCollectionTest.php +++ b/tests/RouteCollectionTest.php @@ -15,8 +15,8 @@ use RuntimeException; use Yiisoft\Middleware\Dispatcher\MiddlewareDispatcher; use Yiisoft\Middleware\Dispatcher\MiddlewareFactory; -use Yiisoft\Router\Group; -use Yiisoft\Router\Route; +use Yiisoft\Router\Builder\GroupBuilder as Group; +use Yiisoft\Router\Builder\RouteBuilder as Route; use Yiisoft\Router\RouteCollection; use Yiisoft\Router\RouteCollector; use Yiisoft\Router\RouteNotFoundException; @@ -89,7 +89,7 @@ public function testRouteOverride(): void $routeCollection = new RouteCollection($collector); $route = $routeCollection->getRoute('my-route'); - $this->assertSame('/{id}', $route->getData('pattern')); + $this->assertSame('/{id}', $route->getPattern()); } public function testRouteWithoutAction(): void @@ -108,7 +108,7 @@ public function testRouteWithoutAction(): void $routeCollection = new RouteCollection($collector); $route = $routeCollection->getRoute('image'); - $this->assertFalse($route->getData('hasMiddlewares')); + $this->assertEmpty($route->getAction()); } public function testGetRouterTree(): void @@ -207,10 +207,10 @@ public function testGroupHost(): void $route1 = $routeCollection->getRoute('image'); $route2 = $routeCollection->getRoute('project'); $route3 = $routeCollection->getRoute('user'); - $this->assertSame('https://yiiframework.com', $route1->getData('host')); - $this->assertCount(2, $route2->getData('hosts')); - $this->assertSame(['https://yiipowered.com', 'https://yiiframework.ru'], $route2->getData('hosts')); - $this->assertSame('https://yiiframework.com', $route3->getData('host')); + $this->assertSame('https://yiiframework.com', $route1->getHosts()[0]); + $this->assertCount(2, $route2->getHosts()); + $this->assertSame(['https://yiipowered.com', 'https://yiiframework.ru'], $route2->getHosts()); + $this->assertSame('https://yiiframework.com', $route3->getHosts()[0]); } public function testGroupName(): void @@ -239,10 +239,10 @@ public function testGroupName(): void $route2 = $routeCollection->getRoute('api/v1/package/downloads'); $route3 = $routeCollection->getRoute('api/index'); $route4 = $routeCollection->getRoute('GET api/user/{username}'); - $this->assertInstanceOf(Route::class, $route1); - $this->assertInstanceOf(Route::class, $route2); - $this->assertInstanceOf(Route::class, $route3); - $this->assertInstanceOf(Route::class, $route4); + $this->assertInstanceOf(\Yiisoft\Router\Route::class, $route1); + $this->assertInstanceOf(\Yiisoft\Router\Route::class, $route2); + $this->assertInstanceOf(\Yiisoft\Router\Route::class, $route3); + $this->assertInstanceOf(\Yiisoft\Router\Route::class, $route4); } public function testCollectorMiddlewareFullstackCalled(): void @@ -277,10 +277,10 @@ public function testCollectorMiddlewareFullstackCalled(): void $route2 = $routeCollection->getRoute('view'); $request = new ServerRequest('GET', '/'); $response1 = $this->getDispatcher() - ->withMiddlewares($route1->getData('enabledMiddlewares')) + ->withMiddlewares($route1->getEnabledMiddlewares()) ->dispatch($request, $this->getRequestHandler()); $response2 = $this->getDispatcher() - ->withMiddlewares($route2->getData('enabledMiddlewares')) + ->withMiddlewares($route2->getEnabledMiddlewares()) ->dispatch($request, $this->getRequestHandler()); $this->assertEquals('middleware1', $response1->getReasonPhrase()); @@ -328,7 +328,7 @@ public function testMiddlewaresOrder(bool $groupWrapped): void TestController::class => new TestController(), ]) ) - ->withMiddlewares($route->getData('enabledMiddlewares')); + ->withMiddlewares($route->getEnabledMiddlewares()); $response = $dispatcher->dispatch($request, $this->getRequestHandler()); $this->assertSame(200, $response->getStatusCode()); @@ -354,7 +354,7 @@ public function testStaticRouteWithCollectorMiddlewares(): void TestMiddleware1::class => new TestMiddleware1(), ]) ) - ->withMiddlewares($route->getData('enabledMiddlewares')); + ->withMiddlewares($route->getEnabledMiddlewares()); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Stack is empty.'); diff --git a/tests/RouteCollectorTest.php b/tests/RouteCollectorTest.php index 5fa1ea10..f30a9d45 100644 --- a/tests/RouteCollectorTest.php +++ b/tests/RouteCollectorTest.php @@ -6,8 +6,8 @@ use Nyholm\Psr7\Response; use PHPUnit\Framework\TestCase; -use Yiisoft\Router\Group; -use Yiisoft\Router\Route; +use Yiisoft\Router\Builder\GroupBuilder as Group; +use Yiisoft\Router\Builder\RouteBuilder as Route; use Yiisoft\Router\RouteCollector; final class RouteCollectorTest extends TestCase @@ -73,12 +73,12 @@ public function testAddMiddleware(): void ->middleware($middleware3, $middleware4) ->middleware($middleware5) ->prependMiddleware($middleware1, $middleware2); - $this->assertCount(5, $collector->getMiddlewareDefinitions()); - $this->assertSame($middleware1, $collector->getMiddlewareDefinitions()[0]); - $this->assertSame($middleware2, $collector->getMiddlewareDefinitions()[1]); - $this->assertSame($middleware3, $collector->getMiddlewareDefinitions()[2]); - $this->assertSame($middleware4, $collector->getMiddlewareDefinitions()[3]); - $this->assertSame($middleware5, $collector->getMiddlewareDefinitions()[4]); + $this->assertCount(5, $collector->getMiddlewares()); + $this->assertSame($middleware1, $collector->getMiddlewares()[0]); + $this->assertSame($middleware2, $collector->getMiddlewares()[1]); + $this->assertSame($middleware3, $collector->getMiddlewares()[2]); + $this->assertSame($middleware4, $collector->getMiddlewares()[3]); + $this->assertSame($middleware5, $collector->getMiddlewares()[4]); } public function testNamedArgumentsInMiddlewareMethods(): void @@ -91,7 +91,7 @@ public function testNamedArgumentsInMiddlewareMethods(): void $collector ->middleware(a: $middleware2) ->prependMiddleware(b: $middleware1); - $this->assertSame($middleware1, $collector->getMiddlewareDefinitions()[0]); - $this->assertSame($middleware2, $collector->getMiddlewareDefinitions()[1]); + $this->assertSame($middleware1, $collector->getMiddlewares()[0]); + $this->assertSame($middleware2, $collector->getMiddlewares()[1]); } } diff --git a/tests/RouteTest.php b/tests/RouteTest.php index cf701bcc..6a808e36 100644 --- a/tests/RouteTest.php +++ b/tests/RouteTest.php @@ -5,23 +5,13 @@ namespace Yiisoft\Router\Tests; use Nyholm\Psr7\Response; -use Nyholm\Psr7\ServerRequest; use PHPUnit\Framework\TestCase; -use Psr\Container\ContainerInterface; -use Psr\EventDispatcher\EventDispatcherInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; -use RuntimeException; use Yiisoft\Http\Method; -use Yiisoft\Middleware\Dispatcher\MiddlewareDispatcher; -use Yiisoft\Middleware\Dispatcher\MiddlewareFactory; use Yiisoft\Router\Route; use Yiisoft\Router\Tests\Support\AssertTrait; -use Yiisoft\Router\Tests\Support\Container; +use Yiisoft\Router\Tests\Support\TestController; use Yiisoft\Router\Tests\Support\TestMiddleware1; use Yiisoft\Router\Tests\Support\TestMiddleware2; -use Yiisoft\Router\Tests\Support\TestController; use Yiisoft\Router\Tests\Support\TestMiddleware3; final class RouteTest extends TestCase @@ -39,8 +29,23 @@ public function testSimpleInstance(): void ); $this->assertInstanceOf(Route::class, $route); - $this->assertCount(2, $route->getData('enabledMiddlewares')); - $this->assertTrue($route->getData('override')); + $this->assertCount(2, $route->getEnabledMiddlewares()); + $this->assertTrue($route->isOverride()); + } + + public function testDisabledMiddlewares(): void + { + $route = new Route( + methods: [Method::GET], + pattern: '/', + action: [TestController::class, 'index'], + middlewares: [TestMiddleware1::class], + override: true, + ); + $route->setDisabledMiddlewares([TestMiddleware2::class]); + + $this->assertCount(1, $route->getDisabledMiddlewares()); + $this->assertSame(TestMiddleware2::class, $route->getDisabledMiddlewares()[0]); } public function testEmptyMethods(): void @@ -53,114 +58,48 @@ public function testEmptyMethods(): void public function testName(): void { - $route = Route::get('/')->name('test.route'); + $route = (new Route([Method::GET], '/'))->setName('test.route'); - $this->assertSame('test.route', $route->getData('name')); + $this->assertSame('test.route', $route->getName()); } public function testNameDefault(): void { - $route = Route::get('/'); + $route = new Route([Method::GET], '/'); - $this->assertSame('GET /', $route->getData('name')); + $this->assertSame('GET /', $route->getName()); } public function testNameDefaultWithHosts(): void { - $route = Route::get('/')->hosts('a.com', 'b.com'); + $route = (new Route([Method::GET], '/'))->setHosts(['a.com', 'b.com']); - $this->assertSame('GET a.com|b.com/', $route->getData('name')); + $this->assertSame('GET a.com|b.com/', $route->getName()); } public function testMethods(): void { - $route = Route::methods([Method::POST, Method::HEAD], '/'); - - $this->assertSame([Method::POST, Method::HEAD], $route->getData('methods')); - } - - public function testGetDataWithWrongKey(): void - { - $route = Route::get(''); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Unknown data key: wrong'); - - $route->getData('wrong'); - } - - public function testGetMethod(): void - { - $route = Route::get('/'); - - $this->assertSame([Method::GET], $route->getData('methods')); - } - - public function testPostMethod(): void - { - $route = Route::post('/'); - - $this->assertSame([Method::POST], $route->getData('methods')); - } - - public function testPutMethod(): void - { - $route = Route::put('/'); - - $this->assertSame([Method::PUT], $route->getData('methods')); - } - - public function testDeleteMethod(): void - { - $route = Route::delete('/'); + $route = new Route([Method::POST, Method::HEAD], '/'); - $this->assertSame([Method::DELETE], $route->getData('methods')); - } - - public function testPatchMethod(): void - { - $route = Route::patch('/'); - - $this->assertSame([Method::PATCH], $route->getData('methods')); - } - - public function testHeadMethod(): void - { - $route = Route::head('/'); - - $this->assertSame([Method::HEAD], $route->getData('methods')); - } - - public function testOptionsMethod(): void - { - $route = Route::options('/'); - - $this->assertSame([Method::OPTIONS], $route->getData('methods')); + $this->assertSame([Method::POST, Method::HEAD], $route->getMethods()); } public function testPattern(): void { - $route = Route::get('/test')->pattern('/test2'); + $route = (new Route([Method::GET], '/test'))->setPattern('/test2'); - $this->assertSame('/test2', $route->getData('pattern')); - } - - public function testHost(): void - { - $route = Route::get('/')->host('https://yiiframework.com/'); - - $this->assertSame('https://yiiframework.com', $route->getData('host')); + $this->assertSame('/test2', $route->getPattern()); } public function testHosts(): void { - $route = Route::get('/') - ->hosts( + $route = (new Route([Method::GET], '/')) + ->setHosts([ 'https://yiiframework.com/', 'yf.com', 'yii.com', - 'yf.ru' - ); + 'yf.ru', + ]); $this->assertSame( [ @@ -169,27 +108,13 @@ public function testHosts(): void 'yii.com', 'yf.ru', ], - $route->getData('hosts') + $route->getHosts() ); } - public function testMultipleHosts(): void - { - $route = Route::get('/') - ->host('https://yiiframework.com/'); - $multipleRoute = Route::get('/') - ->hosts( - 'https://yiiframework.com/', - 'https://yiiframework.ru/' - ); - - $this->assertCount(1, $route->getData('hosts')); - $this->assertCount(2, $multipleRoute->getData('hosts')); - } - public function testDefaults(): void { - $route = Route::get('/{language}')->defaults([ + $route = (new Route([Method::GET], '/{language}'))->setDefaults([ 'language' => 'en', 'age' => 42, ]); @@ -197,14 +122,14 @@ public function testDefaults(): void $this->assertSame([ 'language' => 'en', 'age' => '42', - ], $route->getData('defaults')); + ], $route->getDefaults()); } public function testOverride(): void { - $route = Route::get('/')->override(); + $route = (new Route([Method::GET], '/'))->setOverride(true); - $this->assertTrue($route->getData('override')); + $this->assertTrue($route->isOverride()); } public function dataToString(): array @@ -220,178 +145,18 @@ public function dataToString(): array */ public function testToString(string $expected, string $pattern): void { - $route = Route::methods([Method::GET, Method::POST], $pattern) - ->name('test.route') - ->host('yiiframework.com'); + $route = (new Route([Method::GET, Method::POST], $pattern)) + ->setName('test.route') + ->setHosts(['yiiframework.com']); - $this->assertSame('[test.route] GET,POST ' . $expected, (string)$route); + $this->assertSame('[test.route] GET,POST ' . $expected, (string) $route); } public function testToStringSimple(): void { - $route = Route::get('/'); - - $this->assertSame('GET /', (string)$route); - } - - public function testDispatcherInjecting(): void - { - $request = new ServerRequest('GET', '/'); - $container = $this->getContainer( - [ - TestController::class => new TestController(), - ] - ); - - $route = Route::get('/')->action([TestController::class, 'index']); - - $response = $this - ->getDispatcher($container) - ->withMiddlewares($route->getData('enabledMiddlewares')) - ->dispatch($request, $this->getRequestHandler()); - - $this->assertSame(200, $response->getStatusCode()); - } - - public function testMiddlewareAfterAction(): void - { - $route = Route::get('/')->action([TestController::class, 'index']); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('middleware() can not be used after action().'); - $route->middleware(static fn () => new Response()); - } - - public function testPrependMiddlewareBeforeAction(): void - { - $route = Route::get('/'); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('prependMiddleware() can not be used before action().'); - $route->prependMiddleware(static fn () => new Response()); - } - - public function testDisabledMiddlewareDefinitions(): void - { - $request = new ServerRequest('GET', '/'); - - $route = Route::get('/') - ->middleware(TestMiddleware1::class, TestMiddleware2::class, TestMiddleware3::class) - ->action([TestController::class, 'index']) - ->disableMiddleware(TestMiddleware1::class, TestMiddleware3::class); - - $dispatcher = $this - ->getDispatcher( - $this->getContainer([ - TestMiddleware1::class => new TestMiddleware1(), - TestMiddleware2::class => new TestMiddleware2(), - TestMiddleware3::class => new TestMiddleware3(), - TestController::class => new TestController(), - ]) - ) - ->withMiddlewares($route->getData('enabledMiddlewares')); - - $response = $dispatcher->dispatch($request, $this->getRequestHandler()); - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('2', (string) $response->getBody()); - } - - public function testPrependMiddlewareDefinitions(): void - { - $request = new ServerRequest('GET', '/'); - - $route = Route::get('/') - ->middleware(TestMiddleware3::class) - ->action([TestController::class, 'index']) - ->prependMiddleware(TestMiddleware1::class, TestMiddleware2::class); - - $response = $this - ->getDispatcher( - $this->getContainer([ - TestMiddleware1::class => new TestMiddleware1(), - TestMiddleware2::class => new TestMiddleware2(), - TestMiddleware3::class => new TestMiddleware3(), - TestController::class => new TestController(), - ]) - ) - ->withMiddlewares($route->getData('enabledMiddlewares')) - ->dispatch($request, $this->getRequestHandler()); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('123', (string) $response->getBody()); - } - - public function testPrependMiddlewaresAfterGetEnabledMiddlewares(): void - { - $route = Route::get('/') - ->middleware(TestMiddleware3::class) - ->disableMiddleware(TestMiddleware1::class) - ->action([TestController::class, 'index']); - - $route->getData('enabledMiddlewares'); - - $route = $route->prependMiddleware(TestMiddleware1::class, TestMiddleware2::class); - - $this->assertSame( - [TestMiddleware2::class, TestMiddleware3::class, [TestController::class, 'index']], - $route->getData('enabledMiddlewares') - ); - } - - public function testAddMiddlewareAfterGetEnabledMiddlewares(): void - { - $route = Route::get('/') - ->middleware(TestMiddleware3::class); - - $route->getData('enabledMiddlewares'); - - $route = $route->middleware(TestMiddleware1::class, TestMiddleware2::class); - - $this->assertSame( - [TestMiddleware3::class, TestMiddleware1::class, TestMiddleware2::class], - $route->getData('enabledMiddlewares') - ); - } - - public function testDisableMiddlewareAfterGetEnabledMiddlewares(): void - { - $route = Route::get('/') - ->middleware(TestMiddleware1::class, TestMiddleware2::class, TestMiddleware3::class); + $route = new Route([Method::GET], '/'); - $route->getData('enabledMiddlewares'); - - $route = $route->disableMiddleware(TestMiddleware1::class, TestMiddleware2::class); - - $this->assertSame( - [TestMiddleware3::class], - $route->getData('enabledMiddlewares') - ); - } - - public function testGetEnabledMiddlewaresTwice(): void - { - $route = Route::get('/') - ->middleware(TestMiddleware1::class, TestMiddleware2::class); - - $result1 = $route->getData('enabledMiddlewares'); - $result2 = $route->getData('enabledMiddlewares'); - - $this->assertSame([TestMiddleware1::class, TestMiddleware2::class], $result1); - $this->assertSame($result1, $result2); - } - - public function testMiddlewaresWithKeys(): void - { - $route = Route::get('/') - ->middleware(m3: TestMiddleware3::class) - ->action([TestController::class, 'index']) - ->prependMiddleware(m1: TestMiddleware1::class, m2: TestMiddleware2::class) - ->disableMiddleware(m1: TestMiddleware1::class); - - $this->assertSame( - [TestMiddleware2::class, TestMiddleware3::class, [TestController::class, 'index']], - $route->getData('enabledMiddlewares') - ); + $this->assertSame('GET /', (string) $route); } public function testInvalidMiddlewares(): void @@ -404,15 +169,17 @@ public function testInvalidMiddlewares(): void public function testDebugInfo(): void { - $route = Route::get('/') - ->name('test') - ->host('example.com') - ->defaults(['age' => 42]) - ->override() - ->middleware(TestMiddleware1::class, TestMiddleware2::class) - ->disableMiddleware(TestMiddleware2::class) - ->action('go') - ->prependMiddleware(TestMiddleware3::class); + $route = new Route( + methods: [Method::GET], + pattern: '/', + name: 'test', + action: 'go', + middlewares: [TestMiddleware3::class, TestMiddleware1::class, TestMiddleware2::class], + defaults: ['age' => 42], + hosts: ['example.com'], + override: true, + disabledMiddlewares: [TestMiddleware2::class] + ); $expected = << / + [action] => go [hosts] => Array ( [0] => example.com @@ -435,13 +203,11 @@ public function testDebugInfo(): void ) [override] => 1 - [actionAdded] => 1 [middlewares] => Array ( [0] => Yiisoft\Router\Tests\Support\TestMiddleware3 [1] => Yiisoft\Router\Tests\Support\TestMiddleware1 [2] => Yiisoft\Router\Tests\Support\TestMiddleware2 - [3] => go ) [disabledMiddlewares] => Array @@ -464,9 +230,9 @@ public function testDebugInfo(): void public function testDuplicateHosts(): void { - $route = Route::get('/')->hosts('a.com', 'b.com', 'a.com'); + $route = (new Route([Method::GET], '/'))->setHosts(['a.com', 'b.com', 'a.com']); - $this->assertSame(['a.com', 'b.com'], $route->getData('hosts')); + $this->assertSame(['a.com', 'b.com'], $route->getHosts()); } public function testInvalidHosts(): void @@ -476,51 +242,4 @@ public function testInvalidHosts(): void $route = new Route([Method::GET], '/', hosts: ['b.com', 123]); } - - public function testImmutability(): void - { - $route = Route::get('/'); - $routeWithAction = $route->action(''); - - $this->assertNotSame($route, $route->name('')); - $this->assertNotSame($route, $route->pattern('')); - $this->assertNotSame($route, $route->host('')); - $this->assertNotSame($route, $route->hosts('')); - $this->assertNotSame($route, $route->override()); - $this->assertNotSame($route, $route->defaults([])); - $this->assertNotSame($route, $route->middleware()); - $this->assertNotSame($route, $route->action('')); - $this->assertNotSame($routeWithAction, $routeWithAction->prependMiddleware()); - $this->assertNotSame($route, $route->disableMiddleware('')); - } - - private function getRequestHandler(): RequestHandlerInterface - { - return new class () implements RequestHandlerInterface { - public function handle(ServerRequestInterface $request): ResponseInterface - { - return new Response(404); - } - }; - } - - private function getDispatcher(ContainerInterface $container = null): MiddlewareDispatcher - { - if ($container === null) { - return new MiddlewareDispatcher( - new MiddlewareFactory($this->getContainer()), - $this->createMock(EventDispatcherInterface::class) - ); - } - - return new MiddlewareDispatcher( - new MiddlewareFactory($container), - $this->createMock(EventDispatcherInterface::class) - ); - } - - private function getContainer(array $instances = []): ContainerInterface - { - return new Container($instances); - } } From 626151c2c37c3fdad4ab4b3627fd1732f7ac24c3 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Thu, 9 Nov 2023 07:19:57 +0000 Subject: [PATCH 04/32] Apply fixes from StyleCI --- src/Builder/GroupBuilder.php | 2 +- src/Builder/RouteBuilder.php | 4 +--- src/Group.php | 4 ++-- src/RouteCollection.php | 4 +--- src/RouteCollector.php | 2 +- src/RouteCollectorInterface.php | 2 +- tests/Builder/GroupBuilderTest.php | 1 - tests/Builder/RouteBuilderTest.php | 1 - tests/Debug/RouterCollectorTest.php | 1 - tests/GroupTest.php | 2 +- 10 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/Builder/GroupBuilder.php b/src/Builder/GroupBuilder.php index 9268b806..55fc5c23 100644 --- a/src/Builder/GroupBuilder.php +++ b/src/Builder/GroupBuilder.php @@ -12,7 +12,7 @@ final class GroupBuilder implements RoutableInterface { /** - * @var Group[]|Route[]|RoutableInterface[] + * @var Group[]|RoutableInterface[]|Route[] */ private array $routes = []; diff --git a/src/Builder/RouteBuilder.php b/src/Builder/RouteBuilder.php index f00607a4..f87bd7da 100644 --- a/src/Builder/RouteBuilder.php +++ b/src/Builder/RouteBuilder.php @@ -10,8 +10,6 @@ use Yiisoft\Router\RoutableInterface; use Yiisoft\Router\Route; -use function in_array; - /** * Route defines a mapping from URL to callback / name and vice versa. */ @@ -20,7 +18,7 @@ final class RouteBuilder implements RoutableInterface private ?string $name = null; /** - * @var array|string|callable|null + * @var array|callable|string|null */ private $action = null; diff --git a/src/Group.php b/src/Group.php index f1b2783c..f34b54ff 100644 --- a/src/Group.php +++ b/src/Group.php @@ -9,7 +9,7 @@ final class Group { /** - * @var Group[]|Route[]|RoutableInterface[] + * @var Group[]|RoutableInterface[]|Route[] */ private array $routes = []; @@ -195,7 +195,7 @@ private function assertMiddlewares(array $middlewares): void */ private function assertRoutes(array $routes): void { - /** @var Route|Group|RoutableInterface $route */ + /** @var Group|RoutableInterface|Route $route */ foreach ($routes as $route) { if ($route instanceof Route || $route instanceof self || $route instanceof RoutableInterface) { continue; diff --git a/src/RouteCollection.php b/src/RouteCollection.php index 0b167ff6..10a29a3c 100644 --- a/src/RouteCollection.php +++ b/src/RouteCollection.php @@ -8,8 +8,6 @@ use Psr\Http\Message\ResponseFactoryInterface; use Yiisoft\Http\Method; -use Yiisoft\Router\Builder\RouteBuilder; - use function array_key_exists; use function in_array; use function is_array; @@ -67,7 +65,7 @@ private function ensureItemsInjected(): void /** * Build routes array. * - * @param Group[]|Route[]|RoutableInterface[] $items + * @param Group[]|RoutableInterface[]|Route[] $items */ private function injectItems(array $items): void { diff --git a/src/RouteCollector.php b/src/RouteCollector.php index 52c9d0f9..8c4d3652 100644 --- a/src/RouteCollector.php +++ b/src/RouteCollector.php @@ -7,7 +7,7 @@ final class RouteCollector implements RouteCollectorInterface { /** - * @var Group[]|Route[]|RoutableInterface[] + * @var Group[]|RoutableInterface[]|Route[] */ private array $items = []; diff --git a/src/RouteCollectorInterface.php b/src/RouteCollectorInterface.php index 8cf56aad..711c83d6 100644 --- a/src/RouteCollectorInterface.php +++ b/src/RouteCollectorInterface.php @@ -24,7 +24,7 @@ public function middleware(array|callable|string ...$definition): self; public function prependMiddleware(array|callable|string ...$definition): self; /** - * @return Group[]|Route[]|RoutableInterface[] + * @return Group[]|RoutableInterface[]|Route[] */ public function getItems(): array; diff --git a/tests/Builder/GroupBuilderTest.php b/tests/Builder/GroupBuilderTest.php index 4541ddc6..7526d05f 100644 --- a/tests/Builder/GroupBuilderTest.php +++ b/tests/Builder/GroupBuilderTest.php @@ -273,7 +273,6 @@ public function testName(): void $this->assertSame('api', $group->toRoute()->getNamePrefix()); } - public function testWithCors(): void { $group = Group::create() diff --git a/tests/Builder/RouteBuilderTest.php b/tests/Builder/RouteBuilderTest.php index 68cdca57..3596ca32 100644 --- a/tests/Builder/RouteBuilderTest.php +++ b/tests/Builder/RouteBuilderTest.php @@ -12,7 +12,6 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; -use RuntimeException; use Yiisoft\Http\Method; use Yiisoft\Middleware\Dispatcher\MiddlewareDispatcher; use Yiisoft\Middleware\Dispatcher\MiddlewareFactory; diff --git a/tests/Debug/RouterCollectorTest.php b/tests/Debug/RouterCollectorTest.php index 4c375b4a..e13b71be 100644 --- a/tests/Debug/RouterCollectorTest.php +++ b/tests/Debug/RouterCollectorTest.php @@ -11,7 +11,6 @@ use Yiisoft\Router\Builder\GroupBuilder; use Yiisoft\Router\Builder\RouteBuilder; use Yiisoft\Router\Debug\RouterCollector; -use Yiisoft\Router\Group; use Yiisoft\Router\Route; use Yiisoft\Router\RouteCollection; use Yiisoft\Router\RouteCollectionInterface; diff --git a/tests/GroupTest.php b/tests/GroupTest.php index aa82bb64..39e64dd1 100644 --- a/tests/GroupTest.php +++ b/tests/GroupTest.php @@ -50,7 +50,7 @@ public function testSetMiddlewaresAfterGetEnabledMiddlewares(): void public function testDisableMiddlewareAfterGetEnabledMiddlewares(): void { - $group = (new Group) + $group = (new Group()) ->setMiddlewares([TestMiddleware1::class, TestMiddleware2::class, TestMiddleware3::class]); $group->getEnabledMiddlewares(); From 3390ea9214af0ec2ad790e0e41f7fcbf4b6ee500 Mon Sep 17 00:00:00 2001 From: Rustam Date: Thu, 9 Nov 2023 16:26:18 +0500 Subject: [PATCH 05/32] Minor improvements --- src/Group.php | 4 +--- src/Route.php | 10 +++++----- tests/GroupTest.php | 17 +++++++++++++++++ tests/RouteTest.php | 22 ++++++++++++++++++++++ 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/Group.php b/src/Group.php index f1b2783c..a4e14c18 100644 --- a/src/Group.php +++ b/src/Group.php @@ -156,9 +156,7 @@ public function getEnabledMiddlewares(): array return $this->enabledMiddlewaresCache; } - $this->enabledMiddlewaresCache = MiddlewareFilter::filter($this->middlewares, $this->disabledMiddlewares); - - return $this->enabledMiddlewaresCache; + return $this->enabledMiddlewaresCache = MiddlewareFilter::filter($this->middlewares, $this->disabledMiddlewares); } /** diff --git a/src/Route.php b/src/Route.php index c2308988..8a4c06ef 100644 --- a/src/Route.php +++ b/src/Route.php @@ -65,9 +65,6 @@ public function __construct( private bool $override = false, private array $disabledMiddlewares = [], ) { - if (empty($methods)) { - throw new InvalidArgumentException('$methods cannot be empty.'); - } $this->setMethods($methods); $this->action = $action; $this->setMiddlewares($middlewares); @@ -146,6 +143,9 @@ public function getEnabledMiddlewares(): array public function setMethods(array $methods): self { + if (empty($methods)) { + throw new InvalidArgumentException('$methods cannot be empty.'); + } $this->assertListOfStrings($methods, 'methods'); $this->methods = $methods; return $this; @@ -184,9 +184,9 @@ public function setDefaults(array $defaults): self { /** @var mixed $value */ foreach ($defaults as $key => $value) { - if (!is_scalar($value) && !($value instanceof Stringable)) { + if (!is_scalar($value) && !($value instanceof Stringable) && null !== $value) { throw new \InvalidArgumentException( - 'Invalid $defaults provided, list of scalar or `Stringable` instance expected.' + 'Invalid $defaults provided, indexed array of scalar or `Stringable` or null expected.' ); } $this->defaults[$key] = (string) $value; diff --git a/tests/GroupTest.php b/tests/GroupTest.php index aa82bb64..d9413f3e 100644 --- a/tests/GroupTest.php +++ b/tests/GroupTest.php @@ -7,7 +7,9 @@ use InvalidArgumentException; use Nyholm\Psr7\Response; use PHPUnit\Framework\TestCase; +use Yiisoft\Http\Method; use Yiisoft\Router\Group; +use Yiisoft\Router\Route; use Yiisoft\Router\Tests\Support\TestMiddleware1; use Yiisoft\Router\Tests\Support\TestMiddleware2; use Yiisoft\Router\Tests\Support\TestMiddleware3; @@ -107,4 +109,19 @@ public function testCors(): void $this->assertSame($cors, $group->getCorsMiddleware()); } + + public function testRoutes(): void + { + $group = (new Group())->setRoutes($routes = [new Route([Method::GET], '')]); + + $this->assertSame($routes, $group->getRoutes()); + } + + public function testInvalidRoutes(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid $routes provided, array of `Route` or `Group` or `RoutableInterface` instance expected.'); + + $group = (new Group())->setRoutes([new Route([Method::GET], ''), new \stdClass()]); + } } diff --git a/tests/RouteTest.php b/tests/RouteTest.php index 6a808e36..572efdd8 100644 --- a/tests/RouteTest.php +++ b/tests/RouteTest.php @@ -48,6 +48,20 @@ public function testDisabledMiddlewares(): void $this->assertSame(TestMiddleware2::class, $route->getDisabledMiddlewares()[0]); } + public function testEnabledMiddlewares(): void + { + $route = new Route( + methods: [Method::GET], + pattern: '/', + middlewares: [TestMiddleware1::class, TestMiddleware2::class], + override: true, + ); + $route->setDisabledMiddlewares([TestMiddleware2::class]); + + $this->assertCount(1, $route->getEnabledMiddlewares()); + $this->assertSame(TestMiddleware1::class, $route->getEnabledMiddlewares()[0]); + } + public function testEmptyMethods(): void { $this->expectException(\InvalidArgumentException::class); @@ -167,6 +181,14 @@ public function testInvalidMiddlewares(): void $route = new Route([Method::GET], '/', middlewares: [static fn () => new Response(), (object) ['test' => 1]]); } + public function testInvalidDefaults(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid $defaults provided, indexed array of scalar or `Stringable` or null expected.'); + + $route = new Route([Method::GET], '/', defaults: ['test' => 1, 'foo' => ['bar']]); + } + public function testDebugInfo(): void { $route = new Route( From a6b7acddd183e25be4fff3711b617397ec1add93 Mon Sep 17 00:00:00 2001 From: Rustam Date: Thu, 9 Nov 2023 17:21:37 +0500 Subject: [PATCH 06/32] Improvements --- src/Builder/GroupBuilder.php | 3 +++ src/Builder/RouteBuilder.php | 2 +- src/Group.php | 16 +++------------- src/RoutableInterface.php | 3 +++ src/Route.php | 24 +++++++++--------------- tests/RouteTest.php | 8 ++++++++ 6 files changed, 27 insertions(+), 29 deletions(-) diff --git a/src/Builder/GroupBuilder.php b/src/Builder/GroupBuilder.php index 55fc5c23..c1527535 100644 --- a/src/Builder/GroupBuilder.php +++ b/src/Builder/GroupBuilder.php @@ -9,6 +9,9 @@ use Yiisoft\Router\RoutableInterface; use Yiisoft\Router\Route; +/** + * GroupBuilder allows you to build group of routes using a flexible syntax. + */ final class GroupBuilder implements RoutableInterface { /** diff --git a/src/Builder/RouteBuilder.php b/src/Builder/RouteBuilder.php index f87bd7da..6c6c3f1c 100644 --- a/src/Builder/RouteBuilder.php +++ b/src/Builder/RouteBuilder.php @@ -11,7 +11,7 @@ use Yiisoft\Router\Route; /** - * Route defines a mapping from URL to callback / name and vice versa. + * RouteBuilder allows you to build routes using a flexible syntax. */ final class RouteBuilder implements RoutableInterface { diff --git a/src/Group.php b/src/Group.php index f12e7cc1..3404aab5 100644 --- a/src/Group.php +++ b/src/Group.php @@ -109,8 +109,10 @@ public function setMiddlewares(array $middlewares): self public function setHosts(array $hosts): self { - $this->assertHosts($hosts); foreach ($hosts as $host) { + if (!is_string($host)) { + throw new \InvalidArgumentException('Invalid $hosts provided, list of string expected.'); + } $host = rtrim($host, '/'); if ($host !== '' && !in_array($host, $this->hosts, true)) { @@ -159,18 +161,6 @@ public function getEnabledMiddlewares(): array return $this->enabledMiddlewaresCache = MiddlewareFilter::filter($this->middlewares, $this->disabledMiddlewares); } - /** - * @psalm-assert array $hosts - */ - private function assertHosts(array $hosts): void - { - foreach ($hosts as $host) { - if (!is_string($host)) { - throw new \InvalidArgumentException('Invalid $hosts provided, list of string expected.'); - } - } - } - /** * @psalm-assert list $middlewares */ diff --git a/src/RoutableInterface.php b/src/RoutableInterface.php index 8c3082fe..bde4a0bf 100644 --- a/src/RoutableInterface.php +++ b/src/RoutableInterface.php @@ -4,6 +4,9 @@ namespace Yiisoft\Router; +/** + * An interface for denoting classes that represent a route. + */ interface RoutableInterface { public function toRoute(): Route|Group; diff --git a/src/Route.php b/src/Route.php index 8a4c06ef..2739a6d5 100644 --- a/src/Route.php +++ b/src/Route.php @@ -146,16 +146,22 @@ public function setMethods(array $methods): self if (empty($methods)) { throw new InvalidArgumentException('$methods cannot be empty.'); } - $this->assertListOfStrings($methods, 'methods'); - $this->methods = $methods; + foreach ($methods as $method) { + if (!is_string($method)) { + throw new \InvalidArgumentException('Invalid $methods provided, list of string expected.'); + } + $this->methods[] = $method; + } return $this; } public function setHosts(array $hosts): self { - $this->assertListOfStrings($hosts, 'hosts'); $this->hosts = []; foreach ($hosts as $host) { + if (!is_string($host)) { + throw new \InvalidArgumentException('Invalid $hosts provided, list of string expected.'); + } $host = rtrim($host, '/'); if ($host !== '' && !in_array($host, $this->hosts, true)) { @@ -258,18 +264,6 @@ public function __debugInfo() ]; } - /** - * @psalm-assert array $items - */ - private function assertListOfStrings(array $items, string $argument): void - { - foreach ($items as $item) { - if (!is_string($item)) { - throw new \InvalidArgumentException('Invalid $' . $argument . ' provided, list of string expected.'); - } - } - } - /** * @psalm-assert list $middlewares */ diff --git a/tests/RouteTest.php b/tests/RouteTest.php index 572efdd8..28b22e9a 100644 --- a/tests/RouteTest.php +++ b/tests/RouteTest.php @@ -264,4 +264,12 @@ public function testInvalidHosts(): void $route = new Route([Method::GET], '/', hosts: ['b.com', 123]); } + + public function testInvalidMethods(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid $methods provided, list of string expected.'); + + $route = new Route([1], '/'); + } } From 33f3003788c6c134efe897b1844103af00ffcda5 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Thu, 9 Nov 2023 12:31:12 +0000 Subject: [PATCH 07/32] Apply fixes from StyleCI --- tests/Debug/RouterCollectorTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Debug/RouterCollectorTest.php b/tests/Debug/RouterCollectorTest.php index ba8b78c8..c24266c8 100644 --- a/tests/Debug/RouterCollectorTest.php +++ b/tests/Debug/RouterCollectorTest.php @@ -12,7 +12,6 @@ use Yiisoft\Router\Builder\RouteBuilder; use Yiisoft\Router\CurrentRoute; use Yiisoft\Router\Debug\RouterCollector; -use Yiisoft\Router\Group; use Yiisoft\Router\MatchingResult; use Yiisoft\Router\Route; use Yiisoft\Router\RouteCollection; From 782bf9168a7b60988c51fc223fe23d239df72524 Mon Sep 17 00:00:00 2001 From: Rustam Date: Thu, 9 Nov 2023 17:35:37 +0500 Subject: [PATCH 08/32] Fix --- tests/Debug/DebugRoutesCommandTest.php | 10 +++++----- tests/Debug/UrlMatcherInterfaceProxyTest.php | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/Debug/DebugRoutesCommandTest.php b/tests/Debug/DebugRoutesCommandTest.php index 845d165c..21ff6028 100644 --- a/tests/Debug/DebugRoutesCommandTest.php +++ b/tests/Debug/DebugRoutesCommandTest.php @@ -6,8 +6,8 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Tester\CommandTester; +use Yiisoft\Router\Builder\RouteBuilder; use Yiisoft\Router\Debug\DebugRoutesCommand; -use Yiisoft\Router\Route; use Yiisoft\Router\RouteCollection; use Yiisoft\Router\RouteCollector; use Yiisoft\Router\Tests\Support\TestController; @@ -25,12 +25,12 @@ public function testBase(): void $command = new DebugRoutesCommand( new RouteCollection( (new RouteCollector())->addRoute( - Route::get('/') + RouteBuilder::get('/') ->host('example.com') ->defaults(['SpecialArg' => 1]) ->action(fn () => 'Hello, XXXXXX!') ->name('site/index'), - Route::get('/about') + RouteBuilder::get('/about') ->action([TestController::class, 'index']) ->name('site/about'), ), @@ -61,13 +61,13 @@ public function testSpecificRoute(): void $command = new DebugRoutesCommand( new RouteCollection( (new RouteCollector())->addRoute( - Route::get('/') + RouteBuilder::get('/') ->host('example.com') ->defaults(['SpecialArg' => 1]) ->name('site/index') ->middleware(TestMiddleware1::class) ->action(fn () => 'Hello world!'), - Route::get('/about')->name('site/about'), + RouteBuilder::get('/about')->name('site/about'), ), ), new Debugger( diff --git a/tests/Debug/UrlMatcherInterfaceProxyTest.php b/tests/Debug/UrlMatcherInterfaceProxyTest.php index c6756d27..bbabfae8 100644 --- a/tests/Debug/UrlMatcherInterfaceProxyTest.php +++ b/tests/Debug/UrlMatcherInterfaceProxyTest.php @@ -6,6 +6,7 @@ use Nyholm\Psr7\ServerRequest; use PHPUnit\Framework\TestCase; +use Yiisoft\Http\Method; use Yiisoft\Router\CurrentRoute; use Yiisoft\Router\Debug\RouterCollector; use Yiisoft\Router\Debug\UrlMatcherInterfaceProxy; @@ -19,7 +20,7 @@ final class UrlMatcherInterfaceProxyTest extends TestCase public function testBase(): void { $request = new ServerRequest('GET', '/'); - $route = Route::get('/'); + $route = new Route([Method::GET], '/'); $arguments = ['a' => 19]; $result = MatchingResult::fromSuccess($route, $arguments); From 9c2fa2ac5633c39543f1badc94b75eeb08104ca2 Mon Sep 17 00:00:00 2001 From: Rustam Date: Tue, 21 Oct 2025 21:48:43 +0500 Subject: [PATCH 09/32] Fix tests --- tests/HydratorAttribute/RouteArgumentTest.php | 4 ++-- tests/RouteTest.php | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/HydratorAttribute/RouteArgumentTest.php b/tests/HydratorAttribute/RouteArgumentTest.php index eedf9434..69ed8bfa 100644 --- a/tests/HydratorAttribute/RouteArgumentTest.php +++ b/tests/HydratorAttribute/RouteArgumentTest.php @@ -16,7 +16,7 @@ use Yiisoft\Router\CurrentRoute; use Yiisoft\Router\HydratorAttribute\RouteArgument; use Yiisoft\Router\HydratorAttribute\RouteArgumentResolver; -use Yiisoft\Router\Route as RouterRoute; +use Yiisoft\Router\Builder\RouteBuilder as RouterRoute; use Yiisoft\Test\Support\Container\SimpleContainer; final class RouteArgumentTest extends TestCase @@ -80,7 +80,7 @@ public function testUnexpectedAttributeException(): void private function createHydrator(array $arguments): Hydrator { $currentRoute = new CurrentRoute(); - $currentRoute->setRouteWithArguments(RouterRoute::get('/'), $arguments); + $currentRoute->setRouteWithArguments(RouterRoute::get('/')->toRoute(), $arguments); return new Hydrator( attributeResolverFactory: new ContainerAttributeResolverFactory( diff --git a/tests/RouteTest.php b/tests/RouteTest.php index 1d174ced..aca874a3 100644 --- a/tests/RouteTest.php +++ b/tests/RouteTest.php @@ -5,6 +5,7 @@ namespace Yiisoft\Router\Tests; use Nyholm\Psr7\Response; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Yiisoft\Http\Method; use Yiisoft\Router\Route; From ef3eb1527e1d2d13a3f7bfe0829c8906b9b1c7cd Mon Sep 17 00:00:00 2001 From: Rustam Date: Tue, 21 Oct 2025 21:58:12 +0500 Subject: [PATCH 10/32] Fix cs --- src/Route.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Route.php b/src/Route.php index 2739a6d5..dad055f2 100644 --- a/src/Route.php +++ b/src/Route.php @@ -148,7 +148,7 @@ public function setMethods(array $methods): self } foreach ($methods as $method) { if (!is_string($method)) { - throw new \InvalidArgumentException('Invalid $methods provided, list of string expected.'); + throw new InvalidArgumentException('Invalid $methods provided, list of string expected.'); } $this->methods[] = $method; } @@ -160,7 +160,7 @@ public function setHosts(array $hosts): self $this->hosts = []; foreach ($hosts as $host) { if (!is_string($host)) { - throw new \InvalidArgumentException('Invalid $hosts provided, list of string expected.'); + throw new InvalidArgumentException('Invalid $hosts provided, list of string expected.'); } $host = rtrim($host, '/'); @@ -191,7 +191,7 @@ public function setDefaults(array $defaults): self /** @var mixed $value */ foreach ($defaults as $key => $value) { if (!is_scalar($value) && !($value instanceof Stringable) && null !== $value) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( 'Invalid $defaults provided, indexed array of scalar or `Stringable` or null expected.' ); } @@ -279,7 +279,7 @@ private function assertMiddlewares(array $middlewares): void continue; } - throw new \InvalidArgumentException( + throw new InvalidArgumentException( 'Invalid $middlewares provided, list of string or array or callable expected.' ); } From a9af466b78544371679dd453979ac0d673743175 Mon Sep 17 00:00:00 2001 From: Rustam Date: Thu, 13 Nov 2025 11:14:59 +0500 Subject: [PATCH 11/32] Update dependencies, replace redundant `isArrayList` polyfill with array_is_list, and apply readonly properties --- composer.json | 20 ++++++++++---------- src/Builder/GroupBuilder.php | 2 +- src/Builder/RouteBuilder.php | 4 ++-- src/Debug/DebugRoutesCommand.php | 23 +---------------------- 4 files changed, 14 insertions(+), 35 deletions(-) diff --git a/composer.json b/composer.json index ddd318ba..7b6c6acc 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ } ], "require": { - "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", + "php": "8.1 - 8.4", "psr/event-dispatcher": "^1.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.0 || ^2.0", @@ -39,18 +39,18 @@ "yiisoft/router-implementation": "1.0.0" }, "require-dev": { - "maglnet/composer-require-checker": "^4.7.1", + "maglnet/composer-require-checker": "^4.17.0", "nyholm/psr7": "^1.8.2", - "phpunit/phpunit": "^10.5.45", + "phpunit/phpunit": "^10.5.58", "psr/container": "^1.1 || ^2.0.2", - "rector/rector": "^2.0.9", - "roave/infection-static-analysis-plugin": "^1.35", + "rector/rector": "^2.2.7", + "roave/infection-static-analysis-plugin": "^1.39", "spatie/phpunit-watcher": "^1.24", - "vimeo/psalm": "^5.26.1 || ^6.8.6", - "yiisoft/di": "^1.3", - "yiisoft/dummy-provider": "^1.0.1", - "yiisoft/hydrator": "^1.5", - "yiisoft/test-support": "^3.0.1", + "vimeo/psalm": "^5.26.1 || ^6.13.1", + "yiisoft/di": "^1.4", + "yiisoft/dummy-provider": "^1.1.0", + "yiisoft/hydrator": "^1.6.2", + "yiisoft/test-support": "^3.0.2", "yiisoft/yii-debug": "dev-master" }, "autoload": { diff --git a/src/Builder/GroupBuilder.php b/src/Builder/GroupBuilder.php index c1527535..42a715dc 100644 --- a/src/Builder/GroupBuilder.php +++ b/src/Builder/GroupBuilder.php @@ -40,7 +40,7 @@ final class GroupBuilder implements RoutableInterface private $corsMiddleware = null; private function __construct( - private ?string $prefix = null, + private readonly ?string $prefix = null, private ?string $namePrefix = null, ) { } diff --git a/src/Builder/RouteBuilder.php b/src/Builder/RouteBuilder.php index 6c6c3f1c..ac3a2fdf 100644 --- a/src/Builder/RouteBuilder.php +++ b/src/Builder/RouteBuilder.php @@ -15,7 +15,7 @@ */ final class RouteBuilder implements RoutableInterface { - private ?string $name = null; + private null|string $name = null; /** * @var array|callable|string|null @@ -46,7 +46,7 @@ final class RouteBuilder implements RoutableInterface * @param string[] $methods */ private function __construct( - private array $methods, + private readonly array $methods, private string $pattern, ) { } diff --git a/src/Debug/DebugRoutesCommand.php b/src/Debug/DebugRoutesCommand.php index 6ec1841d..bfaf48c7 100644 --- a/src/Debug/DebugRoutesCommand.php +++ b/src/Debug/DebugRoutesCommand.php @@ -125,7 +125,7 @@ protected function export(mixed $value): string ) { return $value[0] . '::' . $value[1]; } - if (is_array($value) && $this->isArrayList($value)) { + if (is_array($value) && array_is_list($value)) { return implode(', ', array_map(fn ($item) => $this->export($item), $value)); } if (is_string($value)) { @@ -133,25 +133,4 @@ protected function export(mixed $value): string } return VarDumper::create($value)->asString(); } - - /** - * Polyfill for is_array_list() function. - * It is available since PHP 8.1. - */ - private function isArrayList(array $array): bool - { - if ([] === $array) { - return true; - } - - $nextKey = -1; - - foreach ($array as $k => $_) { - if ($k !== ++$nextKey) { - return false; - } - } - - return true; - } } From 2bc167253f52aa782451ac711639e60ab20e3e47 Mon Sep 17 00:00:00 2001 From: rustamwin <16498265+rustamwin@users.noreply.github.com> Date: Thu, 13 Nov 2025 06:15:37 +0000 Subject: [PATCH 12/32] Apply Rector changes (CI) --- src/Debug/DebugRoutesCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Debug/DebugRoutesCommand.php b/src/Debug/DebugRoutesCommand.php index bfaf48c7..6e68bb70 100644 --- a/src/Debug/DebugRoutesCommand.php +++ b/src/Debug/DebugRoutesCommand.php @@ -126,7 +126,7 @@ protected function export(mixed $value): string return $value[0] . '::' . $value[1]; } if (is_array($value) && array_is_list($value)) { - return implode(', ', array_map(fn ($item) => $this->export($item), $value)); + return implode(', ', array_map($this->export(...), $value)); } if (is_string($value)) { return $value; From f025e9d68ab9b7cf0941decdd665ae424aa7c61b Mon Sep 17 00:00:00 2001 From: Rustam Date: Thu, 13 Nov 2025 11:23:39 +0500 Subject: [PATCH 13/32] Update dev dependencies in composer.json --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 7b6c6acc..7d38ab02 100644 --- a/composer.json +++ b/composer.json @@ -39,12 +39,12 @@ "yiisoft/router-implementation": "1.0.0" }, "require-dev": { - "maglnet/composer-require-checker": "^4.17.0", + "maglnet/composer-require-checker": "^4.7.1", "nyholm/psr7": "^1.8.2", "phpunit/phpunit": "^10.5.58", "psr/container": "^1.1 || ^2.0.2", "rector/rector": "^2.2.7", - "roave/infection-static-analysis-plugin": "^1.39", + "roave/infection-static-analysis-plugin": "^1.35", "spatie/phpunit-watcher": "^1.24", "vimeo/psalm": "^5.26.1 || ^6.13.1", "yiisoft/di": "^1.4", From f26efb420c44ebff92217e52b286aae87b983223 Mon Sep 17 00:00:00 2001 From: Rustam Date: Wed, 15 Apr 2026 22:29:02 +0500 Subject: [PATCH 14/32] Refactor tests and route logic: align types, fix assertions, and improve method consistency --- src/Route.php | 2 +- tests/Builder/RouteBuilderTest.php | 4 ++-- tests/CurrentRouteTest.php | 2 +- tests/RouteCollectionTest.php | 8 ++++---- tests/RouteTest.php | 13 +------------ 5 files changed, 9 insertions(+), 20 deletions(-) diff --git a/src/Route.php b/src/Route.php index dad055f2..f7738eb2 100644 --- a/src/Route.php +++ b/src/Route.php @@ -58,7 +58,7 @@ public function __construct( array $methods, private string $pattern, private ?string $name = null, - array|callable|string $action = null, + array|callable|string|null $action = null, array $middlewares = [], array $defaults = [], array $hosts = [], diff --git a/tests/Builder/RouteBuilderTest.php b/tests/Builder/RouteBuilderTest.php index 3596ca32..085aa32d 100644 --- a/tests/Builder/RouteBuilderTest.php +++ b/tests/Builder/RouteBuilderTest.php @@ -173,7 +173,7 @@ public function testOverride(): void $this->assertTrue($route->toRoute()->isOverride()); } - public function dataToString(): array + public static function dataToString(): array { return [ ['yiiframework.com/', '/'], @@ -369,7 +369,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface }; } - private function getDispatcher(ContainerInterface $container = null): MiddlewareDispatcher + private function getDispatcher(ContainerInterface|null $container = null): MiddlewareDispatcher { if ($container === null) { return new MiddlewareDispatcher( diff --git a/tests/CurrentRouteTest.php b/tests/CurrentRouteTest.php index bcfba75f..6628deb0 100644 --- a/tests/CurrentRouteTest.php +++ b/tests/CurrentRouteTest.php @@ -18,7 +18,7 @@ public function testGettersReturnDefaultValuesWhenRouteIsNotSet(): void $currentRoute = new CurrentRoute(); $this->assertNull($currentRoute->getName()); - $this->assertNull($currentRoute->getHost()); + $this->assertNull($currentRoute->getHosts()); $this->assertNull($currentRoute->getPattern()); $this->assertNull($currentRoute->getMethods()); $this->assertNull($currentRoute->getUri()); diff --git a/tests/RouteCollectionTest.php b/tests/RouteCollectionTest.php index 1d055be8..3dc30abd 100644 --- a/tests/RouteCollectionTest.php +++ b/tests/RouteCollectionTest.php @@ -179,10 +179,10 @@ public function testGetRouteTreeReturnsRouteInstances(): void $routeTree = (new RouteCollection($collector))->getRouteTree(false); - $this->assertInstanceOf(Route::class, $routeTree[0]); - $this->assertSame('/api/posts', $routeTree[0]->getData('name')); - $this->assertInstanceOf(Route::class, $routeTree['/v1'][0]); - $this->assertSame('/api/comments', $routeTree['/v1'][0]->getData('name')); + $this->assertInstanceOf(\Yiisoft\Router\Route::class, $routeTree[0]); + $this->assertSame('/api/posts', $routeTree[0]->getName()); + $this->assertInstanceOf(\Yiisoft\Router\Route::class, $routeTree['/v1'][0]); + $this->assertSame('/api/comments', $routeTree['/v1'][0]->getName()); } public function testGetRoutes(): void diff --git a/tests/RouteTest.php b/tests/RouteTest.php index e1125613..1e40832e 100644 --- a/tests/RouteTest.php +++ b/tests/RouteTest.php @@ -181,23 +181,12 @@ public function testInvalidMiddlewares(): void $route = new Route([Method::GET], '/', middlewares: [static fn () => new Response(), (object) ['test' => 1]]); } - public function testMiddlewareAfterAction(): void - { - $route = Route::get('/'); - $route = $route->middleware(TestMiddleware1::class) - ->action([TestController::class, 'index']) - ->middleware(TestMiddleware2::class) - ->middleware(TestMiddleware3::class); - - $route = new Route([Method::GET], '/', middlewares: [static fn () => new Response(), (object) ['test' => 1]]); - } - public function testInvalidDefaults(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid $defaults provided, indexed array of scalar or `Stringable` or null expected.'); - $route = new Route([Method::GET], '/', defaults: ['test' => 1, 'foo' => ['bar']]); + new Route([Method::GET], '/', defaults: ['test' => 1, 'foo' => ['bar']]); } public function testDebugInfo(): void From c9bacd1f8ae435e46c4212d57470b9643b4a6450 Mon Sep 17 00:00:00 2001 From: rustamwin <16498265+rustamwin@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:29:50 +0000 Subject: [PATCH 15/32] Apply PHP CS Fixer and Rector changes (CI) --- src/Builder/GroupBuilder.php | 9 ++- src/Builder/RouteBuilder.php | 13 ++--- src/Group.php | 20 ++++--- src/Route.php | 88 +++++++++++++++------------- tests/Builder/GroupBuilderTest.php | 94 +++++++++++++++--------------- tests/Builder/RouteBuilderTest.php | 32 +++++----- tests/GroupTest.php | 14 ++--- tests/RouteTest.php | 14 ++--- 8 files changed, 147 insertions(+), 137 deletions(-) diff --git a/src/Builder/GroupBuilder.php b/src/Builder/GroupBuilder.php index 42a715dc..7d6be2eb 100644 --- a/src/Builder/GroupBuilder.php +++ b/src/Builder/GroupBuilder.php @@ -42,8 +42,7 @@ final class GroupBuilder implements RoutableInterface private function __construct( private readonly ?string $prefix = null, private ?string $namePrefix = null, - ) { - } + ) {} /** * Create a new group instance. @@ -95,7 +94,7 @@ public function middleware(array|callable|string ...$definition): self $new = clone $this; array_push( $new->middlewares, - ...array_values($definition) + ...array_values($definition), ); return $new; @@ -110,7 +109,7 @@ public function prependMiddleware(array|callable|string ...$definition): self $new = clone $this; array_unshift( $new->middlewares, - ...array_values($definition) + ...array_values($definition), ); $new->middlewareAdded = true; @@ -163,7 +162,7 @@ public function toRoute(): Group|Route middlewares: $this->middlewares, hosts: $this->hosts, disabledMiddlewares: $this->disabledMiddlewares, - corsMiddleware: $this->corsMiddleware + corsMiddleware: $this->corsMiddleware, ); } } diff --git a/src/Builder/RouteBuilder.php b/src/Builder/RouteBuilder.php index ac3a2fdf..ee7c457c 100644 --- a/src/Builder/RouteBuilder.php +++ b/src/Builder/RouteBuilder.php @@ -15,7 +15,7 @@ */ final class RouteBuilder implements RoutableInterface { - private null|string $name = null; + private ?string $name = null; /** * @var array|callable|string|null @@ -48,8 +48,7 @@ final class RouteBuilder implements RoutableInterface private function __construct( private readonly array $methods, private string $pattern, - ) { - } + ) {} public static function get(string $pattern): self { @@ -152,7 +151,7 @@ public function middleware(array|callable|string ...$definition): self $route = clone $this; array_push( $route->middlewares, - ...array_values($definition) + ...array_values($definition), ); return $route; @@ -167,7 +166,7 @@ public function prependMiddleware(array|callable|string ...$definition): self $route = clone $this; array_unshift( $route->middlewares, - ...array_values($definition) + ...array_values($definition), ); return $route; @@ -193,7 +192,7 @@ public function disableMiddleware(mixed ...$definition): self $route = clone $this; array_push( $route->disabledMiddlewares, - ...array_values($definition) + ...array_values($definition), ); return $route; @@ -210,7 +209,7 @@ public function toRoute(): Group|Route defaults: $this->defaults, hosts: $this->hosts, override: $this->override, - disabledMiddlewares: $this->disabledMiddlewares + disabledMiddlewares: $this->disabledMiddlewares, ); } } diff --git a/src/Group.php b/src/Group.php index 3ff298f6..afee03ad 100644 --- a/src/Group.php +++ b/src/Group.php @@ -7,6 +7,11 @@ use InvalidArgumentException; use Yiisoft\Router\Internal\MiddlewareFilter; +use function in_array; +use function is_array; +use function is_callable; +use function is_string; + final class Group { /** @@ -47,12 +52,13 @@ public function __construct( array $middlewares = [], array $hosts = [], private array $disabledMiddlewares = [], - array|callable|string|null $corsMiddleware = null + array|callable|string|null $corsMiddleware = null, ) { $this->setRoutes($routes); $this->setMiddlewares($middlewares); $this->setHosts($hosts); - $this->corsMiddleware = $corsMiddleware;} + $this->corsMiddleware = $corsMiddleware; + } /** * @return Group[]|RoutableInterface[]|Route[] @@ -111,7 +117,7 @@ public function setHosts(array $hosts): self { foreach ($hosts as $host) { if (!is_string($host)) { - throw new \InvalidArgumentException('Invalid $hosts provided, list of string expected.'); + throw new InvalidArgumentException('Invalid $hosts provided, list of string expected.'); } $host = rtrim($host, '/'); @@ -173,8 +179,8 @@ private function assertMiddlewares(array $middlewares): void continue; } - throw new \InvalidArgumentException( - 'Invalid $middlewares provided, list of string or array or callable expected.' + throw new InvalidArgumentException( + 'Invalid $middlewares provided, list of string or array or callable expected.', ); } } @@ -190,8 +196,8 @@ private function assertRoutes(array $routes): void continue; } - throw new \InvalidArgumentException( - 'Invalid $routes provided, array of `Route` or `Group` or `RoutableInterface` instance expected.' + throw new InvalidArgumentException( + 'Invalid $routes provided, array of `Route` or `Group` or `RoutableInterface` instance expected.', ); } } diff --git a/src/Route.php b/src/Route.php index f7738eb2..3ef20a03 100644 --- a/src/Route.php +++ b/src/Route.php @@ -8,6 +8,12 @@ use Stringable; use Yiisoft\Router\Internal\MiddlewareFilter; +use function in_array; +use function is_array; +use function is_callable; +use function is_scalar; +use function is_string; + /** * Route defines a mapping from URL to callback / name and vice versa. */ @@ -72,6 +78,45 @@ public function __construct( $this->setDefaults($defaults); } + public function __toString(): string + { + $result = $this->name === null + ? '' + : '[' . $this->name . '] '; + + if ($this->methods !== []) { + $result .= implode(',', $this->methods) . ' '; + } + + if (!empty($this->hosts)) { + $quoted = array_map(static fn($host) => preg_quote($host, '/'), $this->hosts); + + if (!preg_match('/' . implode('|', $quoted) . '/', $this->pattern)) { + $result .= implode('|', $this->hosts); + } + } + + $result .= $this->pattern; + + return $result; + } + + public function __debugInfo() + { + return [ + 'name' => $this->name, + 'methods' => $this->methods, + 'pattern' => $this->pattern, + 'action' => $this->action, + 'hosts' => $this->hosts, + 'defaults' => $this->defaults, + 'override' => $this->override, + 'middlewares' => $this->middlewares, + 'disabledMiddlewares' => $this->disabledMiddlewares, + 'enabledMiddlewares' => $this->getEnabledMiddlewares(), + ]; + } + /** * @return string[] */ @@ -192,7 +237,7 @@ public function setDefaults(array $defaults): self foreach ($defaults as $key => $value) { if (!is_scalar($value) && !($value instanceof Stringable) && null !== $value) { throw new InvalidArgumentException( - 'Invalid $defaults provided, indexed array of scalar or `Stringable` or null expected.' + 'Invalid $defaults provided, indexed array of scalar or `Stringable` or null expected.', ); } $this->defaults[$key] = (string) $value; @@ -225,45 +270,6 @@ public function setDisabledMiddlewares(array $disabledMiddlewares): self return $this; } - public function __toString(): string - { - $result = $this->name === null - ? '' - : '[' . $this->name . '] '; - - if ($this->methods !== []) { - $result .= implode(',', $this->methods) . ' '; - } - - if (!empty($this->hosts)) { - $quoted = array_map(static fn ($host) => preg_quote($host, '/'), $this->hosts); - - if (!preg_match('/' . implode('|', $quoted) . '/', $this->pattern)) { - $result .= implode('|', $this->hosts); - } - } - - $result .= $this->pattern; - - return $result; - } - - public function __debugInfo() - { - return [ - 'name' => $this->name, - 'methods' => $this->methods, - 'pattern' => $this->pattern, - 'action' => $this->action, - 'hosts' => $this->hosts, - 'defaults' => $this->defaults, - 'override' => $this->override, - 'middlewares' => $this->middlewares, - 'disabledMiddlewares' => $this->disabledMiddlewares, - 'enabledMiddlewares' => $this->getEnabledMiddlewares(), - ]; - } - /** * @psalm-assert list $middlewares */ @@ -280,7 +286,7 @@ private function assertMiddlewares(array $middlewares): void } throw new InvalidArgumentException( - 'Invalid $middlewares provided, list of string or array or callable expected.' + 'Invalid $middlewares provided, list of string or array or callable expected.', ); } } diff --git a/tests/Builder/GroupBuilderTest.php b/tests/Builder/GroupBuilderTest.php index 7526d05f..80c7704d 100644 --- a/tests/Builder/GroupBuilderTest.php +++ b/tests/Builder/GroupBuilderTest.php @@ -29,8 +29,8 @@ public function testAddMiddleware(): void { $group = Group::create(); - $middleware1 = static fn () => new Response(); - $middleware2 = static fn () => new Response(); + $middleware1 = static fn() => new Response(); + $middleware2 = static fn() => new Response(); $group = $group ->middleware($middleware1) @@ -52,7 +52,7 @@ public function testMiddlewaresWithKeys(): void $this->assertSame( [TestMiddleware2::class, TestMiddleware3::class], - $groupRoute->getEnabledMiddlewares() + $groupRoute->getEnabledMiddlewares(), ); } @@ -72,7 +72,7 @@ public function testRoutesAfterMiddleware(): void { $group = Group::create(); - $middleware1 = static fn () => new Response(); + $middleware1 = static fn() => new Response(); $group = $group->prependMiddleware($middleware1); @@ -86,12 +86,12 @@ public function testAddNestedMiddleware(): void { $request = new ServerRequest('GET', '/outergroup/innergroup/test1'); - $action = static fn (ServerRequestInterface $request) => new Response( + $action = static fn(ServerRequestInterface $request) => new Response( 200, [], null, '1.1', - implode('', $request->getAttributes()) + implode('', $request->getAttributes()), ); $middleware1 = static function (ServerRequestInterface $request, RequestHandlerInterface $handler) { @@ -113,7 +113,7 @@ public function testAddNestedMiddleware(): void Route::get('/test1') ->action($action) ->name('request1'), - ) + ), ); $collector = new RouteCollector(); @@ -132,12 +132,12 @@ public function testGroupMiddlewareFullStackCalled(): void { $request = new ServerRequest('GET', '/group/test1'); - $action = static fn (ServerRequestInterface $request) => new Response( + $action = static fn(ServerRequestInterface $request) => new Response( 200, [], null, '1.1', - implode('', $request->getAttributes()) + implode('', $request->getAttributes()), ); $middleware1 = function (ServerRequestInterface $request, RequestHandlerInterface $handler) { $request = $request->withAttribute('middleware', 'middleware1'); @@ -175,9 +175,9 @@ public function testGroupMiddlewareStackInterrupted(): void { $request = new ServerRequest('GET', '/group/test1'); - $action = static fn () => new Response(200); - $middleware1 = fn () => new Response(403); - $middleware2 = fn () => new Response(405); + $action = static fn() => new Response(200); + $middleware1 = fn() => new Response(403); + $middleware2 = fn() => new Response(405); $group = Group::create('/group') ->middleware($middleware1) @@ -185,7 +185,7 @@ public function testGroupMiddlewareStackInterrupted(): void ->routes( Route::get('/test1') ->action($action) - ->name('request1') + ->name('request1'), ); $collector = new RouteCollector(); @@ -207,8 +207,8 @@ public function testAddGroup(): void $listRoute = Route::get('/'); $viewRoute = Route::get('/{id}'); - $middleware1 = static fn () => new Response(); - $middleware2 = static fn () => new Response(); + $middleware1 = static fn() => new Response(); + $middleware2 = static fn() => new Response(); $root = Group::create() ->routes( @@ -220,8 +220,8 @@ public function testAddGroup(): void Group::create('/post') ->routes( $listRoute, - $viewRoute - ) + $viewRoute, + ), ), ); $rootGroup = $root->toRoute(); @@ -277,11 +277,11 @@ public function testWithCors(): void { $group = Group::create() ->routes( - Route::get('/info')->action(static fn () => 'info'), - Route::post('/info')->action(static fn () => 'info'), + Route::get('/info')->action(static fn() => 'info'), + Route::post('/info')->action(static fn() => 'info'), ) ->withCors( - static fn () => new Response(204) + static fn() => new Response(204), ); $collector = new RouteCollector(); @@ -296,14 +296,14 @@ public function testWithCorsWithHostRoutes(): void $group = Group::create() ->routes( Route::get('/info') - ->action(static fn () => 'info') + ->action(static fn() => 'info') ->host('yii.dev'), Route::get('/info') - ->action(static fn () => 'info') + ->action(static fn() => 'info') ->host('yii.test'), ) ->withCors( - static fn () => new Response(204) + static fn() => new Response(204), ); $collector = new RouteCollector(); @@ -318,17 +318,17 @@ public function testWithCorsDoesntDuplicateRoutes(): void $group = Group::create() ->routes( Route::get('/info') - ->action(static fn () => 'info') + ->action(static fn() => 'info') ->host('yii.dev'), Route::post('/info') - ->action(static fn () => 'info') + ->action(static fn() => 'info') ->host('yii.dev'), Route::put('/info') - ->action(static fn () => 'info') + ->action(static fn() => 'info') ->host('yii.test'), ) ->withCors( - static fn () => new Response(204) + static fn() => new Response(204), ); $collector = new RouteCollector(); @@ -341,19 +341,19 @@ public function testWithCorsDoesntDuplicateRoutes(): void public function testWithCorsWithNestedGroups(): void { $group = Group::create()->routes( - Route::get('/info')->action(static fn () => 'info'), - Route::post('/info')->action(static fn () => 'info'), + Route::get('/info')->action(static fn() => 'info'), + Route::post('/info')->action(static fn() => 'info'), Group::create('/v1') ->routes( - Route::get('/post')->action(static fn () => 'post'), - Route::post('/post')->action(static fn () => 'post'), - Route::options('/options')->action(static fn () => 'options'), + Route::get('/post')->action(static fn() => 'post'), + Route::post('/post')->action(static fn() => 'post'), + Route::options('/options')->action(static fn() => 'options'), ) ->withCors( - static fn () => new Response(201) - ) + static fn() => new Response(201), + ), )->withCors( - static fn () => new Response(204) + static fn() => new Response(204), ); $collector = new RouteCollector(); @@ -367,18 +367,18 @@ public function testWithCorsWithNestedGroups(): void public function testWithCorsWithNestedGroups2(): void { $group = Group::create()->routes( - Route::get('/info')->action(static fn () => 'info'), - Route::post('/info')->action(static fn () => 'info'), - Route::get('/v1/post')->action(static fn () => 'post'), + Route::get('/info')->action(static fn() => 'info'), + Route::post('/info')->action(static fn() => 'info'), + Route::get('/v1/post')->action(static fn() => 'post'), Group::create('/v1')->routes( - Route::post('/post')->action(static fn () => 'post'), - Route::options('/options')->action(static fn () => 'options'), + Route::post('/post')->action(static fn() => 'post'), + Route::options('/options')->action(static fn() => 'options'), ), Group::create('/v1')->routes( - Route::put('/post')->action(static fn () => 'post'), - ) + Route::put('/post')->action(static fn() => 'post'), + ), )->withCors( - static fn () => new Response(204) + static fn() => new Response(204), ); $collector = new RouteCollector(); $collector->addRoute($group); @@ -390,11 +390,11 @@ public function testWithCorsWithNestedGroups2(): void public function testMiddlewareAfterRoutes(): void { - $group = Group::create()->routes(Route::get('/info')->action(static fn () => 'info')); + $group = Group::create()->routes(Route::get('/info')->action(static fn() => 'info')); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('middleware() can not be used after routes().'); - $group->middleware(static fn () => new Response()); + $group->middleware(static fn() => new Response()); } public function testDuplicateHosts(): void @@ -419,7 +419,7 @@ public function testImmutability(): void private function getRequestHandler(): RequestHandlerInterface { - return new class () implements RequestHandlerInterface { + return new class implements RequestHandlerInterface { public function handle(ServerRequestInterface $request): ResponseInterface { return new Response(404); @@ -432,7 +432,7 @@ private function getDispatcher(): MiddlewareDispatcher $container = new Container([]); return new MiddlewareDispatcher( new MiddlewareFactory($container), - $this->createMock(EventDispatcherInterface::class) + $this->createMock(EventDispatcherInterface::class), ); } } diff --git a/tests/Builder/RouteBuilderTest.php b/tests/Builder/RouteBuilderTest.php index 085aa32d..9ee400d0 100644 --- a/tests/Builder/RouteBuilderTest.php +++ b/tests/Builder/RouteBuilderTest.php @@ -125,7 +125,7 @@ public function testHosts(): void 'https://yiiframework.com/', 'yf.com', 'yii.com', - 'yf.ru' + 'yf.ru', ); $this->assertSame( @@ -135,7 +135,7 @@ public function testHosts(): void 'yii.com', 'yf.ru', ], - $route->toRoute()->getHosts() + $route->toRoute()->getHosts(), ); } @@ -146,7 +146,7 @@ public function testMultipleHosts(): void $multipleRoute = Route::get('/') ->hosts( 'https://yiiframework.com/', - 'https://yiiframework.ru/' + 'https://yiiframework.ru/', ); $this->assertCount(1, $route->toRoute()->getHosts()); @@ -190,14 +190,14 @@ public function testToString(string $expected, string $pattern): void ->name('test.route') ->host('yiiframework.com'); - $this->assertSame('[test.route] GET,POST ' . $expected, (string)$route->toRoute()); + $this->assertSame('[test.route] GET,POST ' . $expected, (string) $route->toRoute()); } public function testToStringSimple(): void { $route = Route::get('/'); - $this->assertSame('GET /', (string)$route->toRoute()); + $this->assertSame('GET /', (string) $route->toRoute()); } public function testDispatcherInjecting(): void @@ -206,7 +206,7 @@ public function testDispatcherInjecting(): void $container = $this->getContainer( [ TestController::class => new TestController(), - ] + ], ); $route = Route::get('/')->action([TestController::class, 'index']); @@ -235,7 +235,7 @@ public function testDisabledMiddlewareDefinitions(): void TestMiddleware2::class => new TestMiddleware2(), TestMiddleware3::class => new TestMiddleware3(), TestController::class => new TestController(), - ]) + ]), ) ->withMiddlewares($route->toRoute()->getEnabledMiddlewares()); @@ -260,7 +260,7 @@ public function testPrependMiddlewareDefinitions(): void TestMiddleware2::class => new TestMiddleware2(), TestMiddleware3::class => new TestMiddleware3(), TestController::class => new TestController(), - ]) + ]), ) ->withMiddlewares($route->toRoute()->getEnabledMiddlewares()) ->dispatch($request, $this->getRequestHandler()); @@ -282,7 +282,7 @@ public function testPrependMiddlewaresAfterGetEnabledMiddlewares(): void $this->assertSame( [TestMiddleware2::class, TestMiddleware3::class, [TestController::class, 'index']], - $route->toRoute()->getEnabledMiddlewares() + $route->toRoute()->getEnabledMiddlewares(), ); } @@ -297,7 +297,7 @@ public function testAddMiddlewareAfterGetEnabledMiddlewares(): void $this->assertSame( [TestMiddleware3::class, TestMiddleware1::class, TestMiddleware2::class], - $route->toRoute()->getEnabledMiddlewares() + $route->toRoute()->getEnabledMiddlewares(), ); } @@ -312,7 +312,7 @@ public function testDisableMiddlewareAfterGetEnabledMiddlewares(): void $this->assertSame( [TestMiddleware3::class], - $route->toRoute()->getEnabledMiddlewares() + $route->toRoute()->getEnabledMiddlewares(), ); } @@ -338,7 +338,7 @@ public function testMiddlewaresWithKeys(): void $this->assertSame( [TestMiddleware2::class, TestMiddleware3::class, [TestController::class, 'index']], - $route->toRoute()->getEnabledMiddlewares() + $route->toRoute()->getEnabledMiddlewares(), ); } @@ -361,7 +361,7 @@ public function testImmutability(): void private function getRequestHandler(): RequestHandlerInterface { - return new class () implements RequestHandlerInterface { + return new class implements RequestHandlerInterface { public function handle(ServerRequestInterface $request): ResponseInterface { return new Response(404); @@ -369,18 +369,18 @@ public function handle(ServerRequestInterface $request): ResponseInterface }; } - private function getDispatcher(ContainerInterface|null $container = null): MiddlewareDispatcher + private function getDispatcher(?ContainerInterface $container = null): MiddlewareDispatcher { if ($container === null) { return new MiddlewareDispatcher( new MiddlewareFactory($this->getContainer()), - $this->createMock(EventDispatcherInterface::class) + $this->createMock(EventDispatcherInterface::class), ); } return new MiddlewareDispatcher( new MiddlewareFactory($container), - $this->createMock(EventDispatcherInterface::class) + $this->createMock(EventDispatcherInterface::class), ); } diff --git a/tests/GroupTest.php b/tests/GroupTest.php index 36e844cd..8b4b84f2 100644 --- a/tests/GroupTest.php +++ b/tests/GroupTest.php @@ -13,7 +13,7 @@ use Yiisoft\Router\Tests\Support\TestMiddleware1; use Yiisoft\Router\Tests\Support\TestMiddleware2; use Yiisoft\Router\Tests\Support\TestMiddleware3; -use Yiisoft\Router\Tests\Support\TestController; +use stdClass; final class GroupTest extends TestCase { @@ -47,7 +47,7 @@ public function testSetMiddlewaresAfterGetEnabledMiddlewares(): void $this->assertSame( [TestMiddleware2::class, TestMiddleware3::class], - $group->getEnabledMiddlewares() + $group->getEnabledMiddlewares(), ); } @@ -62,7 +62,7 @@ public function testDisableMiddlewareAfterGetEnabledMiddlewares(): void $this->assertSame( [TestMiddleware3::class], - $group->getEnabledMiddlewares() + $group->getEnabledMiddlewares(), ); } @@ -71,8 +71,8 @@ public function testInvalidMiddlewares(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid $middlewares provided, list of string or array or callable expected.'); - $middleware = static fn () => new Response(); - $group = new Group('/api', middlewares: [$middleware, new \stdClass()]); + $middleware = static fn() => new Response(); + $group = new Group('/api', middlewares: [$middleware, new stdClass()]); } public function testHosts(): void @@ -106,7 +106,7 @@ public function testName(): void public function testCors(): void { - $group = (new Group())->setCorsMiddleware($cors = static fn () => new Response()); + $group = (new Group())->setCorsMiddleware($cors = static fn() => new Response()); $this->assertSame($cors, $group->getCorsMiddleware()); } @@ -123,6 +123,6 @@ public function testInvalidRoutes(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid $routes provided, array of `Route` or `Group` or `RoutableInterface` instance expected.'); - $group = (new Group())->setRoutes([new Route([Method::GET], ''), new \stdClass()]); + $group = (new Group())->setRoutes([new Route([Method::GET], ''), new stdClass()]); } } diff --git a/tests/RouteTest.php b/tests/RouteTest.php index 1e40832e..09482d31 100644 --- a/tests/RouteTest.php +++ b/tests/RouteTest.php @@ -66,7 +66,7 @@ public function testEnabledMiddlewares(): void public function testEmptyMethods(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('$methods cannot be empty.'); new Route([], ''); @@ -175,15 +175,15 @@ public function testToStringSimple(): void public function testInvalidMiddlewares(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid $middlewares provided, list of string or array or callable expected.'); - $route = new Route([Method::GET], '/', middlewares: [static fn () => new Response(), (object) ['test' => 1]]); + $route = new Route([Method::GET], '/', middlewares: [static fn() => new Response(), (object) ['test' => 1]]); } public function testInvalidDefaults(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid $defaults provided, indexed array of scalar or `Stringable` or null expected.'); new Route([Method::GET], '/', defaults: ['test' => 1, 'foo' => ['bar']]); @@ -200,7 +200,7 @@ public function testDebugInfo(): void defaults: ['age' => 42], hosts: ['example.com'], override: true, - disabledMiddlewares: [TestMiddleware2::class] + disabledMiddlewares: [TestMiddleware2::class], ); $expected = <<expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid $hosts provided, list of string expected.'); $route = new Route([Method::GET], '/', hosts: ['b.com', 123]); @@ -267,7 +267,7 @@ public function testInvalidHosts(): void public function testInvalidMethods(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid $methods provided, list of string expected.'); $route = new Route([1], '/'); From 2e737fa525e5b43b441cd118c896b3a1c6b538c6 Mon Sep 17 00:00:00 2001 From: Rustam Date: Sun, 19 Apr 2026 02:04:13 +0500 Subject: [PATCH 16/32] Refactor: optimize middleware handling, improve property initialization, and apply `readonly` for constructor injection --- src/Debug/RouterCollector.php | 7 +++++-- src/Group.php | 1 + src/Route.php | 11 +++++------ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/Debug/RouterCollector.php b/src/Debug/RouterCollector.php index 23460992..36d9ac5e 100644 --- a/src/Debug/RouterCollector.php +++ b/src/Debug/RouterCollector.php @@ -21,7 +21,7 @@ final class RouterCollector implements SummaryCollectorInterface private float $matchTime = 0; - public function __construct(private ContainerInterface $container) {} + public function __construct(private readonly ContainerInterface $container) {} public function collect(float $matchTime): void { @@ -128,6 +128,9 @@ private function getMiddlewaresAndAction(?Route $route): array return [[], null]; } - return [$route->getMiddlewares(), $route->getAction()]; + $middlewares = $route->getEnabledMiddlewares(); + $action = $route->getAction(); + + return [$middlewares, $action]; } } diff --git a/src/Group.php b/src/Group.php index afee03ad..2b872e58 100644 --- a/src/Group.php +++ b/src/Group.php @@ -115,6 +115,7 @@ public function setMiddlewares(array $middlewares): self public function setHosts(array $hosts): self { + $this->hosts = []; foreach ($hosts as $host) { if (!is_string($host)) { throw new InvalidArgumentException('Invalid $hosts provided, list of string expected.'); diff --git a/src/Route.php b/src/Route.php index 3ef20a03..913f22a6 100644 --- a/src/Route.php +++ b/src/Route.php @@ -155,7 +155,7 @@ public function getPattern(): string public function getName(): string { - return $this->name ??= (implode(', ', $this->methods) . ' ' . implode('|', $this->hosts) . $this->pattern); + return $this->name ?? (implode(', ', $this->methods) . ' ' . implode('|', $this->hosts) . $this->pattern); } public function isOverride(): bool @@ -179,10 +179,6 @@ public function getEnabledMiddlewares(): array } $this->enabledMiddlewaresCache = MiddlewareFilter::filter($this->middlewares, $this->disabledMiddlewares); - if ($this->action !== null) { - $this->enabledMiddlewaresCache[] = $this->action; - } - return $this->enabledMiddlewaresCache; } @@ -191,6 +187,7 @@ public function setMethods(array $methods): self if (empty($methods)) { throw new InvalidArgumentException('$methods cannot be empty.'); } + $this->methods = []; foreach ($methods as $method) { if (!is_string($method)) { throw new InvalidArgumentException('Invalid $methods provided, list of string expected.'); @@ -220,6 +217,7 @@ public function setHosts(array $hosts): self public function setAction(callable|array|string|null $action): self { $this->action = $action; + $this->enabledMiddlewaresCache = null; return $this; } @@ -233,6 +231,7 @@ public function setMiddlewares(array $middlewares): self public function setDefaults(array $defaults): self { + $this->defaults = []; /** @var mixed $value */ foreach ($defaults as $key => $value) { if (!is_scalar($value) && !($value instanceof Stringable) && null !== $value) { @@ -240,7 +239,7 @@ public function setDefaults(array $defaults): self 'Invalid $defaults provided, indexed array of scalar or `Stringable` or null expected.', ); } - $this->defaults[$key] = (string) $value; + $defaults[$key] = (string) $value; } return $this; } From 51fca55422f3f8bacd926c984d32c3fd8ffccdda Mon Sep 17 00:00:00 2001 From: Rustam Date: Sun, 19 Apr 2026 02:16:35 +0500 Subject: [PATCH 17/32] Refactor middleware handling: ensure action is included in enabledMiddlewares and adjust defaults assignment logic --- src/Debug/RouterCollector.php | 2 +- src/Route.php | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Debug/RouterCollector.php b/src/Debug/RouterCollector.php index 36d9ac5e..f7333f35 100644 --- a/src/Debug/RouterCollector.php +++ b/src/Debug/RouterCollector.php @@ -129,7 +129,7 @@ private function getMiddlewaresAndAction(?Route $route): array } $middlewares = $route->getEnabledMiddlewares(); - $action = $route->getAction(); + $action = array_pop($middlewares); return [$middlewares, $action]; } diff --git a/src/Route.php b/src/Route.php index 913f22a6..c069218e 100644 --- a/src/Route.php +++ b/src/Route.php @@ -179,6 +179,10 @@ public function getEnabledMiddlewares(): array } $this->enabledMiddlewaresCache = MiddlewareFilter::filter($this->middlewares, $this->disabledMiddlewares); + if ($this->action !== null) { + $this->enabledMiddlewaresCache[] = $this->action; + } + return $this->enabledMiddlewaresCache; } @@ -239,7 +243,7 @@ public function setDefaults(array $defaults): self 'Invalid $defaults provided, indexed array of scalar or `Stringable` or null expected.', ); } - $defaults[$key] = (string) $value; + $this->defaults[$key] = (string) $value; } return $this; } From 3149e612d08ebcd44187bdbcf455948bda24d633 Mon Sep 17 00:00:00 2001 From: Rustam Date: Sun, 31 May 2026 13:37:55 +0500 Subject: [PATCH 18/32] Fix issues --- src/Debug/RouterCollector.php | 5 +---- src/Middleware/Router.php | 2 +- src/Route.php | 23 ++++++++++++++----- src/RouteCollection.php | 10 +++------ tests/Builder/GroupBuilderTest.php | 6 ++--- tests/Builder/RouteBuilderTest.php | 12 +++++----- tests/RouteCollectionTest.php | 36 ++++++++++++++++++++++++++---- tests/RouteTest.php | 9 +++++++- 8 files changed, 72 insertions(+), 31 deletions(-) diff --git a/src/Debug/RouterCollector.php b/src/Debug/RouterCollector.php index f7333f35..293f3281 100644 --- a/src/Debug/RouterCollector.php +++ b/src/Debug/RouterCollector.php @@ -128,9 +128,6 @@ private function getMiddlewaresAndAction(?Route $route): array return [[], null]; } - $middlewares = $route->getEnabledMiddlewares(); - $action = array_pop($middlewares); - - return [$middlewares, $action]; + return [$route->getEnabledMiddlewares(), $route->getAction()]; } } diff --git a/src/Middleware/Router.php b/src/Middleware/Router.php index 6dbe4355..4a546ff9 100644 --- a/src/Middleware/Router.php +++ b/src/Middleware/Router.php @@ -55,7 +55,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $this->currentRoute->setRouteWithArguments($result->route(), $result->arguments()); return $this->dispatcher - ->withMiddlewares($result->route()->getEnabledMiddlewares()) + ->withMiddlewares($result->route()->getEnabledMiddlewaresAndAction()) ->dispatch($request, $handler); } } diff --git a/src/Route.php b/src/Route.php index c069218e..701ba7e1 100644 --- a/src/Route.php +++ b/src/Route.php @@ -114,6 +114,7 @@ public function __debugInfo() 'middlewares' => $this->middlewares, 'disabledMiddlewares' => $this->disabledMiddlewares, 'enabledMiddlewares' => $this->getEnabledMiddlewares(), + 'enabledMiddlewaresAndAction' => $this->getEnabledMiddlewaresAndAction(), ]; } @@ -178,12 +179,25 @@ public function getEnabledMiddlewares(): array return $this->enabledMiddlewaresCache; } - $this->enabledMiddlewaresCache = MiddlewareFilter::filter($this->middlewares, $this->disabledMiddlewares); + return $this->enabledMiddlewaresCache = MiddlewareFilter::filter( + $this->middlewares, + $this->disabledMiddlewares, + ); + } + + /** + * Returns the dispatch pipeline: enabled middlewares with the action appended as the final handler. + * + * @return array[]|callable[]|string[] + * @psalm-return list + */ + public function getEnabledMiddlewaresAndAction(): array + { + $stack = $this->getEnabledMiddlewares(); if ($this->action !== null) { - $this->enabledMiddlewaresCache[] = $this->action; + $stack[] = $this->action; } - - return $this->enabledMiddlewaresCache; + return $stack; } public function setMethods(array $methods): self @@ -221,7 +235,6 @@ public function setHosts(array $hosts): self public function setAction(callable|array|string|null $action): self { $this->action = $action; - $this->enabledMiddlewaresCache = null; return $this; } diff --git a/src/RouteCollection.php b/src/RouteCollection.php index fdc9b00d..5367a00a 100644 --- a/src/RouteCollection.php +++ b/src/RouteCollection.php @@ -68,9 +68,7 @@ private function ensureItemsInjected(): void private function injectItems(array $items): void { foreach ($items as $item) { - if ($item instanceof RoutableInterface) { - $item = $item->toRoute(); - } + $item = $item instanceof RoutableInterface ? $item->toRoute() : clone $item; if (!$this->isStaticRoute($item)) { $item->setMiddlewares(array_merge($this->collector->getMiddlewares(), $item->getMiddlewares())); } @@ -109,11 +107,9 @@ private function injectGroup(Group $group, array &$tree, string $prefix = '', st $pattern = null; $hosts = []; foreach ($items as $item) { - if ($item instanceof RoutableInterface) { - $item = $item->toRoute(); - } + $item = $item instanceof RoutableInterface ? $item->toRoute() : clone $item; if (!$this->isStaticRoute($item)) { - $item = $item->setMiddlewares(array_merge($group->getEnabledMiddlewares(), $item->getMiddlewares())); + $item->setMiddlewares(array_merge($group->getEnabledMiddlewares(), $item->getMiddlewares())); } if (!empty($group->getHosts()) && empty($item->getHosts())) { diff --git a/tests/Builder/GroupBuilderTest.php b/tests/Builder/GroupBuilderTest.php index 80c7704d..029936d9 100644 --- a/tests/Builder/GroupBuilderTest.php +++ b/tests/Builder/GroupBuilderTest.php @@ -122,7 +122,7 @@ public function testAddNestedMiddleware(): void $routeCollection = new RouteCollection($collector); $route = $routeCollection->getRoute('request1'); $response = $this->getDispatcher() - ->withMiddlewares($route->getEnabledMiddlewares()) + ->withMiddlewares($route->getEnabledMiddlewaresAndAction()) ->dispatch($request, $this->getRequestHandler()); $this->assertSame(200, $response->getStatusCode()); $this->assertSame('middleware2', $response->getReasonPhrase()); @@ -164,7 +164,7 @@ public function testGroupMiddlewareFullStackCalled(): void $route = $routeCollection->getRoute('request1'); $response = $this->getDispatcher() - ->withMiddlewares($route->getEnabledMiddlewares()) + ->withMiddlewares($route->getEnabledMiddlewaresAndAction()) ->dispatch($request, $this->getRequestHandler()); $this->assertSame(200, $response->getStatusCode()); @@ -195,7 +195,7 @@ public function testGroupMiddlewareStackInterrupted(): void $route = $routeCollection->getRoute('request1'); $response = $this->getDispatcher() - ->withMiddlewares($route->getEnabledMiddlewares()) + ->withMiddlewares($route->getEnabledMiddlewaresAndAction()) ->dispatch($request, $this->getRequestHandler()); $this->assertSame(403, $response->getStatusCode()); diff --git a/tests/Builder/RouteBuilderTest.php b/tests/Builder/RouteBuilderTest.php index 9ee400d0..c5aa3baa 100644 --- a/tests/Builder/RouteBuilderTest.php +++ b/tests/Builder/RouteBuilderTest.php @@ -213,7 +213,7 @@ public function testDispatcherInjecting(): void $response = $this ->getDispatcher($container) - ->withMiddlewares($route->toRoute()->getEnabledMiddlewares()) + ->withMiddlewares($route->toRoute()->getEnabledMiddlewaresAndAction()) ->dispatch($request, $this->getRequestHandler()); $this->assertSame(200, $response->getStatusCode()); @@ -237,7 +237,7 @@ public function testDisabledMiddlewareDefinitions(): void TestController::class => new TestController(), ]), ) - ->withMiddlewares($route->toRoute()->getEnabledMiddlewares()); + ->withMiddlewares($route->toRoute()->getEnabledMiddlewaresAndAction()); $response = $dispatcher->dispatch($request, $this->getRequestHandler()); $this->assertSame(200, $response->getStatusCode()); @@ -262,7 +262,7 @@ public function testPrependMiddlewareDefinitions(): void TestController::class => new TestController(), ]), ) - ->withMiddlewares($route->toRoute()->getEnabledMiddlewares()) + ->withMiddlewares($route->toRoute()->getEnabledMiddlewaresAndAction()) ->dispatch($request, $this->getRequestHandler()); $this->assertSame(200, $response->getStatusCode()); @@ -276,13 +276,13 @@ public function testPrependMiddlewaresAfterGetEnabledMiddlewares(): void ->disableMiddleware(TestMiddleware1::class) ->action([TestController::class, 'index']); - $route->toRoute()->getEnabledMiddlewares(); + $route->toRoute()->getEnabledMiddlewaresAndAction(); $route = $route->prependMiddleware(TestMiddleware1::class, TestMiddleware2::class); $this->assertSame( [TestMiddleware2::class, TestMiddleware3::class, [TestController::class, 'index']], - $route->toRoute()->getEnabledMiddlewares(), + $route->toRoute()->getEnabledMiddlewaresAndAction(), ); } @@ -338,7 +338,7 @@ public function testMiddlewaresWithKeys(): void $this->assertSame( [TestMiddleware2::class, TestMiddleware3::class, [TestController::class, 'index']], - $route->toRoute()->getEnabledMiddlewares(), + $route->toRoute()->getEnabledMiddlewaresAndAction(), ); } diff --git a/tests/RouteCollectionTest.php b/tests/RouteCollectionTest.php index 3dc30abd..b196f550 100644 --- a/tests/RouteCollectionTest.php +++ b/tests/RouteCollectionTest.php @@ -16,8 +16,11 @@ use RuntimeException; use Yiisoft\Middleware\Dispatcher\MiddlewareDispatcher; use Yiisoft\Middleware\Dispatcher\MiddlewareFactory; +use Yiisoft\Http\Method; use Yiisoft\Router\Builder\GroupBuilder as Group; use Yiisoft\Router\Builder\RouteBuilder as Route; +use Yiisoft\Router\Group as RawGroup; +use Yiisoft\Router\Route as RawRoute; use Yiisoft\Router\RouteCollection; use Yiisoft\Router\RouteCollector; use Yiisoft\Router\RouteNotFoundException; @@ -93,6 +96,31 @@ public function testRouteOverride(): void $this->assertSame('/{id}', $route->getPattern()); } + public function testCollectorCanBeReusedWithRawRouteAndGroupInstances(): void + { + $route = new RawRoute([Method::GET], '/users', 'users'); + $group = new RawGroup( + prefix: '/api', + namePrefix: 'api/', + routes: [new RawRoute([Method::GET], '/posts', 'posts')], + ); + + $collector = new RouteCollector(); + $collector->middleware(static fn() => new Response()); + $collector->addRoute($group, $route); + + $first = new RouteCollection($collector); + $second = new RouteCollection($collector); + + $this->assertSame('/api/posts', $first->getRoute('api/posts')->getPattern()); + $this->assertSame('/users', $first->getRoute('users')->getPattern()); + $this->assertSame('/api/posts', $second->getRoute('api/posts')->getPattern()); + $this->assertSame('/users', $second->getRoute('users')->getPattern()); + + $this->assertSame('/posts', $group->getRoutes()[0]->getPattern()); + $this->assertSame('/users', $route->getPattern()); + } + public function testRouteWithoutAction(): void { $group = Group::create() @@ -299,10 +327,10 @@ public function testCollectorMiddlewareFullstackCalled(): void $route2 = $routeCollection->getRoute('view'); $request = new ServerRequest('GET', '/'); $response1 = $this->getDispatcher() - ->withMiddlewares($route1->getEnabledMiddlewares()) + ->withMiddlewares($route1->getEnabledMiddlewaresAndAction()) ->dispatch($request, $this->getRequestHandler()); $response2 = $this->getDispatcher() - ->withMiddlewares($route2->getEnabledMiddlewares()) + ->withMiddlewares($route2->getEnabledMiddlewaresAndAction()) ->dispatch($request, $this->getRequestHandler()); $this->assertEquals('middleware1', $response1->getReasonPhrase()); @@ -341,7 +369,7 @@ public function testMiddlewaresOrder(bool $groupWrapped): void TestController::class => new TestController(), ]), ) - ->withMiddlewares($route->getEnabledMiddlewares()); + ->withMiddlewares($route->getEnabledMiddlewaresAndAction()); $response = $dispatcher->dispatch($request, $this->getRequestHandler()); $this->assertSame(200, $response->getStatusCode()); @@ -367,7 +395,7 @@ public function testStaticRouteWithCollectorMiddlewares(): void TestMiddleware1::class => new TestMiddleware1(), ]), ) - ->withMiddlewares($route->getEnabledMiddlewares()); + ->withMiddlewares($route->getEnabledMiddlewaresAndAction()); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Stack is empty.'); diff --git a/tests/RouteTest.php b/tests/RouteTest.php index 09482d31..033754df 100644 --- a/tests/RouteTest.php +++ b/tests/RouteTest.php @@ -31,7 +31,8 @@ public function testSimpleInstance(): void ); $this->assertInstanceOf(Route::class, $route); - $this->assertCount(2, $route->getEnabledMiddlewares()); + $this->assertCount(1, $route->getEnabledMiddlewares()); + $this->assertCount(2, $route->getEnabledMiddlewaresAndAction()); $this->assertTrue($route->isOverride()); } @@ -238,6 +239,12 @@ public function testDebugInfo(): void ) [enabledMiddlewares] => Array + ( + [0] => Yiisoft\Router\Tests\Support\TestMiddleware3 + [1] => Yiisoft\Router\Tests\Support\TestMiddleware1 + ) + + [enabledMiddlewaresAndAction] => Array ( [0] => Yiisoft\Router\Tests\Support\TestMiddleware3 [1] => Yiisoft\Router\Tests\Support\TestMiddleware1 From 7834d972617881bd57a15b47bc666fc9158c56d2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Jul 2026 07:59:14 +0000 Subject: [PATCH 19/32] Apply PHP CS Fixer and Rector changes (CI) --- tests/RouteCollectionTest.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/RouteCollectionTest.php b/tests/RouteCollectionTest.php index b196f550..8cd6b51b 100644 --- a/tests/RouteCollectionTest.php +++ b/tests/RouteCollectionTest.php @@ -207,9 +207,9 @@ public function testGetRouteTreeReturnsRouteInstances(): void $routeTree = (new RouteCollection($collector))->getRouteTree(false); - $this->assertInstanceOf(\Yiisoft\Router\Route::class, $routeTree[0]); + $this->assertInstanceOf(RawRoute::class, $routeTree[0]); $this->assertSame('/api/posts', $routeTree[0]->getName()); - $this->assertInstanceOf(\Yiisoft\Router\Route::class, $routeTree['/v1'][0]); + $this->assertInstanceOf(RawRoute::class, $routeTree['/v1'][0]); $this->assertSame('/api/comments', $routeTree['/v1'][0]->getName()); } @@ -289,10 +289,10 @@ public function testGroupName(): void $route2 = $routeCollection->getRoute('api/v1/package/downloads'); $route3 = $routeCollection->getRoute('api/index'); $route4 = $routeCollection->getRoute('GET api/user/{username}'); - $this->assertInstanceOf(\Yiisoft\Router\Route::class, $route1); - $this->assertInstanceOf(\Yiisoft\Router\Route::class, $route2); - $this->assertInstanceOf(\Yiisoft\Router\Route::class, $route3); - $this->assertInstanceOf(\Yiisoft\Router\Route::class, $route4); + $this->assertInstanceOf(RawRoute::class, $route1); + $this->assertInstanceOf(RawRoute::class, $route2); + $this->assertInstanceOf(RawRoute::class, $route3); + $this->assertInstanceOf(RawRoute::class, $route4); } public function testCollectorMiddlewareFullstackCalled(): void From fd88652f952cd150a1a62bac66728b3812014827 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Thu, 30 Jul 2026 00:57:55 +0300 Subject: [PATCH 20/32] Fix route collection mutation leaks --- src/CurrentRoute.php | 2 +- src/Route.php | 2 +- src/RouteCollection.php | 4 +-- tests/RouteCollectionTest.php | 54 +++++++++++++++++++++++++++++++++++ tests/RouteTest.php | 4 ++- 5 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/CurrentRoute.php b/src/CurrentRoute.php index 9b9a4ff4..82b7326c 100644 --- a/src/CurrentRoute.php +++ b/src/CurrentRoute.php @@ -42,7 +42,7 @@ public function getName(): ?string /** * Returns the current route hosts. * - * @return array|null The current route hosts. + * @return string[]|null The current route hosts. */ public function getHosts(): ?array { diff --git a/src/Route.php b/src/Route.php index 701ba7e1..b5562ee8 100644 --- a/src/Route.php +++ b/src/Route.php @@ -253,7 +253,7 @@ public function setDefaults(array $defaults): self foreach ($defaults as $key => $value) { if (!is_scalar($value) && !($value instanceof Stringable) && null !== $value) { throw new InvalidArgumentException( - 'Invalid $defaults provided, indexed array of scalar or `Stringable` or null expected.', + 'Invalid $defaults provided, array of scalar, `Stringable`, or null values expected.', ); } $this->defaults[$key] = (string) $value; diff --git a/src/RouteCollection.php b/src/RouteCollection.php index 5367a00a..fb07f2af 100644 --- a/src/RouteCollection.php +++ b/src/RouteCollection.php @@ -68,7 +68,7 @@ private function ensureItemsInjected(): void private function injectItems(array $items): void { foreach ($items as $item) { - $item = $item instanceof RoutableInterface ? $item->toRoute() : clone $item; + $item = clone ($item instanceof RoutableInterface ? $item->toRoute() : $item); if (!$this->isStaticRoute($item)) { $item->setMiddlewares(array_merge($this->collector->getMiddlewares(), $item->getMiddlewares())); } @@ -107,7 +107,7 @@ private function injectGroup(Group $group, array &$tree, string $prefix = '', st $pattern = null; $hosts = []; foreach ($items as $item) { - $item = $item instanceof RoutableInterface ? $item->toRoute() : clone $item; + $item = clone ($item instanceof RoutableInterface ? $item->toRoute() : $item); if (!$this->isStaticRoute($item)) { $item->setMiddlewares(array_merge($group->getEnabledMiddlewares(), $item->getMiddlewares())); } diff --git a/tests/RouteCollectionTest.php b/tests/RouteCollectionTest.php index 8cd6b51b..97c3394a 100644 --- a/tests/RouteCollectionTest.php +++ b/tests/RouteCollectionTest.php @@ -24,6 +24,7 @@ use Yiisoft\Router\RouteCollection; use Yiisoft\Router\RouteCollector; use Yiisoft\Router\RouteNotFoundException; +use Yiisoft\Router\RoutableInterface; use Yiisoft\Router\Tests\Support\TestController; use Yiisoft\Router\Tests\Support\TestMiddleware1; use Yiisoft\Router\Tests\Support\TestMiddleware2; @@ -121,6 +122,59 @@ public function testCollectorCanBeReusedWithRawRouteAndGroupInstances(): void $this->assertSame('/users', $route->getPattern()); } + public function testCollectorCanBeReusedWithRetainedRoutesFromRoutables(): void + { + $route = new RawRoute([Method::GET], '/users', 'users'); + $routeRoutable = new class ($route) implements RoutableInterface { + public function __construct(private readonly RawRoute $route) {} + + public function toRoute(): RawRoute + { + return $this->route; + } + }; + $nestedRoute = new RawRoute([Method::GET], '/posts', 'posts'); + $nestedRouteRoutable = new class ($nestedRoute) implements RoutableInterface { + public function __construct(private readonly RawRoute $route) {} + + public function toRoute(): RawRoute + { + return $this->route; + } + }; + $group = new RawGroup( + prefix: '/api', + namePrefix: 'api/', + routes: [$nestedRouteRoutable], + ); + $groupRoutable = new class ($group) implements RoutableInterface { + public function __construct(private readonly RawGroup $group) {} + + public function toRoute(): RawGroup + { + return $this->group; + } + }; + + $collector = new RouteCollector(); + $collector->middleware(static fn() => new Response()); + $collector->addRoute($groupRoutable, $routeRoutable); + + $first = new RouteCollection($collector); + $second = new RouteCollection($collector); + + $this->assertSame('/api/posts', $first->getRoute('api/posts')->getPattern()); + $this->assertSame('/users', $first->getRoute('users')->getPattern()); + $this->assertSame('/api/posts', $second->getRoute('api/posts')->getPattern()); + $this->assertSame('/users', $second->getRoute('users')->getPattern()); + + $this->assertSame('/posts', $nestedRoute->getPattern()); + $this->assertSame([], $nestedRoute->getMiddlewares()); + $this->assertSame('/users', $route->getPattern()); + $this->assertSame([], $route->getMiddlewares()); + $this->assertSame([], $group->getMiddlewares()); + } + public function testRouteWithoutAction(): void { $group = Group::create() diff --git a/tests/RouteTest.php b/tests/RouteTest.php index 033754df..29ae0d17 100644 --- a/tests/RouteTest.php +++ b/tests/RouteTest.php @@ -185,7 +185,9 @@ public function testInvalidMiddlewares(): void public function testInvalidDefaults(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid $defaults provided, indexed array of scalar or `Stringable` or null expected.'); + $this->expectExceptionMessage( + 'Invalid $defaults provided, array of scalar, `Stringable`, or null values expected.', + ); new Route([Method::GET], '/', defaults: ['test' => 1, 'foo' => ['bar']]); } From 275471c83648526b2e7896095313d09fdbdf17f5 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Thu, 30 Jul 2026 01:04:50 +0300 Subject: [PATCH 21/32] Update documentation for route builders --- .phpstorm.meta.php/Group.php | 18 ------ .phpstorm.meta.php/Route.php | 19 ------ CHANGELOG.md | 5 ++ README.md | 105 ++++++++++++++++++++++++++------- UPGRADE.md | 110 +++++++++++++++++++++++++++++++++++ src/Group.php | 5 ++ src/RoutableInterface.php | 5 +- src/Route.php | 4 +- 8 files changed, 211 insertions(+), 60 deletions(-) delete mode 100644 .phpstorm.meta.php/Group.php delete mode 100644 .phpstorm.meta.php/Route.php diff --git a/.phpstorm.meta.php/Group.php b/.phpstorm.meta.php/Group.php deleted file mode 100644 index c2f0bb06..00000000 --- a/.phpstorm.meta.php/Group.php +++ /dev/null @@ -1,18 +0,0 @@ -name('post-delete') @@ -151,7 +153,7 @@ for middleware examples. If a route should be applied only to a certain host, it could be defined like the following: ```php -use Yiisoft\Router\Route; +use Yiisoft\Router\Builder\RouteBuilder as Route; Route::get('/special') ->name('special') @@ -162,7 +164,7 @@ Route::get('/special') Defaults for parameters could be provided via `defaults()` method: ```php -use Yiisoft\Router\Route; +use Yiisoft\Router\Builder\RouteBuilder as Route; Route::get('/api[/v{version}]') ->name('api-index') @@ -175,7 +177,8 @@ In the above we specify that if "version" is not obtained from URL during matchi Besides action, additional middleware to execute before the action itself could be defined: ```php -use Yiisoft\Router\Route; +use Yiisoft\Router\Builder\RouteBuilder as Route; +use Yiisoft\Http\Method; Route::methods([Method::GET, Method::POST], '/page/add') ->middleware(Authentication::class) @@ -192,7 +195,7 @@ If there is a need to either add middleware to be executed first or remove exist If you combine routes from multiple sources and want last route to have priority over existing ones, mark it as "override": ```php -use Yiisoft\Router\Route; +use Yiisoft\Router\Builder\RouteBuilder as Route; Route::get('/special') ->name('special') @@ -200,14 +203,33 @@ Route::get('/special') ->override(); ``` +`RouteBuilder` is immutable: every configuration method returns a new builder. The collector accepts builders directly +and converts them to `Route` objects while building the collection. + +For configuration generated dynamically, a mutable `Route` data object may be constructed directly: + +```php +use Yiisoft\Http\Method; +use Yiisoft\Router\Route; + +$route = new Route( + methods: [Method::GET, Method::POST], + pattern: '/page/add', + name: 'page-add', + action: [PageController::class, 'actionAdd'], +); + +$route->setHosts(['https://example.com']); +``` + ### Route groups -Routes could be grouped. That is useful for API endpoints and similar cases: +Routes could be grouped with `GroupBuilder`. That is useful for API endpoints and similar cases: ```php -use \Yiisoft\Router\Route; -use \Yiisoft\Router\Group; -use \Yiisoft\Router\RouteCollectorInterface; +use Yiisoft\Router\Builder\GroupBuilder as Group; +use Yiisoft\Router\Builder\RouteBuilder as Route; +use Yiisoft\Router\RouteCollectorInterface; // for obtaining router see adapter package of choice readme $collector = $container->get(RouteCollectorInterface::class); @@ -233,6 +255,49 @@ and `disableMiddleware()`. These middleware are executed prior to matched route' If host is specified, all routes in the group would match only if the host match. +Like `RouteBuilder`, `GroupBuilder` is immutable and is accepted directly by the collector. A mutable `Group` data +object can also be constructed directly: + +```php +use Yiisoft\Router\Group; + +$group = new Group( + prefix: '/api', + namePrefix: 'api/', + routes: [$route], + middlewares: [ApiAuthentication::class], +); +``` + +### Custom route definitions + +`RouteCollectorInterface::addRoute()` accepts `Route`, `Group`, and `RoutableInterface` instances. Implement +`RoutableInterface` when an application or package needs its own route-definition abstraction: + +```php +use Yiisoft\Http\Method; +use Yiisoft\Router\RoutableInterface; +use Yiisoft\Router\Route; + +final class HealthCheckRoute implements RoutableInterface +{ + public function toRoute(): Route + { + return new Route( + methods: [Method::GET], + pattern: '/health', + name: 'health', + action: HealthCheckAction::class, + ); + } +} + +$collector->addRoute(new HealthCheckRoute()); +``` + +The route collection clones the `Route` or `Group` returned by `toRoute()` before applying collection and group +configuration, so implementations may safely return a retained object. + ### Automatic OPTIONS response and CORS By default, router responds automatically to OPTIONS requests based on the routes defined: @@ -246,8 +311,8 @@ Generally that is fine unless you need [CORS headers](https://developer.mozilla. case, you can add a middleware for handling it such as [tuupola/cors-middleware](https://github.com/tuupola/cors-middleware): ```php -use Yiisoft\Router\Group; -use \Tuupola\Middleware\CorsMiddleware; +use Tuupola\Middleware\CorsMiddleware; +use Yiisoft\Router\Builder\GroupBuilder as Group; return [ Group::create('/api') @@ -270,7 +335,7 @@ use Yiisoft\Yii\Http\Handler\NotFoundHandler; use Yiisoft\Yii\Runner\Http\SapiEmitter; use Yiisoft\Yii\Runner\Http\ServerRequestFactory; use Yiisoft\Router\CurrentRoute; -use Yiisoft\Router\Route; +use Yiisoft\Router\Builder\RouteBuilder as Route; use Yiisoft\Router\RouteCollection; use Yiisoft\Router\RouteCollectorInterface; use Yiisoft\Router\Fastroute\UrlMatcher; @@ -344,7 +409,7 @@ modifying URLs for filtering and/or sorting. For such a route: ```php -use \Yiisoft\Router\Route; +use Yiisoft\Router\Builder\RouteBuilder as Route; $routes = [ Route::post('/post/{id:\d+}') @@ -358,8 +423,6 @@ The information could be obtained as follows: use Psr\Http\Message\ResponseInterface use Psr\Http\Message\UriInterface; use Yiisoft\Router\CurrentRoute; -use Yiisoft\Router\Route; - final class PostController { public function actionEdit(CurrentRoute $currentRoute): ResponseInterface @@ -379,7 +442,7 @@ In addition to commonly used `getArgument()` method, the following methods are a - `getArguments()` - To obtain all arguments at once. - `getName()` - To get route name. -- `getHost()` - To get route host. +- `getHosts()` - To get route hosts. - `getPattern()` - To get route pattern. - `getMethods()` - To get route methods. - `getUri()` - To get current URI. diff --git a/UPGRADE.md b/UPGRADE.md index bc3c5f19..7b28d406 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -3,6 +3,116 @@ This file contains the upgrade notes for the Yii Router. These notes highlight changes that could break your application when you upgrade it from one major version to another. +## 5.0.0 + +### Route and group builders + +The immutable fluent APIs were moved from `Yiisoft\Router\Route` and `Yiisoft\Router\Group` to dedicated builder classes: + +- `Yiisoft\Router\Builder\RouteBuilder` +- `Yiisoft\Router\Builder\GroupBuilder` + +Update imports while keeping aliases if you want existing route declarations to remain unchanged: + +```php +// Before +use Yiisoft\Router\Group; +use Yiisoft\Router\Route; + +// After +use Yiisoft\Router\Builder\GroupBuilder as Group; +use Yiisoft\Router\Builder\RouteBuilder as Route; +``` + +Code such as `Route::get('/')->name('home')` and `Group::create('/api')->routes(...)` then continues to use the same +fluent syntax. Builders are immutable and can be passed directly to `RouteCollectorInterface::addRoute()`. + +### `Route` changes + +`Yiisoft\Router\Route` is now a mutable route data object with a public constructor: + +```php +use Yiisoft\Http\Method; +use Yiisoft\Router\Route; + +$route = new Route( + methods: [Method::GET], + pattern: '/', + name: 'home', + action: HomeAction::class, +); +``` + +The static construction and fluent configuration methods moved to `RouteBuilder`. + +`Route::getData()` was removed. Replace it with the corresponding explicit method: + +| Before | After | +|---|---| +| `getData('name')` | `getName()` | +| `getData('pattern')` | `getPattern()` | +| `getData('hosts')` | `getHosts()` | +| `getData('methods')` | `getMethods()` | +| `getData('defaults')` | `getDefaults()` | +| `getData('override')` | `isOverride()` | +| `getData('enabledMiddlewares')` | `getEnabledMiddlewares()` | + +The action is stored separately from route middleware. Use `getAction()` for the action, +`getEnabledMiddlewares()` for filtered middleware only, or `getEnabledMiddlewaresAndAction()` for the dispatch pipeline. +Mutable configuration is available through `setMethods()`, `setPattern()`, `setName()`, `setAction()`, +`setMiddlewares()`, `setDefaults()`, `setHosts()`, `setOverride()`, and `setDisabledMiddlewares()`. + +### `Group` changes + +`Yiisoft\Router\Group` is now a mutable group data object with a public constructor. The static `create()` method and +fluent configuration methods moved to `GroupBuilder`. + +`Group::getData()` was removed. Replace it with the corresponding explicit method: + +| Before | After | +|---|---| +| `getData('prefix')` | `getPrefix()` | +| `getData('namePrefix')` | `getNamePrefix()` | +| `getData('hosts')` | `getHosts()` | +| `getData('routes')` | `getRoutes()` | +| `getData('corsMiddleware')` | `getCorsMiddleware()` | +| `getData('enabledMiddlewares')` | `getEnabledMiddlewares()` | + +Mutable configuration is available through `setPrefix()`, `setNamePrefix()`, `setRoutes()`, `setMiddlewares()`, +`setHosts()`, `setCorsMiddleware()`, and `setDisabledMiddlewares()`. + +### `RoutableInterface` + +`RoutableInterface` was added for custom route definitions. Its `toRoute()` method must return a `Route` or `Group`. +The route collector and groups accept routable instances in addition to route and group data objects. The route +collection clones the returned object before applying collection middleware or group transformations. + +### `RouteCollectorInterface` changes + +`RouteCollectorInterface::addRoute()` now also accepts `RoutableInterface` instances. + +`RouteCollectorInterface::getMiddlewareDefinitions()` was renamed: + +```php +// Before +$collector->getMiddlewareDefinitions(); + +// After +$collector->getMiddlewares(); +``` + +### `CurrentRoute` changes + +`CurrentRoute::getHost()` was replaced by `CurrentRoute::getHosts()` and now returns all route hosts: + +```php +// Before +$host = $currentRoute->getHost(); + +// After +$hosts = $currentRoute->getHosts(); +``` + ## 4.0.0 ### `Route`, `Group` and `MatchingResult` changes diff --git a/src/Group.php b/src/Group.php index 2b872e58..5f3c222d 100644 --- a/src/Group.php +++ b/src/Group.php @@ -12,6 +12,11 @@ use function is_callable; use function is_string; +/** + * Mutable data object that groups routes and applies common configuration to them. + * + * For immutable fluent group definitions, use {@see \Yiisoft\Router\Builder\GroupBuilder}. + */ final class Group { /** diff --git a/src/RoutableInterface.php b/src/RoutableInterface.php index bde4a0bf..2a7cfcb3 100644 --- a/src/RoutableInterface.php +++ b/src/RoutableInterface.php @@ -5,7 +5,10 @@ namespace Yiisoft\Router; /** - * An interface for denoting classes that represent a route. + * Represents a custom route or route-group definition. + * + * The route collection clones the object returned by {@see toRoute()} before applying collection or group + * configuration, so implementations may safely return a retained object. */ interface RoutableInterface { diff --git a/src/Route.php b/src/Route.php index b5562ee8..fe8b2403 100644 --- a/src/Route.php +++ b/src/Route.php @@ -15,7 +15,9 @@ use function is_string; /** - * Route defines a mapping from URL to callback / name and vice versa. + * Mutable data object that defines a mapping from URL to callback / name and vice versa. + * + * For immutable fluent route definitions, use {@see \Yiisoft\Router\Builder\RouteBuilder}. */ final class Route implements Stringable { From dfdd2acfb10c2dba98026406274ac3a99f16ba95 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Jul 2026 22:05:29 +0000 Subject: [PATCH 22/32] Apply PHP CS Fixer and Rector changes (CI) --- src/Group.php | 3 ++- src/Route.php | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Group.php b/src/Group.php index 5f3c222d..619b997b 100644 --- a/src/Group.php +++ b/src/Group.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use Yiisoft\Router\Internal\MiddlewareFilter; +use Yiisoft\Router\Builder\GroupBuilder; use function in_array; use function is_array; @@ -15,7 +16,7 @@ /** * Mutable data object that groups routes and applies common configuration to them. * - * For immutable fluent group definitions, use {@see \Yiisoft\Router\Builder\GroupBuilder}. + * For immutable fluent group definitions, use {@see GroupBuilder}. */ final class Group { diff --git a/src/Route.php b/src/Route.php index fe8b2403..3ea13948 100644 --- a/src/Route.php +++ b/src/Route.php @@ -7,6 +7,7 @@ use InvalidArgumentException; use Stringable; use Yiisoft\Router\Internal\MiddlewareFilter; +use Yiisoft\Router\Builder\RouteBuilder; use function in_array; use function is_array; @@ -17,7 +18,7 @@ /** * Mutable data object that defines a mapping from URL to callback / name and vice versa. * - * For immutable fluent route definitions, use {@see \Yiisoft\Router\Builder\RouteBuilder}. + * For immutable fluent route definitions, use {@see RouteBuilder}. */ final class Route implements Stringable { From a821dcfdacf0138d56d74861fd2f9d939dde0471 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Thu, 30 Jul 2026 01:27:33 +0300 Subject: [PATCH 23/32] Improve mutation test coverage --- src/Builder/GroupBuilder.php | 2 +- src/Builder/RouteBuilder.php | 2 +- src/Route.php | 1 + tests/Builder/GroupBuilderTest.php | 27 ++++++++++++++++----------- tests/RouteTest.php | 24 +++++++++++++++++++----- 5 files changed, 38 insertions(+), 18 deletions(-) diff --git a/src/Builder/GroupBuilder.php b/src/Builder/GroupBuilder.php index 7d6be2eb..cb23002e 100644 --- a/src/Builder/GroupBuilder.php +++ b/src/Builder/GroupBuilder.php @@ -132,7 +132,7 @@ public function host(string $host): self public function hosts(string ...$hosts): self { $new = clone $this; - $new->hosts = array_values($hosts); + $new->hosts = $hosts; return $new; } diff --git a/src/Builder/RouteBuilder.php b/src/Builder/RouteBuilder.php index ee7c457c..3bc46892 100644 --- a/src/Builder/RouteBuilder.php +++ b/src/Builder/RouteBuilder.php @@ -115,7 +115,7 @@ public function host(string $host): self public function hosts(string ...$hosts): self { $route = clone $this; - $route->hosts = array_values($hosts); + $route->hosts = $hosts; return $route; } diff --git a/src/Route.php b/src/Route.php index 3ea13948..afef844c 100644 --- a/src/Route.php +++ b/src/Route.php @@ -179,6 +179,7 @@ public function getDisabledMiddlewares(): array public function getEnabledMiddlewares(): array { if ($this->enabledMiddlewaresCache !== null) { + /** @infection-ignore-all Cached and freshly filtered values are indistinguishable by behavior. */ return $this->enabledMiddlewaresCache; } diff --git a/tests/Builder/GroupBuilderTest.php b/tests/Builder/GroupBuilderTest.php index 029936d9..6c243468 100644 --- a/tests/Builder/GroupBuilderTest.php +++ b/tests/Builder/GroupBuilderTest.php @@ -275,20 +275,25 @@ public function testName(): void public function testWithCors(): void { + $corsMiddleware = static fn() => new Response(204); $group = Group::create() ->routes( - Route::get('/info')->action(static fn() => 'info'), + Route::get('/info') + ->middleware(TestMiddleware1::class) + ->action(static fn() => 'info'), Route::post('/info')->action(static fn() => 'info'), ) - ->withCors( - static fn() => new Response(204), - ); + ->withCors($corsMiddleware); $collector = new RouteCollector(); $collector->addRoute($group); $routeCollection = new RouteCollection($collector); $this->assertCount(3, $routeCollection->getRoutes()); + $this->assertSame( + [$corsMiddleware, TestMiddleware1::class], + $routeCollection->getRoute('GET /info')->getEnabledMiddlewares(), + ); } public function testWithCorsWithHostRoutes(): void @@ -340,6 +345,8 @@ public function testWithCorsDoesntDuplicateRoutes(): void public function testWithCorsWithNestedGroups(): void { + $corsMiddleware = static fn() => new Response(204); + $nestedCorsMiddleware = static fn() => new Response(201); $group = Group::create()->routes( Route::get('/info')->action(static fn() => 'info'), Route::post('/info')->action(static fn() => 'info'), @@ -349,19 +356,17 @@ public function testWithCorsWithNestedGroups(): void Route::post('/post')->action(static fn() => 'post'), Route::options('/options')->action(static fn() => 'options'), ) - ->withCors( - static fn() => new Response(201), - ), - )->withCors( - static fn() => new Response(204), - ); + ->withCors($nestedCorsMiddleware), + )->withCors($corsMiddleware); $collector = new RouteCollector(); $collector->addRoute($group); $routeCollection = new RouteCollection($collector); $this->assertCount(7, $routeCollection->getRoutes()); - $this->assertInstanceOf(\Yiisoft\Router\Route::class, $routeCollection->getRoute('OPTIONS /v1/post')); + $optionsRoute = $routeCollection->getRoute('OPTIONS /v1/post'); + $this->assertInstanceOf(\Yiisoft\Router\Route::class, $optionsRoute); + $this->assertSame([$corsMiddleware], $optionsRoute->getEnabledMiddlewares()); } public function testWithCorsWithNestedGroups2(): void diff --git a/tests/RouteTest.php b/tests/RouteTest.php index 29ae0d17..c5c4f909 100644 --- a/tests/RouteTest.php +++ b/tests/RouteTest.php @@ -4,7 +4,6 @@ namespace Yiisoft\Router\Tests; -use Nyholm\Psr7\Response; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Yiisoft\Http\Method; @@ -96,7 +95,8 @@ public function testNameDefaultWithHosts(): void public function testMethods(): void { - $route = new Route([Method::POST, Method::HEAD], '/'); + $route = new Route([Method::GET], '/'); + $route->setMethods([Method::POST, Method::HEAD]); $this->assertSame([Method::POST, Method::HEAD], $route->getMethods()); } @@ -144,8 +144,11 @@ public function testDefaults(): void public function testOverride(): void { - $route = (new Route([Method::GET], '/'))->setOverride(true); + $route = new Route([Method::GET], '/'); + + $this->assertFalse($route->isOverride()); + $route->setOverride(true); $this->assertTrue($route->isOverride()); } @@ -174,12 +177,23 @@ public function testToStringSimple(): void $this->assertSame('GET /', (string) $route); } - public function testInvalidMiddlewares(): void + public static function invalidMiddlewaresProvider(): array + { + $invalidMiddleware = (object) ['test' => 1]; + + return [ + 'after string' => [[TestMiddleware1::class, $invalidMiddleware]], + 'after callable' => [[static fn() => null, $invalidMiddleware]], + ]; + } + + #[DataProvider('invalidMiddlewaresProvider')] + public function testInvalidMiddlewares(array $middlewares): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid $middlewares provided, list of string or array or callable expected.'); - $route = new Route([Method::GET], '/', middlewares: [static fn() => new Response(), (object) ['test' => 1]]); + new Route([Method::GET], '/', middlewares: $middlewares); } public function testInvalidDefaults(): void From bdd3b49adcb2fe636cd37cb79b7763a6fe320ad2 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Thu, 30 Jul 2026 01:31:47 +0300 Subject: [PATCH 24/32] Remove unrelated refactoring changes --- composer.json | 14 +++++++------- src/Debug/DebugRoutesCommand.php | 23 ++++++++++++++++++++++- src/Debug/RouterCollector.php | 2 +- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/composer.json b/composer.json index 1a003e81..19df6efb 100644 --- a/composer.json +++ b/composer.json @@ -42,16 +42,16 @@ "friendsofphp/php-cs-fixer": "^3.89.1", "maglnet/composer-require-checker": "^4.7.1", "nyholm/psr7": "^1.8.2", - "phpunit/phpunit": "^10.5.58", + "phpunit/phpunit": "^10.5.45", "psr/container": "^1.1 || ^2.0.2", - "rector/rector": "^2.2.7", + "rector/rector": "^2.0.9", "roave/infection-static-analysis-plugin": "^1.35", "spatie/phpunit-watcher": "^1.24", - "vimeo/psalm": "^5.26.1 || ^6.13.1", - "yiisoft/di": "^1.4", - "yiisoft/dummy-provider": "^1.1.0", - "yiisoft/hydrator": "^1.6.2", - "yiisoft/test-support": "^3.0.2", + "vimeo/psalm": "^5.26.1 || ^6.8.6", + "yiisoft/di": "^1.3", + "yiisoft/dummy-provider": "^1.0.1", + "yiisoft/hydrator": "^1.5", + "yiisoft/test-support": "^3.0.1", "yiisoft/yii-debug": "dev-master" }, "autoload": { diff --git a/src/Debug/DebugRoutesCommand.php b/src/Debug/DebugRoutesCommand.php index 9c6df7a5..063e3b83 100644 --- a/src/Debug/DebugRoutesCommand.php +++ b/src/Debug/DebugRoutesCommand.php @@ -129,7 +129,7 @@ protected function export(mixed $value): string ) { return $value[0] . '::' . $value[1]; } - if (is_array($value) && array_is_list($value)) { + if (is_array($value) && $this->isArrayList($value)) { return implode(', ', array_map($this->export(...), $value)); } if (is_string($value)) { @@ -137,4 +137,25 @@ protected function export(mixed $value): string } return VarDumper::create($value)->asString(); } + + /** + * Polyfill for is_array_list() function. + * It is available since PHP 8.1. + */ + private function isArrayList(array $array): bool + { + if ([] === $array) { + return true; + } + + $nextKey = -1; + + foreach ($array as $k => $_) { + if ($k !== ++$nextKey) { + return false; + } + } + + return true; + } } diff --git a/src/Debug/RouterCollector.php b/src/Debug/RouterCollector.php index 293f3281..9711ad54 100644 --- a/src/Debug/RouterCollector.php +++ b/src/Debug/RouterCollector.php @@ -21,7 +21,7 @@ final class RouterCollector implements SummaryCollectorInterface private float $matchTime = 0; - public function __construct(private readonly ContainerInterface $container) {} + public function __construct(private ContainerInterface $container) {} public function collect(float $matchTime): void { From 2791884f4a4a38d2266a777dd769349f1c228825 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Wed, 5 Aug 2026 13:24:08 +0300 Subject: [PATCH 25/32] Keep route factory syntax compatible --- CHANGELOG.md | 3 +- UPGRADE.md | 32 ++++++++++++++------- src/Group.php | 7 ++++- src/Route.php | 46 +++++++++++++++++++++++++++++- tests/Builder/GroupBuilderTest.php | 4 +-- tests/Builder/RouteBuilderTest.php | 2 +- 6 files changed, 77 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 034196ec..be4ee8b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ ## 5.0.0 under development - Chg #225: Introduce immutable route and group builders, make `Route` and `Group` mutable data objects, add - `RoutableInterface`, and replace generic data accessors with explicit getters (@rustamwin) + `RoutableInterface`, replace generic data accessors with explicit getters, and retain static factories as + builder facades (@rustamwin) ## 4.0.3 under development diff --git a/UPGRADE.md b/UPGRADE.md index 7b28d406..0230ad56 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -12,20 +12,28 @@ The immutable fluent APIs were moved from `Yiisoft\Router\Route` and `Yiisoft\Ro - `Yiisoft\Router\Builder\RouteBuilder` - `Yiisoft\Router\Builder\GroupBuilder` -Update imports while keeping aliases if you want existing route declarations to remain unchanged: +The static factory methods on `Route` and `Group` are retained as facades. They now return the corresponding builder +instead of a `Route` or `Group` data object. + +- If you only use fluent route declarations such as `Route::get('/')->name('home')` and pass their results to + `RouteCollectorInterface::addRoute()`, then no changes are required. +- If you type a result of `Route::get()`, `Route::post()`, `Route::methods()`, or another route factory as `Route`, then + change the type to `RouteBuilder` or call `toRoute()` to obtain a `Route` data object. +- If you type a result of `Group::create()` as `Group`, then change the type to `GroupBuilder` or call `toRoute()` to + obtain a `Group` data object. +- If you check a fluent factory result with `instanceof Route` or `instanceof Group`, then check for the corresponding + builder instead, or call `toRoute()` before the check. +- If you call `getData()` on a fluent factory result, then call `toRoute()` and use an explicit getter on the resulting + data object. +- If you want imports to reflect the actual types returned by the factories, then import the builders directly. Aliases + allow route declarations to keep the familiar short names: ```php -// Before -use Yiisoft\Router\Group; -use Yiisoft\Router\Route; - -// After use Yiisoft\Router\Builder\GroupBuilder as Group; use Yiisoft\Router\Builder\RouteBuilder as Route; ``` -Code such as `Route::get('/')->name('home')` and `Group::create('/api')->routes(...)` then continues to use the same -fluent syntax. Builders are immutable and can be passed directly to `RouteCollectorInterface::addRoute()`. +Builders are immutable and can be passed directly to `RouteCollectorInterface::addRoute()`. ### `Route` changes @@ -43,7 +51,8 @@ $route = new Route( ); ``` -The static construction and fluent configuration methods moved to `RouteBuilder`. +The fluent configuration methods moved to `RouteBuilder`. The static construction methods remain available on `Route` +as facades that return a `RouteBuilder`. `Route::getData()` was removed. Replace it with the corresponding explicit method: @@ -64,8 +73,9 @@ Mutable configuration is available through `setMethods()`, `setPattern()`, `setN ### `Group` changes -`Yiisoft\Router\Group` is now a mutable group data object with a public constructor. The static `create()` method and -fluent configuration methods moved to `GroupBuilder`. +`Yiisoft\Router\Group` is now a mutable group data object with a public constructor. The fluent configuration methods +moved to `GroupBuilder`. The static `create()` method remains available on `Group` as a facade that returns a +`GroupBuilder`. `Group::getData()` was removed. Replace it with the corresponding explicit method: diff --git a/src/Group.php b/src/Group.php index 619b997b..33ae17fc 100644 --- a/src/Group.php +++ b/src/Group.php @@ -5,8 +5,8 @@ namespace Yiisoft\Router; use InvalidArgumentException; -use Yiisoft\Router\Internal\MiddlewareFilter; use Yiisoft\Router\Builder\GroupBuilder; +use Yiisoft\Router\Internal\MiddlewareFilter; use function in_array; use function is_array; @@ -66,6 +66,11 @@ public function __construct( $this->corsMiddleware = $corsMiddleware; } + public static function create(?string $prefix = null, ?string $namePrefix = null): GroupBuilder + { + return GroupBuilder::create($prefix, $namePrefix); + } + /** * @return Group[]|RoutableInterface[]|Route[] */ diff --git a/src/Route.php b/src/Route.php index afef844c..0f77669a 100644 --- a/src/Route.php +++ b/src/Route.php @@ -6,8 +6,9 @@ use InvalidArgumentException; use Stringable; -use Yiisoft\Router\Internal\MiddlewareFilter; +use Yiisoft\Http\Method; use Yiisoft\Router\Builder\RouteBuilder; +use Yiisoft\Router\Internal\MiddlewareFilter; use function in_array; use function is_array; @@ -121,6 +122,49 @@ public function __debugInfo() ]; } + public static function get(string $pattern): RouteBuilder + { + return RouteBuilder::get($pattern); + } + + public static function post(string $pattern): RouteBuilder + { + return RouteBuilder::post($pattern); + } + + public static function put(string $pattern): RouteBuilder + { + return RouteBuilder::put($pattern); + } + + public static function delete(string $pattern): RouteBuilder + { + return RouteBuilder::delete($pattern); + } + + public static function patch(string $pattern): RouteBuilder + { + return RouteBuilder::patch($pattern); + } + + public static function head(string $pattern): RouteBuilder + { + return RouteBuilder::head($pattern); + } + + public static function options(string $pattern): RouteBuilder + { + return RouteBuilder::options($pattern); + } + + /** + * @param string[] $methods + */ + public static function methods(array $methods, string $pattern): RouteBuilder + { + return RouteBuilder::methods($methods, $pattern); + } + /** * @return string[] */ diff --git a/tests/Builder/GroupBuilderTest.php b/tests/Builder/GroupBuilderTest.php index 6c243468..e012318c 100644 --- a/tests/Builder/GroupBuilderTest.php +++ b/tests/Builder/GroupBuilderTest.php @@ -14,8 +14,8 @@ use RuntimeException; use Yiisoft\Middleware\Dispatcher\MiddlewareDispatcher; use Yiisoft\Middleware\Dispatcher\MiddlewareFactory; -use Yiisoft\Router\Builder\GroupBuilder as Group; -use Yiisoft\Router\Builder\RouteBuilder as Route; +use Yiisoft\Router\Group; +use Yiisoft\Router\Route; use Yiisoft\Router\RouteCollection; use Yiisoft\Router\RouteCollector; use Yiisoft\Router\Tests\Support\Container; diff --git a/tests/Builder/RouteBuilderTest.php b/tests/Builder/RouteBuilderTest.php index c5aa3baa..706adca0 100644 --- a/tests/Builder/RouteBuilderTest.php +++ b/tests/Builder/RouteBuilderTest.php @@ -15,7 +15,7 @@ use Yiisoft\Http\Method; use Yiisoft\Middleware\Dispatcher\MiddlewareDispatcher; use Yiisoft\Middleware\Dispatcher\MiddlewareFactory; -use Yiisoft\Router\Builder\RouteBuilder as Route; +use Yiisoft\Router\Route; use Yiisoft\Router\Tests\Support\AssertTrait; use Yiisoft\Router\Tests\Support\Container; use Yiisoft\Router\Tests\Support\TestMiddleware1; From 139161bff286657fa476f941e0d9f80bb88da418 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 5 Aug 2026 10:24:57 +0000 Subject: [PATCH 26/32] Apply PHP CS Fixer and Rector changes (CI) --- src/Route.php | 1 - tests/Builder/GroupBuilderTest.php | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Route.php b/src/Route.php index 0f77669a..67379627 100644 --- a/src/Route.php +++ b/src/Route.php @@ -6,7 +6,6 @@ use InvalidArgumentException; use Stringable; -use Yiisoft\Http\Method; use Yiisoft\Router\Builder\RouteBuilder; use Yiisoft\Router\Internal\MiddlewareFilter; diff --git a/tests/Builder/GroupBuilderTest.php b/tests/Builder/GroupBuilderTest.php index e012318c..a7635150 100644 --- a/tests/Builder/GroupBuilderTest.php +++ b/tests/Builder/GroupBuilderTest.php @@ -240,7 +240,7 @@ public function testAddGroup(): void $postGroup = $apiRoute->getRoutes()[1]; $postGroup = $postGroup->toRoute(); - $this->assertInstanceOf(\Yiisoft\Router\Group::class, $postGroup); + $this->assertInstanceOf(Group::class, $postGroup); $this->assertCount(2, $apiRoute->getEnabledMiddlewares()); $this->assertSame($middleware1, $apiRoute->getEnabledMiddlewares()[0]); $this->assertSame($middleware2, $apiRoute->getEnabledMiddlewares()[1]); @@ -365,7 +365,7 @@ public function testWithCorsWithNestedGroups(): void $routeCollection = new RouteCollection($collector); $this->assertCount(7, $routeCollection->getRoutes()); $optionsRoute = $routeCollection->getRoute('OPTIONS /v1/post'); - $this->assertInstanceOf(\Yiisoft\Router\Route::class, $optionsRoute); + $this->assertInstanceOf(Route::class, $optionsRoute); $this->assertSame([$corsMiddleware], $optionsRoute->getEnabledMiddlewares()); } @@ -390,7 +390,7 @@ public function testWithCorsWithNestedGroups2(): void $routeCollection = new RouteCollection($collector); $this->assertCount(8, $routeCollection->getRoutes()); - $this->assertInstanceOf(\Yiisoft\Router\Route::class, $routeCollection->getRoute('OPTIONS /v1/post')); + $this->assertInstanceOf(Route::class, $routeCollection->getRoute('OPTIONS /v1/post')); } public function testMiddlewareAfterRoutes(): void From e1d76888c7274099cc48daad2868e7df16e3e299 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Wed, 5 Aug 2026 13:27:38 +0300 Subject: [PATCH 27/32] Restore deprecated current route host getter --- UPGRADE.md | 5 ++++- src/CurrentRoute.php | 12 ++++++++++++ tests/CurrentRouteTest.php | 2 ++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/UPGRADE.md b/UPGRADE.md index 0230ad56..e1f7518f 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -113,7 +113,10 @@ $collector->getMiddlewares(); ### `CurrentRoute` changes -`CurrentRoute::getHost()` was replaced by `CurrentRoute::getHosts()` and now returns all route hosts: +`CurrentRoute::getHost()` is deprecated but remains functional and returns the first route host. + +- If you only need the first route host, then no changes are required. +- If you need all route hosts, then use `CurrentRoute::getHosts()`: ```php // Before diff --git a/src/CurrentRoute.php b/src/CurrentRoute.php index 82b7326c..938c7318 100644 --- a/src/CurrentRoute.php +++ b/src/CurrentRoute.php @@ -39,6 +39,18 @@ public function getName(): ?string return $this->route?->getName(); } + /** + * Returns the current route host. + * + * @deprecated Use {@see getHosts()} instead. + * + * @return string|null The current route host. + */ + public function getHost(): ?string + { + return $this->route?->getHosts()[0] ?? null; + } + /** * Returns the current route hosts. * diff --git a/tests/CurrentRouteTest.php b/tests/CurrentRouteTest.php index 6628deb0..3f3d33fb 100644 --- a/tests/CurrentRouteTest.php +++ b/tests/CurrentRouteTest.php @@ -18,6 +18,7 @@ public function testGettersReturnDefaultValuesWhenRouteIsNotSet(): void $currentRoute = new CurrentRoute(); $this->assertNull($currentRoute->getName()); + $this->assertNull($currentRoute->getHost()); $this->assertNull($currentRoute->getHosts()); $this->assertNull($currentRoute->getPattern()); $this->assertNull($currentRoute->getMethods()); @@ -42,6 +43,7 @@ public function testGetHost(): void $currentRoute = new CurrentRoute(); $currentRoute->setRouteWithArguments($route, []); + $this->assertSame($route->getHosts()[0], $currentRoute->getHost()); $this->assertSame($route->getHosts(), $currentRoute->getHosts()); } From dfe7a312518fd0e2f0b9d5c70c4710a880acdbdf Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Wed, 5 Aug 2026 13:31:37 +0300 Subject: [PATCH 28/32] Reduce route data object diff --- src/Group.php | 15 ++++++++++----- src/Route.php | 34 +++++++++++++++++----------------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/Group.php b/src/Group.php index 33ae17fc..e3a12ef8 100644 --- a/src/Group.php +++ b/src/Group.php @@ -46,6 +46,16 @@ final class Group */ private $corsMiddleware = null; + /** + * Create a new group instance. + * + * @param string|null $prefix URL prefix to prepend to all routes of the group. + */ + public static function create(?string $prefix = null, ?string $namePrefix = null): GroupBuilder + { + return GroupBuilder::create($prefix, $namePrefix); + } + /** * @param array $disabledMiddlewares Excludes middleware from being invoked when action is handled. * It is useful to avoid invoking one of the parent group middleware for @@ -66,11 +76,6 @@ public function __construct( $this->corsMiddleware = $corsMiddleware; } - public static function create(?string $prefix = null, ?string $namePrefix = null): GroupBuilder - { - return GroupBuilder::create($prefix, $namePrefix); - } - /** * @return Group[]|RoutableInterface[]|Route[] */ diff --git a/src/Route.php b/src/Route.php index 67379627..53bdebf0 100644 --- a/src/Route.php +++ b/src/Route.php @@ -215,23 +215,6 @@ public function getDisabledMiddlewares(): array return $this->disabledMiddlewares; } - /** - * @return array[]|callable[]|string[] - * @psalm-return list - */ - public function getEnabledMiddlewares(): array - { - if ($this->enabledMiddlewaresCache !== null) { - /** @infection-ignore-all Cached and freshly filtered values are indistinguishable by behavior. */ - return $this->enabledMiddlewaresCache; - } - - return $this->enabledMiddlewaresCache = MiddlewareFilter::filter( - $this->middlewares, - $this->disabledMiddlewares, - ); - } - /** * Returns the dispatch pipeline: enabled middlewares with the action appended as the final handler. * @@ -333,6 +316,23 @@ public function setDisabledMiddlewares(array $disabledMiddlewares): self return $this; } + /** + * @return array[]|callable[]|string[] + * @psalm-return list + */ + public function getEnabledMiddlewares(): array + { + if ($this->enabledMiddlewaresCache !== null) { + /** @infection-ignore-all Cached and freshly filtered values are indistinguishable by behavior. */ + return $this->enabledMiddlewaresCache; + } + + return $this->enabledMiddlewaresCache = MiddlewareFilter::filter( + $this->middlewares, + $this->disabledMiddlewares, + ); + } + /** * @psalm-assert list $middlewares */ From 20fe6ec4f285513062c041ab2a22a0cf8b9ec048 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 5 Aug 2026 10:32:13 +0000 Subject: [PATCH 29/32] Apply PHP CS Fixer and Rector changes (CI) --- src/Group.php | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Group.php b/src/Group.php index e3a12ef8..3f2335f3 100644 --- a/src/Group.php +++ b/src/Group.php @@ -46,16 +46,6 @@ final class Group */ private $corsMiddleware = null; - /** - * Create a new group instance. - * - * @param string|null $prefix URL prefix to prepend to all routes of the group. - */ - public static function create(?string $prefix = null, ?string $namePrefix = null): GroupBuilder - { - return GroupBuilder::create($prefix, $namePrefix); - } - /** * @param array $disabledMiddlewares Excludes middleware from being invoked when action is handled. * It is useful to avoid invoking one of the parent group middleware for @@ -76,6 +66,16 @@ public function __construct( $this->corsMiddleware = $corsMiddleware; } + /** + * Create a new group instance. + * + * @param string|null $prefix URL prefix to prepend to all routes of the group. + */ + public static function create(?string $prefix = null, ?string $namePrefix = null): GroupBuilder + { + return GroupBuilder::create($prefix, $namePrefix); + } + /** * @return Group[]|RoutableInterface[]|Route[] */ From 4bb70d759cb022ae3131ac2c10a9e7d8c14e8eb4 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Wed, 5 Aug 2026 13:40:53 +0300 Subject: [PATCH 30/32] Use compatible route syntax in tests --- tests/ConfigTest.php | 2 +- tests/Debug/DebugRoutesCommandTest.php | 10 ++--- tests/Debug/RouterCollectorTest.php | 5 +-- tests/HydratorAttribute/RouteArgumentTest.php | 2 +- tests/Middleware/RouterTest.php | 4 +- tests/RouteCollectionTest.php | 42 +++++++++---------- tests/RouteCollectorTest.php | 7 ++-- 7 files changed, 35 insertions(+), 37 deletions(-) diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index fe7e99fe..1946d7f4 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -12,7 +12,7 @@ use Yiisoft\Router\CurrentRoute; use Yiisoft\Router\Debug\RouterCollector; use Yiisoft\Router\Debug\UrlMatcherInterfaceProxy; -use Yiisoft\Router\Builder\RouteBuilder as Route; +use Yiisoft\Router\Route; use Yiisoft\Router\RouteCollector; use Yiisoft\Router\RouteCollectorInterface; use Yiisoft\Router\UrlMatcherInterface; diff --git a/tests/Debug/DebugRoutesCommandTest.php b/tests/Debug/DebugRoutesCommandTest.php index 7c166435..191cd25e 100644 --- a/tests/Debug/DebugRoutesCommandTest.php +++ b/tests/Debug/DebugRoutesCommandTest.php @@ -6,8 +6,8 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Tester\CommandTester; -use Yiisoft\Router\Builder\RouteBuilder; use Yiisoft\Router\Debug\DebugRoutesCommand; +use Yiisoft\Router\Route; use Yiisoft\Router\RouteCollection; use Yiisoft\Router\RouteCollector; use Yiisoft\Router\Tests\Support\TestController; @@ -25,12 +25,12 @@ public function testBase(): void $command = new DebugRoutesCommand( new RouteCollection( (new RouteCollector())->addRoute( - RouteBuilder::get('/') + Route::get('/') ->host('example.com') ->defaults(['SpecialArg' => 1]) ->action(fn() => 'Hello, XXXXXX!') ->name('site/index'), - RouteBuilder::get('/about') + Route::get('/about') ->action([TestController::class, 'index']) ->name('site/about'), ), @@ -58,13 +58,13 @@ public function testSpecificRoute(): void $command = new DebugRoutesCommand( new RouteCollection( (new RouteCollector())->addRoute( - RouteBuilder::get('/') + Route::get('/') ->host('example.com') ->defaults(['SpecialArg' => 1]) ->name('site/index') ->middleware(TestMiddleware1::class) ->action(fn() => 'Hello world!'), - RouteBuilder::get('/about')->name('site/about'), + Route::get('/about')->name('site/about'), ), ), new Debugger( diff --git a/tests/Debug/RouterCollectorTest.php b/tests/Debug/RouterCollectorTest.php index 35ea3369..4eaac3a3 100644 --- a/tests/Debug/RouterCollectorTest.php +++ b/tests/Debug/RouterCollectorTest.php @@ -8,10 +8,9 @@ use Yiisoft\Di\Container; use Yiisoft\Di\ContainerConfig; use Yiisoft\Http\Method; -use Yiisoft\Router\Builder\GroupBuilder; -use Yiisoft\Router\Builder\RouteBuilder; use Yiisoft\Router\CurrentRoute; use Yiisoft\Router\Debug\RouterCollector; +use Yiisoft\Router\Group; use Yiisoft\Router\MatchingResult; use Yiisoft\Router\Route; use Yiisoft\Router\RouteCollection; @@ -134,7 +133,7 @@ private function createRoutes(): array { return [ new Route([Method::GET], '/'), - GroupBuilder::create('/api')->routes(RouteBuilder::get('/v1')), + Group::create('/api')->routes(Route::get('/v1')), ]; } } diff --git a/tests/HydratorAttribute/RouteArgumentTest.php b/tests/HydratorAttribute/RouteArgumentTest.php index 467c1628..656050bb 100644 --- a/tests/HydratorAttribute/RouteArgumentTest.php +++ b/tests/HydratorAttribute/RouteArgumentTest.php @@ -16,7 +16,7 @@ use Yiisoft\Router\CurrentRoute; use Yiisoft\Router\HydratorAttribute\RouteArgument; use Yiisoft\Router\HydratorAttribute\RouteArgumentResolver; -use Yiisoft\Router\Builder\RouteBuilder as RouterRoute; +use Yiisoft\Router\Route as RouterRoute; use Yiisoft\Test\Support\Container\SimpleContainer; final class RouteArgumentTest extends TestCase diff --git a/tests/Middleware/RouterTest.php b/tests/Middleware/RouterTest.php index 118c0d35..390c7d42 100644 --- a/tests/Middleware/RouterTest.php +++ b/tests/Middleware/RouterTest.php @@ -16,10 +16,10 @@ use Yiisoft\Http\Method; use Yiisoft\Middleware\Dispatcher\MiddlewareFactory; use Yiisoft\Router\CurrentRoute; -use Yiisoft\Router\Builder\GroupBuilder as Group; +use Yiisoft\Router\Group; use Yiisoft\Router\MatchingResult; use Yiisoft\Router\Middleware\Router; -use Yiisoft\Router\Builder\RouteBuilder as Route; +use Yiisoft\Router\Route; use Yiisoft\Router\RouteCollection; use Yiisoft\Router\RouteCollectionInterface; use Yiisoft\Router\RouteCollector; diff --git a/tests/RouteCollectionTest.php b/tests/RouteCollectionTest.php index 97c3394a..1109ca35 100644 --- a/tests/RouteCollectionTest.php +++ b/tests/RouteCollectionTest.php @@ -17,10 +17,8 @@ use Yiisoft\Middleware\Dispatcher\MiddlewareDispatcher; use Yiisoft\Middleware\Dispatcher\MiddlewareFactory; use Yiisoft\Http\Method; -use Yiisoft\Router\Builder\GroupBuilder as Group; -use Yiisoft\Router\Builder\RouteBuilder as Route; -use Yiisoft\Router\Group as RawGroup; -use Yiisoft\Router\Route as RawRoute; +use Yiisoft\Router\Group; +use Yiisoft\Router\Route; use Yiisoft\Router\RouteCollection; use Yiisoft\Router\RouteCollector; use Yiisoft\Router\RouteNotFoundException; @@ -99,11 +97,11 @@ public function testRouteOverride(): void public function testCollectorCanBeReusedWithRawRouteAndGroupInstances(): void { - $route = new RawRoute([Method::GET], '/users', 'users'); - $group = new RawGroup( + $route = new Route([Method::GET], '/users', 'users'); + $group = new Group( prefix: '/api', namePrefix: 'api/', - routes: [new RawRoute([Method::GET], '/posts', 'posts')], + routes: [new Route([Method::GET], '/posts', 'posts')], ); $collector = new RouteCollector(); @@ -124,33 +122,33 @@ public function testCollectorCanBeReusedWithRawRouteAndGroupInstances(): void public function testCollectorCanBeReusedWithRetainedRoutesFromRoutables(): void { - $route = new RawRoute([Method::GET], '/users', 'users'); + $route = new Route([Method::GET], '/users', 'users'); $routeRoutable = new class ($route) implements RoutableInterface { - public function __construct(private readonly RawRoute $route) {} + public function __construct(private readonly Route $route) {} - public function toRoute(): RawRoute + public function toRoute(): Route { return $this->route; } }; - $nestedRoute = new RawRoute([Method::GET], '/posts', 'posts'); + $nestedRoute = new Route([Method::GET], '/posts', 'posts'); $nestedRouteRoutable = new class ($nestedRoute) implements RoutableInterface { - public function __construct(private readonly RawRoute $route) {} + public function __construct(private readonly Route $route) {} - public function toRoute(): RawRoute + public function toRoute(): Route { return $this->route; } }; - $group = new RawGroup( + $group = new Group( prefix: '/api', namePrefix: 'api/', routes: [$nestedRouteRoutable], ); $groupRoutable = new class ($group) implements RoutableInterface { - public function __construct(private readonly RawGroup $group) {} + public function __construct(private readonly Group $group) {} - public function toRoute(): RawGroup + public function toRoute(): Group { return $this->group; } @@ -261,9 +259,9 @@ public function testGetRouteTreeReturnsRouteInstances(): void $routeTree = (new RouteCollection($collector))->getRouteTree(false); - $this->assertInstanceOf(RawRoute::class, $routeTree[0]); + $this->assertInstanceOf(Route::class, $routeTree[0]); $this->assertSame('/api/posts', $routeTree[0]->getName()); - $this->assertInstanceOf(RawRoute::class, $routeTree['/v1'][0]); + $this->assertInstanceOf(Route::class, $routeTree['/v1'][0]); $this->assertSame('/api/comments', $routeTree['/v1'][0]->getName()); } @@ -343,10 +341,10 @@ public function testGroupName(): void $route2 = $routeCollection->getRoute('api/v1/package/downloads'); $route3 = $routeCollection->getRoute('api/index'); $route4 = $routeCollection->getRoute('GET api/user/{username}'); - $this->assertInstanceOf(RawRoute::class, $route1); - $this->assertInstanceOf(RawRoute::class, $route2); - $this->assertInstanceOf(RawRoute::class, $route3); - $this->assertInstanceOf(RawRoute::class, $route4); + $this->assertInstanceOf(Route::class, $route1); + $this->assertInstanceOf(Route::class, $route2); + $this->assertInstanceOf(Route::class, $route3); + $this->assertInstanceOf(Route::class, $route4); } public function testCollectorMiddlewareFullstackCalled(): void diff --git a/tests/RouteCollectorTest.php b/tests/RouteCollectorTest.php index bea8f391..275f30dd 100644 --- a/tests/RouteCollectorTest.php +++ b/tests/RouteCollectorTest.php @@ -6,9 +6,10 @@ use Nyholm\Psr7\Response; use PHPUnit\Framework\TestCase; -use Yiisoft\Router\Builder\GroupBuilder as Group; -use Yiisoft\Router\Builder\RouteBuilder as Route; +use Yiisoft\Router\Group; +use Yiisoft\Router\Route; use Yiisoft\Router\RouteCollector; +use Yiisoft\Router\RoutableInterface; final class RouteCollectorTest extends TestCase { @@ -56,7 +57,7 @@ public function testAddGroup(): void $collector->addRoute($rootGroup, $postGroup, test: $testGroup); $this->assertCount(3, $collector->getItems()); - $this->assertContainsOnlyInstancesOf(Group::class, $collector->getItems()); + $this->assertContainsOnlyInstancesOf(RoutableInterface::class, $collector->getItems()); } public function testAddMiddleware(): void From b5f8e4c08ce2d253fa11583a5d5189e0c3416c21 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Wed, 5 Aug 2026 14:17:19 +0300 Subject: [PATCH 31/32] Preserve fluent builder compatibility --- README.md | 24 ++++++++++++------------ src/Builder/GroupBuilder.php | 16 +--------------- src/Builder/RouteBuilder.php | 2 +- tests/Builder/GroupBuilderTest.php | 23 ++++++++++------------- tests/Builder/RouteBuilderTest.php | 1 + tests/GroupTest.php | 2 +- 6 files changed, 26 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 23bc8640..74c4e7e3 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,8 @@ Common usage of the router looks like the following: ```php use Yiisoft\Router\CurrentRoute; -use Yiisoft\Router\Builder\GroupBuilder as Group; -use Yiisoft\Router\Builder\RouteBuilder as Route; +use Yiisoft\Router\Group; +use Yiisoft\Router\Route; use Yiisoft\Router\RouteCollection; use Yiisoft\Router\RouteCollectorInterface; use Yiisoft\Router\UrlMatcherInterface; @@ -130,7 +130,7 @@ used by the router. A route could match one or more HTTP methods: `GET`, `POST`, multiple methods at once, it could be created using `methods()`. ```php -use Yiisoft\Router\Builder\RouteBuilder as Route; +use Yiisoft\Router\Route; use Yiisoft\Http\Method; Route::delete('/post/{id}') @@ -153,7 +153,7 @@ for middleware examples. If a route should be applied only to a certain host, it could be defined like the following: ```php -use Yiisoft\Router\Builder\RouteBuilder as Route; +use Yiisoft\Router\Route; Route::get('/special') ->name('special') @@ -164,7 +164,7 @@ Route::get('/special') Defaults for parameters could be provided via `defaults()` method: ```php -use Yiisoft\Router\Builder\RouteBuilder as Route; +use Yiisoft\Router\Route; Route::get('/api[/v{version}]') ->name('api-index') @@ -177,7 +177,7 @@ In the above we specify that if "version" is not obtained from URL during matchi Besides action, additional middleware to execute before the action itself could be defined: ```php -use Yiisoft\Router\Builder\RouteBuilder as Route; +use Yiisoft\Router\Route; use Yiisoft\Http\Method; Route::methods([Method::GET, Method::POST], '/page/add') @@ -195,7 +195,7 @@ If there is a need to either add middleware to be executed first or remove exist If you combine routes from multiple sources and want last route to have priority over existing ones, mark it as "override": ```php -use Yiisoft\Router\Builder\RouteBuilder as Route; +use Yiisoft\Router\Route; Route::get('/special') ->name('special') @@ -227,8 +227,8 @@ $route->setHosts(['https://example.com']); Routes could be grouped with `GroupBuilder`. That is useful for API endpoints and similar cases: ```php -use Yiisoft\Router\Builder\GroupBuilder as Group; -use Yiisoft\Router\Builder\RouteBuilder as Route; +use Yiisoft\Router\Group; +use Yiisoft\Router\Route; use Yiisoft\Router\RouteCollectorInterface; // for obtaining router see adapter package of choice readme @@ -312,7 +312,7 @@ case, you can add a middleware for handling it such as [tuupola/cors-middleware] ```php use Tuupola\Middleware\CorsMiddleware; -use Yiisoft\Router\Builder\GroupBuilder as Group; +use Yiisoft\Router\Group; return [ Group::create('/api') @@ -335,7 +335,7 @@ use Yiisoft\Yii\Http\Handler\NotFoundHandler; use Yiisoft\Yii\Runner\Http\SapiEmitter; use Yiisoft\Yii\Runner\Http\ServerRequestFactory; use Yiisoft\Router\CurrentRoute; -use Yiisoft\Router\Builder\RouteBuilder as Route; +use Yiisoft\Router\Route; use Yiisoft\Router\RouteCollection; use Yiisoft\Router\RouteCollectorInterface; use Yiisoft\Router\Fastroute\UrlMatcher; @@ -409,7 +409,7 @@ modifying URLs for filtering and/or sorting. For such a route: ```php -use Yiisoft\Router\Builder\RouteBuilder as Route; +use Yiisoft\Router\Route; $routes = [ Route::post('/post/{id:\d+}') diff --git a/src/Builder/GroupBuilder.php b/src/Builder/GroupBuilder.php index cb23002e..42619188 100644 --- a/src/Builder/GroupBuilder.php +++ b/src/Builder/GroupBuilder.php @@ -4,7 +4,6 @@ namespace Yiisoft\Router\Builder; -use RuntimeException; use Yiisoft\Router\Group; use Yiisoft\Router\RoutableInterface; use Yiisoft\Router\Route; @@ -31,8 +30,6 @@ final class GroupBuilder implements RoutableInterface * @var string[] */ private array $hosts = []; - private bool $routesAdded = false; - private bool $middlewareAdded = false; /** * @var array|callable|string|null Middleware definition for CORS requests. @@ -56,13 +53,8 @@ public static function create(?string $prefix = null, ?string $namePrefix = null public function routes(Group|Route|RoutableInterface ...$routes): self { - if ($this->middlewareAdded) { - throw new RuntimeException('routes() can not be used after prependMiddleware().'); - } - $new = clone $this; $new->routes = $routes; - $new->routesAdded = true; return $new; } @@ -87,10 +79,6 @@ public function withCors(array|callable|string|null $middlewareDefinition): self */ public function middleware(array|callable|string ...$definition): self { - if ($this->routesAdded) { - throw new RuntimeException('middleware() can not be used after routes().'); - } - $new = clone $this; array_push( $new->middlewares, @@ -112,8 +100,6 @@ public function prependMiddleware(array|callable|string ...$definition): self ...array_values($definition), ); - $new->middlewareAdded = true; - return $new; } @@ -132,7 +118,7 @@ public function host(string $host): self public function hosts(string ...$hosts): self { $new = clone $this; - $new->hosts = $hosts; + array_push($new->hosts, ...$hosts); return $new; } diff --git a/src/Builder/RouteBuilder.php b/src/Builder/RouteBuilder.php index 3bc46892..c4a89af7 100644 --- a/src/Builder/RouteBuilder.php +++ b/src/Builder/RouteBuilder.php @@ -115,7 +115,7 @@ public function host(string $host): self public function hosts(string ...$hosts): self { $route = clone $this; - $route->hosts = $hosts; + array_push($route->hosts, ...$hosts); return $route; } diff --git a/tests/Builder/GroupBuilderTest.php b/tests/Builder/GroupBuilderTest.php index a7635150..5d89d354 100644 --- a/tests/Builder/GroupBuilderTest.php +++ b/tests/Builder/GroupBuilderTest.php @@ -11,7 +11,6 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; -use RuntimeException; use Yiisoft\Middleware\Dispatcher\MiddlewareDispatcher; use Yiisoft\Middleware\Dispatcher\MiddlewareFactory; use Yiisoft\Router\Group; @@ -70,16 +69,13 @@ public function testNamedArgumentsInMiddlewareMethods(): void public function testRoutesAfterMiddleware(): void { - $group = Group::create(); - $middleware1 = static fn() => new Response(); - $group = $group->prependMiddleware($middleware1); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('routes() can not be used after prependMiddleware().'); + $group = Group::create() + ->prependMiddleware($middleware1) + ->routes(Route::get('/')); - $group->routes(Route::get('/')->toRoute()); + $this->assertSame([$middleware1], $group->toRoute()->getEnabledMiddlewares()); } public function testAddNestedMiddleware(): void @@ -395,16 +391,17 @@ public function testWithCorsWithNestedGroups2(): void public function testMiddlewareAfterRoutes(): void { - $group = Group::create()->routes(Route::get('/info')->action(static fn() => 'info')); + $middleware = static fn() => new Response(); + $group = Group::create() + ->routes(Route::get('/info')->action(static fn() => 'info')) + ->middleware($middleware); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('middleware() can not be used after routes().'); - $group->middleware(static fn() => new Response()); + $this->assertSame([$middleware], $group->toRoute()->getEnabledMiddlewares()); } public function testDuplicateHosts(): void { - $route = Group::create()->hosts('a.com', 'b.com', 'a.com'); + $route = Group::create()->host('a.com')->hosts('b.com', 'a.com'); $this->assertSame(['a.com', 'b.com'], $route->toRoute()->getHosts()); } diff --git a/tests/Builder/RouteBuilderTest.php b/tests/Builder/RouteBuilderTest.php index 706adca0..a4ef3bf4 100644 --- a/tests/Builder/RouteBuilderTest.php +++ b/tests/Builder/RouteBuilderTest.php @@ -121,6 +121,7 @@ public function testHost(): void public function testHosts(): void { $route = Route::get('/') + ->host('https://yiiframework.com/') ->hosts( 'https://yiiframework.com/', 'yf.com', diff --git a/tests/GroupTest.php b/tests/GroupTest.php index 8b4b84f2..277f34ac 100644 --- a/tests/GroupTest.php +++ b/tests/GroupTest.php @@ -77,7 +77,7 @@ public function testInvalidMiddlewares(): void public function testHosts(): void { - $group = (new Group())->setHosts(['https://yiiframework.com/']); + $group = (new Group())->setHosts(['https://yiiframework.com/', '']); $this->assertSame(['https://yiiframework.com'], $group->getHosts()); } From f5691f412ab362b7d582011601bdd8796edb3851 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Wed, 5 Aug 2026 14:36:57 +0300 Subject: [PATCH 32/32] Clarify builder usage and upgrade guidance --- README.md | 19 ++++++++++--------- UPGRADE.md | 9 +++++++-- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 74c4e7e3..ffa542a7 100644 --- a/README.md +++ b/README.md @@ -124,10 +124,10 @@ application middleware processes the request. ### Routes -Routes are usually defined with `RouteBuilder`. It provides an immutable fluent API and produces the `Route` data object -used by the router. A route could match one or more HTTP methods: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, -`OPTIONS`. There are corresponding static methods for creating a route for a certain method. If a route is to handle -multiple methods at once, it could be created using `methods()`. +Define routes with the static methods on `Route`. They return an immutable builder that the route collector accepts +directly. A route could match one or more HTTP methods: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`. +There are corresponding static methods for creating a route for a certain method. If a route is to handle multiple +methods at once, it could be created using `methods()`. ```php use Yiisoft\Router\Route; @@ -203,8 +203,8 @@ Route::get('/special') ->override(); ``` -`RouteBuilder` is immutable: every configuration method returns a new builder. The collector accepts builders directly -and converts them to `Route` objects while building the collection. +Every fluent configuration method returns a new builder. The collector converts route builders to `Route` objects while +building the collection. Most applications do not need to import or convert builders directly. For configuration generated dynamically, a mutable `Route` data object may be constructed directly: @@ -224,7 +224,8 @@ $route->setHosts(['https://example.com']); ### Route groups -Routes could be grouped with `GroupBuilder`. That is useful for API endpoints and similar cases: +Create route groups with the static `Group::create()` method. It returns an immutable builder and is useful for API +endpoints and similar cases: ```php use Yiisoft\Router\Group; @@ -255,8 +256,8 @@ and `disableMiddleware()`. These middleware are executed prior to matched route' If host is specified, all routes in the group would match only if the host match. -Like `RouteBuilder`, `GroupBuilder` is immutable and is accepted directly by the collector. A mutable `Group` data -object can also be constructed directly: +Every fluent group configuration method returns a new builder, which the collector accepts directly. Most applications +do not need to import or convert group builders. A mutable `Group` data object can also be constructed directly: ```php use Yiisoft\Router\Group; diff --git a/UPGRADE.md b/UPGRADE.md index e1f7518f..95e98297 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -60,11 +60,13 @@ as facades that return a `RouteBuilder`. |---|---| | `getData('name')` | `getName()` | | `getData('pattern')` | `getPattern()` | +| `getData('host')` | `getHosts()[0] ?? null` | | `getData('hosts')` | `getHosts()` | | `getData('methods')` | `getMethods()` | | `getData('defaults')` | `getDefaults()` | | `getData('override')` | `isOverride()` | -| `getData('enabledMiddlewares')` | `getEnabledMiddlewares()` | +| `getData('hasMiddlewares')` | `getMiddlewares() !== [] || getAction() !== null` | +| `getData('enabledMiddlewares')` | `getEnabledMiddlewaresAndAction()` | The action is stored separately from route middleware. Use `getAction()` for the action, `getEnabledMiddlewares()` for filtered middleware only, or `getEnabledMiddlewaresAndAction()` for the dispatch pipeline. @@ -83,8 +85,10 @@ moved to `GroupBuilder`. The static `create()` method remains available on `Grou |---|---| | `getData('prefix')` | `getPrefix()` | | `getData('namePrefix')` | `getNamePrefix()` | +| `getData('host')` | `getHosts()[0] ?? null` | | `getData('hosts')` | `getHosts()` | | `getData('routes')` | `getRoutes()` | +| `getData('hasCorsMiddleware')` | `getCorsMiddleware() !== null` | | `getData('corsMiddleware')` | `getCorsMiddleware()` | | `getData('enabledMiddlewares')` | `getEnabledMiddlewares()` | @@ -101,7 +105,8 @@ collection clones the returned object before applying collection middleware or g `RouteCollectorInterface::addRoute()` now also accepts `RoutableInterface` instances. -`RouteCollectorInterface::getMiddlewareDefinitions()` was renamed: +If you call or implement `RouteCollectorInterface::getMiddlewareDefinitions()`, then rename the method to +`getMiddlewares()`: ```php // Before