diff --git a/CHANGELOG.md b/CHANGELOG.md index 556aff5..083de62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## 4.0.3 under development +- New #196: Add PHP Attributes support (@rustamwin) +- New #196: Add `RoutesProviderInterface` interface providing routes from various resources (@rustamwin) +- Bug #196: Fix the behavior of `Group::hosts()` method to be consistent with `Route::hosts()` method (@rustamwin) +- Chg #196: Make constructor of `Route` and `Group` classes public (@rustamwin) +- Chg #196: Deprecate static methods of `Route` and `Group` classes (@rustamwin) - Enh #276: Explicitly import classes, functions, and constants in the "use" section (@rustamwin) - Enh #277, #281: Remove restrictions from `prependMiddleware()` and `middleware()` methods (@klsoft-web, @vjik) diff --git a/README.md b/README.md index 2bb9d30..5166e74 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ with an adapter package. Currently, the only adapter available is [FastRoute](ht - Ready to use middleware for route matching. - Convenient `CurrentRoute` service that holds information about last matched route. - Out of the box CORS middleware support. +- Declaring routes using PHP attributes. ## Requirements @@ -45,7 +46,7 @@ Additionally, you will need an adapter such as [FastRoute](https://github.com/yi ## Defining routes and URL matching -Common usage of the router looks like the following: +#### Common usage of the router looks like the following ```php use Yiisoft\Router\CurrentRoute; @@ -101,6 +102,36 @@ if (!$result->isSuccess()) { $response = $result->process($request, $notFoundHandler); ``` +#### Using attributes is also supported + +In controller: + +```php +use Yiisoft\Router\Attribute\Get; + +final class SiteController +{ + //... + + #[Get('/')] + public function home(ServerRequestInterface $request): ResponseInterface + { + return $this->responseFactory->createResponse()->withBody( + $this->streamFactory->createStream('You are at homepage.') + ); + } + + #[Get('/test/{id:\w+}')] + public function test(CurrentRoute $currentRoute): ResponseInterface + { + $id = $currentRoute->getArgument('id'); + + return $this->responseFactory->createResponse()->withBody( + $this->streamFactory->createStream('You are at test with argument ' . $id) + ); +} +``` + > Note: Despite `UrlGeneratorInterface` and `UrlMatcherInterface` being common for all adapters available, certain > features and, especially, pattern syntax may differ. To check usage and configuration details, please refer > to specific adapter documentation. All examples in this document are for diff --git a/src/Attribute/Delete.php b/src/Attribute/Delete.php new file mode 100644 index 0000000..13f5fd4 --- /dev/null +++ b/src/Attribute/Delete.php @@ -0,0 +1,58 @@ + $defaults Parameter default values indexed by parameter names. + * @param string[] $hosts Hosts that the route should match. + * @param bool $override Marks route as override. When added, it will replace the 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. + * + * @psalm-param list $middlewares + */ + public function __construct( + string $pattern, + ?string $name = null, + array $middlewares = [], + array $defaults = [], + array $hosts = [], + bool $override = false, + array $disabledMiddlewares = [], + ) { + $this->route = new Route( + method: [Method::DELETE], + pattern: $pattern, + name: $name, + middlewares: $middlewares, + defaults: $defaults, + hosts: $hosts, + override: $override, + disabledMiddlewares: $disabledMiddlewares, + ); + } + + public function getRoute(): Route + { + return $this->route; + } +} diff --git a/src/Attribute/Get.php b/src/Attribute/Get.php new file mode 100644 index 0000000..0828862 --- /dev/null +++ b/src/Attribute/Get.php @@ -0,0 +1,58 @@ + $defaults Parameter default values indexed by parameter names. + * @param string[] $hosts Hosts that the route should match. + * @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. + * + * @psalm-param list $middlewares + */ + public function __construct( + string $pattern, + ?string $name = null, + array $middlewares = [], + array $defaults = [], + array $hosts = [], + bool $override = false, + array $disabledMiddlewares = [], + ) { + $this->route = new Route( + method: [Method::GET], + pattern: $pattern, + name: $name, + middlewares: $middlewares, + defaults: $defaults, + hosts: $hosts, + override: $override, + disabledMiddlewares: $disabledMiddlewares, + ); + } + + public function getRoute(): Route + { + return $this->route; + } +} diff --git a/src/Attribute/Head.php b/src/Attribute/Head.php new file mode 100644 index 0000000..60845db --- /dev/null +++ b/src/Attribute/Head.php @@ -0,0 +1,58 @@ + $defaults Parameter default values indexed by parameter names. + * @param string[] $hosts Hosts that the route should match. + * @param bool $override Marks route as override. When added, it will replace the 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. + * + * @psalm-param list $middlewares + */ + public function __construct( + string $pattern, + ?string $name = null, + array $middlewares = [], + array $defaults = [], + array $hosts = [], + bool $override = false, + array $disabledMiddlewares = [], + ) { + $this->route = new Route( + method: [Method::HEAD], + pattern: $pattern, + name: $name, + middlewares: $middlewares, + defaults: $defaults, + hosts: $hosts, + override: $override, + disabledMiddlewares: $disabledMiddlewares, + ); + } + + public function getRoute(): Route + { + return $this->route; + } +} diff --git a/src/Attribute/Options.php b/src/Attribute/Options.php new file mode 100644 index 0000000..cffa011 --- /dev/null +++ b/src/Attribute/Options.php @@ -0,0 +1,58 @@ + $defaults Parameter default values indexed by parameter names. + * @param string[] $hosts Hosts that the route should match. + * @param bool $override Marks route as override. When added, it will replace the 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. + * + * @psalm-param list $middlewares + */ + public function __construct( + string $pattern, + ?string $name = null, + array $middlewares = [], + array $defaults = [], + array $hosts = [], + bool $override = false, + array $disabledMiddlewares = [], + ) { + $this->route = new Route( + method: [Method::OPTIONS], + pattern: $pattern, + name: $name, + middlewares: $middlewares, + defaults: $defaults, + hosts: $hosts, + override: $override, + disabledMiddlewares: $disabledMiddlewares, + ); + } + + public function getRoute(): Route + { + return $this->route; + } +} diff --git a/src/Attribute/Patch.php b/src/Attribute/Patch.php new file mode 100644 index 0000000..1dbd874 --- /dev/null +++ b/src/Attribute/Patch.php @@ -0,0 +1,58 @@ + $defaults Parameter default values indexed by parameter names. + * @param string[] $hosts Hosts that the route should match. + * @param bool $override Marks route as override. When added, it will replace the 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. + * + * @psalm-param list $middlewares + */ + public function __construct( + string $pattern, + ?string $name = null, + array $middlewares = [], + array $defaults = [], + array $hosts = [], + bool $override = false, + array $disabledMiddlewares = [], + ) { + $this->route = new Route( + method: [Method::PATCH], + pattern: $pattern, + name: $name, + middlewares: $middlewares, + defaults: $defaults, + hosts: $hosts, + override: $override, + disabledMiddlewares: $disabledMiddlewares, + ); + } + + public function getRoute(): Route + { + return $this->route; + } +} diff --git a/src/Attribute/Post.php b/src/Attribute/Post.php new file mode 100644 index 0000000..1a27f22 --- /dev/null +++ b/src/Attribute/Post.php @@ -0,0 +1,58 @@ + $defaults Parameter default values indexed by parameter names. + * @param string[] $hosts Hosts that the route should match. + * @param bool $override Marks route as override. When added, it will replace the 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. + * + * @psalm-param list $middlewares + */ + public function __construct( + string $pattern, + ?string $name = null, + array $middlewares = [], + array $defaults = [], + array $hosts = [], + bool $override = false, + array $disabledMiddlewares = [], + ) { + $this->route = new Route( + method: [Method::POST], + pattern: $pattern, + name: $name, + middlewares: $middlewares, + defaults: $defaults, + hosts: $hosts, + override: $override, + disabledMiddlewares: $disabledMiddlewares, + ); + } + + public function getRoute(): Route + { + return $this->route; + } +} diff --git a/src/Attribute/Put.php b/src/Attribute/Put.php new file mode 100644 index 0000000..4ea7128 --- /dev/null +++ b/src/Attribute/Put.php @@ -0,0 +1,58 @@ + $defaults Parameter default values indexed by parameter names. + * @param string[] $hosts Hosts that the route should match. + * @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. + * + * @psalm-param list $middlewares + */ + public function __construct( + string $pattern, + ?string $name = null, + array $middlewares = [], + array $defaults = [], + array $hosts = [], + bool $override = false, + array $disabledMiddlewares = [], + ) { + $this->route = new Route( + method: [Method::PUT], + pattern: $pattern, + name: $name, + middlewares: $middlewares, + defaults: $defaults, + hosts: $hosts, + override: $override, + disabledMiddlewares: $disabledMiddlewares, + ); + } + + public function getRoute(): Route + { + return $this->route; + } +} diff --git a/src/Attribute/Route.php b/src/Attribute/Route.php new file mode 100644 index 0000000..c79fa20 --- /dev/null +++ b/src/Attribute/Route.php @@ -0,0 +1,59 @@ + $defaults Parameter default values indexed by parameter names. + * @param string[] $hosts Hosts that the route should match. + * @param bool $override Marks route as override. When added, it will replace the 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. + * + * @psalm-param list $middlewares + */ + public function __construct( + array $methods, + string $pattern, + ?string $name = null, + array $middlewares = [], + array $defaults = [], + array $hosts = [], + bool $override = false, + array $disabledMiddlewares = [], + ) { + $this->route = new RouteObject( + method: $methods, + pattern: $pattern, + name: $name, + middlewares: $middlewares, + defaults: $defaults, + hosts: $hosts, + override: $override, + disabledMiddlewares: $disabledMiddlewares, + ); + } + + public function getRoute(): RouteObject + { + return $this->route; + } +} diff --git a/src/Attribute/RouteAttributeInterface.php b/src/Attribute/RouteAttributeInterface.php new file mode 100644 index 0000000..c643960 --- /dev/null +++ b/src/Attribute/RouteAttributeInterface.php @@ -0,0 +1,20 @@ +|null @@ -39,20 +45,51 @@ final class Group */ private $corsMiddleware = null; - private function __construct( - private ?string $prefix = null, - ) {} + /** + * @param string|null $prefix URL prefix to prepend to all routes of the group. + * @param array[]|callable[]|string[] $middlewares Middleware definitions. + * @param string[] $hosts List of host names. + * @param string|null $namePrefix Prefix for route names. + * @param array $disabledMiddlewares Excludes middleware from being invoked when action is handled. + * @param array|callable|string|null $corsMiddleware Middleware definition for CORS requests. + * It is useful to avoid invoking one of the parent group middleware for + * a certain route. + * + * @psalm-param list $middlewares + */ + public function __construct( + private readonly ?string $prefix = null, + array $middlewares = [], + array $hosts = [], + private ?string $namePrefix = null, + private array $disabledMiddlewares = [], + array|callable|string|null $corsMiddleware = null, + ) { + $this->assertMiddlewaresValid($middlewares); + $this->assertHostsValid($hosts); + $this->middlewares = $middlewares; + $this->hosts = $this->normalizeHosts($hosts); + $this->corsMiddleware = $corsMiddleware; + } /** * Create a new group instance. * * @param string|null $prefix URL prefix to prepend to all routes of the group. + * + * @deprecated Use `new Group()` instead. */ public static function create(?string $prefix = null): self { return new self($prefix); } + /** + * Sets the routes for this group. + * + * @param self|Route ...$routes Routes or sub-groups to include in this group. + * @return self New instance with the specified routes. + */ public function routes(self|Route ...$routes): self { $new = clone $this; @@ -109,6 +146,12 @@ public function prependMiddleware(array|callable|string ...$definition): self return $new; } + /** + * Sets the name prefix for all routes in this group. + * + * @param string $namePrefix Prefix to prepend to route names. + * @return self New instance with the specified name prefix. + */ public function namePrefix(string $namePrefix): self { $new = clone $this; @@ -116,22 +159,27 @@ public function namePrefix(string $namePrefix): self return $new; } + /** + * Adds a host requirement for all routes in this group. + * + * @param string $host Host name to match. + * @return self New instance with the specified host. + */ public function host(string $host): self { return $this->hosts($host); } + /** + * Sets host requirements for all routes in this group. + * + * @param string ...$hosts Host names to match. + * @return self New instance with the specified hosts. + */ public function hosts(string ...$hosts): self { $new = clone $this; - - foreach ($hosts as $host) { - $host = rtrim($host, '/'); - - if ($host !== '' && !in_array($host, $new->hosts, true)) { - $new->hosts[] = $host; - } - } + $new->hosts = $this->normalizeHosts($hosts); return $new; } @@ -155,22 +203,36 @@ public function disableMiddleware(mixed ...$definition): self } /** - * @psalm-template T as string + * Returns group data by key. * + * @param string $key Data key to retrieve (`prefix`, `namePrefix`, `host`, `hosts`, `corsMiddleware`, `routes`, + * `hasCorsMiddleware`, `enabledMiddlewares`). + * 1. `prefix` - URL prefix to prepend to all routes of the group. + * 2. `namePrefix` - Prefix for route names. + * 3. `host` - first host requirement. + * 4. `hosts` - all host requirements. + * 5. `corsMiddleware` - Middleware definition for CORS requests. + * 6. `routes` - routes or sub-groups to include in this group. + * 7. `hasCorsMiddleware` - whether the group has CORS middleware. + * 8. `enabledMiddlewares` - all enabled middlewares. + * + * @psalm-template T as string * @psalm-param T $key * + * @throws InvalidArgumentException If the key is unknown. + * @return mixed The requested data. * @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) - * ) - * ) - * ) - * ) - * ) + * 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 { @@ -187,6 +249,32 @@ public function getData(string $key): mixed }; } + private function assertHostsValid(array $hosts): void + { + foreach ($hosts as $host) { + if (!is_string($host)) { + throw new InvalidArgumentException('Invalid $hosts provided, list of string expected.'); + } + } + } + + /** + * @psalm-assert array $middlewareDefinitions + */ + private function assertMiddlewaresValid(array $middlewareDefinitions): void + { + /** @var mixed $middlewareDefinition */ + foreach ($middlewareDefinitions as $middlewareDefinition) { + if (is_string($middlewareDefinition) || is_callable($middlewareDefinition) || is_array($middlewareDefinition)) { + continue; + } + + throw new InvalidArgumentException( + 'Invalid $middlewareDefinitions provided, list of string or array or callable expected.', + ); + } + } + /** * @return array[]|callable[]|string[] * @psalm-return list @@ -202,4 +290,23 @@ private function getEnabledMiddlewares(): array return $this->enabledMiddlewaresCache; } + + /** + * @param string[] $hosts + * + * @return array + */ + private function normalizeHosts(array $hosts): array + { + $normalizedHosts = []; + foreach ($hosts as $host) { + $host = rtrim($host, '/'); + + if ($host !== '' && !in_array($host, $normalizedHosts, true)) { + $normalizedHosts[] = $host; + } + } + + return $normalizedHosts; + } } diff --git a/src/MatchingResult.php b/src/MatchingResult.php index 3d31f8b..5dfe6e1 100644 --- a/src/MatchingResult.php +++ b/src/MatchingResult.php @@ -7,6 +7,9 @@ use RuntimeException; use Yiisoft\Http\Method; +/** + * Result of matching a request against routes. + */ final class MatchingResult { /** @@ -51,6 +54,11 @@ public function isSuccess(): bool return $this->route !== null; } + /** + * Checks if the request method was not allowed for the matched route. + * + * @return bool True if the method was not allowed, false otherwise. + */ public function isMethodFailure(): bool { return $this->route === null && $this->methods !== Method::ALL; @@ -73,6 +81,9 @@ public function methods(): array return $this->methods; } + /** + * @psalm-assert-if-true !null $this->route + */ public function route(): Route { if ($this->route === null) { diff --git a/src/Middleware/Router.php b/src/Middleware/Router.php index e613d9c..6348ce7 100644 --- a/src/Middleware/Router.php +++ b/src/Middleware/Router.php @@ -17,10 +17,20 @@ use Yiisoft\Router\CurrentRoute; use Yiisoft\Router\UrlMatcherInterface; +/** + * Router middleware that matches the request to a route and dispatches to the matched route's middleware. + */ final class Router implements MiddlewareInterface { private readonly MiddlewareDispatcher $dispatcher; + /** + * @param UrlMatcherInterface $matcher URL matcher to find matching routes. + * @param ResponseFactoryInterface $responseFactory Factory for creating responses. + * @param MiddlewareFactory $middlewareFactory Factory for creating middleware instances. + * @param CurrentRoute $currentRoute Current route container. + * @param EventDispatcherInterface|null $eventDispatcher Optional event dispatcher. + */ public function __construct( private readonly UrlMatcherInterface $matcher, private readonly ResponseFactoryInterface $responseFactory, diff --git a/src/Provider/ArrayRoutesProvider.php b/src/Provider/ArrayRoutesProvider.php new file mode 100644 index 0000000..5fa5f94 --- /dev/null +++ b/src/Provider/ArrayRoutesProvider.php @@ -0,0 +1,24 @@ +routes; + } +} diff --git a/src/Provider/FileRoutesProvider.php b/src/Provider/FileRoutesProvider.php new file mode 100644 index 0000000..e5e9102 --- /dev/null +++ b/src/Provider/FileRoutesProvider.php @@ -0,0 +1,91 @@ +file)) { + throw new RuntimeException( + 'Failed to provide routes from "' . $this->file . '". File or directory not found.', + ); + } + /** @infection-ignore-all Equivalent: is_dir implies !is_file for valid paths after file_exists check */ + if (is_dir($this->file) && !is_file($this->file)) { + $directoryRoutes = []; + $files = new CallbackFilterIterator( + new FilesystemIterator( + $this->file, + /** @infection-ignore-all Bitwise flags; CallbackFilterIterator already filters by extension */ + FilesystemIterator::SKIP_DOTS, + ), + fn(SplFileInfo $fileInfo) => $fileInfo->isFile() && $fileInfo->getExtension() === 'php', + ); + $files = iterator_to_array($files, false); + /** @var SplFileInfo[] $files */ + usort($files, static fn(SplFileInfo $a, SplFileInfo $b) => $a->getFilename() <=> $b->getFilename()); + foreach ($files as $file) { + $realPath = $file->getRealPath(); + if ($realPath === false) { + continue; + } + /** @var mixed $fileRoutes */ + $fileRoutes = $scopeRequire($realPath, $this->scope); + if (is_array($fileRoutes) && $this->areRoutesValid($fileRoutes)) { + array_push( + $directoryRoutes, + ...$fileRoutes, + ); + } + } + return $directoryRoutes; + } + + /** @var mixed $routes */ + $routes = $scopeRequire($this->file, $this->scope); + if (!is_array($routes) || !$this->areRoutesValid($routes)) { + throw new RuntimeException( + 'Failed to provide routes from "' . $this->file . '". File must return an array of Route or Group instances.', + ); + } + return $routes; + } + + /** + * @psalm-assert-if-true Route[]|Group[] $routes + */ + private function areRoutesValid(array $routes): bool + { + foreach ($routes as $route) { + if (!$route instanceof Route && !$route instanceof Group) { + return false; + } + } + return true; + } +} diff --git a/src/Provider/RoutesProviderInterface.php b/src/Provider/RoutesProviderInterface.php new file mode 100644 index 0000000..230fa46 --- /dev/null +++ b/src/Provider/RoutesProviderInterface.php @@ -0,0 +1,21 @@ + */ private array $middlewares = []; - private array $disabledMiddlewares = []; - /** * @psalm-var list|null */ private ?array $enabledMiddlewaresCache = null; /** - * @var array + * @var string[] + */ + private array $methods; + /** + * @var string[] + */ + private array $hosts = []; + /** + * @var array */ private array $defaults = []; /** - * @param string[] $methods + * @param string|string[] $method HTTP method or list of methods. + * @param string $pattern URL pattern. + * @param array|callable|string|null $action Action handler. It is a primary middleware definition that + * should be invoked last for a matched route. + * @param string|null $name Route name. + * @param array[]|callable[]|string[] $middlewares Middleware definitions. + * @param array $defaults Parameter default values indexed by parameter names. + * @param string[] $hosts Hosts that the route should match. + * @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. + * + * @psalm-param list $middlewares */ - private function __construct( - private array $methods, + public function __construct( + string|array $method, private string $pattern, - ) {} + array|callable|string|null $action = null, + private ?string $name = null, + array $middlewares = [], + array $defaults = [], + array $hosts = [], + private bool $override = false, + private array $disabledMiddlewares = [], + ) { + $methods = (array) $method; + + if ($methods === []) { + throw new InvalidArgumentException('$method cannot be empty.'); + } + $this->assertListOfStrings($methods, 'methods'); + $this->assertMiddlewares($middlewares); + $this->assertListOfStrings($hosts, 'hosts'); + $this->middlewares = $middlewares; + $this->methods = $methods; + $this->hosts = $this->normalizeHosts($hosts); + $this->defaults = array_map(strval(...), $defaults); + if ($action !== null) { + $this->middlewares[] = $action; + $this->actionAdded = true; + } + } + /** + * Returns a string representation of the route. + * + * @return string String representation including name (if set), methods, hosts, and pattern. + */ public function __toString(): string { $result = $this->name === null @@ -63,7 +109,7 @@ public function __toString(): string $result .= implode(',', $this->methods) . ' '; } - if ($this->hosts) { + if ($this->hosts !== []) { $quoted = array_map(static fn($host) => preg_quote($host, '/'), $this->hosts); if (!preg_match('/' . implode('|', $quoted) . '/', $this->pattern)) { @@ -76,6 +122,11 @@ public function __toString(): string return $result; } + /** + * Returns debug information about the route. + * + * @return array Array with route properties for debugging. + */ public function __debugInfo() { return [ @@ -92,36 +143,92 @@ public function __debugInfo() ]; } + /** + * Creates a GET route. + * + * @param string $pattern URL pattern. + * @return self New route instance. + * + * @deprecated Use `new Route()` instead. + */ public static function get(string $pattern): self { return self::methods([Method::GET], $pattern); } + /** + * Creates a POST route. + * + * @param string $pattern URL pattern. + * @return self New route instance. + * + * @deprecated Use `new Route()` instead. + */ public static function post(string $pattern): self { return self::methods([Method::POST], $pattern); } + /** + * Creates a PUT route. + * + * @param string $pattern URL pattern. + * @return self New route instance. + * + * @deprecated Use `new Route()` instead. + */ public static function put(string $pattern): self { return self::methods([Method::PUT], $pattern); } + /** + * Creates a DELETE route. + * + * @param string $pattern URL pattern. + * @return self New route instance. + * + * @deprecated Use `new Route()` instead. + */ public static function delete(string $pattern): self { return self::methods([Method::DELETE], $pattern); } + /** + * Creates a PATCH route. + * + * @param string $pattern URL pattern. + * @return self New route instance. + * + * @deprecated Use `new Route()` instead. + */ public static function patch(string $pattern): self { return self::methods([Method::PATCH], $pattern); } + /** + * Creates a HEAD route. + * + * @param string $pattern URL pattern. + * @return self New route instance. + * + * @deprecated Use `new Route()` instead. + */ public static function head(string $pattern): self { return self::methods([Method::HEAD], $pattern); } + /** + * Creates an OPTIONS route. + * + * @param string $pattern URL pattern. + * @return self New route instance. + * + * @deprecated Use `new Route()` instead. + */ public static function options(string $pattern): self { return self::methods([Method::OPTIONS], $pattern); @@ -129,12 +236,20 @@ public static function options(string $pattern): self /** * @param string[] $methods + * + * @deprecated Use `new Route()` instead. */ public static function methods(array $methods, string $pattern): self { return new self($methods, $pattern); } + /** + * Sets the route name. + * + * @param string $name Route name. + * @return self New instance with the specified name. + */ public function name(string $name): self { $route = clone $this; @@ -142,6 +257,12 @@ public function name(string $name): self return $route; } + /** + * Sets the URL pattern. + * + * @param string $pattern URL pattern. + * @return self New instance with the specified pattern. + */ public function pattern(string $pattern): self { $new = clone $this; @@ -149,29 +270,33 @@ public function pattern(string $pattern): self return $new; } + /** + * Adds a host requirement. + * + * @param string $host Host name to match. + * @return self New instance with the specified host. + */ public function host(string $host): self { return $this->hosts($host); } + /** + * Sets host requirements. + * + * @param string ...$hosts Host names to match. + * @return self New instance with the specified hosts. + */ public function hosts(string ...$hosts): self { $route = clone $this; - $route->hosts = []; - - foreach ($hosts as $host) { - $host = rtrim($host, '/'); - - if ($host !== '' && !in_array($host, $route->hosts, true)) { - $route->hosts[] = $host; - } - } + $route->hosts = $this->normalizeHosts($hosts); return $route; } /** - * Marks route as override. When added it will replace existing route with the same name. + * Marks route as override. When added, it will replace existing route with the same name. */ public function override(): self { @@ -188,7 +313,7 @@ public function override(): self public function defaults(array $defaults): self { $route = clone $this; - $route->defaults = array_map(\strval(...), $defaults); + $route->defaults = array_map(strval(...), $defaults); return $route; } @@ -257,6 +382,7 @@ public function action(array|callable|string $middlewareDefinition): self $route = clone $this; $route->middlewares[] = $middlewareDefinition; $route->actionAdded = true; + $route->enabledMiddlewaresCache = null; return $route; } @@ -279,24 +405,39 @@ public function disableMiddleware(mixed ...$definition): self } /** + * Returns route data by key. + * + * @param string $key Data key to retrieve (`name`, `pattern`, `host`, `hosts`, `methods`, `defaults`, `override`, + * `hasMiddlewares`, `enabledMiddlewares`). + * 1. `name` - route name. + * 2. `pattern` - route pattern. + * 3. `host` - first host requirement. + * 4. `hosts` - all host requirements. + * 5. `methods` - all HTTP methods. + * 6. `defaults` - all default parameter values. + * 7. `override` - whether the route is marked as override. + * 8. `hasMiddlewares` - whether the route has any middlewares. + * 9. `enabledMiddlewares` - all enabled middlewares. + * * @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) - * ) - * ) - * ) - * ) - * ) - * ) + * 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) + * ) + * ) + * ) + * ) + * ) + * ) + * @throws InvalidArgumentException If the key is unknown. + * @return mixed The requested data. */ public function getData(string $key): mixed { @@ -315,6 +456,57 @@ public function getData(string $key): mixed }; } + /** + * @param string[] $hosts + * + * @return array + */ + private function normalizeHosts(array $hosts): array + { + $normalizedHosts = []; + foreach ($hosts as $host) { + $host = rtrim($host, '/'); + + if ($host !== '' && !in_array($host, $normalizedHosts, true)) { + $normalizedHosts[] = $host; + } + } + return $normalizedHosts; + } + + /** + * @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 array $middlewareDefinitions + */ + private function assertMiddlewares(array $middlewareDefinitions): void + { + /** @var mixed $middlewareDefinition */ + foreach ($middlewareDefinitions as $middlewareDefinition) { + if (is_string($middlewareDefinition)) { + continue; + } + + if (is_callable($middlewareDefinition) || is_array($middlewareDefinition)) { + continue; + } + + throw new InvalidArgumentException( + 'Invalid $middlewareDefinitions provided, list of string or array or callable expected.', + ); + } + } + /** * @return array[]|callable[]|string[] * @psalm-return list diff --git a/src/RouteCollection.php b/src/RouteCollection.php index ac456b0..2e7fbe6 100644 --- a/src/RouteCollection.php +++ b/src/RouteCollection.php @@ -13,7 +13,10 @@ use function is_array; /** + * Collection of routes that manages route registration and builds a route tree. + * * @psalm-type Items = array + * @psalm-suppress DeprecatedInterface. Will be removed in the next major release. */ final class RouteCollection implements RouteCollectionInterface { @@ -29,6 +32,9 @@ final class RouteCollection implements RouteCollectionInterface */ private array $routes = []; + /** + * @param RouteCollectorInterface $collector The route collector to use. + */ public function __construct(private readonly RouteCollectorInterface $collector) {} public function getRoutes(): array @@ -94,7 +100,7 @@ private function injectItem(Group|Route $route): void } /** - * Inject a Group instance into route and item arrays. + * Inject a Group instance into the route and the item arrays. * * @psalm-param Items $tree */ @@ -156,6 +162,7 @@ private function injectGroup(Group $group, array &$tree, string $prefix = '', st /** * @psalm-param Items $tree + * @psalm-suppress DeprecatedMethod. Will be removed in the next major release. */ private function processCors( Group $group, diff --git a/src/RouteCollectionInterface.php b/src/RouteCollectionInterface.php index fe2ff1f..107804d 100644 --- a/src/RouteCollectionInterface.php +++ b/src/RouteCollectionInterface.php @@ -4,17 +4,31 @@ namespace Yiisoft\Router; +/** + * Interface for route collections that provide access to registered routes. + */ interface RouteCollectionInterface { /** - * @return Route[] + * Returns all routes in the collection. + * + * @return Route[] Array of routes indexed by name. */ public function getRoutes(): array; + /** + * Returns a route by name. + * + * @param string $name Route name. + * @return Route The route instance. + * @throws RouteNotFoundException If the route is not found. + */ public function getRoute(string $name): Route; /** * Returns routes tree array. + * + * @return array Hierarchical array of routes and/or groups. */ public function getRouteTree(): array; } diff --git a/src/RouteCollector.php b/src/RouteCollector.php index 4111b59..39d5dd9 100644 --- a/src/RouteCollector.php +++ b/src/RouteCollector.php @@ -4,6 +4,14 @@ namespace Yiisoft\Router; +use Yiisoft\Router\Provider\RoutesProviderInterface; + +/** + * Simple route collector that manages routes, groups, and middleware definitions. + * + * @deprecated Will be removed in the next major release. + * @psalm-suppress DeprecatedInterface. Will be removed in the next major release. + */ final class RouteCollector implements RouteCollectorInterface { /** @@ -16,6 +24,19 @@ final class RouteCollector implements RouteCollectorInterface */ private array $middlewareDefinitions = []; + private bool $providersAreInjected = false; + + /** + * @param RoutesProviderInterface[] $providers + */ + public function __construct(private readonly array $providers = []) {} + + /** + * Adds routes or groups to the collector. + * + * @param Route|Group ...$routes Routes or groups to add. + * @return RouteCollectorInterface The collector instance. + */ public function addRoute(Route|Group ...$routes): RouteCollectorInterface { array_push( @@ -43,11 +64,32 @@ public function prependMiddleware(array|callable|string ...$middlewareDefinition return $this; } + /** + * Returns all registered items (routes and groups). + * + * @return Group[]|Route[] + */ public function getItems(): array { + if (!$this->providersAreInjected) { + $providerItems = []; + foreach ($this->providers as $provider) { + array_push( + $providerItems, + ...$provider->getRoutes(), + ); + } + array_push($this->items, ...$providerItems); + $this->providersAreInjected = true; + } return $this->items; } + /** + * Returns all middleware definitions. + * + * @return array[]|callable[]|string[] + */ public function getMiddlewareDefinitions(): array { return $this->middlewareDefinitions; diff --git a/src/RouteCollectorInterface.php b/src/RouteCollectorInterface.php index 38c5861..05ab352 100644 --- a/src/RouteCollectorInterface.php +++ b/src/RouteCollectorInterface.php @@ -4,6 +4,11 @@ namespace Yiisoft\Router; +/** + * Interface for route collectors that manage route registration. + * + * @deprecated Will be removed in the next major release. + */ interface RouteCollectorInterface { /** @@ -14,21 +19,31 @@ public function addRoute(Route|Group ...$routes): self; /** * Appends a handler middleware definition that should be invoked for a matched route. * First added handler will be executed first. + * + * @param array|callable|string ...$middlewareDefinition Middleware definitions. + * @return self New instance with the middleware appended. */ public function middleware(array|callable|string ...$middlewareDefinition): self; /** * Prepends a handler middleware definition that should be invoked for a matched route. * First added handler will be executed last. + * + * @param array|callable|string ...$middlewareDefinition Middleware definitions. + * @return self New instance with the middleware prepended. */ public function prependMiddleware(array|callable|string ...$middlewareDefinition): self; /** + * Returns all registered items (routes and groups). + * * @return Group[]|Route[] */ public function getItems(): array; /** + * Returns all middleware definitions. + * * @return array[]|callable[]|string[] */ public function getMiddlewareDefinitions(): array; diff --git a/src/UrlMatcherInterface.php b/src/UrlMatcherInterface.php index f802236..efef6c0 100644 --- a/src/UrlMatcherInterface.php +++ b/src/UrlMatcherInterface.php @@ -7,10 +7,15 @@ use Psr\Http\Message\ServerRequestInterface; /** - * `UrlMatcherInterface` allows finding a matching route given a PSR-8 server request. It is preferred to type-hint - * against it in case you need to match URL. + * `UrlMatcherInterface` allows finding a matching route given a server request. */ interface UrlMatcherInterface { + /** + * Matches a server request against registered routes. + * + * @param ServerRequestInterface $request The server request to match. + * @return MatchingResult The result of matching, containing route and parameters if successful. + */ public function match(ServerRequestInterface $request): MatchingResult; } diff --git a/tests/Attribute/DeleteTest.php b/tests/Attribute/DeleteTest.php new file mode 100644 index 0000000..5245613 --- /dev/null +++ b/tests/Attribute/DeleteTest.php @@ -0,0 +1,40 @@ +getRoute(); + + $this->assertSame('/post', $route->getData('pattern')); + $this->assertSame([Method::DELETE], $route->getData('methods')); + } + + public function testOverrideDefaultIsFalse(): void + { + $attribute = new Delete('/'); + + $route = $attribute->getRoute(); + + $this->assertFalse($route->getData('override')); + } + + public function testOverride(): void + { + $attribute = new Delete('/', override: true); + + $route = $attribute->getRoute(); + + $this->assertTrue($route->getData('override')); + } +} diff --git a/tests/Attribute/GetTest.php b/tests/Attribute/GetTest.php new file mode 100644 index 0000000..3441acf --- /dev/null +++ b/tests/Attribute/GetTest.php @@ -0,0 +1,40 @@ +getRoute(); + + $this->assertSame('/post', $route->getData('pattern')); + $this->assertSame([Method::GET], $route->getData('methods')); + } + + public function testOverrideDefaultIsFalse(): void + { + $attribute = new Get('/'); + + $route = $attribute->getRoute(); + + $this->assertFalse($route->getData('override')); + } + + public function testOverride(): void + { + $attribute = new Get('/', override: true); + + $route = $attribute->getRoute(); + + $this->assertTrue($route->getData('override')); + } +} diff --git a/tests/Attribute/HeadTest.php b/tests/Attribute/HeadTest.php new file mode 100644 index 0000000..956d6f3 --- /dev/null +++ b/tests/Attribute/HeadTest.php @@ -0,0 +1,40 @@ +getRoute(); + + $this->assertSame('/post', $route->getData('pattern')); + $this->assertSame([Method::HEAD], $route->getData('methods')); + } + + public function testOverrideDefaultIsFalse(): void + { + $attribute = new Head('/'); + + $route = $attribute->getRoute(); + + $this->assertFalse($route->getData('override')); + } + + public function testOverride(): void + { + $attribute = new Head('/', override: true); + + $route = $attribute->getRoute(); + + $this->assertTrue($route->getData('override')); + } +} diff --git a/tests/Attribute/OptionsTest.php b/tests/Attribute/OptionsTest.php new file mode 100644 index 0000000..4cef38c --- /dev/null +++ b/tests/Attribute/OptionsTest.php @@ -0,0 +1,40 @@ +getRoute(); + + $this->assertSame('/post', $route->getData('pattern')); + $this->assertSame([Method::OPTIONS], $route->getData('methods')); + } + + public function testOverrideDefaultIsFalse(): void + { + $attribute = new Options('/'); + + $route = $attribute->getRoute(); + + $this->assertFalse($route->getData('override')); + } + + public function testOverride(): void + { + $attribute = new Options('/', override: true); + + $route = $attribute->getRoute(); + + $this->assertTrue($route->getData('override')); + } +} diff --git a/tests/Attribute/PatchTest.php b/tests/Attribute/PatchTest.php new file mode 100644 index 0000000..130f7c0 --- /dev/null +++ b/tests/Attribute/PatchTest.php @@ -0,0 +1,40 @@ +getRoute(); + + $this->assertSame('/post', $route->getData('pattern')); + $this->assertSame([Method::PATCH], $route->getData('methods')); + } + + public function testOverrideDefaultIsFalse(): void + { + $attribute = new Patch('/'); + + $route = $attribute->getRoute(); + + $this->assertFalse($route->getData('override')); + } + + public function testOverride(): void + { + $attribute = new Patch('/', override: true); + + $route = $attribute->getRoute(); + + $this->assertTrue($route->getData('override')); + } +} diff --git a/tests/Attribute/PostTest.php b/tests/Attribute/PostTest.php new file mode 100644 index 0000000..cfefeda --- /dev/null +++ b/tests/Attribute/PostTest.php @@ -0,0 +1,40 @@ +getRoute(); + + $this->assertSame('/post', $route->getData('pattern')); + $this->assertSame([Method::POST], $route->getData('methods')); + } + + public function testOverrideDefaultIsFalse(): void + { + $attribute = new Post('/'); + + $route = $attribute->getRoute(); + + $this->assertFalse($route->getData('override')); + } + + public function testOverride(): void + { + $attribute = new Post('/', override: true); + + $route = $attribute->getRoute(); + + $this->assertTrue($route->getData('override')); + } +} diff --git a/tests/Attribute/PutTest.php b/tests/Attribute/PutTest.php new file mode 100644 index 0000000..d8ee153 --- /dev/null +++ b/tests/Attribute/PutTest.php @@ -0,0 +1,40 @@ +getRoute(); + + $this->assertSame('/post', $route->getData('pattern')); + $this->assertSame([Method::PUT], $route->getData('methods')); + } + + public function testOverrideDefaultIsFalse(): void + { + $attribute = new Put('/'); + + $route = $attribute->getRoute(); + + $this->assertFalse($route->getData('override')); + } + + public function testOverride(): void + { + $attribute = new Put('/', override: true); + + $route = $attribute->getRoute(); + + $this->assertTrue($route->getData('override')); + } +} diff --git a/tests/Attribute/RouteTest.php b/tests/Attribute/RouteTest.php new file mode 100644 index 0000000..b566674 --- /dev/null +++ b/tests/Attribute/RouteTest.php @@ -0,0 +1,40 @@ +getRoute(); + + $this->assertSame('/post', $route->getData('pattern')); + $this->assertSame([Method::GET, Method::HEAD], $route->getData('methods')); + } + + public function testOverrideDefaultIsFalse(): void + { + $attribute = new Route([Method::GET, Method::HEAD], '/'); + + $route = $attribute->getRoute(); + + $this->assertFalse($route->getData('override')); + } + + public function testOverride(): void + { + $attribute = new Route([Method::GET, Method::HEAD], '/', override: true); + + $route = $attribute->getRoute(); + + $this->assertTrue($route->getData('override')); + } +} diff --git a/tests/GroupTest.php b/tests/GroupTest.php index 016b02a..c33060c 100644 --- a/tests/GroupTest.php +++ b/tests/GroupTest.php @@ -23,6 +23,7 @@ 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 { @@ -41,6 +42,15 @@ public function testAddMiddleware(): void $this->assertSame($middleware2, $group->getData('enabledMiddlewares')[1]); } + public function testInvalidMiddlewares(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid $middlewareDefinitions provided, list of string or array or callable expected.'); + + $middleware = static fn() => new Response(); + $group = new Group('/api', [$middleware, new stdClass()]); + } + public function testDisabledMiddlewareDefinitions(): void { $group = Group::create() @@ -299,6 +309,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'); @@ -473,6 +491,32 @@ public function testImmutability(): void $this->assertNotSame($group, $group->disableMiddleware()); } + public function testBuiltMiddlewares(): void + { + $group = Group::create() + ->middleware(static fn() => new Response(200)) + ->prependMiddleware(TestMiddleware1::class); + + $builtMiddlewareDefinitions = $group->getData('enabledMiddlewares'); + + $this->assertSame($builtMiddlewareDefinitions, $group->getData('enabledMiddlewares')); + } + + public function testValidHostsInConstructor(): void + { + $group = new Group(hosts: ['example.com', 'test.com']); + + $this->assertSame(['example.com', 'test.com'], $group->getData('hosts')); + } + + public function testValidMiddlewaresInConstructor(): void + { + $callable = static fn(): ResponseInterface => new Response(); + $group = new Group(middlewares: ['SomeClass', $callable, ['Class', 'method']]); + + $this->assertCount(3, $group->getData('enabledMiddlewares')); + } + private function getRequestHandler(): RequestHandlerInterface { return new class implements RequestHandlerInterface { diff --git a/tests/Provider/ArrayRoutesProviderTest.php b/tests/Provider/ArrayRoutesProviderTest.php new file mode 100644 index 0000000..edbc7c1 --- /dev/null +++ b/tests/Provider/ArrayRoutesProviderTest.php @@ -0,0 +1,25 @@ +routes(Route::get('/blog')), + ]; + + $resource = new ArrayRoutesProvider($routes); + + $this->assertSame($routes, $resource->getRoutes()); + } +} diff --git a/tests/Provider/FileRoutesProviderTest.php b/tests/Provider/FileRoutesProviderTest.php new file mode 100644 index 0000000..cc2fc18 --- /dev/null +++ b/tests/Provider/FileRoutesProviderTest.php @@ -0,0 +1,79 @@ +routes = require $this->file; + } + + public function testGetRoutes(): void + { + $provider = new FileRoutesProvider($this->file); + + $this->assertEquals($this->routes, $provider->getRoutes()); + } + + public function testGetRoutesInDirectory(): void + { + $provider = new FileRoutesProvider(dirname($this->file)); + + $this->assertEquals($this->routes, $provider->getRoutes()); + } + + public function testGetRoutesWithNotExistFile(): void + { + $file = __DIR__ . '/wrong.php'; + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to provide routes from "' . $file . '". File or directory not found.'); + + $provider = new FileRoutesProvider($file); + $provider->getRoutes(); + } + + public function testGetRoutesWithInvalidRoutes(): void + { + $file = dirname(__DIR__) . '/Support/resources/foo.php'; + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to provide routes from "' . $file . '". File must return an array of Route or Group instances.'); + + $provider = new FileRoutesProvider($file); + $provider->getRoutes(); + } + + public function testGetRoutesWithScope(): void + { + $file = dirname(__DIR__) . '/Support/resources/scope/scope_routes.php'; + + $provider = new FileRoutesProvider($file, ['prefix' => '/api']); + $routes = $provider->getRoutes(); + + $this->assertCount(1, $routes); + $this->assertSame('/api/test', $routes[0]->getData('pattern')); + } + + public function testGetRoutesInDirectoryWithNonPhpFiles(): void + { + $dir = dirname(__DIR__) . '/Support/resources/mixed_dir'; + + $provider = new FileRoutesProvider($dir); + $routes = $provider->getRoutes(); + + $this->assertCount(1, $routes); + $this->assertSame('/mixed', $routes[0]->getData('pattern')); + } +} diff --git a/tests/RouteCollectionTest.php b/tests/RouteCollectionTest.php index 1715c21..5086968 100644 --- a/tests/RouteCollectionTest.php +++ b/tests/RouteCollectionTest.php @@ -80,8 +80,8 @@ public function testRouteOverride(): void { $listRoute = Route::get('/')->name('my-route'); $viewRoute = Route::get('/{id}') - ->name('my-route') - ->override(); + ->name('my-route') + ->override(); $group = Group::create()->routes($listRoute, $viewRoute); @@ -96,13 +96,13 @@ public function testRouteOverride(): void public function testRouteWithoutAction(): void { $group = Group::create() - ->middleware(fn() => 1) - ->routes( - Route::get('/test') - ->action(fn() => 2) - ->name('test'), - Route::get('/images/{sile}')->name('image'), - ); + ->middleware(fn() => 1) + ->routes( + Route::get('/test') + ->action(fn() => 2) + ->name('test'), + Route::get('/images/{sile}')->name('image'), + ); $collector = new RouteCollector(); $collector->addRoute($group); @@ -115,31 +115,31 @@ public function testRouteWithoutAction(): void public function testGetRouterTree(): void { $group1 = Group::create('/api') - ->routes( - Route::get('/test') - ->action(fn() => 2) - ->name('/test'), - Route::get('/images/{sile}')->name('/image'), - Group::create('/v1') - ->routes( - Route::get('/posts')->name('/posts'), - Route::get('/post/{sile}')->name('/post/view'), - ) - ->namePrefix('/v1'), - Group::create('/v1') - ->routes( - Route::get('/tags')->name('/tags'), - Route::get('/tag/{slug}')->name('/tag/view'), - ) - ->namePrefix('/v1'), - )->namePrefix('/api'); + ->routes( + Route::get('/test') + ->action(fn() => 2) + ->name('/test'), + Route::get('/images/{sile}')->name('/image'), + Group::create('/v1') + ->routes( + Route::get('/posts')->name('/posts'), + Route::get('/post/{sile}')->name('/post/view'), + ) + ->namePrefix('/v1'), + Group::create('/v1') + ->routes( + Route::get('/tags')->name('/tags'), + Route::get('/tag/{slug}')->name('/tag/view'), + ) + ->namePrefix('/v1'), + )->namePrefix('/api'); $group2 = Group::create('/api') - ->routes( - Route::get('/posts')->name('/posts'), - Route::get('/post/{sile}')->name('/post/view'), - ) - ->namePrefix('/api'); + ->routes( + Route::get('/posts')->name('/posts'), + Route::get('/post/{sile}')->name('/post/view'), + ) + ->namePrefix('/api'); $collector = new RouteCollector(); $collector->addRoute($group1, $group2); @@ -188,13 +188,13 @@ public function testGetRouteTreeReturnsRouteInstances(): void public function testGetRoutes(): void { $group = Group::create() - ->middleware(fn() => 1) - ->routes( - Route::get('/test') - ->action(fn() => 2) - ->name('test'), - Route::get('/images/{sile}')->name('image'), - ); + ->middleware(fn() => 1) + ->routes( + Route::get('/test') + ->action(fn() => 2) + ->name('test'), + Route::get('/images/{sile}')->name('image'), + ); $collector = new RouteCollector(); $collector->addRoute($group); @@ -208,19 +208,19 @@ public function testGetRoutes(): void public function testGroupHost(): void { $group = Group::create() - ->routes( - Group::create() - ->routes( - Route::get('/project/{name}')->name('project'), - ) - ->hosts('https://yiipowered.com/', 'https://yiiframework.ru/'), - Group::create() - ->routes( - Route::get('/user/{username}')->name('user'), - ), - Route::get('/images/{name}')->name('image'), - ) - ->host('https://yiiframework.com/'); + ->routes( + Group::create() + ->routes( + Route::get('/project/{name}')->name('project'), + ) + ->hosts('https://yiipowered.com/', 'https://yiiframework.ru/'), + Group::create() + ->routes( + Route::get('/user/{username}')->name('user'), + ), + Route::get('/images/{name}')->name('image'), + ) + ->host('https://yiiframework.com/'); $collector = new RouteCollector(); $collector->addRoute($group); @@ -238,20 +238,20 @@ public function testGroupHost(): void public function testGroupName(): void { $group = Group::create('api') - ->routes( - Group::create()->routes( - Group::create('/v1') - ->routes( - Route::get('/package/downloads/{package}')->name('/package/downloads'), - ) - ->namePrefix('/v1'), - Group::create()->routes( - Route::get('')->name('/index'), - ), - Route::get('/post/{slug}')->name('/post/view'), - Route::get('/user/{username}'), - ), - )->namePrefix('api'); + ->routes( + Group::create()->routes( + Group::create('/v1') + ->routes( + Route::get('/package/downloads/{package}')->name('/package/downloads'), + ) + ->namePrefix('/v1'), + Group::create()->routes( + Route::get('')->name('/index'), + ), + Route::get('/post/{slug}')->name('/post/view'), + Route::get('/user/{username}'), + ), + )->namePrefix('api'); $collector = new RouteCollector(); $collector->addRoute($group); @@ -277,11 +277,11 @@ public function testCollectorMiddlewareFullstackCalled(): void implode('', $request->getAttributes()), ); $listRoute = Route::get('/') - ->action($action) - ->name('list'); + ->action($action) + ->name('list'); $viewRoute = Route::get('/{id}') - ->action($action) - ->name('view'); + ->action($action) + ->name('view'); $group = Group::create(null)->routes($listRoute); @@ -322,9 +322,9 @@ public function testMiddlewaresOrder(bool $groupWrapped): void ->prependMiddleware(TestMiddleware1::class); $rawRoute = Route::get('/') - ->middleware(TestMiddleware3::class) - ->action([TestController::class, 'index']) - ->name('main'); + ->middleware(TestMiddleware3::class) + ->action([TestController::class, 'index']) + ->name('main'); $collector->addRoute( $groupWrapped ? Group::create()->routes($rawRoute) : $rawRoute, diff --git a/tests/RouteCollectorTest.php b/tests/RouteCollectorTest.php index e51dc34..6ec021c 100644 --- a/tests/RouteCollectorTest.php +++ b/tests/RouteCollectorTest.php @@ -7,6 +7,7 @@ use Nyholm\Psr7\Response; use PHPUnit\Framework\TestCase; use Yiisoft\Router\Group; +use Yiisoft\Router\Provider\ArrayRoutesProvider; use Yiisoft\Router\Route; use Yiisoft\Router\RouteCollector; @@ -59,6 +60,48 @@ public function testAddGroup(): void $this->assertContainsOnlyInstancesOf(Group::class, $collector->getItems()); } + public function testWithProvider(): void + { + $logoutRoute = Route::post('/logout'); + $listRoute = Route::get('/'); + $viewRoute = Route::get('/{id}'); + $postGroup = Group::create('/post') + ->routes( + $listRoute, + $viewRoute, + ); + + $rootGroup = Group::create() + ->routes( + Group::create('/api') + ->routes( + $logoutRoute, + $postGroup, + ), + ); + + $testGroup = Group::create() + ->routes( + Route::get('test/'), + ); + + $collector = new RouteCollector([new ArrayRoutesProvider([$rootGroup, $postGroup, $testGroup])]); + + $this->assertCount(3, $collector->getItems()); + $this->assertContainsOnlyInstancesOf(Group::class, $collector->getItems()); + } + + public function testEnsureProvidersCollectedOnce(): void + { + $collector = new RouteCollector([new ArrayRoutesProvider([Route::get('/')])]); + $collector->addRoute(Route::get('/test')); + $this->assertCount(2, $collector->getItems()); + $this->assertContainsOnlyInstancesOf(Route::class, $collector->getItems()); + + $collector->addRoute(Route::get('/test2')); + $this->assertCount(3, $collector->getItems()); + } + public function testAddMiddleware(): void { $collector = new RouteCollector(); diff --git a/tests/RouteTest.php b/tests/RouteTest.php index 44d1d74..79195c8 100644 --- a/tests/RouteTest.php +++ b/tests/RouteTest.php @@ -19,9 +19,10 @@ use Yiisoft\Router\Route; use Yiisoft\Router\Tests\Support\AssertTrait; use Yiisoft\Router\Tests\Support\Container; +use Yiisoft\Router\Tests\Support\CustomResponseMiddleware; +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; use InvalidArgumentException; @@ -29,6 +30,44 @@ final class RouteTest extends TestCase { use AssertTrait; + public function testSimpleInstance(): void + { + $route = new Route( + method: [Method::GET], + pattern: '/', + action: [TestController::class, 'index'], + middlewares: [TestMiddleware1::class, fn() => new Response(), TestMiddleware2::class], + override: true, + ); + + $this->assertInstanceOf(Route::class, $route); + $this->assertCount(4, $route->getData('enabledMiddlewares')); + $this->assertTrue($route->getData('override')); + } + + public function testEmptyMethods(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$method cannot be empty.'); + + new Route([], ''); + } + + public function testInvalidMethods(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid $methods provided, list of string expected.'); + + new Route([Method::GET, 1], ''); + } + + public function testStringMethodConvertedToArray(): void + { + $route = new Route(Method::POST, '/'); + + $this->assertSame([Method::POST], $route->getData('methods')); + } + public function testName(): void { $route = Route::get('/')->name('test.route'); @@ -133,12 +172,12 @@ public function testHost(): void public function testHosts(): void { $route = Route::get('/') - ->hosts( - 'https://yiiframework.com/', - 'yf.com', - 'yii.com', - 'yf.ru', - ); + ->hosts( + 'https://yiiframework.com/', + 'yf.com', + 'yii.com', + 'yf.ru', + ); $this->assertSame( [ @@ -154,12 +193,12 @@ public function testHosts(): void public function testMultipleHosts(): void { $route = Route::get('/') - ->host('https://yiiframework.com/'); + ->host('https://yiiframework.com/'); $multipleRoute = Route::get('/') - ->hosts( - 'https://yiiframework.com/', - 'https://yiiframework.ru/', - ); + ->hosts( + 'https://yiiframework.com/', + 'https://yiiframework.ru/', + ); $this->assertCount(1, $route->getData('hosts')); $this->assertCount(2, $multipleRoute->getData('hosts')); @@ -197,8 +236,8 @@ public static 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'); + ->name('test.route') + ->host('yiiframework.com'); $this->assertSame('[test.route] GET,POST ' . $expected, (string) $route); } @@ -255,6 +294,52 @@ public function testMiddlewareAfterAction(): void ); } + public function testDefaultsConvertedToStringInConstructor(): void + { + $route = new Route( + method: [Method::GET], + pattern: '/{language}', + defaults: ['language' => 'en', 'age' => 42], + ); + + $this->assertSame([ + 'language' => 'en', + 'age' => '42', + ], $route->getData('defaults')); + } + + public function testActionAddedViaConstructorMiddlewareInsertedBefore(): void + { + $route = new Route( + method: [Method::GET], + pattern: '/', + action: [TestController::class, 'index'], + ); + + $route = $route->middleware(TestMiddleware1::class); + + $this->assertSame( + [TestMiddleware1::class, [TestController::class, 'index']], + $route->getData('enabledMiddlewares'), + ); + } + + public function testInvalidMiddlewareAfterString(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid $middlewareDefinitions provided, list of string or array or callable expected.'); + + new Route([Method::GET], '/', middlewares: ['ValidString', (object) ['test' => 1]]); + } + + public function testInvalidMiddlewares(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid $middlewareDefinitions provided, list of string or array or callable expected.'); + + $route = new Route([Method::GET], '/', middlewares: [static fn() => new Response(), (object) ['test' => 1]]); + } + public function testDisabledMiddlewareDefinitions(): void { $request = new ServerRequest('GET', '/'); @@ -367,13 +452,13 @@ public function testGetEnabledMiddlewaresTwice(): void public function testMiddlewaresWithKeys(): void { $route = Route::get('/') - ->middleware(m3: TestMiddleware3::class) + ->middleware(m3: TestMiddleware3::class, custom: $custom = ['class' => CustomResponseMiddleware::class, '__construct()' => ['code' => 500]]) ->action([TestController::class, 'index']) ->prependMiddleware(m1: TestMiddleware1::class, m2: TestMiddleware2::class) ->disableMiddleware(m1: TestMiddleware1::class); $this->assertSame( - [TestMiddleware2::class, TestMiddleware3::class, [TestController::class, 'index']], + [TestMiddleware2::class, TestMiddleware3::class, $custom, [TestController::class, 'index']], $route->getData('enabledMiddlewares'), ); } @@ -381,14 +466,14 @@ public function testMiddlewaresWithKeys(): 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); + ->name('test') + ->host('example.com') + ->defaults(['age' => 42]) + ->override() + ->middleware(TestMiddleware1::class, TestMiddleware2::class) + ->disableMiddleware(TestMiddleware2::class) + ->action('go') + ->prependMiddleware(TestMiddleware3::class); $expected = <<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('/'); @@ -462,6 +555,17 @@ public function testImmutability(): void $this->assertNotSame($route, $route->disableMiddleware('')); } + public function testBuiltMiddlewares(): void + { + $route = Route::get('') + ->middleware(TestMiddleware1::class) + ->action(static fn() => new Response(200)); + + $builtMiddlewareDefinitions = $route->getData('enabledMiddlewares'); + + $this->assertSame($builtMiddlewareDefinitions, $route->getData('enabledMiddlewares')); + } + private function getRequestHandler(): RequestHandlerInterface { return new class implements RequestHandlerInterface { diff --git a/tests/Support/TestController.php b/tests/Support/TestController.php index 44c1789..baccc67 100644 --- a/tests/Support/TestController.php +++ b/tests/Support/TestController.php @@ -14,4 +14,9 @@ public function index(ServerRequestInterface $request): ResponseInterface { return new Response(200, [], $request->getAttribute('content', '')); } + + public function attributeAction(): Response + { + return new Response(200, [], 'test'); + } } diff --git a/tests/Support/resources/foo.php b/tests/Support/resources/foo.php new file mode 100644 index 0000000..4bf0067 --- /dev/null +++ b/tests/Support/resources/foo.php @@ -0,0 +1,8 @@ +routes(Route::get('/blog')), +]; diff --git a/tests/Support/resources/scope/scope_routes.php b/tests/Support/resources/scope/scope_routes.php new file mode 100644 index 0000000..9f4a4d0 --- /dev/null +++ b/tests/Support/resources/scope/scope_routes.php @@ -0,0 +1,11 @@ +