├── LICENSE ├── README.md ├── composer.json └── src ├── BadRouteException.php ├── Cache.php ├── Cache ├── FileCache.php └── Psr16Cache.php ├── ConfigureRoutes.php ├── DataGenerator.php ├── DataGenerator ├── CharCountBased.php ├── GroupCountBased.php ├── GroupPosBased.php ├── MarkBased.php └── RegexBasedAbstract.php ├── Dispatcher.php ├── Dispatcher ├── CharCountBased.php ├── GroupCountBased.php ├── GroupPosBased.php ├── MarkBased.php ├── RegexBasedAbstract.php └── Result │ ├── Matched.php │ ├── MethodNotAllowed.php │ └── NotMatched.php ├── Exception.php ├── FastRoute.php ├── GenerateUri.php ├── GenerateUri ├── FromProcessedConfiguration.php └── UriCouldNotBeGenerated.php ├── Route.php ├── RouteCollector.php ├── RouteParser.php ├── RouteParser └── Std.php └── functions.php /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 by Nikita Popov. 2 | 3 | Some rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are 7 | met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above 13 | copyright notice, this list of conditions and the following 14 | disclaimer in the documentation and/or other materials provided 15 | with the distribution. 16 | 17 | * The names of the contributors may not be used to endorse or 18 | promote products derived from this software without specific 19 | prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 22 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 23 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 24 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 25 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 26 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 27 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 28 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 29 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 30 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 31 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | FastRoute - Fast request router for PHP 2 | ======================================= 3 | 4 | [![Build Status](https://img.shields.io/github/actions/workflow/status/nikic/FastRoute/phpunit.yml?branch=master&style=flat-square)](https://github.com/nikic/FastRoute/actions?query=workflow%3A%22PHPUnit%20Tests%22+branch%3Amaster) 5 | 6 | This library provides a fast implementation of a regular expression based router. [Blog post explaining how the 7 | implementation works and why it is fast.][blog_post] 8 | 9 | Install 10 | ------- 11 | 12 | To install with composer: 13 | 14 | ```sh 15 | composer require nikic/fast-route 16 | ``` 17 | 18 | Requires PHP 8.1 or newer. 19 | 20 | Usage 21 | ----- 22 | 23 | Here's a basic usage example: 24 | 25 | ```php 26 | addRoute('GET', '/users', 'get_all_users_handler'); 32 | // {id} must be a number (\d+) 33 | $r->addRoute('GET', '/user/{id:\d+}', 'get_user_handler'); 34 | // The /{title} suffix is optional 35 | $r->addRoute('GET', '/articles/{id:\d+}[/{title}]', 'get_article_handler'); 36 | }); 37 | 38 | // Fetch method and URI from somewhere 39 | $httpMethod = $_SERVER['REQUEST_METHOD']; 40 | $uri = $_SERVER['REQUEST_URI']; 41 | 42 | // Strip query string (?foo=bar) and decode URI 43 | if (false !== $pos = strpos($uri, '?')) { 44 | $uri = substr($uri, 0, $pos); 45 | } 46 | $uri = rawurldecode($uri); 47 | 48 | $routeInfo = $dispatcher->dispatch($httpMethod, $uri); 49 | switch ($routeInfo[0]) { 50 | case FastRoute\Dispatcher::NOT_FOUND: 51 | // ... 404 Not Found 52 | break; 53 | case FastRoute\Dispatcher::METHOD_NOT_ALLOWED: 54 | $allowedMethods = $routeInfo[1]; 55 | // ... 405 Method Not Allowed 56 | break; 57 | case FastRoute\Dispatcher::FOUND: 58 | $handler = $routeInfo[1]; 59 | $vars = $routeInfo[2]; 60 | // ... call $handler with $vars 61 | break; 62 | } 63 | ``` 64 | 65 | ### Defining routes 66 | 67 | The routes are defined by calling the `FastRoute\simpleDispatcher()` function, which accepts 68 | a callable taking a `FastRoute\ConfigureRoutes` instance. The routes are added by calling 69 | `addRoute()` on the collector instance: 70 | 71 | ```php 72 | $r->addRoute($method, $routePattern, $handler); 73 | ``` 74 | 75 | The `$method` is an uppercase HTTP method string for which a certain route should match. It 76 | is possible to specify multiple valid methods using an array: 77 | 78 | ```php 79 | // These two calls 80 | $r->addRoute('GET', '/test', 'handler'); 81 | $r->addRoute('POST', '/test', 'handler'); 82 | // Are equivalent to this one call 83 | $r->addRoute(['GET', 'POST'], '/test', 'handler'); 84 | ``` 85 | 86 | By default, the `$routePattern` uses a syntax where `{foo}` specifies a placeholder with name `foo` 87 | and matching the regex `[^/]+`. To adjust the pattern the placeholder matches, you can specify 88 | a custom pattern by writing `{bar:[0-9]+}`. Some examples: 89 | 90 | ```php 91 | // Matches /user/42, but not /user/xyz 92 | $r->addRoute('GET', '/user/{id:\d+}', 'handler'); 93 | 94 | // Matches /user/foobar, but not /user/foo/bar 95 | $r->addRoute('GET', '/user/{name}', 'handler'); 96 | 97 | // Matches /user/foo/bar as well 98 | $r->addRoute('GET', '/user/{name:.+}', 'handler'); 99 | ``` 100 | 101 | Custom patterns for route placeholders cannot use capturing groups. For example `{lang:(en|de)}` 102 | is not a valid placeholder, because `()` is a capturing group. Instead you can use either 103 | `{lang:en|de}` or `{lang:(?:en|de)}`. 104 | 105 | Furthermore, parts of the route enclosed in `[...]` are considered optional, so that `/foo[bar]` 106 | will match both `/foo` and `/foobar`. Optional parts are only supported in a trailing position, 107 | not in the middle of a route. 108 | 109 | ```php 110 | // This route 111 | $r->addRoute('GET', '/user/{id:\d+}[/{name}]', 'handler'); 112 | // Is equivalent to these two routes 113 | $r->addRoute('GET', '/user/{id:\d+}', 'handler'); 114 | $r->addRoute('GET', '/user/{id:\d+}/{name}', 'handler'); 115 | 116 | // Multiple nested optional parts are possible as well 117 | $r->addRoute('GET', '/user[/{id:\d+}[/{name}]]', 'handler'); 118 | 119 | // This route is NOT valid, because optional parts can only occur at the end 120 | $r->addRoute('GET', '/user[/{id:\d+}]/{name}', 'handler'); 121 | ``` 122 | 123 | The `$handler` parameter does not necessarily have to be a callback, it could also be a controller 124 | class name or any other kind of data you wish to associate with the route. FastRoute only tells you 125 | which handler corresponds to your URI, how you interpret it is up to you. 126 | 127 | #### Shortcut methods for common request methods 128 | 129 | For the `GET`, `POST`, `PUT`, `PATCH`, `DELETE` and `HEAD` request methods shortcut methods are available. For example: 130 | 131 | ```php 132 | $r->get('/get-route', 'get_handler'); 133 | $r->post('/post-route', 'post_handler'); 134 | ``` 135 | 136 | Is equivalent to: 137 | 138 | ```php 139 | $r->addRoute('GET', '/get-route', 'get_handler'); 140 | $r->addRoute('POST', '/post-route', 'post_handler'); 141 | ``` 142 | 143 | #### Route Groups 144 | 145 | Additionally, you can specify routes inside a group. All routes defined inside a group will have a common prefix. 146 | 147 | For example, defining your routes as: 148 | 149 | ```php 150 | $r->addGroup('/admin', function (FastRoute\ConfigureRoutes $r) { 151 | $r->addRoute('GET', '/do-something', 'handler'); 152 | $r->addRoute('GET', '/do-another-thing', 'handler'); 153 | $r->addRoute('GET', '/do-something-else', 'handler'); 154 | }); 155 | ``` 156 | 157 | Will have the same result as: 158 | 159 | ```php 160 | $r->addRoute('GET', '/admin/do-something', 'handler'); 161 | $r->addRoute('GET', '/admin/do-another-thing', 'handler'); 162 | $r->addRoute('GET', '/admin/do-something-else', 'handler'); 163 | ``` 164 | 165 | Nested groups are also supported, in which case the prefixes of all the nested groups are combined. 166 | 167 | ### Caching 168 | 169 | The reason `simpleDispatcher` accepts a callback for defining the routes is to allow seamless 170 | caching. By using `cachedDispatcher` instead of `simpleDispatcher` you can cache the generated 171 | routing data and construct the dispatcher from the cached information: 172 | 173 | ```php 174 | addRoute('GET', '/user/{name}/{id:[0-9]+}', 'handler0'); 178 | $r->addRoute('GET', '/user/{id:[0-9]+}', 'handler1'); 179 | $r->addRoute('GET', '/user/{name}', 'handler2'); 180 | }, [ 181 | 'cacheKey' => __DIR__ . '/route.cache', /* required */ 182 | // 'cacheFile' => __DIR__ . '/route.cache', /* will still work for v1 compatibility */ 183 | 'cacheDisabled' => IS_DEBUG_ENABLED, /* optional, enabled by default */ 184 | 'cacheDriver' => FastRoute\Cache\FileCache::class, /* optional, class name or instance of the cache driver - defaults to file cache */ 185 | ]); 186 | ``` 187 | 188 | The second parameter to the function is an options array, which can be used to specify the cache 189 | key (e.g. file location when using files for caching), caching driver, among other things. 190 | 191 | ### Dispatching a URI 192 | 193 | A URI is dispatched by calling the `dispatch()` method of the created dispatcher. This method 194 | accepts the HTTP method and a URI. Getting those two bits of information (and normalizing them 195 | appropriately) is your job - this library is not bound to the PHP web SAPIs. 196 | 197 | The `dispatch()` method returns an array whose first element contains a status code. It is one 198 | of `Dispatcher::NOT_FOUND`, `Dispatcher::METHOD_NOT_ALLOWED` and `Dispatcher::FOUND`. For the 199 | method not allowed status the second array element contains a list of HTTP methods allowed for 200 | the supplied URI. For example: 201 | 202 | [FastRoute\Dispatcher::METHOD_NOT_ALLOWED, ['GET', 'POST']] 203 | 204 | > **NOTE:** The HTTP specification requires that a `405 Method Not Allowed` response include the 205 | `Allow:` header to detail available methods for the requested resource. Applications using FastRoute 206 | should use the second array element to add this header when relaying a 405 response. 207 | 208 | For the found status the second array element is the handler that was associated with the route 209 | and the third array element is a dictionary of placeholder names to their values. For example: 210 | 211 | /* Routing against GET /user/nikic/42 */ 212 | 213 | [FastRoute\Dispatcher::FOUND, 'handler0', ['name' => 'nikic', 'id' => '42']] 214 | 215 | ### Overriding the route parser and dispatcher 216 | 217 | The routing process makes use of three components: A route parser, a data generator and a 218 | dispatcher. The three components adhere to the following interfaces: 219 | 220 | ```php 221 | 'FastRoute\\RouteParser\\Std', 282 | 'dataGenerator' => 'FastRoute\\DataGenerator\\MarkBased', 283 | 'dispatcher' => 'FastRoute\\Dispatcher\\MarkBased', 284 | ]); 285 | ``` 286 | 287 | The above options array corresponds to the defaults. By replacing `MarkBased` with 288 | `GroupCountBased` you could switch to a different dispatching strategy. 289 | 290 | ### A Note on HEAD Requests 291 | 292 | The HTTP spec requires servers to [support both GET and HEAD methods][2616-511]: 293 | 294 | > The methods GET and HEAD MUST be supported by all general-purpose servers 295 | 296 | To avoid forcing users to manually register HEAD routes for each resource we fallback to matching an 297 | available GET route for a given resource. The PHP web SAPI transparently removes the entity body 298 | from HEAD responses so this behavior has no effect on the vast majority of users. 299 | 300 | However, implementers using FastRoute outside the web SAPI environment (e.g. a custom server) MUST 301 | NOT send entity bodies generated in response to HEAD requests. If you are a non-SAPI user this is 302 | *your responsibility*; FastRoute has no purview to prevent you from breaking HTTP in such cases. 303 | 304 | Finally, note that applications MAY always specify their own HEAD method route for a given 305 | resource to bypass this behavior entirely. 306 | 307 | ### Credits 308 | 309 | This library is based on a router that [Levi Morrison][levi] implemented for the Aerys server. 310 | 311 | A large number of tests, as well as HTTP compliance considerations, were provided by [Daniel Lowrey][rdlowrey]. 312 | 313 | 314 | [2616-511]: http://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html#sec5.1.1 "RFC 2616 Section 5.1.1" 315 | [blog_post]: http://nikic.github.io/2014/02/18/Fast-request-routing-using-regular-expressions.html 316 | [levi]: https://github.com/morrisonlevi 317 | [rdlowrey]: https://github.com/rdlowrey 318 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nikic/fast-route", 3 | "description": "Fast request router for PHP", 4 | "license": "BSD-3-Clause", 5 | "keywords": [ 6 | "routing", 7 | "router" 8 | ], 9 | "authors": [ 10 | { 11 | "name": "Nikita Popov", 12 | "email": "nikic@php.net" 13 | } 14 | ], 15 | "require": { 16 | "php": ">=8.1.0", 17 | "psr/simple-cache": "^2.0 || ^3.0" 18 | }, 19 | "require-dev": { 20 | "lcobucci/coding-standard": "^11.0", 21 | "phpbench/phpbench": "^1.2", 22 | "phpstan/extension-installer": "^1.1", 23 | "phpstan/phpstan": "^1.10", 24 | "phpstan/phpstan-deprecation-rules": "^1.1", 25 | "phpstan/phpstan-phpunit": "^1.3", 26 | "phpstan/phpstan-strict-rules": "^1.5", 27 | "phpunit/phpunit": "^10.3" 28 | }, 29 | "autoload": { 30 | "psr-4": { 31 | "FastRoute\\": "src/" 32 | }, 33 | "files": [ 34 | "src/functions.php" 35 | ] 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "FastRoute\\Benchmark\\": "benchmark/", 40 | "FastRoute\\Test\\": "test/" 41 | } 42 | }, 43 | "config": { 44 | "allow-plugins": { 45 | "dealerdirect/phpcodesniffer-composer-installer": true, 46 | "phpstan/extension-installer": true 47 | }, 48 | "preferred-install": "dist", 49 | "sort-packages": true 50 | }, 51 | "extra": { 52 | "branch-alias": { 53 | "dev-master": "2.0-dev" 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/BadRouteException.php: -------------------------------------------------------------------------------- 1 | cache->get($key); 21 | 22 | if (is_array($result)) { 23 | // @phpstan-ignore-next-line because we won´t be able to validate the array shape in a performant way 24 | return $result; 25 | } 26 | 27 | $data = $loader(); 28 | $this->cache->set($key, $data); 29 | 30 | return $data; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/ConfigureRoutes.php: -------------------------------------------------------------------------------- 1 | addRoute('*', $route, $handler) 39 | * 40 | * @param ExtraParameters $extraParameters 41 | */ 42 | public function any(string $route, mixed $handler, array $extraParameters = []): void; 43 | 44 | /** 45 | * Adds a GET route to the collection 46 | * 47 | * This is simply an alias of $this->addRoute('GET', $route, $handler) 48 | * 49 | * @param ExtraParameters $extraParameters 50 | */ 51 | public function get(string $route, mixed $handler, array $extraParameters = []): void; 52 | 53 | /** 54 | * Adds a POST route to the collection 55 | * 56 | * This is simply an alias of $this->addRoute('POST', $route, $handler) 57 | * 58 | * @param ExtraParameters $extraParameters 59 | */ 60 | public function post(string $route, mixed $handler, array $extraParameters = []): void; 61 | 62 | /** 63 | * Adds a PUT route to the collection 64 | * 65 | * This is simply an alias of $this->addRoute('PUT', $route, $handler) 66 | * 67 | * @param ExtraParameters $extraParameters 68 | */ 69 | public function put(string $route, mixed $handler, array $extraParameters = []): void; 70 | 71 | /** 72 | * Adds a DELETE route to the collection 73 | * 74 | * This is simply an alias of $this->addRoute('DELETE', $route, $handler) 75 | * 76 | * @param ExtraParameters $extraParameters 77 | */ 78 | public function delete(string $route, mixed $handler, array $extraParameters = []): void; 79 | 80 | /** 81 | * Adds a PATCH route to the collection 82 | * 83 | * This is simply an alias of $this->addRoute('PATCH', $route, $handler) 84 | * 85 | * @param ExtraParameters $extraParameters 86 | */ 87 | public function patch(string $route, mixed $handler, array $extraParameters = []): void; 88 | 89 | /** 90 | * Adds a HEAD route to the collection 91 | * 92 | * This is simply an alias of $this->addRoute('HEAD', $route, $handler) 93 | * 94 | * @param ExtraParameters $extraParameters 95 | */ 96 | public function head(string $route, mixed $handler, array $extraParameters = []): void; 97 | 98 | /** 99 | * Adds an OPTIONS route to the collection 100 | * 101 | * This is simply an alias of $this->addRoute('OPTIONS', $route, $handler) 102 | * 103 | * @param ExtraParameters $extraParameters 104 | */ 105 | public function options(string $route, mixed $handler, array $extraParameters = []): void; 106 | 107 | /** 108 | * Returns the processed aggregated route data. 109 | * 110 | * @return ProcessedData 111 | */ 112 | public function processedRoutes(): array; 113 | } 114 | -------------------------------------------------------------------------------- /src/DataGenerator.php: -------------------------------------------------------------------------------- 1 | 9 | * @phpstan-type StaticRoutes array> 10 | * @phpstan-type DynamicRouteChunk array{regex: string, suffix?: string, routeMap: array, ExtraParameters}>} 11 | * @phpstan-type DynamicRouteChunks list 12 | * @phpstan-type DynamicRoutes array 13 | * @phpstan-type RouteData array{StaticRoutes, DynamicRoutes} 14 | */ 15 | interface DataGenerator 16 | { 17 | /** 18 | * Adds a route to the data generator. The route data uses the 19 | * same format that is returned by RouterParser::parser(). 20 | * 21 | * The handler doesn't necessarily need to be a callable, it 22 | * can be arbitrary data that will be returned when the route 23 | * matches. 24 | * 25 | * @param ParsedRoute $routeData 26 | * @param ExtraParameters $extraParameters 27 | */ 28 | public function addRoute(string $httpMethod, array $routeData, mixed $handler, array $extraParameters = []): void; 29 | 30 | /** 31 | * Returns dispatcher data in some unspecified format, which 32 | * depends on the used method of dispatch. 33 | * 34 | * @return RouteData 35 | */ 36 | public function getData(): array; 37 | } 38 | -------------------------------------------------------------------------------- /src/DataGenerator/CharCountBased.php: -------------------------------------------------------------------------------- 1 | $route) { 27 | $suffixLen++; 28 | $suffix .= "\t"; 29 | 30 | $regexes[] = '(?:' . $regex . '/(\t{' . $suffixLen . '})\t{' . ($count - $suffixLen) . '})'; 31 | $routeMap[$suffix] = [$route->handler, $route->variables, $route->extraParameters]; 32 | } 33 | 34 | $regex = '~^(?|' . implode('|', $regexes) . ')$~'; 35 | 36 | return ['regex' => $regex, 'suffix' => '/' . $suffix, 'routeMap' => $routeMap]; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/DataGenerator/GroupCountBased.php: -------------------------------------------------------------------------------- 1 | $route) { 26 | $numVariables = count($route->variables); 27 | $numGroups = max($numGroups, $numVariables); 28 | 29 | $regexes[] = $regex . str_repeat('()', $numGroups - $numVariables); 30 | $routeMap[$numGroups + 1] = [$route->handler, $route->variables, $route->extraParameters]; 31 | 32 | ++$numGroups; 33 | } 34 | 35 | $regex = '~^(?|' . implode('|', $regexes) . ')$~'; 36 | 37 | return ['regex' => $regex, 'routeMap' => $routeMap]; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/DataGenerator/GroupPosBased.php: -------------------------------------------------------------------------------- 1 | $route) { 24 | $regexes[] = $regex; 25 | $routeMap[$offset] = [$route->handler, $route->variables, $route->extraParameters]; 26 | 27 | $offset += count($route->variables); 28 | } 29 | 30 | $regex = '~^(?:' . implode('|', $regexes) . ')$~'; 31 | 32 | return ['regex' => $regex, 'routeMap' => $routeMap]; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/DataGenerator/MarkBased.php: -------------------------------------------------------------------------------- 1 | $route) { 24 | $regexes[] = $regex . '(*MARK:' . $markName . ')'; 25 | $routeMap[$markName] = [$route->handler, $route->variables, $route->extraParameters]; 26 | 27 | ++$markName; 28 | } 29 | 30 | $regex = '~^(?|' . implode('|', $regexes) . ')$~'; 31 | 32 | return ['regex' => $regex, 'routeMap' => $routeMap]; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/DataGenerator/RegexBasedAbstract.php: -------------------------------------------------------------------------------- 1 | > */ 36 | protected array $methodToRegexToRoutesMap = []; 37 | 38 | abstract protected function getApproxChunkSize(): int; 39 | 40 | /** 41 | * @param array $regexToRoutesMap 42 | * 43 | * @return DynamicRouteChunk 44 | */ 45 | abstract protected function processChunk(array $regexToRoutesMap): array; 46 | 47 | /** @inheritDoc */ 48 | public function addRoute(string $httpMethod, array $routeData, mixed $handler, array $extraParameters = []): void 49 | { 50 | if ($this->isStaticRoute($routeData)) { 51 | $this->addStaticRoute($httpMethod, $routeData, $handler, $extraParameters); 52 | } else { 53 | $this->addVariableRoute($httpMethod, $routeData, $handler, $extraParameters); 54 | } 55 | } 56 | 57 | /** @inheritDoc */ 58 | public function getData(): array 59 | { 60 | if ($this->methodToRegexToRoutesMap === []) { 61 | return [$this->staticRoutes, []]; 62 | } 63 | 64 | return [$this->staticRoutes, $this->generateVariableRouteData()]; 65 | } 66 | 67 | /** @return DynamicRoutes */ 68 | private function generateVariableRouteData(): array 69 | { 70 | $data = []; 71 | foreach ($this->methodToRegexToRoutesMap as $method => $regexToRoutesMap) { 72 | $chunkSize = $this->computeChunkSize(count($regexToRoutesMap)); 73 | $chunks = array_chunk($regexToRoutesMap, $chunkSize, true); 74 | $data[$method] = array_map([$this, 'processChunk'], $chunks); 75 | } 76 | 77 | return $data; 78 | } 79 | 80 | /** @return positive-int */ 81 | private function computeChunkSize(int $count): int 82 | { 83 | $numParts = max(1, round($count / $this->getApproxChunkSize())); 84 | $size = (int) ceil($count / $numParts); 85 | assert($size > 0); 86 | 87 | return $size; 88 | } 89 | 90 | /** @param ParsedRoute $routeData */ 91 | private function isStaticRoute(array $routeData): bool 92 | { 93 | return count($routeData) === 1 && is_string($routeData[0]); 94 | } 95 | 96 | /** 97 | * @param ParsedRoute $routeData 98 | * @param ExtraParameters $extraParameters 99 | */ 100 | private function addStaticRoute(string $httpMethod, array $routeData, mixed $handler, array $extraParameters): void 101 | { 102 | $routeStr = $routeData[0]; 103 | assert(is_string($routeStr)); 104 | 105 | if (isset($this->staticRoutes[$httpMethod][$routeStr])) { 106 | throw BadRouteException::alreadyRegistered($routeStr, $httpMethod); 107 | } 108 | 109 | if (isset($this->methodToRegexToRoutesMap[$httpMethod])) { 110 | foreach ($this->methodToRegexToRoutesMap[$httpMethod] as $route) { 111 | if ($route->matches($routeStr)) { 112 | throw BadRouteException::shadowedByVariableRoute($routeStr, $route->regex, $httpMethod); 113 | } 114 | } 115 | } 116 | 117 | $this->staticRoutes[$httpMethod][$routeStr] = [$handler, $extraParameters]; 118 | } 119 | 120 | /** 121 | * @param ParsedRoute $routeData 122 | * @param ExtraParameters $extraParameters 123 | */ 124 | private function addVariableRoute(string $httpMethod, array $routeData, mixed $handler, array $extraParameters): void 125 | { 126 | $route = new Route($httpMethod, $routeData, $handler, $extraParameters); 127 | $regex = $route->regex; 128 | 129 | if (isset($this->methodToRegexToRoutesMap[$httpMethod][$regex])) { 130 | throw BadRouteException::alreadyRegistered($regex, $httpMethod); 131 | } 132 | 133 | $this->methodToRegexToRoutesMap[$httpMethod][$regex] = $route; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/Dispatcher.php: -------------------------------------------------------------------------------- 1 | 'value', ...]] 24 | */ 25 | public function dispatch(string $httpMethod, string $uri): Matched|NotMatched|MethodNotAllowed; 26 | } 27 | -------------------------------------------------------------------------------- /src/Dispatcher/CharCountBased.php: -------------------------------------------------------------------------------- 1 | handler = $handler; 35 | $result->variables = $vars; 36 | $result->extraParameters = $extraParameters; 37 | 38 | return $result; 39 | } 40 | 41 | return null; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Dispatcher/GroupCountBased.php: -------------------------------------------------------------------------------- 1 | handler = $handler; 32 | $result->variables = $vars; 33 | $result->extraParameters = $extraParameters; 34 | 35 | return $result; 36 | } 37 | 38 | return null; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Dispatcher/GroupPosBased.php: -------------------------------------------------------------------------------- 1 | handler = $handler; 38 | $result->variables = $vars; 39 | $result->extraParameters = $extraParameters; 40 | 41 | return $result; 42 | } 43 | 44 | return null; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Dispatcher/MarkBased.php: -------------------------------------------------------------------------------- 1 | handler = $handler; 31 | $result->variables = $vars; 32 | $result->extraParameters = $extraParameters; 33 | 34 | return $result; 35 | } 36 | 37 | return null; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Dispatcher/RegexBasedAbstract.php: -------------------------------------------------------------------------------- 1 | staticRouteMap, $this->variableRouteData] = $data; 33 | } 34 | 35 | /** @param DynamicRouteChunks $routeData */ 36 | abstract protected function dispatchVariableRoute(array $routeData, string $uri): ?Matched; 37 | 38 | public function dispatch(string $httpMethod, string $uri): Matched|NotMatched|MethodNotAllowed 39 | { 40 | if (isset($this->staticRouteMap[$httpMethod][$uri])) { 41 | $result = new Matched(); 42 | $result->handler = $this->staticRouteMap[$httpMethod][$uri][0]; 43 | $result->extraParameters = $this->staticRouteMap[$httpMethod][$uri][1]; 44 | 45 | return $result; 46 | } 47 | 48 | if (isset($this->variableRouteData[$httpMethod])) { 49 | $result = $this->dispatchVariableRoute($this->variableRouteData[$httpMethod], $uri); 50 | if ($result !== null) { 51 | return $result; 52 | } 53 | } 54 | 55 | // For HEAD requests, attempt fallback to GET 56 | if ($httpMethod === 'HEAD') { 57 | if (isset($this->staticRouteMap['GET'][$uri])) { 58 | $result = new Matched(); 59 | $result->handler = $this->staticRouteMap['GET'][$uri][0]; 60 | $result->extraParameters = $this->staticRouteMap['GET'][$uri][1]; 61 | 62 | return $result; 63 | } 64 | 65 | if (isset($this->variableRouteData['GET'])) { 66 | $result = $this->dispatchVariableRoute($this->variableRouteData['GET'], $uri); 67 | if ($result !== null) { 68 | return $result; 69 | } 70 | } 71 | } 72 | 73 | // If nothing else matches, try fallback routes 74 | if (isset($this->staticRouteMap['*'][$uri])) { 75 | $result = new Matched(); 76 | $result->handler = $this->staticRouteMap['*'][$uri][0]; 77 | $result->extraParameters = $this->staticRouteMap['*'][$uri][1]; 78 | 79 | return $result; 80 | } 81 | 82 | if (isset($this->variableRouteData['*'])) { 83 | $result = $this->dispatchVariableRoute($this->variableRouteData['*'], $uri); 84 | if ($result !== null) { 85 | return $result; 86 | } 87 | } 88 | 89 | // Find allowed methods for this URI by matching against all other HTTP methods as well 90 | $allowedMethods = []; 91 | 92 | foreach ($this->staticRouteMap as $method => $uriMap) { 93 | if ($method === $httpMethod || ! isset($uriMap[$uri])) { 94 | continue; 95 | } 96 | 97 | $allowedMethods[] = $method; 98 | } 99 | 100 | foreach ($this->variableRouteData as $method => $routeData) { 101 | if ($method === $httpMethod) { 102 | continue; 103 | } 104 | 105 | $result = $this->dispatchVariableRoute($routeData, $uri); 106 | if ($result === null) { 107 | continue; 108 | } 109 | 110 | $allowedMethods[] = $method; 111 | } 112 | 113 | // If there are no allowed methods the route simply does not exist 114 | if ($allowedMethods !== []) { 115 | $result = new MethodNotAllowed(); 116 | $result->allowedMethods = $allowedMethods; 117 | 118 | return $result; 119 | } 120 | 121 | return new NotMatched(); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Dispatcher/Result/Matched.php: -------------------------------------------------------------------------------- 1 | > 15 | */ 16 | final class Matched implements ArrayAccess 17 | { 18 | /** @readonly */ 19 | public mixed $handler; 20 | 21 | /** 22 | * @readonly 23 | * @var array $variables 24 | */ 25 | public array $variables = []; 26 | 27 | /** 28 | * @readonly 29 | * @var ExtraParameters 30 | */ 31 | public array $extraParameters = []; 32 | 33 | public function offsetExists(mixed $offset): bool 34 | { 35 | return $offset >= 0 && $offset <= 2; 36 | } 37 | 38 | public function offsetGet(mixed $offset): mixed 39 | { 40 | return match ($offset) { 41 | 0 => Dispatcher::FOUND, 42 | 1 => $this->handler, 43 | 2 => $this->variables, 44 | default => throw new OutOfBoundsException() 45 | }; 46 | } 47 | 48 | public function offsetSet(mixed $offset, mixed $value): void 49 | { 50 | throw new RuntimeException('Result cannot be changed'); 51 | } 52 | 53 | public function offsetUnset(mixed $offset): void 54 | { 55 | throw new RuntimeException('Result cannot be changed'); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Dispatcher/Result/MethodNotAllowed.php: -------------------------------------------------------------------------------- 1 | > */ 12 | final class MethodNotAllowed implements ArrayAccess 13 | { 14 | /** 15 | * @readonly 16 | * @var non-empty-list $allowedMethods 17 | */ 18 | public array $allowedMethods; 19 | 20 | public function offsetExists(mixed $offset): bool 21 | { 22 | return $offset === 0 || $offset === 1; 23 | } 24 | 25 | public function offsetGet(mixed $offset): mixed 26 | { 27 | return match ($offset) { 28 | 0 => Dispatcher::METHOD_NOT_ALLOWED, 29 | 1 => $this->allowedMethods, 30 | default => throw new OutOfBoundsException(), 31 | }; 32 | } 33 | 34 | public function offsetSet(mixed $offset, mixed $value): void 35 | { 36 | throw new RuntimeException('Result cannot be changed'); 37 | } 38 | 39 | public function offsetUnset(mixed $offset): void 40 | { 41 | throw new RuntimeException('Result cannot be changed'); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Dispatcher/Result/NotMatched.php: -------------------------------------------------------------------------------- 1 | */ 12 | final class NotMatched implements ArrayAccess 13 | { 14 | public function offsetExists(mixed $offset): bool 15 | { 16 | return $offset === 0; 17 | } 18 | 19 | public function offsetGet(mixed $offset): mixed 20 | { 21 | return match ($offset) { 22 | 0 => Dispatcher::NOT_FOUND, 23 | default => throw new OutOfBoundsException(), 24 | }; 25 | } 26 | 27 | public function offsetSet(mixed $offset, mixed $value): void 28 | { 29 | throw new RuntimeException('Result cannot be changed'); 30 | } 31 | 32 | public function offsetUnset(mixed $offset): void 33 | { 34 | throw new RuntimeException('Result cannot be changed'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Exception.php: -------------------------------------------------------------------------------- 1 | $routeParser 21 | * @param class-string $dataGenerator 22 | * @param class-string $dispatcher 23 | * @param class-string $routesConfiguration 24 | * @param class-string $uriGenerator 25 | * @param Cache|class-string|null $cacheDriver 26 | * @param non-empty-string|null $cacheKey 27 | */ 28 | private function __construct( 29 | private readonly Closure $routeDefinitionCallback, 30 | private readonly string $routeParser, 31 | private readonly string $dataGenerator, 32 | private readonly string $dispatcher, 33 | private readonly string $routesConfiguration, 34 | private readonly string $uriGenerator, 35 | private readonly Cache|string|null $cacheDriver, 36 | private readonly ?string $cacheKey, 37 | ) { 38 | } 39 | 40 | /** 41 | * @param Closure(ConfigureRoutes):void $routeDefinitionCallback 42 | * @param non-empty-string $cacheKey 43 | */ 44 | public static function recommendedSettings(Closure $routeDefinitionCallback, string $cacheKey): self 45 | { 46 | return new self( 47 | $routeDefinitionCallback, 48 | RouteParser\Std::class, 49 | DataGenerator\MarkBased::class, 50 | Dispatcher\MarkBased::class, 51 | RouteCollector::class, 52 | GenerateUri\FromProcessedConfiguration::class, 53 | FileCache::class, 54 | $cacheKey, 55 | ); 56 | } 57 | 58 | public function disableCache(): self 59 | { 60 | return new self( 61 | $this->routeDefinitionCallback, 62 | $this->routeParser, 63 | $this->dataGenerator, 64 | $this->dispatcher, 65 | $this->routesConfiguration, 66 | $this->uriGenerator, 67 | null, 68 | null, 69 | ); 70 | } 71 | 72 | /** 73 | * @param Cache|class-string $driver 74 | * @param non-empty-string $cacheKey 75 | */ 76 | public function withCache(Cache|string $driver, string $cacheKey): self 77 | { 78 | return new self( 79 | $this->routeDefinitionCallback, 80 | $this->routeParser, 81 | $this->dataGenerator, 82 | $this->dispatcher, 83 | $this->routesConfiguration, 84 | $this->uriGenerator, 85 | $driver, 86 | $cacheKey, 87 | ); 88 | } 89 | 90 | public function useCharCountDispatcher(): self 91 | { 92 | return $this->useCustomDispatcher(DataGenerator\CharCountBased::class, Dispatcher\CharCountBased::class); 93 | } 94 | 95 | public function useGroupCountDispatcher(): self 96 | { 97 | return $this->useCustomDispatcher(DataGenerator\GroupCountBased::class, Dispatcher\GroupCountBased::class); 98 | } 99 | 100 | public function useGroupPosDispatcher(): self 101 | { 102 | return $this->useCustomDispatcher(DataGenerator\GroupPosBased::class, Dispatcher\GroupPosBased::class); 103 | } 104 | 105 | public function useMarkDispatcher(): self 106 | { 107 | return $this->useCustomDispatcher(DataGenerator\MarkBased::class, Dispatcher\MarkBased::class); 108 | } 109 | 110 | /** 111 | * @param class-string $dataGenerator 112 | * @param class-string $dispatcher 113 | */ 114 | public function useCustomDispatcher(string $dataGenerator, string $dispatcher): self 115 | { 116 | return new self( 117 | $this->routeDefinitionCallback, 118 | $this->routeParser, 119 | $dataGenerator, 120 | $dispatcher, 121 | $this->routesConfiguration, 122 | $this->uriGenerator, 123 | $this->cacheDriver, 124 | $this->cacheKey, 125 | ); 126 | } 127 | 128 | /** @param class-string $uriGenerator */ 129 | public function withUriGenerator(string $uriGenerator): self 130 | { 131 | return new self( 132 | $this->routeDefinitionCallback, 133 | $this->routeParser, 134 | $this->dataGenerator, 135 | $this->dispatcher, 136 | $this->routesConfiguration, 137 | $uriGenerator, 138 | $this->cacheDriver, 139 | $this->cacheKey, 140 | ); 141 | } 142 | 143 | /** @return ProcessedData */ 144 | private function buildConfiguration(): array 145 | { 146 | if ($this->processedConfiguration !== null) { 147 | return $this->processedConfiguration; 148 | } 149 | 150 | $loader = function (): array { 151 | $configuredRoutes = new $this->routesConfiguration( 152 | new $this->routeParser(), 153 | new $this->dataGenerator(), 154 | ); 155 | 156 | ($this->routeDefinitionCallback)($configuredRoutes); 157 | 158 | return $configuredRoutes->processedRoutes(); 159 | }; 160 | 161 | if ($this->cacheDriver === null) { 162 | return $this->processedConfiguration = $loader(); 163 | } 164 | 165 | assert(is_string($this->cacheKey)); 166 | 167 | $cache = is_string($this->cacheDriver) 168 | ? new $this->cacheDriver() 169 | : $this->cacheDriver; 170 | 171 | return $this->processedConfiguration = $cache->get($this->cacheKey, $loader); 172 | } 173 | 174 | public function dispatcher(): Dispatcher 175 | { 176 | return new $this->dispatcher($this->buildConfiguration()); 177 | } 178 | 179 | public function uriGenerator(): GenerateUri 180 | { 181 | return new $this->uriGenerator($this->buildConfiguration()[2]); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/GenerateUri.php: -------------------------------------------------------------------------------- 1 | 11 | * @phpstan-type UriSubstitutions array 12 | */ 13 | interface GenerateUri 14 | { 15 | /** 16 | * @param UriSubstitutions $substitutions 17 | * 18 | * @throws UriCouldNotBeGenerated 19 | */ 20 | public function forRoute(string $name, array $substitutions = []): string; 21 | } 22 | -------------------------------------------------------------------------------- /src/GenerateUri/FromProcessedConfiguration.php: -------------------------------------------------------------------------------- 1 | processedConfiguration)) { 32 | throw UriCouldNotBeGenerated::routeIsUndefined($name); 33 | } 34 | 35 | $missingParameters = []; 36 | 37 | foreach ($this->processedConfiguration[$name] as $parsedRoute) { 38 | $missingParameters = $this->missingParameters($parsedRoute, $substitutions); 39 | 40 | // Only attempt to generate the path if we have the necessary info 41 | if (count($missingParameters) === 0) { 42 | return $this->generatePath($name, $parsedRoute, $substitutions); 43 | } 44 | } 45 | 46 | assert(count($missingParameters) > 0); 47 | 48 | throw UriCouldNotBeGenerated::insufficientParameters( 49 | $name, 50 | $missingParameters, 51 | array_keys($substitutions), 52 | ); 53 | } 54 | 55 | /** 56 | * Returns the expected parameters that were not passed as substitutions 57 | * 58 | * @param ParsedRoute $parts 59 | * @param UriSubstitutions $substitutions 60 | * 61 | * @return list 62 | */ 63 | private function missingParameters(array $parts, array $substitutions): array 64 | { 65 | $missingParameters = []; 66 | 67 | foreach ($parts as $part) { 68 | if (is_string($part) || array_key_exists($part[0], $substitutions)) { 69 | continue; 70 | } 71 | 72 | $missingParameters[] = $part[0]; 73 | } 74 | 75 | return $missingParameters; 76 | } 77 | 78 | /** 79 | * @param ParsedRoute $parsedRoute 80 | * @param UriSubstitutions $substitutions 81 | */ 82 | private function generatePath(string $route, array $parsedRoute, array $substitutions): string 83 | { 84 | $path = ''; 85 | 86 | foreach ($parsedRoute as $part) { 87 | if (is_string($part)) { 88 | $path .= $part; 89 | 90 | continue; 91 | } 92 | 93 | [$parameterName, $regex] = $part; 94 | 95 | if (preg_match('~^' . $regex . '$~u', $substitutions[$parameterName]) !== 1) { 96 | throw UriCouldNotBeGenerated::parameterDoesNotMatchThePattern($route, $parameterName, $regex); 97 | } 98 | 99 | $path .= $substitutions[$parameterName]; 100 | } 101 | 102 | return $path; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/GenerateUri/UriCouldNotBeGenerated.php: -------------------------------------------------------------------------------- 1 | $missingParameters 37 | * @param list $givenParameters 38 | */ 39 | public static function insufficientParameters( 40 | string $route, 41 | array $missingParameters, 42 | array $givenParameters, 43 | ): self { 44 | return new self( 45 | sprintf( 46 | 'Route "%s" expects at least parameter values for [%s], but received %s', 47 | $route, 48 | implode(',', $missingParameters), 49 | count($givenParameters) === 0 ? 'none' : '[' . implode(',', $givenParameters) . ']', 50 | ), 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Route.php: -------------------------------------------------------------------------------- 1 | $variables */ 21 | public readonly array $variables; 22 | 23 | /** 24 | * @param ParsedRoute $routeData 25 | * @param ExtraParameters $extraParameters 26 | */ 27 | public function __construct( 28 | public readonly string $httpMethod, 29 | array $routeData, 30 | public readonly mixed $handler, 31 | public readonly array $extraParameters, 32 | ) { 33 | [$this->regex, $this->variables] = self::extractRegex($routeData); 34 | } 35 | 36 | /** 37 | * @param ParsedRoute $routeData 38 | * 39 | * @return array{string, array} 40 | */ 41 | private static function extractRegex(array $routeData): array 42 | { 43 | $regex = ''; 44 | $variables = []; 45 | 46 | foreach ($routeData as $part) { 47 | if (is_string($part)) { 48 | $regex .= preg_quote($part, '~'); 49 | continue; 50 | } 51 | 52 | [$varName, $regexPart] = $part; 53 | 54 | $variables[$varName] = $varName; 55 | $regex .= '(' . $regexPart . ')'; 56 | } 57 | 58 | return [$regex, $variables]; 59 | } 60 | 61 | /** 62 | * Tests whether this route matches the given string. 63 | */ 64 | public function matches(string $str): bool 65 | { 66 | $regex = '~^' . $this->regex . '$~'; 67 | 68 | return (bool) preg_match($regex, $str); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/RouteCollector.php: -------------------------------------------------------------------------------- 1 | currentGroupPrefix . $route; 34 | $parsedRoutes = $this->routeParser->parse($route); 35 | 36 | $extraParameters = [self::ROUTE_REGEX => $route] + $extraParameters; 37 | 38 | foreach ((array) $httpMethod as $method) { 39 | foreach ($parsedRoutes as $parsedRoute) { 40 | $this->dataGenerator->addRoute($method, $parsedRoute, $handler, $extraParameters); 41 | } 42 | } 43 | 44 | if (array_key_exists(self::ROUTE_NAME, $extraParameters)) { 45 | $this->registerNamedRoute($extraParameters[self::ROUTE_NAME], $parsedRoutes); 46 | } 47 | } 48 | 49 | /** @param ParsedRoutes $parsedRoutes */ 50 | private function registerNamedRoute(mixed $name, array $parsedRoutes): void 51 | { 52 | if (! is_string($name) || $name === '') { 53 | throw BadRouteException::invalidRouteName($name); 54 | } 55 | 56 | if (array_key_exists($name, $this->namedRoutes)) { 57 | throw BadRouteException::namedRouteAlreadyDefined($name); 58 | } 59 | 60 | $this->namedRoutes[$name] = array_reverse($parsedRoutes); 61 | } 62 | 63 | public function addGroup(string $prefix, callable $callback): void 64 | { 65 | $previousGroupPrefix = $this->currentGroupPrefix; 66 | $this->currentGroupPrefix = $previousGroupPrefix . $prefix; 67 | $callback($this); 68 | $this->currentGroupPrefix = $previousGroupPrefix; 69 | } 70 | 71 | /** @inheritDoc */ 72 | public function any(string $route, mixed $handler, array $extraParameters = []): void 73 | { 74 | $this->addRoute('*', $route, $handler, $extraParameters); 75 | } 76 | 77 | /** @inheritDoc */ 78 | public function get(string $route, mixed $handler, array $extraParameters = []): void 79 | { 80 | $this->addRoute('GET', $route, $handler, $extraParameters); 81 | } 82 | 83 | /** @inheritDoc */ 84 | public function post(string $route, mixed $handler, array $extraParameters = []): void 85 | { 86 | $this->addRoute('POST', $route, $handler, $extraParameters); 87 | } 88 | 89 | /** @inheritDoc */ 90 | public function put(string $route, mixed $handler, array $extraParameters = []): void 91 | { 92 | $this->addRoute('PUT', $route, $handler, $extraParameters); 93 | } 94 | 95 | /** @inheritDoc */ 96 | public function delete(string $route, mixed $handler, array $extraParameters = []): void 97 | { 98 | $this->addRoute('DELETE', $route, $handler, $extraParameters); 99 | } 100 | 101 | /** @inheritDoc */ 102 | public function patch(string $route, mixed $handler, array $extraParameters = []): void 103 | { 104 | $this->addRoute('PATCH', $route, $handler, $extraParameters); 105 | } 106 | 107 | /** @inheritDoc */ 108 | public function head(string $route, mixed $handler, array $extraParameters = []): void 109 | { 110 | $this->addRoute('HEAD', $route, $handler, $extraParameters); 111 | } 112 | 113 | /** @inheritDoc */ 114 | public function options(string $route, mixed $handler, array $extraParameters = []): void 115 | { 116 | $this->addRoute('OPTIONS', $route, $handler, $extraParameters); 117 | } 118 | 119 | /** @inheritDoc */ 120 | public function processedRoutes(): array 121 | { 122 | $data = $this->dataGenerator->getData(); 123 | $data[] = $this->namedRoutes; 124 | 125 | return $data; 126 | } 127 | 128 | /** 129 | * @deprecated 130 | * 131 | * @see ConfigureRoutes::processedRoutes() 132 | * 133 | * @return ProcessedData 134 | */ 135 | public function getData(): array 136 | { 137 | return $this->processedRoutes(); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/RouteParser.php: -------------------------------------------------------------------------------- 1 | 8 | * @phpstan-type ParsedRoutes list 9 | */ 10 | interface RouteParser 11 | { 12 | /** 13 | * Parses a route string into multiple route data arrays. 14 | * 15 | * The expected output is defined using an example: 16 | * 17 | * For the route string "/fixedRoutePart/{varName}[/moreFixed/{varName2:\d+}]", if {varName} is interpreted as 18 | * a placeholder and [...] is interpreted as an optional route part, the expected result is: 19 | * 20 | * [ 21 | * // first route: without optional part 22 | * [ 23 | * "/fixedRoutePart/", 24 | * ["varName", "[^/]+"], 25 | * ], 26 | * // second route: with optional part 27 | * [ 28 | * "/fixedRoutePart/", 29 | * ["varName", "[^/]+"], 30 | * "/moreFixed/", 31 | * ["varName2", [0-9]+"], 32 | * ], 33 | * ] 34 | * 35 | * Here one route string was converted into two route data arrays. 36 | * 37 | * @param string $route Route string to parse 38 | * 39 | * @return ParsedRoutes Array of route data arrays 40 | */ 41 | public function parse(string $route): array; 42 | } 43 | -------------------------------------------------------------------------------- /src/RouteParser/Std.php: -------------------------------------------------------------------------------- 1 | $segment) { 81 | if ($segment === '' && $n !== 0) { 82 | throw new BadRouteException('Empty optional part'); 83 | } 84 | 85 | $currentRoute .= $segment; 86 | $parsedRoutes[] = $this->parsePlaceholders($currentRoute); 87 | } 88 | 89 | return $parsedRoutes; 90 | } 91 | 92 | /** 93 | * Parses a route string that does not contain optional segments. 94 | * 95 | * @return ParsedRoute 96 | */ 97 | private function parsePlaceholders(string $route): array 98 | { 99 | if ((int) preg_match_all('~' . self::VARIABLE_REGEX . '~x', $route, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER) === 0) { 100 | return [$route]; 101 | } 102 | 103 | $offset = 0; 104 | $routeData = []; 105 | 106 | $parsedVariableNames = []; 107 | 108 | foreach ($matches as $set) { 109 | if ($set[0][1] > $offset) { 110 | $routeData[] = substr($route, $offset, $set[0][1] - $offset); 111 | } 112 | 113 | if (in_array($set[1][0], $parsedVariableNames, true)) { 114 | throw BadRouteException::placeholderAlreadyDefined($set[1][0]); 115 | } 116 | 117 | if (isset($set[2])) { 118 | $this->guardAgainstCapturingGroupUsage(trim($set[2][0]), $set[1][0]); 119 | } 120 | 121 | $parsedVariableNames[] = $set[1][0]; 122 | 123 | $routeData[] = [ 124 | $set[1][0], 125 | isset($set[2]) ? trim($set[2][0]) : self::DEFAULT_DISPATCH_REGEX, 126 | ]; 127 | 128 | $offset = $set[0][1] + strlen($set[0][0]); 129 | } 130 | 131 | if ($offset !== strlen($route)) { 132 | $routeData[] = substr($route, $offset); 133 | } 134 | 135 | return $routeData; 136 | } 137 | 138 | private function guardAgainstCapturingGroupUsage(string $regex, string $variableName): void 139 | { 140 | // Needs to have at least a ( to contain a capturing group 141 | if (! str_contains($regex, '(')) { 142 | return; 143 | } 144 | 145 | // Semi-accurate detection for capturing groups 146 | if (preg_match(self::CAPTURING_GROUPS_REGEX, $regex) !== 1) { 147 | return; 148 | } 149 | 150 | throw BadRouteException::variableWithCaptureGroup($regex, $variableName); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/functions.php: -------------------------------------------------------------------------------- 1 | , dataGenerator?: class-string, dispatcher?: class-string, routeCollector?: class-string, cacheDisabled?: bool, cacheKey?: string, cacheFile?: string, cacheDriver?: class-string|Cache} $options 21 | */ 22 | function simpleDispatcher(callable $routeDefinitionCallback, array $options = []): Dispatcher 23 | { 24 | return \FastRoute\cachedDispatcher( 25 | $routeDefinitionCallback, 26 | ['cacheDisabled' => true] + $options, 27 | ); 28 | } 29 | 30 | /** 31 | * @deprecated since v2.0 and will be removed in v3.0 32 | * 33 | * @see FastRoute::recommendedSettings() 34 | * 35 | * @param callable(ConfigureRoutes):void $routeDefinitionCallback 36 | * @param array{routeParser?: class-string, dataGenerator?: class-string, dispatcher?: class-string, routeCollector?: class-string, cacheDisabled?: bool, cacheKey?: string, cacheFile?: string, cacheDriver?: class-string|Cache} $options 37 | */ 38 | function cachedDispatcher(callable $routeDefinitionCallback, array $options = []): Dispatcher 39 | { 40 | $options += [ 41 | 'routeParser' => RouteParser\Std::class, 42 | 'dataGenerator' => DataGenerator\MarkBased::class, 43 | 'dispatcher' => Dispatcher\MarkBased::class, 44 | 'routeCollector' => RouteCollector::class, 45 | 'cacheDisabled' => false, 46 | 'cacheDriver' => FileCache::class, 47 | ]; 48 | 49 | $loader = static function () use ($routeDefinitionCallback, $options): array { 50 | $routeCollector = new $options['routeCollector']( 51 | new $options['routeParser'](), 52 | new $options['dataGenerator']() 53 | ); 54 | 55 | $routeDefinitionCallback($routeCollector); 56 | 57 | return $routeCollector->processedRoutes(); 58 | }; 59 | 60 | if ($options['cacheDisabled'] === true) { 61 | return new $options['dispatcher']($loader()); 62 | } 63 | 64 | $cacheKey = $options['cacheKey'] ?? $options['cacheFile'] ?? null; 65 | 66 | if ($cacheKey === null) { 67 | throw new LogicException('Must specify "cacheKey" option'); 68 | } 69 | 70 | $cache = $options['cacheDriver']; 71 | 72 | if (is_string($cache)) { 73 | $cache = new $cache(); 74 | } 75 | 76 | return new $options['dispatcher']($cache->get($cacheKey, $loader)); 77 | } 78 | } 79 | --------------------------------------------------------------------------------