├── .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 |
4 |
5 |
Yii Router
6 |
7 |
8 |
9 | [](https://packagist.org/packages/yiisoft/router)
10 | [](https://packagist.org/packages/yiisoft/router)
11 | [](https://github.com/yiisoft/router/actions/workflows/build.yml)
12 | [](https://codecov.io/gh/yiisoft/router)
13 | [](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/router/master)
14 | [](https://github.com/yiisoft/router/actions/workflows/static.yml?query=branch%3Amaster)
15 | [](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 | [](https://opencollective.com/yiisoft)
405 |
406 | ## Follow updates
407 |
408 | [](https://www.yiiframework.com/)
409 | [](https://twitter.com/yiiframework)
410 | [](https://t.me/yii3en)
411 | [](https://www.facebook.com/groups/yiitalk)
412 | [](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 |