├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── src ├── Exception │ ├── MethodNotAllowed.php │ └── RouteNotFound.php ├── Helper.php ├── Route.php ├── Router.php ├── RouterInterface.php ├── RouterMiddleware.php ├── Traits │ └── RouteTrait.php └── UrlGenerator.php └── tests ├── RouteTest.php └── RouterTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /.idea/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 F. Michel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP Router 2 | 3 | PHP Router is a simple and efficient routing library designed for PHP applications. It provides a straightforward way to define routes, handle HTTP requests, and generate URLs. Built with PSR-7 message implementation in mind, it seamlessly integrates with PHP applications. 4 | 5 | 6 | ## Installation 7 | 8 | You can install PHP Router via Composer. Just run: 9 | 10 | ### Composer Require 11 | ``` 12 | composer require phpdevcommunity/php-router 13 | ``` 14 | 15 | ## Requirements 16 | 17 | * PHP version 7.4 or above 18 | * Enable URL rewriting on your web server 19 | * Optional: PSR-7 HTTP Message package (e.g., guzzlehttp/psr7) 20 | 21 | ## Usage 22 | 23 | 1. **Define Routes**: Define routes using the `Route` class provided by PHP Router. 24 | 25 | 2. **Initialize Router**: Initialize the `Router` class with the defined routes. 26 | 27 | 3. **Match Requests**: Match incoming HTTP requests to defined routes. 28 | 29 | 4. **Handle Requests**: Handle matched routes by executing appropriate controllers or handlers. 30 | 31 | 5. **Generate URLs**: Generate URLs for named routes. 32 | 33 | 34 | ## Example 35 | ```php 36 | 1], 52 | ['id' => 2], 53 | ['id' => 3] 54 | ]); 55 | } 56 | 57 | public function get(int $id) 58 | { 59 | // db get post by id 60 | return json_encode(['id' => $id]); 61 | } 62 | 63 | public function put(int $id) 64 | { 65 | // db edited post by id 66 | return json_encode(['id' => $id]); 67 | } 68 | 69 | public function post() 70 | { 71 | // db create post 72 | return json_encode(['id' => 4]); 73 | } 74 | } 75 | ``` 76 | 77 | ```php 78 | // Define your routes 79 | $routes = [ 80 | new \PhpDevCommunity\Route('home_page', '/', [IndexController::class]), 81 | new \PhpDevCommunity\Route('api_articles_collection', '/api/articles', [ArticleController::class, 'getAll']), 82 | new \PhpDevCommunity\Route('api_articles', '/api/articles/{id}', [ArticleController::class, 'get']), 83 | ]; 84 | 85 | // Initialize the router 86 | $router = new \PhpDevCommunity\Router($routes, 'http://localhost'); 87 | 88 | try { 89 | // Match incoming request 90 | $route = $router->match(ServerRequestFactory::fromGlobals()); 91 | 92 | // Handle the matched route 93 | $handler = $route->getHandler(); 94 | $attributes = $route->getAttributes(); 95 | $controllerName = $handler[0]; 96 | $methodName = $handler[1] ?? null; 97 | $controller = new $controllerName(); 98 | 99 | // Invoke the controller method 100 | if (!is_callable($controller)) { 101 | $controller = [$controller, $methodName]; 102 | } 103 | echo $controller(...array_values($attributes)); 104 | 105 | } catch (\PhpDevCommunity\Exception\MethodNotAllowed $exception) { 106 | header("HTTP/1.0 405 Method Not Allowed"); 107 | exit(); 108 | } catch (\PhpDevCommunity\Exception\RouteNotFound $exception) { 109 | header("HTTP/1.0 404 Not Found"); 110 | exit(); 111 | } 112 | ``` 113 | 114 | ## Features 115 | 116 | - Lightweight and easy-to-use 117 | - Supports HTTP method-based routing 118 | - Flexible route definition with attribute constraints 119 | - Exception handling for method not allowed and route not found scenarios 120 | 121 | ## Route Definition 122 | 123 | Routes can be defined using the `Route` class provided by PHP Router. You can specify HTTP methods, attribute constraints, and handler methods for each route. 124 | 125 | ```php 126 | $route = new \PhpDevCommunity\Route('api_articles_post', '/api/articles', [ArticleController::class, 'post'], ['POST']); 127 | $route = new \PhpDevCommunity\Route('api_articles_put', '/api/articles/{id}', [ArticleController::class, 'put'], ['PUT']); 128 | ``` 129 | ### Easier Route Definition with Static Methods 130 | 131 | To make route definition even simpler and more intuitive, the `RouteTrait` provides static methods for creating different types of HTTP routes. Here's how to use them: 132 | 133 | #### Method `get()` 134 | 135 | ```php 136 | /** 137 | * Creates a new GET route with the given name, path, and handler. 138 | * 139 | * @param string $name The name of the route. 140 | * @param string $path The path of the route. 141 | * @param mixed $handler The handler for the route. 142 | * @return BaseRoute The newly created GET route. 143 | */ 144 | public static function get(string $name, string $path, $handler): BaseRoute 145 | { 146 | return new BaseRoute($name, $path, $handler); 147 | } 148 | ``` 149 | 150 | Example Usage: 151 | 152 | ```php 153 | $route = Route::get('home', '/', [HomeController::class, 'index']); 154 | ``` 155 | 156 | #### Method `post()` 157 | 158 | ```php 159 | /** 160 | * Creates a new POST route with the given name, path, and handler. 161 | * 162 | * @param string $name The name of the route. 163 | * @param string $path The path of the route. 164 | * @param mixed $handler The handler for the route. 165 | * @return BaseRoute The newly created POST route. 166 | */ 167 | public static function post(string $name, string $path, $handler): BaseRoute 168 | { 169 | return new BaseRoute($name, $path, $handler, ['POST']); 170 | } 171 | ``` 172 | 173 | Example Usage: 174 | 175 | ```php 176 | $route = Route::post('submit_form', '/submit', [FormController::class, 'submit']); 177 | ``` 178 | 179 | #### Method `put()` 180 | 181 | ```php 182 | /** 183 | * Creates a new PUT route with the given name, path, and handler. 184 | * 185 | * @param string $name The name of the route. 186 | * @param string $path The path of the route. 187 | * @param mixed $handler The handler for the route. 188 | * @return BaseRoute The newly created PUT route. 189 | */ 190 | public static function put(string $name, string $path, $handler): BaseRoute 191 | { 192 | return new BaseRoute($name, $path, $handler, ['PUT']); 193 | } 194 | ``` 195 | 196 | Example Usage: 197 | 198 | ```php 199 | $route = Route::put('update_item', '/item/{id}', [ItemController::class, 'update']); 200 | ``` 201 | 202 | #### Method `delete()` 203 | 204 | ```php 205 | /** 206 | * Creates a new DELETE route with the given name, path, and handler. 207 | * 208 | * @param string $name The name of the route. 209 | * @param string $path The path of the route. 210 | * @param mixed $handler The handler for the route. 211 | * @return BaseRoute The newly created DELETE route. 212 | */ 213 | public static function delete(string $name, string $path, $handler): BaseRoute 214 | { 215 | return new BaseRoute($name, $path, $handler, ['DELETE']); 216 | } 217 | ``` 218 | 219 | Example Usage: 220 | 221 | ```php 222 | $route = Route::delete('delete_item', '/item/{id}', [ItemController::class, 'delete']); 223 | ``` 224 | 225 | ### Using `where` Constraints in the Route Object 226 | 227 | The `Route` object allows you to define constraints on route parameters using the `where` methods. These constraints validate and filter parameter values based on regular expressions. Here's how to use them: 228 | 229 | #### Method `whereNumber()` 230 | 231 | This method applies a numeric constraint to the specified route parameters. 232 | 233 | ```php 234 | /** 235 | * Sets a number constraint on the specified route parameters. 236 | * 237 | * @param mixed ...$parameters The route parameters to apply the constraint to. 238 | * @return self The updated Route instance. 239 | */ 240 | public function whereNumber(...$parameters): self 241 | { 242 | $this->assignExprToParameters($parameters, '[0-9]+'); 243 | return $this; 244 | } 245 | ``` 246 | 247 | Example Usage: 248 | 249 | ```php 250 | $route = (new Route('example', '/example/{id}'))->whereNumber('id'); 251 | ``` 252 | 253 | #### Method `whereSlug()` 254 | 255 | This method applies a slug constraint to the specified route parameters, allowing alphanumeric characters and hyphens. 256 | 257 | ```php 258 | /** 259 | * Sets a slug constraint on the specified route parameters. 260 | * 261 | * @param mixed ...$parameters The route parameters to apply the constraint to. 262 | * @return self The updated Route instance. 263 | */ 264 | public function whereSlug(...$parameters): self 265 | { 266 | $this->assignExprToParameters($parameters, '[a-z0-9-]+'); 267 | return $this; 268 | } 269 | ``` 270 | 271 | Example Usage: 272 | 273 | ```php 274 | $route = (new Route('article', '/article/{slug}'))->whereSlug('slug'); 275 | ``` 276 | 277 | #### Method `whereAlphaNumeric()` 278 | 279 | This method applies an alphanumeric constraint to the specified route parameters. 280 | 281 | ```php 282 | /** 283 | * Sets an alphanumeric constraint on the specified route parameters. 284 | * 285 | * @param mixed ...$parameters The route parameters to apply the constraint to. 286 | * @return self The updated Route instance. 287 | */ 288 | public function whereAlphaNumeric(...$parameters): self 289 | { 290 | $this->assignExprToParameters($parameters, '[a-zA-Z0-9]+'); 291 | return $this; 292 | } 293 | ``` 294 | 295 | Example Usage: 296 | 297 | ```php 298 | $route = (new Route('user', '/user/{username}'))->whereAlphaNumeric('username'); 299 | ``` 300 | 301 | #### Method `whereAlpha()` 302 | 303 | This method applies an alphabetic constraint to the specified route parameters. 304 | 305 | ```php 306 | /** 307 | * Sets an alphabetic constraint on the specified route parameters. 308 | * 309 | * @param mixed ...$parameters The route parameters to apply the constraint to. 310 | * @return self The updated Route instance. 311 | */ 312 | public function whereAlpha(...$parameters): self 313 | { 314 | $this->assignExprToParameters($parameters, '[a-zA-Z]+'); 315 | return $this; 316 | } 317 | ``` 318 | 319 | Example Usage: 320 | 321 | ```php 322 | $route = (new Route('category', '/category/{name}'))->whereAlpha('name'); 323 | ``` 324 | 325 | #### Method `whereTwoSegments()` 326 | 327 | This method applies a constraint to match exactly two path segments separated by a slash. 328 | 329 | ```php 330 | /** 331 | * Sets a constraint for exactly two path segments separated by a slash. 332 | * 333 | * Example: /{segment1}/{segment2} 334 | * 335 | * @param mixed ...$parameters The route parameters to apply the constraint to. 336 | * @return self The updated Route instance. 337 | */ 338 | public function whereTwoSegments(...$parameters): self 339 | { 340 | $this->assignExprToParameters($parameters, '[a-zA-Z0-9\-_]+/[a-zA-Z0-9\-_]+'); 341 | foreach ($parameters as $parameter) { 342 | $this->path = str_replace(sprintf('{%s}', $parameter), sprintf('{%s*}', $parameter), $this->path); 343 | } 344 | return $this; 345 | } 346 | ``` 347 | 348 | Example Usage: 349 | 350 | ```php 351 | $route = (new Route('profile', '/profile/{username}/{id}'))->whereTwoSegments('username', 'id'); 352 | ``` 353 | 354 | #### Method `whereAnything()` 355 | 356 | This method applies a constraint to match any characters. 357 | 358 | ```php 359 | /** 360 | * Sets a constraint to match any characters. 361 | * 362 | * Example: /{anyPath} 363 | * 364 | * @param mixed ...$parameters The route parameters to apply the constraint to. 365 | * @return self The updated Route instance. 366 | */ 367 | public function whereAnything(...$parameters): self 368 | { 369 | $this->assignExprToParameters($parameters, '.+'); 370 | foreach ($parameters as $parameter) { 371 | $this->path = str_replace(sprintf('{%s}', $parameter), sprintf('{%s*}', $parameter), $this->path); 372 | } 373 | return $this; 374 | } 375 | ``` 376 | 377 | Example Usage: 378 | 379 | ```php 380 | $route = (new Route('any', '/{anyPath}'))->whereAnything('anyPath'); 381 | ``` 382 | 383 | #### Method `whereDate()` 384 | 385 | This method applies a date constraint to the specified route parameters, expecting a format `YYYY-MM-DD`. 386 | 387 | ```php 388 | /** 389 | * Sets a date constraint on the specified route parameters. 390 | * 391 | * Example: /{date} 392 | * 393 | * @param mixed ...$parameters The route parameters to apply the constraint to. 394 | * @return self The updated Route instance. 395 | */ 396 | public function whereDate(...$parameters): self 397 | { 398 | $this->assignExprToParameters($parameters, '\d{4}-\d{2}-\d{2}'); 399 | return $this; 400 | } 401 | ``` 402 | 403 | Example Usage: 404 | 405 | ```php 406 | $route = (new Route('date', '/date/{date}'))->whereDate('date'); 407 | ``` 408 | 409 | #### Method `whereYearMonth()` 410 | 411 | This method applies a year-month constraint to the specified route parameters, expecting a format `YYYY-MM`. 412 | 413 | ```php 414 | /** 415 | * Sets a year/month constraint on the specified route parameters. 416 | * 417 | * Example: /{yearMonth} 418 | * 419 | * @param mixed ...$parameters The route parameters to apply the constraint to. 420 | * @return self The updated Route instance. 421 | */ 422 | public function whereYearMonth(...$parameters): self 423 | { 424 | $this->assignExprToParameters($parameters, '\d{4}-\d{2}'); 425 | return $this; 426 | } 427 | ``` 428 | 429 | Example Usage: 430 | 431 | ```php 432 | $route = (new Route('yearMonth', '/yearMonth/{yearMonth}'))->whereYearMonth('yearMonth'); 433 | ``` 434 | 435 | #### Method `whereEmail()` 436 | 437 | This method applies an email constraint to the specified route parameters. 438 | 439 | ```php 440 | /** 441 | * Sets an email constraint on the specified route parameters. 442 | * 443 | * Example: /{email} 444 | * 445 | * @param mixed ...$parameters The route parameters to apply the constraint to. 446 | * @return self The updated Route instance. 447 | */ 448 | public function whereEmail(...$parameters): self 449 | { 450 | $this->assignExprToParameters($parameters, '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'); 451 | return $this; 452 | } 453 | ``` 454 | 455 | Example Usage: 456 | 457 | ```php 458 | $route = (new Route('user', '/user/{email}'))->whereEmail('email'); 459 | ``` 460 | 461 | #### Method `whereUuid()` 462 | 463 | This method applies a UUID constraint to the specified route parameters. 464 | 465 | ```php 466 | /** 467 | * Sets a UUID constraint on the specified route parameters. 468 | * 469 | * Example: /{uuid} 470 | * 471 | * @param mixed ...$parameters The route parameters to apply the constraint to. 472 | * @return self The updated Route instance. 473 | */ 474 | public function whereUuid(...$parameters): self 475 | { 476 | $this->assignExprToParameters($parameters, '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}'); 477 | return $this; 478 | } 479 | ``` 480 | 481 | Example Usage: 482 | 483 | ```php 484 | $route = (new Route('profile', '/profile/{uuid}'))->whereUuid('uuid'); 485 | ``` 486 | 487 | #### Method `whereBool()` 488 | 489 | This method applies a boolean constraint to the specified route parameters, accepting `true`, `false`, `1`, and `0`. 490 | 491 | ```php 492 | /** 493 | * Sets a boolean constraint on the specified route parameters. 494 | * 495 | * Example: /{isActive} 496 | * 497 | * @param mixed ...$parameters The route parameters to apply the constraint to. 498 | * @return self The updated Route instance. 499 | */ 500 | public function whereBool(...$parameters): self 501 | { 502 | $this->assignExprToParameters($parameters, 'true|false|1|0'); 503 | return $this; 504 | } 505 | ``` 506 | 507 | Example Usage: 508 | 509 | ```php 510 | $route = (new Route('status', '/status/{isActive}'))->whereBool('isActive'); 511 | ``` 512 | 513 | #### Method `where()` 514 | 515 | This method allows you to define a custom constraint on a specified route parameter. 516 | 517 | ```php 518 | /** 519 | * Sets a custom constraint on the specified route parameter. 520 | * 521 | * @param string $parameter The route parameter to apply the constraint to. 522 | * @param string $expression The regular expression constraint. 523 | * @return self The updated Route instance. 524 | */ 525 | public function where(string $parameter, string $expression): self 526 | { 527 | $this->wheres[$parameter] = $expression; 528 | return $this; 529 | } 530 | ``` 531 | 532 | Example Usage: 533 | 534 | ```php 535 | $route = (new Route('product', '/product/{code}'))->where('code', '\d{4}'); 536 | ``` 537 | 538 | By using these `where` methods, you can apply precise constraints on your route parameters, ensuring proper validation of input values. 539 | 540 | ## Generating URLs 541 | 542 | Generate URLs for named routes using the `generateUri` method. 543 | 544 | ```php 545 | echo $router->generateUri('home_page'); // / 546 | echo $router->generateUri('api_articles', ['id' => 1]); // /api/articles/1 547 | echo $router->generateUri('api_articles', ['id' => 1], true); // http://localhost/api/articles/1 548 | ``` 549 | ## Contributing 550 | 551 | Contributions are welcome! Feel free to open issues or submit pull requests to help improve the library. 552 | 553 | ## License 554 | 555 | This library is open-source software licensed under the [MIT license](https://opensource.org/licenses/MIT). 556 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phpdevcommunity/php-router", 3 | "description": "A versatile and efficient PHP routing solution designed to streamline route management within PHP applications.", 4 | "type": "library", 5 | "autoload": { 6 | "psr-4": { 7 | "PhpDevCommunity\\": "src", 8 | "Test\\PhpDevCommunity\\": "tests" 9 | } 10 | }, 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "F. Michel", 15 | "homepage": "https://www.phpdevcommunity.com" 16 | } 17 | ], 18 | "minimum-stability": "alpha", 19 | "require": { 20 | "php": ">=7.4", 21 | "psr/http-message": "^1.0|^2.0", 22 | "psr/http-server-middleware": "^1.0", 23 | "psr/http-factory": "^1.0" 24 | }, 25 | "require-dev": { 26 | "phpdevcommunity/unitester": "^0.1.0@alpha" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Exception/MethodNotAllowed.php: -------------------------------------------------------------------------------- 1 | 31 | */ 32 | private array $methods = []; 33 | 34 | /** 35 | * @var array 36 | */ 37 | private array $attributes = []; 38 | 39 | /** 40 | * @var array 41 | */ 42 | private array $wheres = []; 43 | 44 | /** 45 | * Constructor for the Route class. 46 | * 47 | * @param string $name The name of the route. 48 | * @param string $path The path of the route. 49 | * @param mixed $handler The handler for the route. 50 | * $handler = [ 51 | * 0 => (string) Controller name : HomeController::class. 52 | * 1 => (string|null) Method name or null if invoke method 53 | * ] 54 | * @param array $methods The HTTP methods for the route. Default is ['GET', 'HEAD']. 55 | * 56 | * @throws InvalidArgumentException If the HTTP methods argument is empty. 57 | */ 58 | public function __construct(string $name, string $path, $handler, array $methods = ['GET', 'HEAD']) 59 | { 60 | if ($methods === []) { 61 | throw new InvalidArgumentException('HTTP methods argument was empty; must contain at least one method'); 62 | } 63 | $this->name = $name; 64 | $this->path = Helper::trimPath($path); 65 | $this->handler = $handler; 66 | $this->methods = $methods; 67 | 68 | if (in_array('GET', $this->methods) && !in_array('HEAD', $this->methods)) { 69 | $this->methods[] = 'HEAD'; 70 | } 71 | } 72 | 73 | /** 74 | * Matches a given path against the route's path and extracts attribute values. 75 | * 76 | * @param string $path The path to match against. 77 | * @return bool True if the path matches the route's path, false otherwise. 78 | */ 79 | public function match(string $path): bool 80 | { 81 | $regex = $this->getPath(); 82 | // This loop replaces all route variables like {var} or {var*} with corresponding regex patterns. 83 | // If the variable name ends with '*', it means the value can contain slashes (e.g. /foo/bar). 84 | // In that case, we use a permissive regex: (?P.+) — matches everything including slashes. 85 | // Otherwise, we use a strict regex: (?P[^/]++), which excludes slashes for standard segments. 86 | // The possessive quantifier '++' is used for better performance (avoids unnecessary backtracking). 87 | foreach ($this->getVarsNames() as $variable) { 88 | $varName = trim($variable, '{\}'); 89 | $end = '*'; 90 | if ((@substr_compare($varName, $end, -strlen($end)) == 0)) { 91 | $varName = rtrim($varName, $end); 92 | $regex = str_replace($variable, '(?P<' . $varName . '>.+)', $regex); // allows slashes 93 | continue; 94 | } 95 | $regex = str_replace($variable, '(?P<' . $varName . '>[^/]++)', $regex); // faster, excludes slashes 96 | } 97 | 98 | if (!preg_match('#^' . $regex . '$#sD', Helper::trimPath($path), $matches)) { 99 | return false; 100 | } 101 | 102 | $values = array_filter($matches, static function ($key) { 103 | return is_string($key); 104 | }, ARRAY_FILTER_USE_KEY); 105 | 106 | foreach ($values as $key => $value) { 107 | if (array_key_exists($key, $this->wheres)) { 108 | $pattern = $this->wheres[$key]; 109 | $delimiter = '#'; 110 | $regex = $delimiter . '^' . $pattern . '$' . $delimiter; 111 | if (!preg_match($regex, $value)) { 112 | return false; 113 | } 114 | } 115 | $this->attributes[$key] = $value; 116 | } 117 | 118 | return true; 119 | } 120 | 121 | /** 122 | * Returns the name of the Route. 123 | * 124 | * @return string The name of the Route. 125 | */ 126 | public function getName(): string 127 | { 128 | return $this->name; 129 | } 130 | 131 | /** 132 | * Returns the path of the Route. 133 | * 134 | * @return string The path of the Route. 135 | */ 136 | public function getPath(): string 137 | { 138 | return $this->path; 139 | } 140 | 141 | public function getHandler() 142 | { 143 | return $this->handler; 144 | } 145 | 146 | /** 147 | * Returns the HTTP methods for the Route. 148 | * 149 | * @return array The HTTP methods for the Route. 150 | */ 151 | public function getMethods(): array 152 | { 153 | return $this->methods; 154 | } 155 | 156 | public function getVarsNames(): array 157 | { 158 | preg_match_all('/{[^}]*}/', $this->path, $matches); 159 | return reset($matches) ?? []; 160 | } 161 | 162 | public function hasAttributes(): bool 163 | { 164 | return $this->getVarsNames() !== []; 165 | } 166 | 167 | /** 168 | * @return array 169 | */ 170 | public function getAttributes(): array 171 | { 172 | return $this->attributes; 173 | } 174 | 175 | /** 176 | * Sets a number constraint on the specified route parameters. 177 | * 178 | * @param mixed ...$parameters The route parameters to apply the constraint to. 179 | * @return self The updated Route instance. 180 | */ 181 | public function whereNumber(...$parameters): self 182 | { 183 | $this->assignExprToParameters($parameters, '[0-9]+'); 184 | return $this; 185 | } 186 | 187 | /** 188 | * Sets a slug constraint on the specified route parameters. 189 | * 190 | * @param mixed ...$parameters The route parameters to apply the constraint to. 191 | * @return self The updated Route instance. 192 | */ 193 | public function whereSlug(...$parameters): self 194 | { 195 | $this->assignExprToParameters($parameters, '[a-z0-9-]+'); 196 | return $this; 197 | } 198 | 199 | /** 200 | * Sets an alphanumeric constraint on the specified route parameters. 201 | * 202 | * @param mixed ...$parameters The route parameters to apply the constraint to. 203 | * @return self The updated Route instance. 204 | */ 205 | public function whereAlphaNumeric(...$parameters): self 206 | { 207 | $this->assignExprToParameters($parameters, '[a-zA-Z0-9]+'); 208 | return $this; 209 | } 210 | 211 | /** 212 | * Sets an alphabetic constraint on the specified route parameters. 213 | * 214 | * @param mixed ...$parameters The route parameters to apply the constraint to. 215 | * @return self The updated Route instance. 216 | */ 217 | public function whereAlpha(...$parameters): self 218 | { 219 | $this->assignExprToParameters($parameters, '[a-zA-Z]+'); 220 | return $this; 221 | } 222 | 223 | public function whereTwoSegments(...$parameters): self 224 | { 225 | $this->assignExprToParameters($parameters, '[a-zA-Z0-9\-_]+/[a-zA-Z0-9\-_]+'); 226 | foreach ($parameters as $parameter) { 227 | $this->path = str_replace(sprintf('{%s}', $parameter), sprintf('{%s*}', $parameter), $this->path); 228 | } 229 | return $this; 230 | } 231 | 232 | public function whereAnything(string $parameter): self 233 | { 234 | $this->assignExprToParameters([$parameter], '.+'); 235 | $this->path = str_replace(sprintf('{%s}', $parameter), sprintf('{%s*}', $parameter), $this->path); 236 | return $this; 237 | } 238 | 239 | public function whereDate(...$parameters): self 240 | { 241 | $this->assignExprToParameters($parameters, '\d{4}-\d{2}-\d{2}'); 242 | return $this; 243 | } 244 | 245 | public function whereYearMonth(...$parameters): self 246 | { 247 | $this->assignExprToParameters($parameters, '\d{4}-\d{2}'); 248 | return $this; 249 | } 250 | 251 | public function whereEmail(...$parameters): self 252 | { 253 | $this->assignExprToParameters($parameters, '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'); 254 | return $this; 255 | } 256 | 257 | public function whereUuid(...$parameters): self 258 | { 259 | $this->assignExprToParameters($parameters, '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}'); 260 | return $this; 261 | } 262 | 263 | public function whereBool(...$parameters): self 264 | { 265 | $this->assignExprToParameters($parameters, 'true|false|1|0'); 266 | return $this; 267 | } 268 | 269 | /** 270 | * Sets a custom constraint on the specified route parameter. 271 | * 272 | * @param string $parameter The route parameter to apply the constraint to. 273 | * @param string $expression The regular expression constraint. 274 | * @return self The updated Route instance. 275 | */ 276 | public function where(string $parameter, string $expression): self 277 | { 278 | $this->wheres[$parameter] = $expression; 279 | return $this; 280 | } 281 | 282 | private function assignExprToParameters(array $parameters, string $expression): void 283 | { 284 | foreach ($parameters as $parameter) { 285 | $this->where($parameter, $expression); 286 | } 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /src/Router.php: -------------------------------------------------------------------------------- 1 | $routes The routes to initialize the Router with. 21 | * @param string $defaultUri The default URI for the Router. 22 | */ 23 | public function __construct(array $routes = [], string $defaultUri = 'http://localhost') 24 | { 25 | $this->routes = new \ArrayObject(); 26 | $this->urlGenerator = new UrlGenerator($this->routes, $defaultUri); 27 | foreach ($routes as $route) { 28 | $this->add($route); 29 | } 30 | } 31 | 32 | 33 | 34 | /** 35 | * Add a Route to the collection. 36 | * 37 | * @param Route $route The Route to add 38 | * @return self 39 | */ 40 | public function add(Route $route): self 41 | { 42 | $this->routes->offsetSet($route->getName(), $route); 43 | return $this; 44 | } 45 | 46 | /** 47 | * Matches a server request to a route based on the request's URI and method. 48 | * 49 | * @param ServerRequestInterface $serverRequest The server request to match. 50 | * @return Route The matched route. 51 | * @throws MethodNotAllowed Method Not Allowed : $method 52 | * * @throws RouteNotFound No route found for $path 53 | */ 54 | public function match(ServerRequestInterface $serverRequest): Route 55 | { 56 | return $this->matchFromPath($serverRequest->getUri()->getPath(), $serverRequest->getMethod()); 57 | } 58 | 59 | /** 60 | * Match a route from the given path and method. 61 | * 62 | * @param string $path The path to match 63 | * @param string $method The HTTP method 64 | * @throws MethodNotAllowed Method Not Allowed : $method 65 | * @throws RouteNotFound No route found for $path 66 | * @return Route 67 | */ 68 | public function matchFromPath(string $path, string $method): Route 69 | { 70 | /** 71 | * @var Route $route 72 | */ 73 | $routeMatchedButMethodNotAllowed = false; 74 | foreach ($this->routes as $route) { 75 | if ($route->match($path) === false) { 76 | continue; 77 | } 78 | 79 | if (!in_array($method, $route->getMethods())) { 80 | $routeMatchedButMethodNotAllowed = true; 81 | continue; 82 | } 83 | return $route; 84 | } 85 | 86 | if ($routeMatchedButMethodNotAllowed) { 87 | throw new MethodNotAllowed( 88 | 'Method Not Allowed : ' . $method, 89 | self::METHOD_NOT_ALLOWED 90 | ); 91 | } 92 | 93 | throw new RouteNotFound( 94 | 'No route found for ' . $path, 95 | self::NO_ROUTE 96 | ); 97 | } 98 | 99 | /** 100 | * Generate a URI based on the provided name, parameters, and settings. 101 | * 102 | * @param string $name The name used for generating the URI. 103 | * @param array $parameters An array of parameters to be included in the URI. 104 | * @param bool $absoluteUrl Whether the generated URI should be an absolute URL. 105 | * @return string The generated URI. 106 | */ 107 | public function generateUri(string $name, array $parameters = [], bool $absoluteUrl = false): string 108 | { 109 | return $this->urlGenerator->generate($name, $parameters, $absoluteUrl); 110 | } 111 | 112 | public function getUrlGenerator(): UrlGenerator 113 | { 114 | return $this->urlGenerator; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/RouterInterface.php: -------------------------------------------------------------------------------- 1 | router = $router; 28 | $this->responseFactory = $responseFactory; 29 | } 30 | 31 | 32 | /** 33 | * Process the request and return a response. 34 | * 35 | * @param ServerRequestInterface $request description of request parameter 36 | * @param RequestHandlerInterface $handler description of handler parameter 37 | * @return ResponseInterface 38 | */ 39 | public function process( 40 | ServerRequestInterface $request, 41 | RequestHandlerInterface $handler 42 | ): ResponseInterface 43 | { 44 | try { 45 | $route = $this->router->match($request); 46 | $request = $request->withAttribute(self::ATTRIBUTE_KEY, $route); 47 | } catch (MethodNotAllowed $exception) { 48 | return $this->responseFactory->createResponse(405); 49 | } catch (RouteNotFound $exception) { 50 | return $this->responseFactory->createResponse(404); 51 | } 52 | return $handler->handle($request); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Traits/RouteTrait.php: -------------------------------------------------------------------------------- 1 | routes = $routes; 29 | $this->defaultUri = $defaultUri; 30 | } 31 | 32 | /** 33 | * Generates a URL based on the given route name and parameters. 34 | * 35 | * @param string $name The name of the route. 36 | * @param array $parameters The parameters for the route. Default is an empty array. 37 | * @param bool $absoluteUrl Whether to generate an absolute URL. Default is false. 38 | * @return string The generated URL. 39 | * @throws InvalidArgumentException If the route name is unknown or if the route requires parameters but none are provided. 40 | */ 41 | public function generate(string $name, array $parameters = [], bool $absoluteUrl = false): string 42 | { 43 | if ($this->routes->offsetExists($name) === false) { 44 | throw new InvalidArgumentException( 45 | sprintf('Unknown %s name route', $name) 46 | ); 47 | } 48 | /*** @var Route $route */ 49 | $route = $this->routes[$name]; 50 | if ($route->hasAttributes() === true && $parameters === []) { 51 | throw new InvalidArgumentException( 52 | sprintf('%s route need parameters: %s', $name, implode(',', $route->getVarsNames())) 53 | ); 54 | } 55 | 56 | $url = self::resolveUri($route, $parameters); 57 | if ($absoluteUrl === true) { 58 | $url = ltrim(Helper::trimPath($this->defaultUri), '/') . $url; 59 | } 60 | return $url; 61 | } 62 | 63 | private static function resolveUri(Route $route, array $parameters): string 64 | { 65 | $uri = $route->getPath(); 66 | foreach ($route->getVarsNames() as $variable) { 67 | $varName = trim($variable, '{\}'); 68 | if (array_key_exists($varName, $parameters) === false) { 69 | throw new InvalidArgumentException( 70 | sprintf('%s not found in parameters to generate url', $varName) 71 | ); 72 | } 73 | $uri = str_replace($variable, $parameters[$varName], $uri); 74 | } 75 | return $uri; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tests/RouteTest.php: -------------------------------------------------------------------------------- 1 | testMatchRoute(); 26 | $this->testNotMatchRoute(); 27 | $this->testException(); 28 | $this->testWheres(); 29 | $this->testWhereDate(); 30 | $this->testWhereYearMonth(); 31 | $this->testWhereEmail(); 32 | $this->testWhereUuid(); 33 | $this->testWhereBool(); 34 | $this->whereAnything(); 35 | } 36 | 37 | public function testNotMatchRoute() 38 | { 39 | $routeWithoutAttribute = new Route('view_articles', '/view/article/', ['App\\Controller\\HomeController', 'home']); 40 | $routeWithAttribute = new Route('view_article', '/view/article/{article}', ['App\\Controller\\HomeController', 'home']); 41 | 42 | $this->assertFalse($routeWithoutAttribute->match('/view/article/1')); 43 | $this->assertFalse($routeWithAttribute->match('/view/article/')); 44 | } 45 | 46 | public function testMatchRoute() 47 | { 48 | $routeWithAttribute = new Route('view_article', '/view/article/{article}', ['App\\Controller\\HomeController', 'home']); 49 | $routeWithAttributes = new Route('view_article_page', '/view/article/{article}/{page}', ['App\\Controller\\HomeController', 'home']); 50 | $routeWithoutAttribute = new Route('view_articles', '/view/article', ['App\\Controller\\HomeController', 'home']); 51 | 52 | $this->assertTrue($routeWithAttribute->match('/view/article/1')); 53 | $this->assertTrue($routeWithAttributes->match('/view/article/1/24')); 54 | $this->assertTrue($routeWithoutAttribute->match('/view/article/')); 55 | } 56 | 57 | public function testException() 58 | { 59 | $this->expectException(InvalidArgumentException::class, function () { 60 | new Route('view_articles', '/view', ['App\\Controller\\HomeController', 'home'], []); 61 | }); 62 | } 63 | 64 | public function testWheres() 65 | { 66 | $routes = [ 67 | Route::get('blog.show', '/blog/{id}', function () { 68 | })->whereNumber('id'), 69 | Route::get('blog.show', '/blog/{slug}', function () { 70 | })->whereSlug('slug'), 71 | Route::get('blog.show', '/blog/{slug}/{id}', function () { 72 | }) 73 | ->whereNumber('id') 74 | ->whereSlug('slug'), 75 | Route::get('invoice.show', '/invoice/{number}', function () { 76 | })->whereAlphaNumeric('number'), 77 | Route::get('invoice.show', '/invoice/{number}', function () { 78 | })->whereAlpha('number'), 79 | Route::get('invoice.with.slash', '/invoice/{slash*}', function () { 80 | }), 81 | Route::get('invoice.with.slash', '/invoice/{slash}', function () { 82 | })->whereTwoSegments('slash'), 83 | ]; 84 | 85 | 86 | $route = $routes[0]; 87 | $this->assertTrue($route->match('/blog/1')); 88 | $this->assertStrictEquals(['id' => '1'], $route->getAttributes()); 89 | $this->assertFalse($route->match('/blog/F1')); 90 | 91 | $route = $routes[1]; 92 | $this->assertTrue($route->match('/blog/title-of-article')); 93 | $this->assertStrictEquals(['slug' => 'title-of-article'], $route->getAttributes()); 94 | $this->assertFalse($routes[1]->match('/blog/title_of_article')); 95 | 96 | $route = $routes[2]; 97 | $this->assertTrue($routes[2]->match('/blog/title-of-article/12')); 98 | $this->assertStrictEquals(['slug' => 'title-of-article', 'id' => '12'], $route->getAttributes()); 99 | 100 | $route = $routes[3]; 101 | $this->assertTrue($route->match('/invoice/F0004')); 102 | $this->assertStrictEquals(['number' => 'F0004'], $route->getAttributes()); 103 | 104 | $route = $routes[4]; 105 | $this->assertFalse($routes[4]->match('/invoice/F0004')); 106 | $this->assertTrue($routes[4]->match('/invoice/FROUIAUI')); 107 | $this->assertStrictEquals(['number' => 'FROUIAUI'], $route->getAttributes()); 108 | 109 | $route = $routes[5]; 110 | $this->assertTrue($route->match('/invoice/FROUIAUI/12/24-25')); 111 | $this->assertStrictEquals(['slash' => 'FROUIAUI/12/24-25'], $route->getAttributes()); 112 | 113 | $route = $routes[6]; 114 | $this->assertFalse($route->match('/invoice/FROUIAUI/12/24-25')); 115 | $this->assertTrue($route->match('/invoice/FROUIAUI/toto')); 116 | $this->assertStrictEquals(['slash' => 'FROUIAUI/toto'], $route->getAttributes()); 117 | } 118 | 119 | public function testWhereDate() 120 | { 121 | $route = Route::get('example', '/example/{date}', function () { 122 | })->whereDate('date'); 123 | $this->assertTrue($route->match('/example/2022-12-31')); 124 | $this->assertFalse($route->match('/example/12-31-2022')); 125 | $this->assertFalse($route->match('/example/2022-13')); 126 | } 127 | 128 | public function testWhereYearMonth() 129 | { 130 | $route = Route::get('example', '/example/{yearMonth}', function () { 131 | })->whereYearMonth('yearMonth'); 132 | $this->assertTrue($route->match('/example/2022-12')); 133 | $this->assertFalse($route->match('/example/12-31-2022')); 134 | $this->assertFalse($route->match('/example/2022-13-10')); 135 | } 136 | 137 | public function testWhereEmail() 138 | { 139 | $route = Route::get('example', '/example/{email}/{email2}', function () { 140 | })->whereEmail('email', 'email2'); 141 | $this->assertTrue($route->match('/example/0L5yT@example.com/0L5yT@example.com')); 142 | $this->assertFalse($route->match('/example/@example.com/0L5yT@example.com')); 143 | $this->assertFalse($route->match('/example/0L5yT@example.com/toto')); 144 | } 145 | 146 | public function testWhereUuid() 147 | { 148 | $route = Route::get('example', '/example/{uuid}', function () { 149 | })->whereEmail('uuid'); 150 | $route->whereUuid('uuid'); 151 | 152 | $this->assertTrue($route->match('/example/123e4567-e89b-12d3-a456-426614174000')); 153 | 154 | $this->assertFalse($route->match('/example/123e4567-e89b-12d3-a456-42661417400z')); 155 | $this->assertFalse($route->match('/example/invalid-uuid')); 156 | 157 | $route = Route::get('example', '/example/{uuid}/unused', function () { 158 | })->whereEmail('uuid'); 159 | $route->whereUuid('uuid'); 160 | 161 | $this->assertFalse($route->match('/example/123e4567-e89b-12d3-a456-426614174000')); 162 | $this->assertTrue($route->match('/example/123e4567-e89b-12d3-a456-426614174000/unused')); 163 | 164 | $this->assertFalse($route->match('/example/123e4567-e89b-12d3-a456-42661417400z/unused')); 165 | $this->assertFalse($route->match('/example/invalid-uuid/unused')); 166 | } 167 | 168 | public function testWhereBool() 169 | { 170 | $route = Route::get('example', '/example/{bool}', function () { 171 | })->whereBool('bool'); 172 | $this->assertTrue($route->match('/example/true')); 173 | $this->assertTrue($route->match('/example/1')); 174 | $this->assertTrue($route->match('/example/false')); 175 | $this->assertTrue($route->match('/example/0')); 176 | $this->assertFalse($route->match('/example/invalid')); 177 | 178 | } 179 | 180 | private function whereAnything() 181 | { 182 | $route = Route::get('example', '/example/{anything}', function () { 183 | })->whereAnything('anything'); 184 | $this->assertTrue($route->match('/example/anything')); 185 | $this->assertTrue($route->match('/example/anything/anything')); 186 | $this->assertTrue($route->match('/example/anything/anything/anything')); 187 | $base64 = $this->generateComplexString(); 188 | $this->assertTrue($route->match('/example/' . $base64)); 189 | $this->assertStrictEquals(['anything' => $base64], $route->getAttributes()); 190 | 191 | } 192 | 193 | private function generateComplexString(): string 194 | { 195 | $characters = 'ABCDEFGHIJKLMklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[{]}\\|;:\'",<.>/?`~'; 196 | $complexString = ''; 197 | for ($i = 0; $i < 200; $i++) { 198 | $complexString .= $characters[random_int(0, strlen($characters) - 1)]; 199 | } 200 | $complexString .= '-' . time(); 201 | return $complexString; 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /tests/RouterTest.php: -------------------------------------------------------------------------------- 1 | router = (new Router()) 19 | ->add(new Route('home_page', '/home', ['App\\Controller\\HomeController', 'home'])) 20 | ->add(new Route('article_page', '/view/article', ['App\\Controller\\HomeController', 'article'])) 21 | ->add(new Route('article_page_by_id', '/view/article/{id}', ['App\\Controller\\HomeController', 'article'])) 22 | ->add(new Route('article_page_by_id_and_page', '/view/article/{id}/{page}', ['App\\Controller\\HomeController', 'article'])); 23 | } 24 | 25 | protected function tearDown(): void 26 | { 27 | // TODO: Implement tearDown() method. 28 | } 29 | 30 | protected function execute(): void 31 | { 32 | $this->testMatchRoute(); 33 | $this->testNotFoundException(); 34 | $this->testMethodNotAllowedException(); 35 | $this->testGenerateUrl(); 36 | $this->testGenerateAbsoluteUrl(); 37 | } 38 | 39 | public function testMatchRoute() 40 | { 41 | $route = $this->router->matchFromPath('/view/article/25', 'GET'); 42 | $this->assertInstanceOf(Route::class, $route); 43 | 44 | $this->assertNotEmpty($route->getHandler()); 45 | $this->assertNotEmpty($route->getMethods()); 46 | $this->assertStrictEquals(['id' => '25'], $route->getAttributes()); 47 | $this->assertInstanceOf(Route::class, $this->router->matchFromPath('/home', 'GET')); 48 | } 49 | 50 | public function testNotFoundException() 51 | { 52 | $this->expectException(RouteNotFound::class, function () { 53 | $this->router->matchFromPath('/homes', 'GET'); 54 | }); 55 | } 56 | 57 | public function testMethodNotAllowedException() 58 | { 59 | $this->expectException(MethodNotAllowed::class, function () { 60 | $this->router->matchFromPath('/home', 'PUT'); 61 | }); 62 | } 63 | 64 | public function testGenerateUrl() 65 | { 66 | $urlHome = $this->router->generateUri('home_page'); 67 | $urlArticle = $this->router->generateUri('article_page'); 68 | $urlArticleWithParam = $this->router->generateUri('article_page_by_id', ['id' => 25]); 69 | $routeArticleWithParams = $this->router->generateUri('article_page_by_id_and_page', ['id' => 25, 'page' => 3]); 70 | 71 | $this->assertStrictEquals($urlHome, '/home'); 72 | $this->assertStrictEquals($urlArticle, '/view/article'); 73 | $this->assertStrictEquals($urlArticleWithParam, '/view/article/25'); 74 | $this->assertStrictEquals($routeArticleWithParams, '/view/article/25/3'); 75 | 76 | $this->expectException(InvalidArgumentException::class, function () { 77 | $this->router->generateUri('article_page_by_id_and_page', ['id' => 25]); 78 | }); 79 | } 80 | 81 | public function testGenerateAbsoluteUrl() 82 | { 83 | $urlHome = $this->router->generateUri('home_page', [], true); 84 | $urlArticle = $this->router->generateUri('article_page', [], true); 85 | $urlArticleWithParam = $this->router->generateUri('article_page_by_id', ['id' => 25], true); 86 | $routeArticleWithParams = $this->router->generateUri('article_page_by_id_and_page', ['id' => 25, 'page' => 3], true); 87 | 88 | $this->assertStrictEquals($urlHome, 'http://localhost/home'); 89 | $this->assertStrictEquals($urlArticle, 'http://localhost/view/article'); 90 | $this->assertStrictEquals($urlArticleWithParam, 'http://localhost/view/article/25'); 91 | $this->assertStrictEquals($routeArticleWithParams, 'http://localhost/view/article/25/3'); 92 | 93 | $this->expectException(InvalidArgumentException::class, function () { 94 | $this->router->generateUri('article_page_by_id_and_page', ['id' => 25], true); 95 | }); 96 | } 97 | 98 | } 99 | --------------------------------------------------------------------------------