├── .phpstorm.meta.php ├── Group.php └── Route.php ├── .phpunit-watcher.yml ├── .styleci.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── UPGRADE.md ├── composer-require-checker.json ├── composer.json ├── config ├── di.php └── params.php ├── infection.json.dist ├── psalm.xml ├── rector.php └── src ├── CurrentRoute.php ├── Debug ├── DebugRoutesCommand.php ├── RouterCollector.php └── UrlMatcherInterfaceProxy.php ├── Group.php ├── HydratorAttribute ├── RouteArgument.php └── RouteArgumentResolver.php ├── Internal └── MiddlewareFilter.php ├── MatchingResult.php ├── Middleware └── Router.php ├── Route.php ├── RouteCollection.php ├── RouteCollectionInterface.php ├── RouteCollector.php ├── RouteCollectorInterface.php ├── RouteNotFoundException.php ├── UrlGeneratorInterface.php └── UrlMatcherInterface.php /.phpstorm.meta.php/Group.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | Yii 4 | 5 |

Yii Router

6 |
7 |

8 | 9 | [![Latest Stable Version](https://poser.pugx.org/yiisoft/router/v)](https://packagist.org/packages/yiisoft/router) 10 | [![Total Downloads](https://poser.pugx.org/yiisoft/router/downloads)](https://packagist.org/packages/yiisoft/router) 11 | [![Build status](https://github.com/yiisoft/router/actions/workflows/build.yml/badge.svg)](https://github.com/yiisoft/router/actions/workflows/build.yml) 12 | [![Code coverage](https://codecov.io/gh/yiisoft/router/graph/badge.svg?token=FxndVgUmF0)](https://codecov.io/gh/yiisoft/router) 13 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2Frouter%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/router/master) 14 | [![Static analysis](https://github.com/yiisoft/router/actions/workflows/static.yml/badge.svg?branch=master)](https://github.com/yiisoft/router/actions/workflows/static.yml?query=branch%3Amaster) 15 | [![type-coverage](https://shepherd.dev/github/yiisoft/router/coverage.svg)](https://shepherd.dev/github/yiisoft/router) 16 | 17 | The package provides [PSR-7](https://www.php-fig.org/psr/psr-7/) compatible request routing and 18 | a [PSR-15 middleware](https://www.php-fig.org/psr/psr-15/) ready to be used in an application. 19 | Instead of implementing routing from ground up, the package provides an interface for configuring routes and could be used 20 | with an adapter package. Currently, the only adapter available is [FastRoute](https://github.com/yiisoft/router-fastroute). 21 | 22 | ## Features 23 | 24 | - URL matching and URL generation supporting HTTP methods, hosts, and defaults. 25 | - Good IDE support for defining routes. 26 | - Route groups with infinite nesting. 27 | - Middleware support for both individual routes and groups. 28 | - Ready to use middleware for route matching. 29 | - Convenient `CurrentRoute` service that holds information about last matched route. 30 | - Out of the box CORS middleware support. 31 | 32 | ## Requirements 33 | 34 | - PHP 8.1 or higher. 35 | 36 | ## Installation 37 | 38 | The package could be installed with [Composer](https://getcomposer.org): 39 | 40 | ```shell 41 | composer require yiisoft/router 42 | ``` 43 | 44 | Additionally, you will need an adapter such as [FastRoute](https://github.com/yiisoft/router-fastroute). 45 | 46 | ## Defining routes and URL matching 47 | 48 | Common usage of the router looks like the following: 49 | 50 | ```php 51 | use Yiisoft\Router\CurrentRoute; 52 | use Yiisoft\Router\Group; 53 | use Yiisoft\Router\Route; 54 | use Yiisoft\Router\RouteCollection; 55 | use Yiisoft\Router\RouteCollectorInterface; 56 | use Yiisoft\Router\UrlMatcherInterface; 57 | use Yiisoft\Router\Fastroute\UrlMatcher; 58 | use Psr\Http\Message\ServerRequestInterface; 59 | use Psr\Http\Server\RequestHandlerInterface; 60 | 61 | // Define routes 62 | $routes = [ 63 | Route::get('/') 64 | ->action(static function (ServerRequestInterface $request, RequestHandlerInterface $next) use ($responseFactory) { 65 | $response = $responseFactory->createResponse(); 66 | $response 67 | ->getBody() 68 | ->write('You are at homepage.'); 69 | return $response; 70 | }), 71 | Route::get('/test/{id:\w+}') 72 | ->action(static function (CurrentRoute $currentRoute, RequestHandlerInterface $next) use ($responseFactory) { 73 | $id = $currentRoute->getArgument('id'); 74 | 75 | $response = $responseFactory->createResponse(); 76 | $response 77 | ->getBody() 78 | ->write('You are at test with argument ' . $id); 79 | return $response; 80 | }) 81 | ]; 82 | 83 | // Add routes defined to route collector 84 | $collector = $container->get(RouteCollectorInterface::class); 85 | $collector->addRoute(Group::create(null)->routes($routes)); 86 | 87 | // Initialize URL matcher 88 | /** @var UrlMatcherInterface $urlMatcher */ 89 | $urlMatcher = new UrlMatcher(new RouteCollection($collector)); 90 | 91 | // Do the match against $request which is PSR-7 ServerRequestInterface. 92 | $result = $urlMatcher->match($request); 93 | 94 | if (!$result->isSuccess()) { 95 | // 404 96 | } 97 | 98 | // $result->arguments() contains arguments from the match. 99 | 100 | // Run middleware assigned to a route found. 101 | $response = $result->process($request, $notFoundHandler); 102 | ``` 103 | 104 | > Note: Despite `UrlGeneratorInterface` and `UrlMatcherInterface` being common for all adapters available, certain 105 | > features and, especially, pattern syntax may differ. To check usage and configuration details, please refer 106 | > to specific adapter documentation. All examples in this document are for 107 | > [FastRoute adapter](https://github.com/yiisoft/router-fastroute). 108 | 109 | ### Middleware usage 110 | 111 | In order to simplify usage in PSR-middleware based application, there is a ready to use middleware provided: 112 | 113 | ```php 114 | $router = $container->get(Yiisoft\Router\UrlMatcherInterface::class); 115 | $responseFactory = $container->get(\Psr\Http\Message\ResponseFactoryInterface::class); 116 | 117 | $routerMiddleware = new Yiisoft\Router\Middleware\Router($router, $responseFactory, $container); 118 | 119 | // Add middleware to your middleware handler of choice. 120 | ``` 121 | 122 | In case of a route match router middleware executes handler middleware attached to the route. If there is no match, next 123 | application middleware processes the request. 124 | 125 | ### Routes 126 | 127 | Route could match for one or more HTTP methods: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`. There are 128 | corresponding static methods for creating a route for a certain method. If a route is to handle multiple methods at once, 129 | it could be created using `methods()`. 130 | 131 | ```php 132 | use Yiisoft\Router\Route; 133 | 134 | Route::delete('/post/{id}') 135 | ->name('post-delete') 136 | ->action([PostController::class, 'actionDelete']); 137 | 138 | Route::methods([Method::GET, Method::POST], '/page/add') 139 | ->name('page-add') 140 | ->action([PageController::class, 'actionAdd']); 141 | ``` 142 | 143 | If you want to generate a URL based on route and its parameters, give it a name with `name()`. Check "Creating URLs" 144 | for details. 145 | 146 | `action()` in the above is a primary middleware definition that is invoked last when matching result `process()` 147 | method is called. How middleware are executed and what middleware formats are accepted is defined by middleware 148 | dispatcher used. See [readme of yiisoft/middleware-dispatcher](https://github.com/yiisoft/middleware-dispatcher) 149 | for middleware examples. 150 | 151 | If a route should be applied only to a certain host, it could be defined like the following: 152 | 153 | ```php 154 | use Yiisoft\Router\Route; 155 | 156 | Route::get('/special') 157 | ->name('special') 158 | ->action(SpecialAction::class) 159 | ->host('https://www.yiiframework.com'); 160 | ``` 161 | 162 | Defaults for parameters could be provided via `defaults()` method: 163 | 164 | ```php 165 | use Yiisoft\Router\Route; 166 | 167 | Route::get('/api[/v{version}]') 168 | ->name('api-index') 169 | ->action(ApiAction::class) 170 | ->defaults(['version' => 1]); 171 | ``` 172 | 173 | In the above we specify that if "version" is not obtained from URL during matching then it will be `1`. 174 | 175 | Besides action, additional middleware to execute before the action itself could be defined: 176 | 177 | ```php 178 | use Yiisoft\Router\Route; 179 | 180 | Route::methods([Method::GET, Method::POST], '/page/add') 181 | ->middleware(Authentication::class) 182 | ->middleware(ExtraHeaders::class) 183 | ->action([PostController::class, 'add']) 184 | ->name('blog/add'); 185 | ``` 186 | 187 | It is typically used for a certain actions that could be reused for multiple routes such as authentication. 188 | 189 | If there is a need to either add middleware to be executed first or remove existing middleware from a route, 190 | `prependMiddleware()` and `disableMiddleware()` could be used. 191 | 192 | If you combine routes from multiple sources and want last route to have priority over existing ones, mark it as "override": 193 | 194 | ```php 195 | use Yiisoft\Router\Route; 196 | 197 | Route::get('/special') 198 | ->name('special') 199 | ->action(SpecialAction::class) 200 | ->override(); 201 | ``` 202 | 203 | ### Route groups 204 | 205 | Routes could be grouped. That is useful for API endpoints and similar cases: 206 | 207 | ```php 208 | use \Yiisoft\Router\Route; 209 | use \Yiisoft\Router\Group; 210 | use \Yiisoft\Router\RouteCollectorInterface; 211 | 212 | // for obtaining router see adapter package of choice readme 213 | $collector = $container->get(RouteCollectorInterface::class); 214 | 215 | $collector->addRoute( 216 | Group::create('/api') 217 | ->middleware(ApiAuthentication::class) 218 | ->host('https://example.com') 219 | ->routes([ 220 | Route::get('/comments'), 221 | Group::create('/posts')->routes([ 222 | Route::get('/list'), 223 | ]), 224 | ]) 225 | ); 226 | ``` 227 | 228 | A group could have a prefix, such as `/api` in the above. The prefix is applied for each group's route both when 229 | matching and when generating URLs. 230 | 231 | Similar to individual routes, a group could have a set of middleware managed using `middleware()`, `prependMiddleware()`, 232 | and `disableMiddleware()`. These middleware are executed prior to matched route's own middleware and action. 233 | 234 | If host is specified, all routes in the group would match only if the host match. 235 | 236 | ### Automatic OPTIONS response and CORS 237 | 238 | By default, router responds automatically to OPTIONS requests based on the routes defined: 239 | 240 | ``` 241 | HTTP/1.1 204 No Content 242 | Allow: GET, HEAD 243 | ``` 244 | 245 | Generally that is fine unless you need [CORS headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS). In this 246 | case, you can add a middleware for handling it such as [tuupola/cors-middleware](https://github.com/tuupola/cors-middleware): 247 | 248 | ```php 249 | use Yiisoft\Router\Group; 250 | use \Tuupola\Middleware\CorsMiddleware; 251 | 252 | return [ 253 | Group::create('/api') 254 | ->withCors(CorsMiddleware::class) 255 | ->routes([ 256 | // ... 257 | ] 258 | ); 259 | ]; 260 | ``` 261 | 262 | ## Creating URLs 263 | 264 | URLs could be created using `UrlGeneratorInterface::generate()`. Let's assume a route is defined like the following: 265 | 266 | ```php 267 | use Psr\Http\Message\ServerRequestInterface; 268 | use Psr\Http\Server\RequestHandlerInterface; 269 | use Psr\Http\Message\ResponseFactoryInterface; 270 | use Yiisoft\Yii\Http\Handler\NotFoundHandler; 271 | use Yiisoft\Yii\Runner\Http\SapiEmitter; 272 | use Yiisoft\Yii\Runner\Http\ServerRequestFactory; 273 | use Yiisoft\Router\CurrentRoute; 274 | use Yiisoft\Router\Route; 275 | use Yiisoft\Router\RouteCollection; 276 | use Yiisoft\Router\RouteCollectorInterface; 277 | use Yiisoft\Router\Fastroute\UrlMatcher; 278 | 279 | 280 | $request = $container 281 | ->get(ServerRequestFactory::class) 282 | ->createFromGlobals(); 283 | $responseFactory = $container->get(ResponseFactoryInterface::class); 284 | $notFoundHandler = new NotFoundHandler($responseFactory); 285 | $collector = $container->get(RouteCollectorInterface::class); 286 | $collector->addRoute( 287 | Route::get('/test/{id:\w+}') 288 | ->action(static function (CurrentRoute $currentRoute, RequestHandlerInterface $next) use ($responseFactory) { 289 | $id = $currentRoute->getArgument('id'); 290 | $response = $responseFactory->createResponse(); 291 | $response 292 | ->getBody() 293 | ->write('You are at test with argument ' . $id); 294 | 295 | return $response; 296 | }) 297 | ->name('test') 298 | ); 299 | $router = new UrlMatcher(new RouteCollection($collector)); 300 | $route = $router->match($request); 301 | $response = $route->process($request, $notFoundHandler); 302 | $emitter = new SapiEmitter(); 303 | $emitter->emit($response, $request->getMethod() === Method::HEAD); 304 | ``` 305 | 306 | Then that is how URL could be obtained for it: 307 | 308 | ```php 309 | use Yiisoft\Router\UrlGeneratorInterface; 310 | 311 | function getUrl(UrlGeneratorInterface $urlGenerator, $parameters = []) 312 | { 313 | return $urlGenerator->generate('test', $parameters); 314 | } 315 | ``` 316 | 317 | Absolute URL cold be generated using `UrlGeneratorInterface::generateAbsolute()`: 318 | 319 | ```php 320 | use Yiisoft\Router\UrlGeneratorInterface; 321 | 322 | function getUrl(UrlGeneratorInterface $urlGenerator, $parameters = []) 323 | { 324 | return $urlGenerator->generateAbsolute('test', $parameters); 325 | } 326 | ``` 327 | 328 | Also, there is a handy `UrlGeneratorInterface::generateFromCurrent()` method. It allows generating a URL that is 329 | a modified version of the current URL: 330 | 331 | ```php 332 | use Yiisoft\Router\UrlGeneratorInterface; 333 | 334 | function getUrl(UrlGeneratorInterface $urlGenerator, $id) 335 | { 336 | return $urlGenerator->generateFromCurrent(['id' => 42]); 337 | } 338 | ``` 339 | 340 | In the above, ID will be replaced with 42 and the rest of the parameters will stay the same. That is useful for 341 | modifying URLs for filtering and/or sorting. 342 | 343 | ## Obtaining current route information 344 | 345 | For such a route: 346 | 347 | ```php 348 | use \Yiisoft\Router\Route; 349 | 350 | $routes = [ 351 | Route::post('/post/{id:\d+}') 352 | ->action([PostController::class, 'actionEdit']), 353 | ]; 354 | ``` 355 | 356 | The information could be obtained as follows: 357 | 358 | ```php 359 | use Psr\Http\Message\ResponseInterface 360 | use Psr\Http\Message\UriInterface; 361 | use Yiisoft\Router\CurrentRoute; 362 | use Yiisoft\Router\Route; 363 | 364 | final class PostController 365 | { 366 | public function actionEdit(CurrentRoute $currentRoute): ResponseInterface 367 | { 368 | $postId = $currentRoute->getArgument('id'); 369 | if ($postId === null) { 370 | throw new \InvalidArgumentException('Post ID is not specified.'); 371 | } 372 | 373 | // ... 374 | 375 | } 376 | } 377 | ``` 378 | 379 | In addition to commonly used `getArgument()` method, the following methods are available: 380 | 381 | - `getArguments()` - To obtain all arguments at once. 382 | - `getName()` - To get route name. 383 | - `getHost()` - To get route host. 384 | - `getPattern()` - To get route pattern. 385 | - `getMethods()` - To get route methods. 386 | - `getUri()` - To get current URI. 387 | 388 | ## Documentation 389 | 390 | - [Internals](docs/internals.md) 391 | 392 | If you need help or have a question, the [Yii Forum](https://forum.yiiframework.com/c/yii-3-0/63) is a good place for that. 393 | You may also check out other [Yii Community Resources](https://www.yiiframework.com/community). 394 | 395 | ## License 396 | 397 | The Yii Router is free software. It is released under the terms of the BSD License. 398 | Please see [`LICENSE`](./LICENSE.md) for more information. 399 | 400 | Maintained by [Yii Software](https://www.yiiframework.com/). 401 | 402 | ## Support the project 403 | 404 | [![Open Collective](https://img.shields.io/badge/Open%20Collective-sponsor-7eadf1?logo=open%20collective&logoColor=7eadf1&labelColor=555555)](https://opencollective.com/yiisoft) 405 | 406 | ## Follow updates 407 | 408 | [![Official website](https://img.shields.io/badge/Powered_by-Yii_Framework-green.svg?style=flat)](https://www.yiiframework.com/) 409 | [![Twitter](https://img.shields.io/badge/twitter-follow-1DA1F2?logo=twitter&logoColor=1DA1F2&labelColor=555555?style=flat)](https://twitter.com/yiiframework) 410 | [![Telegram](https://img.shields.io/badge/telegram-join-1DA1F2?style=flat&logo=telegram)](https://t.me/yii3en) 411 | [![Facebook](https://img.shields.io/badge/facebook-join-1DA1F2?style=flat&logo=facebook&logoColor=ffffff)](https://www.facebook.com/groups/yiitalk) 412 | [![Slack](https://img.shields.io/badge/slack-join-1DA1F2?style=flat&logo=slack)](https://yiiframework.com/go/slack) 413 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | # Upgrading Instructions for Yii Router 2 | 3 | This file contains the upgrade notes for the Yii Router. 4 | These notes highlight changes that could break your application when you upgrade it from one major version to another. 5 | 6 | ## 4.0.0 7 | 8 | ### `Route`, `Group` and `MatchingResult` changes 9 | 10 | In this release classes `Route`, `Group` and `MatchingResult` are made dispatcher-independent. Now you don't can inject 11 | own middleware dispatcher to group or to route. 12 | 13 | The following backward incompatible changes have been made. 14 | 15 | #### `Route` 16 | 17 | - Removed parameter `$dispatcher` from `Route` creating methods: `get()`, `post()`, `put()`, `delete()`, `patch()`, 18 | `head()`, `options()`, `methods()`. 19 | - Removed methods `Route::injectDispatcher()` and `Route::withDispatcher()`. 20 | - `Route::getData()` changes: 21 | - removed elements `dispatcherWithMiddlewares` and `hasDispatcher`; 22 | - added element `enabledMiddlewares`. 23 | 24 | #### `Group` 25 | 26 | - Removed parameter `$dispatcher` from `Group::create()` method. 27 | - Removed method `Group::withDispatcher()`. 28 | - `Group::getData()` changes: 29 | - removed element `hasDispatcher`; 30 | - key `items` renamed to `routes`; 31 | - key `middlewareDefinitions` renamed to `enabledMiddlewares`. 32 | 33 | #### `MatchingResult` 34 | 35 | - Removed `MatchingResult` implementation from `MiddlewareInterface`, so it is no longer middleware. 36 | - Removed method `MatchingResult::process()`. 37 | 38 | ### `UrlGeneratorInterface` changes 39 | 40 | Contract is changed: 41 | 42 | - on URL generation all unused arguments must be moved to query parameters, if query parameter with 43 | such name doesn't exist; 44 | - added `$hash` parameter to `generate()`, `generateAbsolute()` and `generateFromCurrent()` methods. 45 | 46 | You should change your interface implementations accordingly. 47 | -------------------------------------------------------------------------------- /composer-require-checker.json: -------------------------------------------------------------------------------- 1 | { 2 | "symbol-whitelist" : [ 3 | "Psr\\Container\\ContainerInterface", 4 | "Yiisoft\\Hydrator\\Attribute\\Parameter\\ParameterAttributeInterface", 5 | "Yiisoft\\Hydrator\\Attribute\\Parameter\\ParameterAttributeResolverInterface", 6 | "Yiisoft\\Hydrator\\AttributeHandling\\ParameterAttributeResolveContext", 7 | "Yiisoft\\Hydrator\\AttributeHandling\\Exception\\UnexpectedAttributeException", 8 | "Yiisoft\\Hydrator\\Result", 9 | "Yiisoft\\VarDumper\\VarDumper", 10 | "Yiisoft\\Yii\\Debug\\Debugger", 11 | "Yiisoft\\Yii\\Debug\\Collector\\CollectorTrait", 12 | "Yiisoft\\Yii\\Debug\\Collector\\SummaryCollectorInterface", 13 | "Symfony\\Component\\Console\\Command\\Command", 14 | "Symfony\\Component\\Console\\Helper\\Table", 15 | "Symfony\\Component\\Console\\Input\\InputArgument", 16 | "Symfony\\Component\\Console\\Input\\InputInterface", 17 | "Symfony\\Component\\Console\\Output\\OutputInterface", 18 | "Symfony\\Component\\Console\\Style\\SymfonyStyle", 19 | "Symfony\\Component\\Console\\Attribute\\AsCommand" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yiisoft/router", 3 | "type": "library", 4 | "description": "Yii router", 5 | "keywords": [ 6 | "web", 7 | "router", 8 | "middleware" 9 | ], 10 | "homepage": "https://www.yiiframework.com/", 11 | "license": "BSD-3-Clause", 12 | "support": { 13 | "issues": "https://github.com/yiisoft/router/issues?state=open", 14 | "source": "https://github.com/yiisoft/router", 15 | "forum": "https://www.yiiframework.com/forum/", 16 | "wiki": "https://www.yiiframework.com/wiki/", 17 | "irc": "ircs://irc.libera.chat:6697/yii", 18 | "chat": "https://t.me/yii3en" 19 | }, 20 | "funding": [ 21 | { 22 | "type": "opencollective", 23 | "url": "https://opencollective.com/yiisoft" 24 | }, 25 | { 26 | "type": "github", 27 | "url": "https://github.com/sponsors/yiisoft" 28 | } 29 | ], 30 | "require": { 31 | "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", 32 | "psr/event-dispatcher": "^1.0", 33 | "psr/http-factory": "^1.0", 34 | "psr/http-message": "^1.0 || ^2.0", 35 | "psr/http-server-handler": "^1.0", 36 | "psr/http-server-middleware": "^1.0", 37 | "yiisoft/http": "^1.2", 38 | "yiisoft/middleware-dispatcher": "^5.0", 39 | "yiisoft/router-implementation": "1.0.0" 40 | }, 41 | "require-dev": { 42 | "maglnet/composer-require-checker": "^4.7.1", 43 | "nyholm/psr7": "^1.8.2", 44 | "phpunit/phpunit": "^10.5.45", 45 | "psr/container": "^1.1 || ^2.0.2", 46 | "rector/rector": "^2.0.9", 47 | "roave/infection-static-analysis-plugin": "^1.35", 48 | "spatie/phpunit-watcher": "^1.24", 49 | "vimeo/psalm": "^5.26.1 || ^6.8.6", 50 | "yiisoft/di": "^1.3", 51 | "yiisoft/dummy-provider": "^1.0.1", 52 | "yiisoft/hydrator": "^1.5", 53 | "yiisoft/test-support": "^3.0.1", 54 | "yiisoft/yii-debug": "dev-master" 55 | }, 56 | "autoload": { 57 | "psr-4": { 58 | "Yiisoft\\Router\\": "src" 59 | } 60 | }, 61 | "autoload-dev": { 62 | "psr-4": { 63 | "Yiisoft\\Router\\Tests\\": "tests" 64 | } 65 | }, 66 | "suggest": { 67 | "yiisoft/router-fastroute": "Router implementation based on nikic/FastRoute", 68 | "yiisoft/hydrator": "Needed to use `RouteArgument` attribute" 69 | }, 70 | "extra": { 71 | "config-plugin-options": { 72 | "source-directory": "config" 73 | }, 74 | "config-plugin": { 75 | "di": "di.php", 76 | "params": "params.php" 77 | } 78 | }, 79 | "config": { 80 | "sort-packages": true, 81 | "bump-after-update": "dev", 82 | "allow-plugins": { 83 | "infection/extension-installer": true, 84 | "composer/package-versions-deprecated": true, 85 | "yiisoft/config": false 86 | } 87 | }, 88 | "scripts": { 89 | "composer-require-checker": "composer-require-checker", 90 | "rector": "rector", 91 | "test": "phpunit --testdox --no-interaction", 92 | "test-watch": "phpunit-watcher watch" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /config/di.php: -------------------------------------------------------------------------------- 1 | RouteCollector::class, 11 | CurrentRoute::class => [ 12 | 'reset' => function () { 13 | $this->route = null; 14 | $this->uri = null; 15 | $this->arguments = []; 16 | }, 17 | ], 18 | ]; 19 | -------------------------------------------------------------------------------- /config/params.php: -------------------------------------------------------------------------------- 1 | [ 12 | 'collectors.web' => [ 13 | RouterCollector::class, 14 | ], 15 | 'trackedServices' => [ 16 | UrlMatcherInterface::class => [UrlMatcherInterfaceProxy::class, RouterCollector::class], 17 | ], 18 | ], 19 | 'yiisoft/yii-console' => [ 20 | 'commands' => [ 21 | DebugRoutesCommand::COMMAND_NAME => DebugRoutesCommand::class, 22 | ], 23 | ], 24 | ]; 25 | -------------------------------------------------------------------------------- /infection.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "directories": [ 4 | "src" 5 | ] 6 | }, 7 | "logs": { 8 | "text": "php:\/\/stderr", 9 | "stryker": { 10 | "report": "master" 11 | } 12 | }, 13 | "mutators": { 14 | "@default": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withPaths([ 12 | __DIR__ . '/src', 13 | __DIR__ . '/tests', 14 | ]) 15 | ->withPhpSets(php81: true) 16 | ->withRules([ 17 | InlineConstructorDefaultToPropertyRector::class, 18 | ]) 19 | ->withSkip([ 20 | ClosureToArrowFunctionRector::class, 21 | NullToStrictStringFuncCallArgRector::class, 22 | ]); 23 | -------------------------------------------------------------------------------- /src/CurrentRoute.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | private array $arguments = []; 31 | 32 | /** 33 | * Returns the current route name. 34 | * 35 | * @return string|null The current route name. 36 | */ 37 | public function getName(): ?string 38 | { 39 | return $this->route?->getData('name'); 40 | } 41 | 42 | /** 43 | * Returns the current route host. 44 | * 45 | * @return string|null The current route host. 46 | */ 47 | public function getHost(): ?string 48 | { 49 | return $this->route?->getData('host'); 50 | } 51 | 52 | /** 53 | * Returns the current route pattern. 54 | * 55 | * @return string|null The current route pattern. 56 | */ 57 | public function getPattern(): ?string 58 | { 59 | return $this->route?->getData('pattern'); 60 | } 61 | 62 | /** 63 | * Returns the current route methods. 64 | * 65 | * @return string[]|null The current route methods. 66 | */ 67 | public function getMethods(): ?array 68 | { 69 | return $this->route?->getData('methods'); 70 | } 71 | 72 | /** 73 | * Returns the current URI. 74 | * 75 | * @return UriInterface|null The current URI. 76 | */ 77 | public function getUri(): ?UriInterface 78 | { 79 | return $this->uri; 80 | } 81 | 82 | /** 83 | * @param array $arguments 84 | * 85 | * @internal 86 | */ 87 | public function setRouteWithArguments(Route $route, array $arguments): void 88 | { 89 | if ($this->route === null && $this->arguments === []) { 90 | $this->route = $route; 91 | $this->arguments = $arguments; 92 | return; 93 | } 94 | throw new LogicException('Can not set route/arguments since it was already set.'); 95 | } 96 | 97 | /** 98 | * @internal 99 | */ 100 | public function setUri(UriInterface $uri): void 101 | { 102 | if ($this->uri === null) { 103 | $this->uri = $uri; 104 | return; 105 | } 106 | throw new LogicException('Can not set URI since it was already set.'); 107 | } 108 | 109 | /** 110 | * @return array Arguments. 111 | */ 112 | public function getArguments(): array 113 | { 114 | return $this->arguments; 115 | } 116 | 117 | public function getArgument(string $name, ?string $default = null): ?string 118 | { 119 | return $this->arguments[$name] ?? $default; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Debug/DebugRoutesCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('route', InputArgument::IS_ARRAY, 'Route name'); 39 | } 40 | 41 | /** 42 | * @psalm-suppress MixedArgument, MixedAssignment, MixedArrayAccess 43 | */ 44 | protected function execute(InputInterface $input, OutputInterface $output): int 45 | { 46 | $this->debugger->stop(); 47 | 48 | $io = new SymfonyStyle($input, $output); 49 | 50 | if ($input->hasArgument('route') && !empty($input->getArgument('route'))) { 51 | /** 52 | * @var string[] $routes 53 | */ 54 | $routes = (array) $input->getArgument('route'); 55 | foreach ($routes as $route) { 56 | $route = $this->routeCollection->getRoute($route); 57 | $data = $route->__debugInfo(); 58 | $action = ''; 59 | $middlewares = []; 60 | if (!empty($data['enabledMiddlewares'])) { 61 | $middlewareDefinitions = $data['enabledMiddlewares']; 62 | $action = array_pop($middlewareDefinitions); 63 | $middlewares = $middlewareDefinitions; 64 | } 65 | 66 | $io->title($data['name']); 67 | $definitionList = [ 68 | ['Methods' => $this->export($data['methods'])], 69 | ['Name' => $data['name']], 70 | ['Pattern' => $data['pattern']], 71 | ]; 72 | if (!empty($action)) { 73 | $definitionList[] = ['Action' => $this->export($action)]; 74 | } 75 | if (!empty($data['defaults'])) { 76 | $definitionList[] = ['Defaults' => $this->export($data['defaults'])]; 77 | } 78 | if (!empty($data['hosts'])) { 79 | $definitionList[] = ['Hosts' => $this->export($data['hosts'])]; 80 | } 81 | 82 | $io->definitionList(...$definitionList); 83 | if (!empty($middlewares)) { 84 | $io->section('Middlewares'); 85 | foreach ($middlewares as $middleware) { 86 | $io->writeln(is_string($middleware) ? $middleware : $this->export($middleware)); 87 | } 88 | } 89 | } 90 | 91 | return 0; 92 | } 93 | 94 | $table = new Table($output); 95 | $rows = []; 96 | foreach ($this->routeCollection->getRoutes() as $route) { 97 | $data = $route->__debugInfo(); 98 | $action = ''; 99 | if (!empty($data['enabledMiddlewares'])) { 100 | $middlewareDefinitions = $data['enabledMiddlewares']; 101 | $action = array_pop($middlewareDefinitions); 102 | } 103 | $rows[] = [ 104 | 'methods' => $this->export($data['methods']), 105 | 'name' => $data['name'], 106 | 'hosts' => $this->export($data['hosts']), 107 | 'pattern' => $data['pattern'], 108 | 'defaults' => $this->export($data['defaults']), 109 | 'action' => $this->export($action), 110 | ]; 111 | } 112 | $table->addRows($rows); 113 | $table->render(); 114 | 115 | return 0; 116 | } 117 | 118 | protected function export(mixed $value): string 119 | { 120 | if (is_array($value) 121 | && count($value) === 2 122 | && isset($value[0], $value[1]) 123 | && is_string($value[0]) 124 | && is_string($value[1]) 125 | ) { 126 | return $value[0] . '::' . $value[1]; 127 | } 128 | if (is_array($value) && $this->isArrayList($value)) { 129 | return implode(', ', array_map(fn ($item) => $this->export($item), $value)); 130 | } 131 | if (is_string($value)) { 132 | return $value; 133 | } 134 | return VarDumper::create($value)->asString(); 135 | } 136 | 137 | /** 138 | * Polyfill for is_array_list() function. 139 | * It is available since PHP 8.1. 140 | */ 141 | private function isArrayList(array $array): bool 142 | { 143 | if ([] === $array) { 144 | return true; 145 | } 146 | 147 | $nextKey = -1; 148 | 149 | foreach ($array as $k => $_) { 150 | if ($k !== ++$nextKey) { 151 | return false; 152 | } 153 | } 154 | 155 | return true; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/Debug/RouterCollector.php: -------------------------------------------------------------------------------- 1 | isActive()) { 31 | return; 32 | } 33 | $this->matchTime = $matchTime; 34 | } 35 | 36 | public function getCollected(): array 37 | { 38 | if (!$this->isActive()) { 39 | return []; 40 | } 41 | /** 42 | * @var RouteCollectionInterface|null $routeCollection 43 | */ 44 | $routeCollection = $this->container->has(RouteCollectionInterface::class) 45 | ? $this->container->get(RouteCollectionInterface::class) 46 | : null; 47 | 48 | $currentRoute = $this->getCurrentRoute(); 49 | $route = $this->getRouteByCurrentRoute($currentRoute); 50 | [$middlewares, $action] = $this->getMiddlewaresAndAction($route); 51 | 52 | $result = [ 53 | 'currentRoute' => null, 54 | ]; 55 | if ($currentRoute !== null && $route !== null) { 56 | $result['currentRoute'] = [ 57 | 'matchTime' => $this->matchTime, 58 | 'name' => $route->getData('name'), 59 | 'pattern' => $route->getData('pattern'), 60 | 'arguments' => $currentRoute->getArguments(), 61 | 'host' => $route->getData('host'), 62 | 'uri' => (string) $currentRoute->getUri(), 63 | 'action' => $action, 64 | 'middlewares' => $middlewares, 65 | ]; 66 | } 67 | if ($routeCollection !== null) { 68 | $result['routesTree'] = $routeCollection->getRouteTree(); 69 | $result['routes'] = $routeCollection->getRoutes(); 70 | $result['routeTime'] = $this->matchTime; 71 | } 72 | return $result; 73 | } 74 | 75 | public function getSummary(): array 76 | { 77 | if (!$this->isActive()) { 78 | return []; 79 | } 80 | $currentRoute = $this->getCurrentRoute(); 81 | $route = $this->getRouteByCurrentRoute($currentRoute); 82 | 83 | if ($currentRoute === null || $route === null) { 84 | return [ 85 | 'router' => null, 86 | ]; 87 | } 88 | 89 | [$middlewares, $action] = $this->getMiddlewaresAndAction($route); 90 | 91 | return [ 92 | 'router' => [ 93 | 'matchTime' => $this->matchTime, 94 | 'name' => $route->getData('name'), 95 | 'pattern' => $route->getData('pattern'), 96 | 'arguments' => $currentRoute->getArguments(), 97 | 'host' => $route->getData('host'), 98 | 'uri' => (string) $currentRoute->getUri(), 99 | 'action' => $action, 100 | 'middlewares' => $middlewares, 101 | ], 102 | ]; 103 | } 104 | 105 | /** 106 | * @psalm-suppress MixedReturnStatement, MixedInferredReturnType 107 | */ 108 | private function getCurrentRoute(): ?CurrentRoute 109 | { 110 | return $this->container->has(CurrentRoute::class) ? $this->container->get(CurrentRoute::class) : null; 111 | } 112 | 113 | /** 114 | * @psalm-suppress MixedReturnStatement, MixedInferredReturnType 115 | */ 116 | private function getRouteByCurrentRoute(?CurrentRoute $currentRoute): ?Route 117 | { 118 | if ($currentRoute === null) { 119 | return null; 120 | } 121 | $reflection = new ReflectionObject($currentRoute); 122 | 123 | $reflectionProperty = $reflection->getProperty('route'); 124 | $reflectionProperty->setAccessible(true); 125 | 126 | /** 127 | * @var Route $value 128 | */ 129 | return $reflectionProperty->getValue($currentRoute); 130 | } 131 | 132 | private function getMiddlewaresAndAction(?Route $route): array 133 | { 134 | if ($route === null) { 135 | return [[], null]; 136 | } 137 | 138 | $middlewares = $route->getData('enabledMiddlewares'); 139 | $action = array_pop($middlewares); 140 | 141 | return [$middlewares, $action]; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Debug/UrlMatcherInterfaceProxy.php: -------------------------------------------------------------------------------- 1 | urlMatcher->match($request); 26 | $this->routerCollector->collect(microtime(true) - $timeStart); 27 | 28 | return $result; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Group.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | private array $middlewares = []; 25 | 26 | /** 27 | * @var string[] 28 | */ 29 | private array $hosts = []; 30 | private ?string $namePrefix = null; 31 | private bool $routesAdded = false; 32 | private bool $middlewareAdded = false; 33 | private array $disabledMiddlewares = []; 34 | 35 | /** 36 | * @psalm-var list|null 37 | */ 38 | private ?array $enabledMiddlewaresCache = null; 39 | 40 | /** 41 | * @var array|callable|string|null Middleware definition for CORS requests. 42 | */ 43 | private $corsMiddleware = null; 44 | 45 | private function __construct( 46 | private ?string $prefix = null 47 | ) { 48 | } 49 | 50 | /** 51 | * Create a new group instance. 52 | * 53 | * @param string|null $prefix URL prefix to prepend to all routes of the group. 54 | */ 55 | public static function create(?string $prefix = null): self 56 | { 57 | return new self($prefix); 58 | } 59 | 60 | public function routes(self|Route ...$routes): self 61 | { 62 | if ($this->middlewareAdded) { 63 | throw new RuntimeException('routes() can not be used after prependMiddleware().'); 64 | } 65 | 66 | $new = clone $this; 67 | $new->routes = $routes; 68 | $new->routesAdded = true; 69 | 70 | return $new; 71 | } 72 | 73 | /** 74 | * Adds a middleware definition that handles CORS requests. 75 | * If set, routes for {@see Method::OPTIONS} request will be added automatically. 76 | * 77 | * @param array|callable|string|null $middlewareDefinition Middleware definition for CORS requests. 78 | */ 79 | public function withCors(array|callable|string|null $middlewareDefinition): self 80 | { 81 | $group = clone $this; 82 | $group->corsMiddleware = $middlewareDefinition; 83 | 84 | return $group; 85 | } 86 | 87 | /** 88 | * Appends a handler middleware definition that should be invoked for a matched route. 89 | * First added handler will be executed first. 90 | */ 91 | public function middleware(array|callable|string ...$definition): self 92 | { 93 | if ($this->routesAdded) { 94 | throw new RuntimeException('middleware() can not be used after routes().'); 95 | } 96 | 97 | $new = clone $this; 98 | array_push( 99 | $new->middlewares, 100 | ...array_values($definition) 101 | ); 102 | 103 | $new->enabledMiddlewaresCache = null; 104 | 105 | return $new; 106 | } 107 | 108 | /** 109 | * Prepends a handler middleware definition that should be invoked for a matched route. 110 | * First added handler will be executed last. 111 | */ 112 | public function prependMiddleware(array|callable|string ...$definition): self 113 | { 114 | $new = clone $this; 115 | array_unshift( 116 | $new->middlewares, 117 | ...array_values($definition) 118 | ); 119 | 120 | $new->middlewareAdded = true; 121 | $new->enabledMiddlewaresCache = null; 122 | 123 | return $new; 124 | } 125 | 126 | public function namePrefix(string $namePrefix): self 127 | { 128 | $new = clone $this; 129 | $new->namePrefix = $namePrefix; 130 | return $new; 131 | } 132 | 133 | public function host(string $host): self 134 | { 135 | return $this->hosts($host); 136 | } 137 | 138 | public function hosts(string ...$hosts): self 139 | { 140 | $new = clone $this; 141 | 142 | foreach ($hosts as $host) { 143 | $host = rtrim($host, '/'); 144 | 145 | if ($host !== '' && !in_array($host, $new->hosts, true)) { 146 | $new->hosts[] = $host; 147 | } 148 | } 149 | 150 | return $new; 151 | } 152 | 153 | /** 154 | * Excludes middleware from being invoked when action is handled. 155 | * It is useful to avoid invoking one of the parent group middleware for 156 | * a certain route. 157 | */ 158 | public function disableMiddleware(mixed ...$definition): self 159 | { 160 | $new = clone $this; 161 | array_push( 162 | $new->disabledMiddlewares, 163 | ...array_values($definition), 164 | ); 165 | 166 | $new->enabledMiddlewaresCache = null; 167 | 168 | return $new; 169 | } 170 | 171 | /** 172 | * @psalm-template T as string 173 | * 174 | * @psalm-param T $key 175 | * 176 | * @psalm-return ( 177 | * T is ('prefix'|'namePrefix'|'host') ? string|null : 178 | * (T is 'routes' ? Group[]|Route[] : 179 | * (T is 'hosts' ? array : 180 | * (T is ('hasCorsMiddleware') ? bool : 181 | * (T is 'enabledMiddlewares' ? list : 182 | * (T is 'corsMiddleware' ? array|callable|string|null : mixed) 183 | * ) 184 | * ) 185 | * ) 186 | * ) 187 | * ) 188 | */ 189 | public function getData(string $key): mixed 190 | { 191 | return match ($key) { 192 | 'prefix' => $this->prefix, 193 | 'namePrefix' => $this->namePrefix, 194 | 'host' => $this->hosts[0] ?? null, 195 | 'hosts' => $this->hosts, 196 | 'corsMiddleware' => $this->corsMiddleware, 197 | 'routes' => $this->routes, 198 | 'hasCorsMiddleware' => $this->corsMiddleware !== null, 199 | 'enabledMiddlewares' => $this->getEnabledMiddlewares(), 200 | default => throw new InvalidArgumentException('Unknown data key: ' . $key), 201 | }; 202 | } 203 | 204 | /** 205 | * @return array[]|callable[]|string[] 206 | * @psalm-return list 207 | */ 208 | private function getEnabledMiddlewares(): array 209 | { 210 | if ($this->enabledMiddlewaresCache !== null) { 211 | return $this->enabledMiddlewaresCache; 212 | } 213 | 214 | $this->enabledMiddlewaresCache = MiddlewareFilter::filter($this->middlewares, $this->disabledMiddlewares); 215 | 216 | return $this->enabledMiddlewaresCache; 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/HydratorAttribute/RouteArgument.php: -------------------------------------------------------------------------------- 1 | name; 21 | } 22 | 23 | public function getResolver(): string 24 | { 25 | return RouteArgumentResolver::class; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/HydratorAttribute/RouteArgumentResolver.php: -------------------------------------------------------------------------------- 1 | currentRoute->getArguments(); 32 | 33 | $name = $attribute->getName() ?? $context->getParameter()->getName(); 34 | 35 | if (array_key_exists($name, $arguments)) { 36 | return Result::success($arguments[$name]); 37 | } 38 | 39 | return Result::fail(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Internal/MiddlewareFilter.php: -------------------------------------------------------------------------------- 1 | $middlewares 17 | * @psalm-return list 18 | */ 19 | public static function filter(array $middlewares, array $disabledMiddlewares): array 20 | { 21 | $result = []; 22 | 23 | foreach ($middlewares as $middleware) { 24 | if (in_array($middleware, $disabledMiddlewares, true)) { 25 | continue; 26 | } 27 | 28 | $result[] = $middleware; 29 | } 30 | 31 | return $result; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/MatchingResult.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | private array $arguments = []; 17 | 18 | /** 19 | * @var string[] 20 | */ 21 | private array $methods = []; 22 | 23 | private function __construct(private readonly ?Route $route) 24 | { 25 | } 26 | 27 | /** 28 | * @param string[] $arguments 29 | * @psalm-param array $arguments 30 | */ 31 | public static function fromSuccess(Route $route, array $arguments): self 32 | { 33 | $new = new self($route); 34 | $new->arguments = $arguments; 35 | return $new; 36 | } 37 | 38 | /** 39 | * @param string[] $methods 40 | */ 41 | public static function fromFailure(array $methods): self 42 | { 43 | $new = new self(null); 44 | $new->methods = $methods; 45 | return $new; 46 | } 47 | 48 | /** 49 | * @psalm-assert-if-true !null $this->route 50 | */ 51 | public function isSuccess(): bool 52 | { 53 | return $this->route !== null; 54 | } 55 | 56 | public function isMethodFailure(): bool 57 | { 58 | return $this->route === null && $this->methods !== Method::ALL; 59 | } 60 | 61 | /** 62 | * @return string[] 63 | * @psalm-return array 64 | */ 65 | public function arguments(): array 66 | { 67 | return $this->arguments; 68 | } 69 | 70 | /** 71 | * @return string[] 72 | */ 73 | public function methods(): array 74 | { 75 | return $this->methods; 76 | } 77 | 78 | public function route(): Route 79 | { 80 | if ($this->route === null) { 81 | throw new RuntimeException('There is no route in the matching result.'); 82 | } 83 | 84 | return $this->route; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Middleware/Router.php: -------------------------------------------------------------------------------- 1 | dispatcher = new MiddlewareDispatcher($middlewareFactory, $eventDispatcher); 32 | } 33 | 34 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 35 | { 36 | $result = $this->matcher->match($request); 37 | 38 | $this->currentRoute->setUri($request->getUri()); 39 | 40 | if ($result->isMethodFailure()) { 41 | if ($request->getMethod() === Method::OPTIONS) { 42 | return $this->responseFactory 43 | ->createResponse(Status::NO_CONTENT) 44 | ->withHeader('Allow', implode(', ', $result->methods())); 45 | } 46 | return $this->responseFactory 47 | ->createResponse(Status::METHOD_NOT_ALLOWED) 48 | ->withHeader('Allow', implode(', ', $result->methods())); 49 | } 50 | 51 | if (!$result->isSuccess()) { 52 | return $handler->handle($request); 53 | } 54 | 55 | $this->currentRoute->setRouteWithArguments($result->route(), $result->arguments()); 56 | 57 | return $this->dispatcher 58 | ->withMiddlewares($result->route()->getData('enabledMiddlewares')) 59 | ->dispatch($request, $handler); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Route.php: -------------------------------------------------------------------------------- 1 | 32 | */ 33 | private array $middlewares = []; 34 | 35 | private array $disabledMiddlewares = []; 36 | 37 | /** 38 | * @psalm-var list|null 39 | */ 40 | private ?array $enabledMiddlewaresCache = null; 41 | 42 | /** 43 | * @var array 44 | */ 45 | private array $defaults = []; 46 | 47 | /** 48 | * @param string[] $methods 49 | */ 50 | private function __construct( 51 | private array $methods, 52 | private string $pattern, 53 | ) { 54 | } 55 | 56 | public static function get(string $pattern): self 57 | { 58 | return self::methods([Method::GET], $pattern); 59 | } 60 | 61 | public static function post(string $pattern): self 62 | { 63 | return self::methods([Method::POST], $pattern); 64 | } 65 | 66 | public static function put(string $pattern): self 67 | { 68 | return self::methods([Method::PUT], $pattern); 69 | } 70 | 71 | public static function delete(string $pattern): self 72 | { 73 | return self::methods([Method::DELETE], $pattern); 74 | } 75 | 76 | public static function patch(string $pattern): self 77 | { 78 | return self::methods([Method::PATCH], $pattern); 79 | } 80 | 81 | public static function head(string $pattern): self 82 | { 83 | return self::methods([Method::HEAD], $pattern); 84 | } 85 | 86 | public static function options(string $pattern): self 87 | { 88 | return self::methods([Method::OPTIONS], $pattern); 89 | } 90 | 91 | /** 92 | * @param string[] $methods 93 | */ 94 | public static function methods(array $methods, string $pattern): self 95 | { 96 | return new self($methods, $pattern); 97 | } 98 | 99 | public function name(string $name): self 100 | { 101 | $route = clone $this; 102 | $route->name = $name; 103 | return $route; 104 | } 105 | 106 | public function pattern(string $pattern): self 107 | { 108 | $new = clone $this; 109 | $new->pattern = $pattern; 110 | return $new; 111 | } 112 | 113 | public function host(string $host): self 114 | { 115 | return $this->hosts($host); 116 | } 117 | 118 | public function hosts(string ...$hosts): self 119 | { 120 | $route = clone $this; 121 | $route->hosts = []; 122 | 123 | foreach ($hosts as $host) { 124 | $host = rtrim($host, '/'); 125 | 126 | if ($host !== '' && !in_array($host, $route->hosts, true)) { 127 | $route->hosts[] = $host; 128 | } 129 | } 130 | 131 | return $route; 132 | } 133 | 134 | /** 135 | * Marks route as override. When added it will replace existing route with the same name. 136 | */ 137 | public function override(): self 138 | { 139 | $route = clone $this; 140 | $route->override = true; 141 | return $route; 142 | } 143 | 144 | /** 145 | * Parameter default values indexed by parameter names. 146 | * 147 | * @psalm-param array $defaults 148 | */ 149 | public function defaults(array $defaults): self 150 | { 151 | $route = clone $this; 152 | $route->defaults = array_map('\strval', $defaults); 153 | return $route; 154 | } 155 | 156 | /** 157 | * Appends a handler middleware definition that should be invoked for a matched route. 158 | * First added handler will be executed first. 159 | */ 160 | public function middleware(array|callable|string ...$definition): self 161 | { 162 | if ($this->actionAdded) { 163 | throw new RuntimeException('middleware() can not be used after action().'); 164 | } 165 | 166 | $route = clone $this; 167 | array_push( 168 | $route->middlewares, 169 | ...array_values($definition) 170 | ); 171 | 172 | $route->enabledMiddlewaresCache = null; 173 | 174 | return $route; 175 | } 176 | 177 | /** 178 | * Prepends a handler middleware definition that should be invoked for a matched route. Last added handlers will be 179 | * executed first. 180 | * 181 | * Passed definitions will be added to beginning. For example: 182 | * 183 | * ```php 184 | * // Resulting middleware stack order: Middleware1, Middleware2, Middleware3 185 | * Route::get('/') 186 | * ->middleware(Middleware3::class) 187 | * ->prependMiddleware(Middleware1::class, Middleware2::class) 188 | * ``` 189 | */ 190 | public function prependMiddleware(array|callable|string ...$definition): self 191 | { 192 | if (!$this->actionAdded) { 193 | throw new RuntimeException('prependMiddleware() can not be used before action().'); 194 | } 195 | 196 | $route = clone $this; 197 | array_unshift( 198 | $route->middlewares, 199 | ...array_values($definition) 200 | ); 201 | 202 | $route->enabledMiddlewaresCache = null; 203 | 204 | return $route; 205 | } 206 | 207 | /** 208 | * Appends action handler. It is a primary middleware definition that should be invoked last for a matched route. 209 | */ 210 | public function action(array|callable|string $middlewareDefinition): self 211 | { 212 | $route = clone $this; 213 | $route->middlewares[] = $middlewareDefinition; 214 | $route->actionAdded = true; 215 | return $route; 216 | } 217 | 218 | /** 219 | * Excludes middleware from being invoked when action is handled. 220 | * It is useful to avoid invoking one of the parent group middleware for 221 | * a certain route. 222 | */ 223 | public function disableMiddleware(mixed ...$definition): self 224 | { 225 | $route = clone $this; 226 | array_push( 227 | $route->disabledMiddlewares, 228 | ...array_values($definition) 229 | ); 230 | 231 | $route->enabledMiddlewaresCache = null; 232 | 233 | return $route; 234 | } 235 | 236 | /** 237 | * @psalm-template T as string 238 | * 239 | * @psalm-param T $key 240 | * 241 | * @psalm-return ( 242 | * T is ('name'|'pattern') ? string : 243 | * (T is 'host' ? string|null : 244 | * (T is 'hosts' ? array : 245 | * (T is 'methods' ? array : 246 | * (T is 'defaults' ? array : 247 | * (T is ('override'|'hasMiddlewares') ? bool : 248 | * (T is 'enabledMiddlewares' ? array : mixed) 249 | * ) 250 | * ) 251 | * ) 252 | * ) 253 | * ) 254 | * ) 255 | */ 256 | public function getData(string $key): mixed 257 | { 258 | return match ($key) { 259 | 'name' => $this->name ?? 260 | (implode(', ', $this->methods) . ' ' . implode('|', $this->hosts) . $this->pattern), 261 | 'pattern' => $this->pattern, 262 | 'host' => $this->hosts[0] ?? null, 263 | 'hosts' => $this->hosts, 264 | 'methods' => $this->methods, 265 | 'defaults' => $this->defaults, 266 | 'override' => $this->override, 267 | 'hasMiddlewares' => $this->middlewares !== [], 268 | 'enabledMiddlewares' => $this->getEnabledMiddlewares(), 269 | default => throw new InvalidArgumentException('Unknown data key: ' . $key), 270 | }; 271 | } 272 | 273 | public function __toString(): string 274 | { 275 | $result = $this->name === null 276 | ? '' 277 | : '[' . $this->name . '] '; 278 | 279 | if ($this->methods !== []) { 280 | $result .= implode(',', $this->methods) . ' '; 281 | } 282 | 283 | if ($this->hosts) { 284 | $quoted = array_map(static fn ($host) => preg_quote($host, '/'), $this->hosts); 285 | 286 | if (!preg_match('/' . implode('|', $quoted) . '/', $this->pattern)) { 287 | $result .= implode('|', $this->hosts); 288 | } 289 | } 290 | 291 | $result .= $this->pattern; 292 | 293 | return $result; 294 | } 295 | 296 | public function __debugInfo() 297 | { 298 | return [ 299 | 'name' => $this->name, 300 | 'methods' => $this->methods, 301 | 'pattern' => $this->pattern, 302 | 'hosts' => $this->hosts, 303 | 'defaults' => $this->defaults, 304 | 'override' => $this->override, 305 | 'actionAdded' => $this->actionAdded, 306 | 'middlewares' => $this->middlewares, 307 | 'disabledMiddlewares' => $this->disabledMiddlewares, 308 | 'enabledMiddlewares' => $this->getEnabledMiddlewares(), 309 | ]; 310 | } 311 | 312 | /** 313 | * @return array[]|callable[]|string[] 314 | * @psalm-return list 315 | */ 316 | private function getEnabledMiddlewares(): array 317 | { 318 | if ($this->enabledMiddlewaresCache !== null) { 319 | return $this->enabledMiddlewaresCache; 320 | } 321 | 322 | $this->enabledMiddlewaresCache = MiddlewareFilter::filter($this->middlewares, $this->disabledMiddlewares); 323 | 324 | return $this->enabledMiddlewaresCache; 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /src/RouteCollection.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class RouteCollection implements RouteCollectionInterface 19 | { 20 | /** 21 | * @psalm-var Items 22 | */ 23 | private array $items = []; 24 | 25 | /** 26 | * All attached routes as Route instances. 27 | * 28 | * @var Route[] 29 | */ 30 | private array $routes = []; 31 | 32 | public function __construct(private readonly RouteCollectorInterface $collector) 33 | { 34 | } 35 | 36 | public function getRoutes(): array 37 | { 38 | $this->ensureItemsInjected(); 39 | return $this->routes; 40 | } 41 | 42 | public function getRoute(string $name): Route 43 | { 44 | $this->ensureItemsInjected(); 45 | if (!array_key_exists($name, $this->routes)) { 46 | throw new RouteNotFoundException($name); 47 | } 48 | 49 | return $this->routes[$name]; 50 | } 51 | 52 | public function getRouteTree(bool $routeAsString = true): array 53 | { 54 | $this->ensureItemsInjected(); 55 | return $this->buildTree($this->items, $routeAsString); 56 | } 57 | 58 | private function ensureItemsInjected(): void 59 | { 60 | if ($this->items === []) { 61 | $this->injectItems($this->collector->getItems()); 62 | } 63 | } 64 | 65 | /** 66 | * Build routes array. 67 | * 68 | * @param Group[]|Route[] $items 69 | */ 70 | private function injectItems(array $items): void 71 | { 72 | foreach ($items as $item) { 73 | if (!$this->isStaticRoute($item)) { 74 | $item = $item->prependMiddleware(...$this->collector->getMiddlewareDefinitions()); 75 | } 76 | $this->injectItem($item); 77 | } 78 | } 79 | 80 | /** 81 | * Add an item into routes array. 82 | */ 83 | private function injectItem(Group|Route $route): void 84 | { 85 | if ($route instanceof Group) { 86 | $this->injectGroup($route, $this->items); 87 | return; 88 | } 89 | 90 | $routeName = $route->getData('name'); 91 | $this->items[] = $routeName; 92 | if (isset($this->routes[$routeName]) && !$route->getData('override')) { 93 | throw new InvalidArgumentException("A route with name '$routeName' already exists."); 94 | } 95 | $this->routes[$routeName] = $route; 96 | } 97 | 98 | /** 99 | * Inject a Group instance into route and item arrays. 100 | * 101 | * @psalm-param Items $tree 102 | */ 103 | private function injectGroup(Group $group, array &$tree, string $prefix = '', string $namePrefix = ''): void 104 | { 105 | $prefix .= (string) $group->getData('prefix'); 106 | $namePrefix .= (string) $group->getData('namePrefix'); 107 | $items = $group->getData('routes'); 108 | $pattern = null; 109 | $hosts = []; 110 | foreach ($items as $item) { 111 | if (!$this->isStaticRoute($item)) { 112 | $item = $item->prependMiddleware(...$group->getData('enabledMiddlewares')); 113 | } 114 | 115 | if (!empty($group->getData('hosts')) && empty($item->getData('hosts'))) { 116 | $item = $item->hosts(...$group->getData('hosts')); 117 | } 118 | 119 | if ($item instanceof Group) { 120 | if ($group->getData('hasCorsMiddleware')) { 121 | $item = $item->withCors($group->getData('corsMiddleware')); 122 | } 123 | if (empty($item->getData('prefix'))) { 124 | $this->injectGroup($item, $tree, $prefix, $namePrefix); 125 | continue; 126 | } 127 | /** @psalm-suppress PossiblyNullArrayOffset Checked group prefix on not empty above. */ 128 | if (!isset($tree[$item->getData('prefix')])) { 129 | $tree[$item->getData('prefix')] = []; 130 | } 131 | /** 132 | * @psalm-suppress MixedArgumentTypeCoercion 133 | * @psalm-suppress MixedArgument,PossiblyNullArrayOffset 134 | * Checked group prefix on not empty above. 135 | */ 136 | $this->injectGroup($item, $tree[$item->getData('prefix')], $prefix, $namePrefix); 137 | continue; 138 | } 139 | 140 | $modifiedItem = $item->pattern($prefix . $item->getData('pattern')); 141 | 142 | if (!str_contains($modifiedItem->getData('name'), implode(', ', $modifiedItem->getData('methods')))) { 143 | $modifiedItem = $modifiedItem->name($namePrefix . $modifiedItem->getData('name')); 144 | } 145 | 146 | if ($group->getData('hasCorsMiddleware')) { 147 | $this->processCors($group, $hosts, $pattern, $modifiedItem, $tree); 148 | } 149 | 150 | $routeName = $modifiedItem->getData('name'); 151 | $tree[] = $routeName; 152 | if (isset($this->routes[$routeName]) && !$modifiedItem->getData('override')) { 153 | throw new InvalidArgumentException("A route with name '$routeName' already exists."); 154 | } 155 | $this->routes[$routeName] = $modifiedItem; 156 | } 157 | } 158 | 159 | /** 160 | * @psalm-param Items $tree 161 | */ 162 | private function processCors( 163 | Group $group, 164 | array &$hosts, 165 | ?string &$pattern, 166 | Route &$modifiedItem, 167 | array &$tree 168 | ): void { 169 | /** @var array|callable|string $middleware */ 170 | $middleware = $group->getData('corsMiddleware'); 171 | $isNotDuplicate = !in_array(Method::OPTIONS, $modifiedItem->getData('methods'), true) 172 | && ($pattern !== $modifiedItem->getData('pattern') || $hosts !== $modifiedItem->getData('hosts')); 173 | 174 | $pattern = $modifiedItem->getData('pattern'); 175 | $hosts = $modifiedItem->getData('hosts'); 176 | $optionsRoute = Route::options($pattern); 177 | if (!empty($hosts)) { 178 | $optionsRoute = $optionsRoute->hosts(...$hosts); 179 | } 180 | if ($isNotDuplicate) { 181 | $optionsRoute = $optionsRoute->middleware($middleware); 182 | 183 | $routeName = $optionsRoute->getData('name'); 184 | $tree[] = $routeName; 185 | $this->routes[$routeName] = $optionsRoute->action( 186 | static fn (ResponseFactoryInterface $responseFactory) => $responseFactory->createResponse(204) 187 | ); 188 | } 189 | $modifiedItem = $modifiedItem->prependMiddleware($middleware); 190 | } 191 | 192 | /** 193 | * Builds route tree from items. 194 | * 195 | * @psalm-param Items $items 196 | */ 197 | private function buildTree(array $items, bool $routeAsString): array 198 | { 199 | $tree = []; 200 | foreach ($items as $key => $item) { 201 | if (is_array($item)) { 202 | /** @psalm-var Items $item */ 203 | $tree[$key] = $this->buildTree($item, $routeAsString); 204 | } else { 205 | $tree[] = $routeAsString ? (string) $this->getRoute($item) : $this->getRoute($item); 206 | } 207 | } 208 | return $tree; 209 | } 210 | 211 | private function isStaticRoute(Group|Route $item): bool 212 | { 213 | return $item instanceof Route && !$item->getData('hasMiddlewares'); 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/RouteCollectionInterface.php: -------------------------------------------------------------------------------- 1 | items, 23 | ...array_values($routes) 24 | ); 25 | return $this; 26 | } 27 | 28 | public function middleware(array|callable|string ...$middlewareDefinition): RouteCollectorInterface 29 | { 30 | array_push( 31 | $this->middlewareDefinitions, 32 | ...array_values($middlewareDefinition) 33 | ); 34 | return $this; 35 | } 36 | 37 | public function prependMiddleware(array|callable|string ...$middlewareDefinition): RouteCollectorInterface 38 | { 39 | array_unshift( 40 | $this->middlewareDefinitions, 41 | ...array_values($middlewareDefinition) 42 | ); 43 | return $this; 44 | } 45 | 46 | public function getItems(): array 47 | { 48 | return $this->items; 49 | } 50 | 51 | public function getMiddlewareDefinitions(): array 52 | { 53 | return $this->middlewareDefinitions; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/RouteCollectorInterface.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | interface UrlGeneratorInterface 15 | { 16 | /** 17 | * Generates URL from named route, arguments, and query parameters. 18 | * 19 | * @param string $name Name of the route. 20 | * @param array $arguments Argument-value set. Unused arguments will be moved to query parameters, if query 21 | * parameter with such name doesn't exist. 22 | * @param array $queryParameters Parameter-value set. 23 | * @param string|null $hash Hash part (fragment identifier) of the URL. 24 | * 25 | * @throws RouteNotFoundException In case there is no route with the name specified. 26 | * 27 | * @return string URL generated. 28 | * 29 | * @psalm-param UrlArgumentsType $arguments 30 | */ 31 | public function generate( 32 | string $name, 33 | array $arguments = [], 34 | array $queryParameters = [], 35 | ?string $hash = null, 36 | ): string; 37 | 38 | /** 39 | * Generates absolute URL from named route, arguments, and query parameters. 40 | * 41 | * @param string $name Name of the route. 42 | * @param array $arguments Argument-value set. Unused arguments will be moved to query parameters, if query 43 | * parameter with such name doesn't exist. 44 | * @param array $queryParameters Parameter-value set. 45 | * @param string|null $hash Hash part (fragment identifier) of the URL. 46 | * @param string|null $scheme Host scheme. 47 | * @param string|null $host Host for manual setup. 48 | * 49 | * @throws RouteNotFoundException In case there is no route with the name specified. 50 | * 51 | * @return string URL generated. 52 | * 53 | * @psalm-param UrlArgumentsType $arguments 54 | */ 55 | public function generateAbsolute( 56 | string $name, 57 | array $arguments = [], 58 | array $queryParameters = [], 59 | ?string $hash = null, 60 | ?string $scheme = null, 61 | ?string $host = null 62 | ): string; 63 | 64 | /** 65 | * Generate URL from the current route replacing some of its arguments with values specified. 66 | * 67 | * @param array $replacedArguments New argument values indexed by replaced argument names. Unused arguments will be 68 | * moved to query parameters, if query parameter with such name doesn't exist. 69 | * @param array $queryParameters Parameter-value set. 70 | * @param string|null $hash Hash part (fragment identifier) of the URL. 71 | * @param string|null $fallbackRouteName Name of a route that should be used if current route. 72 | * can not be determined. 73 | * 74 | * @psalm-param UrlArgumentsType $replacedArguments 75 | */ 76 | public function generateFromCurrent( 77 | array $replacedArguments, 78 | array $queryParameters = [], 79 | ?string $hash = null, 80 | ?string $fallbackRouteName = null 81 | ): string; 82 | 83 | public function getUriPrefix(): string; 84 | 85 | public function setUriPrefix(string $name): void; 86 | 87 | /** 88 | * Set default argument value. 89 | * 90 | * @param string $name Name of argument to provide default value for. 91 | * @param bool|float|int|string|Stringable|null $value Default value. 92 | */ 93 | public function setDefaultArgument(string $name, bool|float|int|string|Stringable|null $value): void; 94 | } 95 | -------------------------------------------------------------------------------- /src/UrlMatcherInterface.php: -------------------------------------------------------------------------------- 1 |