├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml ├── src ├── Dispatching │ ├── Caller.php │ └── Matcher.php ├── Exceptions │ ├── InvalidCallableException.php │ ├── RouteNotFoundException.php │ └── UndefinedRouteException.php ├── Publisher │ ├── HttpPublisher.php │ └── Publisher.php ├── Router.php ├── Routing │ ├── Attributes.php │ ├── Repository.php │ ├── Route.php │ ├── State.php │ └── Storekeeper.php ├── Url.php └── View │ ├── PhpView.php │ └── View.php └── tests ├── Common ├── SampleClass.php ├── SampleConstructorController.php ├── SampleController.php ├── SampleInterface.php ├── SampleMiddleware.php ├── StopperMiddleware.php └── TrapPublisher.php ├── Features ├── ContainerTest.php ├── ControllerTest.php ├── GroupingTest.php ├── HttpMethodTest.php ├── InjectionTest.php ├── MiddlewareTest.php ├── NamingTest.php ├── ParametersTest.php ├── PatternsTest.php ├── ResponseTest.php ├── RouteTest.php ├── UrlTest.php └── ViewTest.php ├── TestCase.php ├── Units └── HttpPublisherTest.php └── resources └── views └── sample.phtml /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | github: miladrahimi 3 | custom: ["https://miladrahimi.com/pay.html", "https://www.paypal.com/paypalme/realmiladrahimi"] 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | run: 5 | strategy: 6 | matrix: 7 | include: 8 | - php: '7.4' 9 | - php: '8.0' 10 | - php: '8.1' 11 | - php: '8.2' 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | - name: Set up PHP 17 | uses: shivammathur/setup-php@v2 18 | with: 19 | php-version: "${{ matrix.php }}" 20 | - name: Install dependencies 21 | run: composer self-update && composer install && composer dump-autoload 22 | - name: Run tests and collect coverage 23 | run: vendor/bin/phpunit --coverage-clover coverage.xml . 24 | - name: Upload coverage to Codecov 25 | uses: codecov/codecov-action@v4-beta 26 | env: 27 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /vendor 3 | /examples 4 | /composer.lock 5 | /build 6 | .DS_Store 7 | .phpunit.result.cache 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Milad Rahimi 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Latest Stable Version](https://poser.pugx.org/miladrahimi/phprouter/v)](//packagist.org/packages/miladrahimi/phprouter) 2 | [![Total Downloads](https://poser.pugx.org/miladrahimi/phprouter/downloads)](//packagist.org/packages/miladrahimi/phprouter) 3 | [![Build](https://github.com/miladrahimi/phprouter/actions/workflows/ci.yml/badge.svg)](https://github.com/miladrahimi/phprouter/actions/workflows/ci.yml) 4 | [![codecov](https://codecov.io/gh/miladrahimi/phprouter/graph/badge.svg?token=KctrYUweFd)](https://codecov.io/gh/miladrahimi/phprouter) 5 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/miladrahimi/phprouter/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/miladrahimi/phprouter/?branch=master) 6 | [![License](https://poser.pugx.org/miladrahimi/phprouter/license)](//packagist.org/packages/miladrahimi/phprouter) 7 | 8 | # PhpRouter 9 | 10 | PhpRouter is a full-featured yet very fast HTTP URL router for PHP projects. 11 | 12 | Some of the provided features: 13 | * Route parameters 14 | * Predefined route parameter patterns 15 | * Middleware 16 | * Closure and class controllers/middleware 17 | * Route groups (by prefix, middleware, and domain) 18 | * Route naming (and generating route by name) 19 | * PSR-7 requests and responses 20 | * Views (simple PHP/HTML views) 21 | * Multiple (sub)domains (using regex patterns) 22 | * Custom HTTP methods 23 | * Integrated with an IoC Container ([PhpContainer](https://github.com/miladrahimi/phpcontainer)) 24 | * Method and constructor auto-injection of Request, Route, Url, etc 25 | 26 | The current version requires PHP `v7.4` or newer versions including `v8.*`. 27 | 28 | ## Contents 29 | - [Versions](#versions) 30 | - [Documentation](#documentation) 31 | - [Installation](#installation) 32 | - [Configuration](#configuration) 33 | - [Getting Started](#getting-started) 34 | - [HTTP Methods](#http-methods) 35 | - [Controllers](#controllers) 36 | - [Route Parameters](#route-parameters) 37 | - [Requests and Responses](#requests-and-responses) 38 | - [Views](#views) 39 | - [Route Groups](#route-groups) 40 | - [Middleware](#middleware) 41 | - [Domains and Subdomains](#domains-and-subdomains) 42 | - [Route Names](#route-names) 43 | - [Current Route](#current-route) 44 | - [Error Handling](#error-handling) 45 | - [License](#license) 46 | 47 | ## Versions 48 | * **v5.x.x** (Current, Supported) 49 | * v4.x.x 50 | * v3.x.x 51 | * v2.x.x 52 | * v1.x.x 53 | 54 | ## Documentation 55 | 56 | ### Installation 57 | 58 | Install [Composer](https://getcomposer.org) and run following command in your project's root directory: 59 | 60 | ```bash 61 | composer require miladrahimi/phprouter "5.*" 62 | ``` 63 | 64 | ### Configuration 65 | 66 | First of all, 67 | you need to configure your webserver to handle all the HTTP requests with a single PHP file like the `index.php` file. 68 | Here you can see sample configurations for NGINX and Apache HTTP Server. 69 | 70 | * NGINX configuration sample: 71 | ```nginx 72 | location / { 73 | try_files $uri $uri/ /index.php?$query_string; 74 | } 75 | ``` 76 | 77 | * Apache `.htaccess` sample: 78 | ```apacheconfig 79 | 80 | 81 | Options -MultiViews 82 | 83 | 84 | RewriteEngine On 85 | 86 | RewriteCond %{REQUEST_FILENAME} !-d 87 | RewriteRule ^(.*)/$ /$1 [L,R=301] 88 | 89 | RewriteCond %{REQUEST_FILENAME} !-d 90 | RewriteCond %{REQUEST_FILENAME} !-f 91 | RewriteRule ^ index.php [L] 92 | 93 | ``` 94 | 95 | ### Getting Started 96 | 97 | It's so easy to work with PhpRouter! Just take a look at the following example. 98 | 99 | * JSON API Example: 100 | 101 | ```php 102 | use MiladRahimi\PhpRouter\Router; 103 | use Laminas\Diactoros\Response\JsonResponse; 104 | 105 | $router = Router::create(); 106 | 107 | $router->get('/', function () { 108 | return new JsonResponse(['message' => 'ok']); 109 | }); 110 | 111 | $router->dispatch(); 112 | ``` 113 | 114 | * View Example: 115 | 116 | ```php 117 | use MiladRahimi\PhpRouter\Router; 118 | use MiladRahimi\PhpRouter\View\View 119 | 120 | $router = Router::create(); 121 | $router->setupView('/../views'); 122 | 123 | $router->get('/', function (View $view) { 124 | return $view->make('profile', ['user' => 'Jack']); 125 | }); 126 | 127 | $router->dispatch(); 128 | ``` 129 | 130 | ### HTTP Methods 131 | 132 | The following example illustrates how to declare different routes for different HTTP methods. 133 | 134 | ```php 135 | use MiladRahimi\PhpRouter\Router; 136 | 137 | $router = Router::create(); 138 | 139 | $router->get('/', function () { /* ... */ }); 140 | $router->post('/', function () { /* ... */ }); 141 | $router->put('/', function () { /* ... */ }); 142 | $router->patch('/', function () { /* ... */ }); 143 | $router->delete('/', function () { /* ... */ }); 144 | 145 | $router->dispatch(); 146 | ``` 147 | 148 | You can use the `define()` method for other HTTP methods like this example: 149 | 150 | ```php 151 | use MiladRahimi\PhpRouter\Router; 152 | 153 | $router = Router::create(); 154 | 155 | $router->define('GET', '/', function () { /* ... */ }); 156 | $router->define('OPTIONS', '/', function () { /* ... */ }); 157 | $router->define('CUSTOM', '/', function () { /* ... */ }); 158 | 159 | $router->dispatch(); 160 | ``` 161 | 162 | If you don't care about HTTP verbs, you can use the `any()` method. 163 | 164 | ```php 165 | use MiladRahimi\PhpRouter\Router; 166 | 167 | $router = Router::create(); 168 | 169 | $router->any('/', function () { 170 | return 'This is Home! No matter what the HTTP method is!'; 171 | }); 172 | 173 | $router->dispatch(); 174 | ``` 175 | 176 | ### Controllers 177 | 178 | #### Closure Controllers 179 | 180 | ```php 181 | use MiladRahimi\PhpRouter\Router; 182 | 183 | $router = Router::create(); 184 | 185 | $router->get('/', function () { 186 | return 'This is a closure controller!'; 187 | }); 188 | 189 | $router->dispatch(); 190 | ``` 191 | 192 | #### Class Method Controllers 193 | 194 | ```php 195 | use MiladRahimi\PhpRouter\Router; 196 | 197 | class UsersController 198 | { 199 | function index() 200 | { 201 | return 'Class: UsersController & Method: index'; 202 | } 203 | 204 | function handle() 205 | { 206 | return 'Class UsersController.'; 207 | } 208 | } 209 | 210 | $router = Router::create(); 211 | 212 | // Controller: Class=UsersController Method=index() 213 | $router->get('/method', [UsersController::class, 'index']); 214 | 215 | // Controller: Class=UsersController Method=handle() 216 | $router->get('/class', UsersController::class); 217 | 218 | $router->dispatch(); 219 | ``` 220 | 221 | ### Route Parameters 222 | 223 | A URL might have one or more variable parts like product IDs on a shopping website. 224 | We call it a route parameter. 225 | You can catch them by controller method arguments like the example below. 226 | 227 | ```php 228 | use MiladRahimi\PhpRouter\Router; 229 | 230 | $router = Router::create(); 231 | 232 | // Required parameter 233 | $router->get('/post/{id}', function ($id) { 234 | return "The content of post $id"; 235 | }); 236 | 237 | // Optional parameter 238 | $router->get('/welcome/{name?}', function ($name = null) { 239 | return 'Welcome ' . ($name ?: 'Dear User'); 240 | }); 241 | 242 | // Optional parameter, Optional / (Slash)! 243 | $router->get('/profile/?{user?}', function ($user = null) { 244 | return ($user ?: 'Your') . ' profile'; 245 | }); 246 | 247 | // Optional parameter with default value 248 | $router->get('/roles/{role?}', function ($role = 'guest') { 249 | return "Your role is $role"; 250 | }); 251 | 252 | // Multiple parameters 253 | $router->get('/post/{pid}/comment/{cid}', function ($pid, $cid) { 254 | return "The comment $cid of the post $pid"; 255 | }); 256 | 257 | $router->dispatch(); 258 | ``` 259 | 260 | #### Route Parameter Patterns 261 | 262 | In default, route parameters can have any value, but you can define regex patterns to limit them. 263 | 264 | ```php 265 | use MiladRahimi\PhpRouter\Router; 266 | 267 | $router = Router::create(); 268 | 269 | // "id" must be numeric 270 | $router->pattern('id', '[0-9]+'); 271 | 272 | $router->get('/post/{id}', function (int $id) { /* ... */ }); 273 | 274 | $router->dispatch(); 275 | ``` 276 | 277 | ### Requests and Responses 278 | 279 | PhpRouter uses [laminas-diactoros](https://github.com/laminas/laminas-diactoros/) 280 | (formerly known as [zend-diactoros](https://github.com/zendframework/zend-diactoros)) 281 | package (v2) to provide [PSR-7](https://www.php-fig.org/psr/psr-7) 282 | request and response objects to your controllers and middleware. 283 | 284 | #### Requests 285 | 286 | You can catch the request object in your controllers like this example: 287 | 288 | ```php 289 | use MiladRahimi\PhpRouter\Router; 290 | use Laminas\Diactoros\ServerRequest; 291 | use Laminas\Diactoros\Response\JsonResponse; 292 | 293 | $router = Router::create(); 294 | 295 | $router->get('/', function (ServerRequest $request) { 296 | $method = $request->getMethod(); 297 | $uriPath = $request->getUri()->getPath(); 298 | $headers = $request->getHeaders(); 299 | $queryParameters = $request->getQueryParams(); 300 | $bodyContents = $request->getBody()->getContents(); 301 | // ... 302 | }); 303 | 304 | $router->dispatch(); 305 | ``` 306 | 307 | #### Responses 308 | 309 | The example below illustrates the built-in responses. 310 | 311 | ```php 312 | use Laminas\Diactoros\Response\RedirectResponse; 313 | use MiladRahimi\PhpRouter\Router; 314 | use Laminas\Diactoros\Response\EmptyResponse; 315 | use Laminas\Diactoros\Response\HtmlResponse; 316 | use Laminas\Diactoros\Response\JsonResponse; 317 | use Laminas\Diactoros\Response\TextResponse; 318 | 319 | $router = Router::create(); 320 | 321 | $router->get('/html/1', function () { 322 | return 'This is an HTML response'; 323 | }); 324 | $router->get('/html/2', function () { 325 | return new HtmlResponse('This is also an HTML response', 200); 326 | }); 327 | $router->get('/json', function () { 328 | return new JsonResponse(['error' => 'Unauthorized!'], 401); 329 | }); 330 | $router->get('/text', function () { 331 | return new TextResponse('This is a plain text...'); 332 | }); 333 | $router->get('/empty', function () { 334 | return new EmptyResponse(204); 335 | }); 336 | $router->get('/redirect', function () { 337 | return new RedirectResponse('https://miladrahimi.com'); 338 | }); 339 | 340 | $router->dispatch(); 341 | ``` 342 | 343 | ### Views 344 | 345 | You might need to create a classic-style server-side rendered (SSR) website that uses views. 346 | PhpRouter has a simple feature for working with PHP/HTML views. 347 | Look at the following example. 348 | 349 | ```php 350 | use MiladRahimi\PhpRouter\Router; 351 | use MiladRahimi\PhpRouter\View\View 352 | 353 | $router = Router::create(); 354 | 355 | // Setup view feature and set the directory of view files 356 | $router->setupView(__DIR__ . '/../views'); 357 | 358 | $router->get('/profile', function (View $view) { 359 | // It looks for a view with path: __DIR__/../views/profile.phtml 360 | return $view->make('profile', ['user' => 'Jack']); 361 | }); 362 | 363 | $router->get('/blog/post', function (View $view) { 364 | // It looks for a view with path: __DIR__/../views/blog/post.phtml 365 | return $view->make('blog.post', ['post' => $post]); 366 | }); 367 | 368 | $router->dispatch(); 369 | ``` 370 | 371 | There is also some points: 372 | * View files must have the ".phtml" extension (e.g. `profile.phtml`). 373 | * You can separate directories with `.` (e.g. `blog.post` for `blog/post.phtml`). 374 | 375 | View files are pure PHP or mixed with HTML. 376 | You should use PHP language with template style in the view files. 377 | This is a sample view file: 378 | 379 | ```php 380 |

381 | 386 | ``` 387 | 388 | ### Route Groups 389 | 390 | You can categorize routes into groups. 391 | The groups can have common attributes like middleware, domain, or prefix. 392 | The following example shows how to group routes: 393 | 394 | ```php 395 | use MiladRahimi\PhpRouter\Router; 396 | 397 | $router = Router::create(); 398 | 399 | // A group with uri prefix 400 | $router->group(['prefix' => '/admin'], function (Router $router) { 401 | // URI: /admin/setting 402 | $router->get('/setting', function () { 403 | return 'Setting Panel'; 404 | }); 405 | }); 406 | 407 | // All of group attributes together! 408 | $attributes = [ 409 | 'prefix' => '/admin', 410 | 'domain' => 'shop.example.com', 411 | 'middleware' => [AuthMiddleware::class], 412 | ]; 413 | 414 | $router->group($attributes, function (Router $router) { 415 | // URL: http://shop.example.com/admin/users 416 | // Domain: shop.example.com 417 | // Middleware: AuthMiddleware 418 | $router->get('/users', [UsersController::class, 'index']); 419 | }); 420 | 421 | $router->dispatch(); 422 | ``` 423 | 424 | The group attributes will be explained later in this documentation. 425 | 426 | You can use [Attributes](src/Routing/Attributes.php) enum, as well. 427 | 428 | ### Middleware 429 | 430 | PhpRouter supports middleware. 431 | You can use it for different purposes, such as authentication, authorization, throttles, and so forth. 432 | Middleware runs before and after controllers, and it can check and manipulate requests and responses. 433 | 434 | Here you can see the request lifecycle considering some middleware: 435 | 436 | ``` 437 | [Request] ↦ Router ↦ Middleware 1 ↦ ... ↦ Middleware N ↦ Controller 438 | ↧ 439 | [Response] ↤ Router ↤ Middleware 1 ↤ ... ↤ Middleware N ↤ [Response] 440 | ``` 441 | 442 | To declare a middleware, you can use closures and classes just like controllers. 443 | To use the middleware, you must group the routes and mention the middleware in the group attributes. 444 | Caution! The middleware attribute in groups takes an array of middleware, not a single one. 445 | 446 | ```php 447 | use MiladRahimi\PhpRouter\Router; 448 | use Psr\Http\Message\ServerRequestInterface; 449 | use Laminas\Diactoros\Response\JsonResponse; 450 | 451 | class AuthMiddleware 452 | { 453 | public function handle(ServerRequestInterface $request, Closure $next) 454 | { 455 | if ($request->getHeader('Authorization')) { 456 | // Call the next middleware/controller 457 | return $next($request); 458 | } 459 | 460 | return new JsonResponse(['error' => 'Unauthorized!'], 401); 461 | } 462 | } 463 | 464 | $router = Router::create(); 465 | 466 | // The middleware attribute takes an array of middleware, not a single one! 467 | $router->group(['middleware' => [AuthMiddleware::class]], function(Router $router) { 468 | $router->get('/admin', function () { 469 | return 'Admin API'; 470 | }); 471 | }); 472 | 473 | $router->dispatch(); 474 | ``` 475 | 476 | As you can see, the middleware catches the request and the `$next` closure. 477 | The closure calls the next middleware or the controller if no middleware is left. 478 | The middleware must return a response, as well. 479 | A middleware can break the lifecycle and return a response itself, 480 | or it can call the `$next` closure to continue the lifecycle. 481 | 482 | ### Domains and Subdomains 483 | 484 | Your application may serve different services on different domains or subdomains. 485 | In this case, you can specify the domain or subdomain for your routes. 486 | See this example: 487 | 488 | ```php 489 | use MiladRahimi\PhpRouter\Router; 490 | 491 | $router = Router::create(); 492 | 493 | // Domain 494 | $router->group(['domain' => 'shop.com'], function(Router $router) { 495 | $router->get('/', function () { 496 | return 'This is shop.com'; 497 | }); 498 | }); 499 | 500 | // Subdomain 501 | $router->group(['domain' => 'admin.shop.com'], function(Router $router) { 502 | $router->get('/', function () { 503 | return 'This is admin.shop.com'; 504 | }); 505 | }); 506 | 507 | // Subdomain with regex pattern 508 | $router->group(['domain' => '(.*).example.com'], function(Router $router) { 509 | $router->get('/', function () { 510 | return 'This is a subdomain'; 511 | }); 512 | }); 513 | 514 | $router->dispatch(); 515 | ``` 516 | 517 | ### Route Names 518 | 519 | You can assign names to your routes and use them in your codes instead of the hard-coded URLs. 520 | See this example: 521 | 522 | ```php 523 | use MiladRahimi\PhpRouter\Router; 524 | use Laminas\Diactoros\Response\JsonResponse; 525 | use MiladRahimi\PhpRouter\Url; 526 | 527 | $router = Router::create(); 528 | 529 | $router->get('/about', [AboutController::class, 'show'], 'about'); 530 | $router->get('/post/{id}', [PostController::class, 'show'], 'post'); 531 | $router->get('/links', function (Url $url) { 532 | return new JsonResponse([ 533 | 'about' => $url->make('about'), /* Result: /about */ 534 | 'post1' => $url->make('post', ['id' => 1]), /* Result: /post/1 */ 535 | 'post2' => $url->make('post', ['id' => 2]) /* Result: /post/2 */ 536 | ]); 537 | }); 538 | 539 | $router->dispatch(); 540 | ``` 541 | 542 | ### Current Route 543 | 544 | You might need to get information about the current route in your controller or middleware. 545 | This example shows how to get this information. 546 | 547 | ```php 548 | use MiladRahimi\PhpRouter\Router; 549 | use Laminas\Diactoros\Response\JsonResponse; 550 | use MiladRahimi\PhpRouter\Routing\Route; 551 | 552 | $router = Router::create(); 553 | 554 | $router->get('/{id}', function (Route $route) { 555 | return new JsonResponse([ 556 | 'uri' => $route->getUri(), /* Result: "/1" */ 557 | 'name' => $route->getName(), /* Result: "sample" */ 558 | 'path' => $route->getPath(), /* Result: "/{id}" */ 559 | 'method' => $route->getMethod(), /* Result: "GET" */ 560 | 'domain' => $route->getDomain(), /* Result: null */ 561 | 'parameters' => $route->getParameters(), /* Result: {"id": "1"} */ 562 | 'middleware' => $route->getMiddleware(), /* Result: [] */ 563 | 'controller' => $route->getController(), /* Result: {} */ 564 | ]); 565 | }, 'sample'); 566 | 567 | $router->dispatch(); 568 | ``` 569 | 570 | ### IoC Container 571 | 572 | PhpRouter uses [PhpContainer](https://github.com/miladrahimi/phpcontainer) to provide an IoC container for the package itself and your application's dependencies. 573 | 574 | #### How does PhpRouter use the container? 575 | 576 | PhpRouter binds route parameters, HTTP Request, Route (Current route), Url (URL generator), Container itself. 577 | The controller method or constructor can resolve these dependencies and catch them. 578 | 579 | #### How can your app use the container? 580 | 581 | Just look at the following example. 582 | 583 | ```php 584 | use MiladRahimi\PhpContainer\Container; 585 | use MiladRahimi\PhpRouter\Router; 586 | 587 | $router = Router::create(); 588 | 589 | $router->getContainer()->singleton(Database::class, MySQL::class); 590 | $router->getContainer()->singleton(Config::class, JsonConfig::class); 591 | 592 | // Resolve directly 593 | $router->get('/', function (Database $database, Config $config) { 594 | // Use MySQL and JsonConfig... 595 | }); 596 | 597 | // Resolve container 598 | $router->get('/', function (Container $container) { 599 | $database = $container->get(Database::class); 600 | $config = $container->get(Config::class); 601 | }); 602 | 603 | $router->dispatch(); 604 | ``` 605 | 606 | Check [PhpContainer](https://github.com/miladrahimi/phpcontainer) for more information about this powerful IoC container. 607 | 608 | ### Error Handling 609 | 610 | Your application runs through the `Router::dispatch()` method. 611 | You should put it in a `try` block and catch exceptions. 612 | It throws your application and PhpRouter exceptions. 613 | 614 | ```php 615 | use MiladRahimi\PhpRouter\Router; 616 | use MiladRahimi\PhpRouter\Exceptions\RouteNotFoundException; 617 | use Laminas\Diactoros\Response\HtmlResponse; 618 | 619 | $router = Router::create(); 620 | 621 | $router->get('/', function () { 622 | return 'Home.'; 623 | }); 624 | 625 | try { 626 | $router->dispatch(); 627 | } catch (RouteNotFoundException $e) { 628 | // It's 404! 629 | $router->getPublisher()->publish(new HtmlResponse('Not found.', 404)); 630 | } catch (Throwable $e) { 631 | // Log and report... 632 | $router->getPublisher()->publish(new HtmlResponse('Internal error.', 500)); 633 | } 634 | ``` 635 | 636 | PhpRouter throws the following exceptions: 637 | 638 | * `RouteNotFoundException` if PhpRouter cannot find any route that matches the user request. 639 | * `InvalidCallableException` if PhpRouter cannot invoke the controller or middleware. 640 | 641 | The `RouteNotFoundException` should be considered `404 Not found` error. 642 | 643 | ## License 644 | 645 | PhpRouter is initially created by [Milad Rahimi](https://miladrahimi.com) and released under the [MIT License](http://opensource.org/licenses/mit-license.php). 646 | 647 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "miladrahimi/phprouter", 3 | "description": "A powerful, lightweight, and very fast HTTP URL router for PHP projects.", 4 | "keywords": [ 5 | "Route", 6 | "Router", 7 | "Routing", 8 | "URL Router", 9 | "HTTP Router", 10 | "URL", 11 | "HTTP" 12 | ], 13 | "homepage": "https://github.com/miladrahimi/phprouter", 14 | "type": "library", 15 | "license": "MIT", 16 | "authors": [ 17 | { 18 | "name": "Milad Rahimi", 19 | "email": "i@miladrahimi.com", 20 | "homepage": "https://miladrahimi.com", 21 | "role": "Developer" 22 | } 23 | ], 24 | "support": { 25 | "email": "i@miladrahimi.com", 26 | "source": "https://github.com/miladrahimi/phprouter/issues" 27 | }, 28 | "require": { 29 | "php": ">=7.4", 30 | "ext-json": "*", 31 | "ext-mbstring": "*", 32 | "laminas/laminas-diactoros": "^2", 33 | "miladrahimi/phpcontainer": "^5" 34 | }, 35 | "require-dev": { 36 | "phpunit/phpunit": "^7|^8|^9" 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "MiladRahimi\\PhpRouter\\": "src/" 41 | } 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { 45 | "MiladRahimi\\PhpRouter\\Tests\\": "tests/" 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | ./src 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ./tests 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/Dispatching/Caller.php: -------------------------------------------------------------------------------- 1 | container = $container; 22 | } 23 | 24 | /** 25 | * Call a callable stack 26 | * 27 | * @param string[] $callables 28 | * @param ServerRequestInterface $request 29 | * @param int $index 30 | * @return mixed 31 | * @throws ContainerException 32 | * @throws InvalidCallableException 33 | */ 34 | public function stack(array $callables, ServerRequestInterface $request, int $index = 0) 35 | { 36 | $this->container->singleton(ServerRequest::class, $request); 37 | $this->container->singleton(ServerRequestInterface::class, $request); 38 | 39 | if (isset($callables[$index + 1])) { 40 | $this->container->closure('$next', function (ServerRequestInterface $request) use ($callables, $index) { 41 | return $this->stack($callables, $request, $index + 1); 42 | }); 43 | } else { 44 | $this->container->delete('$next'); 45 | } 46 | 47 | return $this->call($callables[$index]); 48 | } 49 | 50 | /** 51 | * Run a callable 52 | * 53 | * @param Closure|callable|string $callable 54 | * @return mixed 55 | * @throws InvalidCallableException 56 | * @throws ContainerException 57 | */ 58 | public function call($callable) 59 | { 60 | if (is_array($callable)) { 61 | if (count($callable) != 2) { 62 | throw new InvalidCallableException('Invalid callable: ' . implode(',', $callable)); 63 | } 64 | 65 | [$class, $method] = $callable; 66 | 67 | if (!class_exists($class)) { 68 | throw new InvalidCallableException("Class `$class` not found."); 69 | } 70 | 71 | $object = $this->container->instantiate($class); 72 | 73 | if (!method_exists($object, $method)) { 74 | throw new InvalidCallableException("Method `$class::$method` is not declared."); 75 | } 76 | 77 | $callable = [$object, $method]; 78 | } else { 79 | if (is_string($callable)) { 80 | if (class_exists($callable)) { 81 | $callable = new $callable(); 82 | } else { 83 | throw new InvalidCallableException("Class `$callable` not found."); 84 | } 85 | } 86 | 87 | if (is_object($callable) && !$callable instanceof Closure) { 88 | if (method_exists($callable, 'handle')) { 89 | $callable = [$callable, 'handle']; 90 | } else { 91 | throw new InvalidCallableException("Method `handle` is not declared."); 92 | } 93 | } 94 | } 95 | 96 | if (!is_callable($callable)) { 97 | throw new InvalidCallableException('Invalid callable.'); 98 | } 99 | 100 | return $this->container->call($callable); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Dispatching/Matcher.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 20 | } 21 | 22 | /** 23 | * Find the right route for the given request and defined patterns 24 | * 25 | * @param ServerRequestInterface $request 26 | * @param string[] $patterns 27 | * @return Route 28 | * @throws RouteNotFoundException 29 | */ 30 | public function find(ServerRequestInterface $request, array $patterns): Route 31 | { 32 | foreach ($this->repository->findByMethod($request->getMethod()) as $route) { 33 | $parameters = []; 34 | 35 | if ($this->compare($route, $request, $parameters, $patterns)) { 36 | $route->setUri($request->getUri()->getPath()); 37 | $route->setParameters($this->pruneRouteParameters($parameters)); 38 | 39 | return $route; 40 | } 41 | } 42 | 43 | throw new RouteNotFoundException(); 44 | } 45 | 46 | /** 47 | * Prune route parameters (remove unnecessary parameters) 48 | * 49 | * @param string[] $parameters 50 | * @return string[] 51 | */ 52 | private function pruneRouteParameters(array $parameters): array 53 | { 54 | return array_filter($parameters, function ($value, $name) { 55 | return is_numeric($name) === false; 56 | }, ARRAY_FILTER_USE_BOTH); 57 | } 58 | 59 | /** 60 | * Compare the route with the given HTTP request 61 | * 62 | * @param Route $route 63 | * @param ServerRequestInterface $request 64 | * @param string[] $parameters 65 | * @param string[] $patterns 66 | * @return bool 67 | */ 68 | private function compare(Route $route, ServerRequestInterface $request, array &$parameters, array $patterns): bool 69 | { 70 | return ( 71 | $this->compareDomain($route->getDomain(), $request->getUri()->getHost()) && 72 | $this->compareUri($route->getPath(), $request->getUri()->getPath(), $parameters, $patterns) 73 | ); 74 | } 75 | 76 | /** 77 | * Check if given request domain matches given route domain 78 | * 79 | * @param string|null $routeDomain 80 | * @param string $requestDomain 81 | * @return bool 82 | */ 83 | private function compareDomain(?string $routeDomain, string $requestDomain): bool 84 | { 85 | return !$routeDomain || preg_match('@^' . $routeDomain . '$@', $requestDomain); 86 | } 87 | 88 | /** 89 | * Check if given request uri matches given uri method 90 | * 91 | * @param string $path 92 | * @param string $uri 93 | * @param string[] $parameters 94 | * @param string[] $patterns 95 | * @return bool 96 | */ 97 | private function compareUri(string $path, string $uri, array &$parameters, array $patterns): bool 98 | { 99 | return preg_match('@^' . $this->regexUri($path, $patterns) . '$@', $uri, $parameters); 100 | } 101 | 102 | /** 103 | * Convert route to regex 104 | * 105 | * @param string $path 106 | * @param string[] $patterns 107 | * @return string 108 | */ 109 | private function regexUri(string $path, array $patterns): string 110 | { 111 | return preg_replace_callback('@{([^}]+)}@', function (array $match) use ($patterns) { 112 | return $this->regexParameter($match[1], $patterns); 113 | }, $path); 114 | } 115 | 116 | /** 117 | * Convert route parameter to regex 118 | * 119 | * @param string $name 120 | * @param array $patterns 121 | * @return string 122 | */ 123 | private function regexParameter(string $name, array $patterns): string 124 | { 125 | if ($name[-1] == '?') { 126 | $name = substr($name, 0, -1); 127 | $suffix = '?'; 128 | } else { 129 | $suffix = ''; 130 | } 131 | 132 | $pattern = $patterns[$name] ?? '[^/]+'; 133 | 134 | return '(?<' . $name . '>' . $pattern . ')' . $suffix; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidCallableException.php: -------------------------------------------------------------------------------- 1 | getStatusCode()); 21 | 22 | foreach ($response->getHeaders() as $name => $values) { 23 | @header($name . ': ' . $response->getHeaderLine($name)); 24 | } 25 | 26 | fwrite($output, $response->getBody()); 27 | } elseif (is_scalar($response)) { 28 | fwrite($output, $response); 29 | } elseif ($response === null) { 30 | fwrite($output, ''); 31 | } else { 32 | fwrite($output, json_encode($response)); 33 | } 34 | 35 | fclose($output); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Publisher/Publisher.php: -------------------------------------------------------------------------------- 1 | container = $container; 53 | $this->storekeeper = $storekeeper; 54 | $this->matcher = $matcher; 55 | $this->caller = $caller; 56 | $this->publisher = $publisher; 57 | } 58 | 59 | /** 60 | * Create a new Router instance 61 | * 62 | * @return static 63 | */ 64 | public static function create(): self 65 | { 66 | $container = new Container(); 67 | $container->singleton(Container::class, $container); 68 | $container->singleton(ContainerInterface::class, $container); 69 | $container->singleton(Repository::class, new Repository()); 70 | $container->singleton(Publisher::class, HttpPublisher::class); 71 | 72 | return $container->instantiate(Router::class); 73 | } 74 | 75 | /** 76 | * Setup (enable) View 77 | * @param string $directory 78 | * @link View 79 | * 80 | */ 81 | public function setupView(string $directory): void 82 | { 83 | $this->container->singleton(View::class, function () use ($directory) { 84 | return new PhpView($directory); 85 | }); 86 | } 87 | 88 | /** 89 | * Group routes with the given common attributes 90 | * 91 | * @param array $attributes 92 | * @param Closure $body 93 | */ 94 | public function group(array $attributes, Closure $body): void 95 | { 96 | $oldState = clone $this->storekeeper->getState(); 97 | 98 | $this->storekeeper->getState()->append($attributes); 99 | 100 | call_user_func($body, $this); 101 | 102 | $this->storekeeper->setState($oldState); 103 | } 104 | 105 | /** 106 | * Dispatch routes (and run the application) 107 | * 108 | * @throws ContainerException 109 | * @throws InvalidCallableException 110 | * @throws RouteNotFoundException 111 | */ 112 | public function dispatch() 113 | { 114 | $request = ServerRequestFactory::fromGlobals(); 115 | 116 | $route = $this->matcher->find($request, $this->patterns); 117 | $this->container->singleton(Route::class, $route); 118 | 119 | foreach ($route->getParameters() as $key => $value) { 120 | $this->container->singleton('$' . $key, $value); 121 | } 122 | 123 | $stack = array_merge($route->getMiddleware(), [$route->getController()]); 124 | $this->publisher->publish($this->caller->stack($stack, $request)); 125 | } 126 | 127 | /** 128 | * Define a parameter pattern 129 | * 130 | * @param string $name 131 | * @param string $pattern 132 | */ 133 | public function pattern(string $name, string $pattern) 134 | { 135 | $this->patterns[$name] = $pattern; 136 | } 137 | 138 | /** 139 | * Index all the defined routes 140 | * 141 | * @return Route[] 142 | */ 143 | public function all(): array 144 | { 145 | return $this->storekeeper->getRepository()->all(); 146 | } 147 | 148 | /** 149 | * Define a new route 150 | * 151 | * @param string $method 152 | * @param string $path 153 | * @param Closure|string|array $controller 154 | * @param string|null $name 155 | */ 156 | public function define(string $method, string $path, $controller, ?string $name = null): void 157 | { 158 | $this->storekeeper->add($method, $path, $controller, $name); 159 | } 160 | 161 | /** 162 | * Map a controller to given route for all the http methods 163 | * 164 | * @param string $path 165 | * @param Closure|string|array $controller 166 | * @param string|null $name 167 | */ 168 | public function any(string $path, $controller, ?string $name = null): void 169 | { 170 | $this->define('*', $path, $controller, $name); 171 | } 172 | 173 | /** 174 | * Map a controller to given GET route 175 | * 176 | * @param string $path 177 | * @param Closure|string|array $controller 178 | * @param string|null $name 179 | */ 180 | public function get(string $path, $controller, ?string $name = null): void 181 | { 182 | $this->define('GET', $path, $controller, $name); 183 | } 184 | 185 | /** 186 | * Map a controller to given POST route 187 | * 188 | * @param string $path 189 | * @param Closure|string|array $controller 190 | * @param string|null $name 191 | */ 192 | public function post(string $path, $controller, ?string $name = null): void 193 | { 194 | $this->define('POST', $path, $controller, $name); 195 | } 196 | 197 | /** 198 | * Map a controller to given PUT route 199 | * 200 | * @param string $path 201 | * @param Closure|string|array $controller 202 | * @param string|null $name 203 | */ 204 | public function put(string $path, $controller, ?string $name = null): void 205 | { 206 | $this->define('PUT', $path, $controller, $name); 207 | } 208 | 209 | /** 210 | * Map a controller to given PATCH route 211 | * 212 | * @param string $path 213 | * @param Closure|string|array $controller 214 | * @param string|null $name 215 | */ 216 | public function patch(string $path, $controller, ?string $name = null): void 217 | { 218 | $this->define('PATCH', $path, $controller, $name); 219 | } 220 | 221 | /** 222 | * Map a controller to given DELETE route 223 | * 224 | * @param string $path 225 | * @param Closure|string|array $controller 226 | * @param string|null $name 227 | */ 228 | public function delete(string $path, $controller, ?string $name = null): void 229 | { 230 | $this->define('DELETE', $path, $controller, $name); 231 | } 232 | 233 | /** 234 | * @return Container 235 | */ 236 | public function getContainer(): Container 237 | { 238 | return $this->container; 239 | } 240 | 241 | /** 242 | * @param Container $container 243 | */ 244 | public function setContainer(Container $container): void 245 | { 246 | $this->container = $container; 247 | } 248 | 249 | /** 250 | * @return Publisher 251 | */ 252 | public function getPublisher(): Publisher 253 | { 254 | return $this->publisher; 255 | } 256 | 257 | /** 258 | * @param Publisher $publisher 259 | */ 260 | public function setPublisher(Publisher $publisher): void 261 | { 262 | $this->publisher = $publisher; 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/Routing/Attributes.php: -------------------------------------------------------------------------------- 1 | routes['method'][$method][] = $route; 41 | 42 | if ($name !== null) { 43 | $this->routes['name'][$name] = $route; 44 | } 45 | } 46 | 47 | /** 48 | * Find routes by method 49 | * 50 | * @param string $method 51 | * @return Route[] 52 | */ 53 | public function findByMethod(string $method): array 54 | { 55 | $routes = array_merge( 56 | $this->routes['method']['*'] ?? [], 57 | $this->routes['method'][$method] ?? [] 58 | ); 59 | 60 | krsort($routes); 61 | 62 | return $routes; 63 | } 64 | 65 | /** 66 | * Find route by name 67 | * 68 | * @param string $name 69 | * @return Route|null 70 | */ 71 | public function findByName(string $name): ?Route 72 | { 73 | return $this->routes['name'][$name] ?? null; 74 | } 75 | 76 | /** 77 | * Index all the defined routes 78 | * 79 | * @return Route[] 80 | */ 81 | public function all(): array 82 | { 83 | $all = []; 84 | foreach ($this->routes['method'] as $group) { 85 | foreach ($group as $route) { 86 | $all[] = $route; 87 | } 88 | } 89 | 90 | return $all; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Routing/Route.php: -------------------------------------------------------------------------------- 1 | method = $method; 76 | $this->path = $path; 77 | $this->controller = $controller; 78 | $this->name = $name; 79 | $this->middleware = $middleware; 80 | $this->domain = $domain; 81 | } 82 | 83 | /** 84 | * @return array 85 | */ 86 | public function toArray(): array 87 | { 88 | return [ 89 | 'method' => $this->getMethod(), 90 | 'path' => $this->getPath(), 91 | 'controller' => $this->getController(), 92 | 'name' => $this->getName(), 93 | 'middleware' => $this->getMiddleware(), 94 | 'domain' => $this->getDomain(), 95 | 'uri' => $this->getUri(), 96 | 'parameters' => $this->getParameters(), 97 | ]; 98 | } 99 | 100 | public function toJson(): string 101 | { 102 | return json_encode($this->toArray()); 103 | } 104 | 105 | public function __toString(): string 106 | { 107 | return $this->toJson(); 108 | } 109 | 110 | public function getName(): ?string 111 | { 112 | return $this->name; 113 | } 114 | 115 | public function getPath(): string 116 | { 117 | return $this->path; 118 | } 119 | 120 | public function getMethod(): string 121 | { 122 | return $this->method; 123 | } 124 | 125 | public function getController() 126 | { 127 | return $this->controller; 128 | } 129 | 130 | public function getMiddleware(): array 131 | { 132 | return $this->middleware; 133 | } 134 | 135 | public function getDomain(): ?string 136 | { 137 | return $this->domain; 138 | } 139 | 140 | public function getParameters(): array 141 | { 142 | return $this->parameters; 143 | } 144 | 145 | public function setParameters(array $parameters): void 146 | { 147 | $this->parameters = $parameters; 148 | } 149 | 150 | public function getUri(): ?string 151 | { 152 | return $this->uri; 153 | } 154 | 155 | public function setUri(?string $uri): void 156 | { 157 | $this->uri = $uri; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/Routing/State.php: -------------------------------------------------------------------------------- 1 | prefix = $prefix; 26 | $this->middleware = $middleware; 27 | $this->domain = $domain; 28 | } 29 | 30 | /** 31 | * Append new attributes to the existing ones 32 | */ 33 | public function append(array $attributes): void 34 | { 35 | $this->domain = $attributes[Attributes::DOMAIN] ?? null; 36 | $this->prefix .= $attributes[Attributes::PREFIX] ?? ''; 37 | $this->middleware = array_merge($this->middleware, $attributes[Attributes::MIDDLEWARE] ?? []); 38 | } 39 | 40 | public function getPrefix(): string 41 | { 42 | return $this->prefix; 43 | } 44 | 45 | public function getMiddleware(): array 46 | { 47 | return $this->middleware; 48 | } 49 | 50 | public function getDomain(): ?string 51 | { 52 | return $this->domain; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Routing/Storekeeper.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 19 | $this->state = $state; 20 | } 21 | 22 | /** 23 | * Add a new route 24 | * 25 | * @param string $method 26 | * @param string $path 27 | * @param Closure|string|array $controller 28 | * @param string|null $name 29 | */ 30 | public function add(string $method, string $path, $controller, ?string $name = null): void 31 | { 32 | $this->repository->save( 33 | $method, 34 | $this->state->getPrefix() . $path, 35 | $controller, 36 | $name, 37 | $this->state->getMiddleware(), 38 | $this->state->getDomain() 39 | ); 40 | } 41 | 42 | public function getState(): State 43 | { 44 | return $this->state; 45 | } 46 | 47 | public function setState(State $state): void 48 | { 49 | $this->state = $state; 50 | } 51 | 52 | public function getRepository(): Repository 53 | { 54 | return $this->repository; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Url.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 18 | } 19 | 20 | /** 21 | * Generate (make) URL by name based on the defined routes 22 | * 23 | * @param string $name 24 | * @param string[] $parameters 25 | * @return string 26 | * @throws UndefinedRouteException 27 | */ 28 | public function make(string $name, array $parameters = []): string 29 | { 30 | if (!($route = $this->repository->findByName($name))) { 31 | throw new UndefinedRouteException("There is no route named `$name`."); 32 | } 33 | 34 | $uri = $route->getPath(); 35 | 36 | foreach ($parameters as $key => $value) { 37 | $uri = preg_replace('/\??{' . $key . '\??}/', $value, $uri); 38 | } 39 | 40 | $uri = preg_replace('/{[^}]+\?}/', '', $uri); 41 | return str_replace('/?', '', $uri); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/View/PhpView.php: -------------------------------------------------------------------------------- 1 | directory = $directory; 18 | } 19 | 20 | /** 21 | * @inheritDoc 22 | */ 23 | public function make(string $name, array $data = [], int $httpStatus = 200, array $httpHeaders = []) 24 | { 25 | $file = str_replace('.', DIRECTORY_SEPARATOR, $name) . '.phtml'; 26 | $path = join('/', [$this->directory, $file]); 27 | 28 | http_response_code($httpStatus); 29 | 30 | foreach ($httpHeaders as $name => $values) { 31 | @header($name . ': ' . $values); 32 | } 33 | 34 | extract($data); 35 | 36 | require $path; 37 | 38 | return null; 39 | } 40 | } -------------------------------------------------------------------------------- /src/View/View.php: -------------------------------------------------------------------------------- 1 | sample = $sample; 12 | } 13 | 14 | public function getSampleClassName(): string 15 | { 16 | return get_class($this->sample); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/Common/SampleController.php: -------------------------------------------------------------------------------- 1 | content = $content ?: 'empty'; 18 | } 19 | 20 | public function handle(ServerRequestInterface $request, $next) 21 | { 22 | static::$output[] = $this->content; 23 | 24 | return $next($request); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/Common/StopperMiddleware.php: -------------------------------------------------------------------------------- 1 | content = $content ?: mt_rand(1, 9999999); 19 | } 20 | 21 | public function handle(): TextResponse 22 | { 23 | static::$output[] = $this->content; 24 | 25 | return new TextResponse('Stopped in middleware.'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/Common/TrapPublisher.php: -------------------------------------------------------------------------------- 1 | responseCode = $response->getStatusCode(); 28 | 29 | foreach ($response->getHeaders() as $name => $values) { 30 | $value = $response->getHeaderLine($name); 31 | $this->headerLines[] = $name.': '.$value; 32 | } 33 | 34 | $this->output = $response->getBody(); 35 | } elseif (is_scalar($response)) { 36 | $this->output = (string)$response; 37 | } else { 38 | $this->output = json_encode($response); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/Features/ContainerTest.php: -------------------------------------------------------------------------------- 1 | router(); 21 | 22 | $container = $router->getContainer(); 23 | $container->singleton(SampleInterface::class, SampleClass::class); 24 | $router->setContainer($container); 25 | 26 | $router->get('/', function (Container $container) { 27 | return get_class($container->get(SampleInterface::class)); 28 | }); 29 | 30 | $router->dispatch(); 31 | 32 | $this->assertEquals(SampleClass::class, $this->output($router)); 33 | } 34 | 35 | /** 36 | * @throws Throwable 37 | */ 38 | public function test_binding_and_resolving_with_controller_method() 39 | { 40 | $router = $this->router(); 41 | 42 | $container = $router->getContainer(); 43 | $container->singleton(SampleInterface::class, SampleClass::class); 44 | $router->setContainer($container); 45 | 46 | $router->get('/', function (SampleInterface $sample) { 47 | return get_class($sample); 48 | }); 49 | 50 | $router->dispatch(); 51 | 52 | $this->assertEquals(SampleClass::class, $this->output($router)); 53 | } 54 | 55 | /** 56 | * @throws Throwable 57 | */ 58 | public function test_binding_and_resolving_with_controller_constructor() 59 | { 60 | $router = $this->router(); 61 | 62 | $container = $router->getContainer(); 63 | $container->singleton(SampleInterface::class, SampleClass::class); 64 | $router->setContainer($container); 65 | 66 | $router->get('/', [SampleConstructorController::class, 'getSampleClassName']); 67 | 68 | $router->dispatch(); 69 | 70 | $this->assertEquals(SampleClass::class, $this->output($router)); 71 | } 72 | 73 | /** 74 | * @throws Throwable 75 | */ 76 | public function test_binding_router_object() 77 | { 78 | $router = $this->router(); 79 | 80 | $router->get('/', function (Router $router) { 81 | return count($router->all()); 82 | }); 83 | 84 | $router->dispatch(); 85 | 86 | $this->assertEquals('1', $this->output($router)); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /tests/Features/ControllerTest.php: -------------------------------------------------------------------------------- 1 | router(); 18 | $router->get('/', function () { 19 | return 'Closure'; 20 | }); 21 | $router->dispatch(); 22 | 23 | $this->assertEquals('Closure', $this->output($router)); 24 | } 25 | 26 | /** 27 | * @throws Throwable 28 | */ 29 | public function test_with_a_method_controller() 30 | { 31 | $router = $this->router(); 32 | $router->get('/', [SampleController::class, 'home']); 33 | $router->dispatch(); 34 | 35 | $this->assertEquals('Home', $this->output($router)); 36 | } 37 | 38 | /** 39 | * @throws Throwable 40 | */ 41 | public function test_with_an_invalid_array_as_controller_it_should_fail() 42 | { 43 | $router = $this->router(); 44 | $router->get('/', ['invalid', 'array', 'controller']); 45 | 46 | $this->expectException(InvalidCallableException::class); 47 | $this->expectExceptionMessage('Invalid callable: invalid,array,controller'); 48 | $router->dispatch(); 49 | } 50 | 51 | /** 52 | * @throws Throwable 53 | */ 54 | public function test_with_an_invalid_class_as_controller_it_should_fail() 55 | { 56 | $router = $this->router(); 57 | $router->get('/', ['InvalidController', 'show']); 58 | 59 | $this->expectException(InvalidCallableException::class); 60 | $this->expectExceptionMessage('Class `InvalidController` not found.'); 61 | $router->dispatch(); 62 | } 63 | 64 | /** 65 | * @throws Throwable 66 | */ 67 | public function test_with_an_int_as_controller_it_should_fail() 68 | { 69 | $router = $this->router(); 70 | $router->get('/', 666); 71 | 72 | $this->expectException(InvalidCallableException::class); 73 | $this->expectExceptionMessage('Invalid callable.'); 74 | $router->dispatch(); 75 | } 76 | 77 | /** 78 | * @throws Throwable 79 | */ 80 | public function test_with_an_handle_less_class_as_controller_it_should_fail() 81 | { 82 | $router = $this->router(); 83 | $router->get('/', SampleController::class); 84 | 85 | $this->expectException(InvalidCallableException::class); 86 | $this->expectExceptionMessage('Method `handle` is not declared.'); 87 | $router->dispatch(); 88 | } 89 | 90 | /** 91 | * @throws Throwable 92 | */ 93 | public function test_with_an_invalid_method_as_controller_it_should_fail() 94 | { 95 | $router = $this->router(); 96 | $router->get('/', [SampleController::class, 'invalid']); 97 | 98 | $this->expectException(InvalidCallableException::class); 99 | $this->expectExceptionMessage('Method `' . SampleController::class . '::invalid` is not declared.'); 100 | $router->dispatch(); 101 | } 102 | 103 | /** 104 | * @throws Throwable 105 | */ 106 | public function test_with_multiple_controller_for_the_same_route_it_should_call_the_last_one() 107 | { 108 | $router = $this->router(); 109 | $router->get('/', [SampleController::class, 'home']); 110 | $router->get('/', [SampleController::class, 'page']); 111 | $router->dispatch(); 112 | 113 | $this->assertEquals('Page', $this->output($router)); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /tests/Features/GroupingTest.php: -------------------------------------------------------------------------------- 1 | router(); 19 | $router->group([], function (Router $router) { 20 | $router->get('/', [SampleController::class, 'ok']); 21 | }); 22 | $router->dispatch(); 23 | 24 | $this->assertEquals('OK', $this->output($router)); 25 | } 26 | 27 | /** 28 | * @throws Throwable 29 | */ 30 | public function test_with_a_middleware() 31 | { 32 | $middleware = new SampleMiddleware(666); 33 | 34 | $router = $this->router(); 35 | $router->group(['middleware' => [$middleware]], function (Router $router) { 36 | $router->get('/', [SampleController::class, 'ok']); 37 | }); 38 | $router->dispatch(); 39 | 40 | $this->assertEquals('OK', $this->output($router)); 41 | $this->assertContains($middleware->content, SampleMiddleware::$output); 42 | } 43 | 44 | /** 45 | * @throws Throwable 46 | */ 47 | public function test_nested_groups_with_middleware() 48 | { 49 | $group1Middleware = new SampleMiddleware(mt_rand(1, 9999999)); 50 | $group2Middleware = new SampleMiddleware(mt_rand(1, 9999999)); 51 | 52 | $router = $this->router(); 53 | $router->group(['middleware' => [$group1Middleware]], function (Router $router) use ($group2Middleware) { 54 | $router->group(['middleware' => [$group2Middleware]], function (Router $router) { 55 | $router->get('/', [SampleController::class, 'ok']); 56 | }); 57 | }); 58 | $router->dispatch(); 59 | 60 | $this->assertEquals('OK', $this->output($router)); 61 | $this->assertContains($group1Middleware->content, SampleMiddleware::$output); 62 | $this->assertContains($group2Middleware->content, SampleMiddleware::$output); 63 | } 64 | 65 | /** 66 | * @throws Throwable 67 | */ 68 | public function test_with_a_prefix() 69 | { 70 | $this->mockRequest('GET', 'https://example.com/group/page'); 71 | 72 | $router = $this->router(); 73 | $router->group(['prefix' => '/group'], function (Router $router) { 74 | $router->get('/page', [SampleController::class, 'ok']); 75 | }); 76 | $router->dispatch(); 77 | 78 | $this->assertEquals('OK', $this->output($router)); 79 | } 80 | 81 | /** 82 | * @throws Throwable 83 | */ 84 | public function test_nested_groups_with_prefix() 85 | { 86 | $this->mockRequest('GET', 'https://example.com/group1/group2/page'); 87 | 88 | $router = $this->router(); 89 | $router->group(['prefix' => '/group1'], function (Router $router) { 90 | $router->group(['prefix' => '/group2'], function (Router $router) { 91 | $router->get('/page', [SampleController::class, 'ok']); 92 | }); 93 | }); 94 | $router->dispatch(); 95 | 96 | $this->assertEquals('OK', $this->output($router)); 97 | } 98 | 99 | /** 100 | * @throws Throwable 101 | */ 102 | public function test_with_domain() 103 | { 104 | $this->mockRequest('GET', 'https://sub.domain.tld/'); 105 | 106 | $router = $this->router(); 107 | $router->group(['domain' => 'sub.domain.tld'], function (Router $router) { 108 | $router->get('/', [SampleController::class, 'ok']); 109 | }); 110 | $router->dispatch(); 111 | 112 | $this->assertEquals('OK', $this->output($router)); 113 | } 114 | 115 | /** 116 | * @throws Throwable 117 | */ 118 | public function test_nested_groups_with_domain_it_should_consider_the_inner_group_domain() 119 | { 120 | $this->mockRequest('GET', 'https://sub2.domain.com/'); 121 | 122 | $router = $this->router(); 123 | $router->group(['domain' => 'sub1.domain.com'], function (Router $router) { 124 | $router->group(['domain' => 'sub2.domain.com'], function (Router $router) { 125 | $router->get('/', [SampleController::class, 'ok']); 126 | }); 127 | }); 128 | $router->dispatch(); 129 | 130 | $this->assertEquals('OK', $this->output($router)); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /tests/Features/HttpMethodTest.php: -------------------------------------------------------------------------------- 1 | mockRequest('GET', 'https://example.com/'); 18 | 19 | $router = $this->router(); 20 | $router->get('/', [SampleController::class, 'home']); 21 | $router->dispatch(); 22 | 23 | $this->assertEquals('Home', $this->output($router)); 24 | } 25 | 26 | /** 27 | * @throws Throwable 28 | */ 29 | public function test_a_post_route() 30 | { 31 | $this->mockRequest('POST', 'https://example.com/'); 32 | 33 | $router = $this->router(); 34 | $router->post('/', [SampleController::class, 'home']); 35 | $router->dispatch(); 36 | 37 | $this->assertEquals('Home', $this->output($router)); 38 | } 39 | 40 | /** 41 | * @throws Throwable 42 | */ 43 | public function test_a_put_route() 44 | { 45 | $this->mockRequest('PUT', 'https://example.com/'); 46 | 47 | $router = $this->router(); 48 | $router->put('/', [SampleController::class, 'home']); 49 | $router->dispatch(); 50 | 51 | $this->assertEquals('Home', $this->output($router)); 52 | } 53 | 54 | /** 55 | * @throws Throwable 56 | */ 57 | public function test_a_patch_route() 58 | { 59 | $this->mockRequest('PATCH', 'https://example.com/'); 60 | 61 | $router = $this->router(); 62 | $router->patch('/', [SampleController::class, 'home']); 63 | $router->dispatch(); 64 | 65 | $this->assertEquals('Home', $this->output($router)); 66 | } 67 | 68 | /** 69 | * @throws Throwable 70 | */ 71 | public function test_a_delete_route() 72 | { 73 | $this->mockRequest('DELETE', 'https://example.com/'); 74 | 75 | $router = $this->router(); 76 | $router->delete('/', [SampleController::class, 'home']); 77 | $router->dispatch(); 78 | 79 | $this->assertEquals('Home', $this->output($router)); 80 | } 81 | 82 | /** 83 | * @throws Throwable 84 | */ 85 | public function test_map_a_post_route() 86 | { 87 | $this->mockRequest('POST', 'https://example.com/'); 88 | 89 | $router = $this->router(); 90 | $router->define('POST', '/', [SampleController::class, 'home']); 91 | $router->dispatch(); 92 | 93 | $this->assertEquals('Home', $this->output($router)); 94 | } 95 | 96 | /** 97 | * @throws Throwable 98 | */ 99 | public function test_map_a_custom_method() 100 | { 101 | $this->mockRequest('CUSTOM', 'https://example.com/'); 102 | 103 | $router = $this->router(); 104 | $router->define('CUSTOM', '/', [SampleController::class, 'home']); 105 | $router->dispatch(); 106 | 107 | $this->assertEquals('Home', $this->output($router)); 108 | } 109 | 110 | /** 111 | * @throws Throwable 112 | */ 113 | public function test_any_with_some_methods() 114 | { 115 | $router = $this->router(); 116 | $router->any('/', function (ServerRequest $request) { 117 | return $request->getMethod(); 118 | }); 119 | 120 | $this->mockRequest('GET', 'https://example.com/'); 121 | $router->dispatch(); 122 | $this->assertEquals('GET', $this->output($router)); 123 | 124 | $this->mockRequest('POST', 'https://example.com/'); 125 | $router->dispatch(); 126 | $this->assertEquals('POST', $this->output($router)); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /tests/Features/InjectionTest.php: -------------------------------------------------------------------------------- 1 | router(); 21 | $router->get('/', function (ServerRequest $request) { 22 | return get_class($request); 23 | }); 24 | $router->dispatch(); 25 | 26 | $this->assertEquals(ServerRequest::class, $this->output($router)); 27 | } 28 | 29 | /** 30 | * @throws Throwable 31 | */ 32 | public function test_injecting_request_by_interface() 33 | { 34 | $router = $this->router(); 35 | $router->get('/', function (ServerRequestInterface $request) { 36 | return get_class($request); 37 | }); 38 | $router->dispatch(); 39 | 40 | $this->assertEquals(ServerRequest::class, $this->output($router)); 41 | } 42 | 43 | /** 44 | * @throws Throwable 45 | */ 46 | public function test_injecting_route() 47 | { 48 | $router = $this->router(); 49 | $router->get('/', function (Route $route) { 50 | return $route->getPath(); 51 | }); 52 | $router->dispatch(); 53 | 54 | $this->assertEquals('/', $this->output($router)); 55 | } 56 | 57 | /** 58 | * @throws Throwable 59 | */ 60 | public function test_injecting_container() 61 | { 62 | $router = $this->router(); 63 | $router->get('/', function (Container $container) { 64 | return get_class($container); 65 | }); 66 | $router->dispatch(); 67 | 68 | $this->assertEquals(Container::class, $this->output($router)); 69 | } 70 | 71 | /** 72 | * @throws Throwable 73 | */ 74 | public function test_injecting_container_by_interface() 75 | { 76 | $router = $this->router(); 77 | $router->get('/', function (ContainerInterface $container) { 78 | return get_class($container); 79 | }); 80 | $router->dispatch(); 81 | 82 | $this->assertEquals(Container::class, $this->output($router)); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/Features/MiddlewareTest.php: -------------------------------------------------------------------------------- 1 | router(); 23 | $router->group(['middleware' => [$middleware]], function (Router $r) { 24 | $r->get('/', [SampleController::class, 'ok']); 25 | }); 26 | $router->dispatch(); 27 | 28 | $this->assertEquals('OK', $this->output($router)); 29 | $this->assertContains($middleware->content, SampleMiddleware::$output); 30 | } 31 | 32 | /** 33 | * @throws Throwable 34 | */ 35 | public function test_with_a_single_middleware_as_a_string() 36 | { 37 | $middleware = SampleMiddleware::class; 38 | 39 | $router = $this->router(); 40 | $router->group(['middleware' => [$middleware]], function (Router $r) { 41 | $r->get('/', [SampleController::class, 'ok']); 42 | }); 43 | $router->dispatch(); 44 | 45 | $this->assertEquals('OK', $this->output($router)); 46 | $this->assertEquals('empty', SampleMiddleware::$output[0]); 47 | } 48 | 49 | /** 50 | * @throws Throwable 51 | */ 52 | public function test_with_a_stopper_middleware() 53 | { 54 | $middleware = new StopperMiddleware(666); 55 | 56 | $router = $this->router(); 57 | $router->group(['middleware' => [$middleware]], function (Router $r) { 58 | $r->get('/', [SampleController::class, 'ok']); 59 | }); 60 | $router->dispatch(); 61 | 62 | $this->assertEquals('Stopped in middleware.', $this->output($router)); 63 | $this->assertContains($middleware->content, StopperMiddleware::$output); 64 | } 65 | 66 | /** 67 | * @throws Throwable 68 | */ 69 | public function test_with_invalid_middleware() 70 | { 71 | $this->expectException(InvalidCallableException::class); 72 | 73 | $router = $this->router(); 74 | $router->group(['middleware' => ['UnknownMiddleware']], function (Router $r) { 75 | $r->get('/', [SampleController::class, 'ok']); 76 | }); 77 | $router->dispatch(); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /tests/Features/NamingTest.php: -------------------------------------------------------------------------------- 1 | router(); 17 | $router->get('/', function (Route $route) { 18 | return $route->getName(); 19 | }, 'home'); 20 | $router->dispatch(); 21 | 22 | $this->assertEquals('home', $this->output($router)); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Features/ParametersTest.php: -------------------------------------------------------------------------------- 1 | mockRequest('GET', "https://example.com/products/$id"); 17 | 18 | $router = $this->router(); 19 | $router->get('/products/{id}', function ($id) { 20 | return $id; 21 | }); 22 | $router->dispatch(); 23 | 24 | $this->assertEquals($id, $this->output($router)); 25 | } 26 | 27 | /** 28 | * @throws Throwable 29 | */ 30 | public function test_with_some_required_parameters() 31 | { 32 | $pid = random_int(1, 100); 33 | $cid = random_int(1, 100); 34 | $this->mockRequest('GET', "https://example.com/products/$pid/comments/$cid"); 35 | 36 | $router = $this->router(); 37 | $router->get('/products/{pid}/comments/{cid}', function ($pid, $cid) { 38 | return $pid . $cid; 39 | }); 40 | $router->dispatch(); 41 | 42 | $this->assertEquals($pid . $cid, $this->output($router)); 43 | } 44 | 45 | /** 46 | * @throws Throwable 47 | */ 48 | public function test_with_a_provided_optional_parameter() 49 | { 50 | $id = random_int(1, 100); 51 | $this->mockRequest('GET', "https://example.com/products/$id"); 52 | 53 | $router = $this->router(); 54 | $router->get('/products/{id?}', function ($id = 'default') { 55 | return $id; 56 | }); 57 | $router->dispatch(); 58 | 59 | $this->assertEquals($id, $this->output($router)); 60 | } 61 | 62 | /** 63 | * @throws Throwable 64 | */ 65 | public function test_with_a_unprovided_optional_parameter() 66 | { 67 | $this->mockRequest('GET', "https://example.com/products/"); 68 | 69 | $router = $this->router(); 70 | $router->get('/products/{id?}', function ($id = 'default') { 71 | return $id; 72 | }); 73 | $router->dispatch(); 74 | 75 | $this->assertEquals('default', $this->output($router)); 76 | } 77 | 78 | /** 79 | * @throws Throwable 80 | */ 81 | public function test_with_a_unprovided_optional_parameter_and_slash() 82 | { 83 | $this->mockRequest('GET', "https://example.com/products"); 84 | 85 | $router = $this->router(); 86 | $router->get('/products/?{id?}', function ($id = 'default') { 87 | return $id; 88 | }); 89 | $router->dispatch(); 90 | 91 | $this->assertEquals('default', $this->output($router)); 92 | } 93 | 94 | /** 95 | * @throws Throwable 96 | */ 97 | public function test_with_some_optional_parameters() 98 | { 99 | $pid = random_int(1, 100); 100 | $cid = random_int(1, 100); 101 | $this->mockRequest('GET', "https://example.com/products/$pid/comments/$cid"); 102 | 103 | $router = $this->router(); 104 | $router->get('/products/{pid?}/comments/{cid?}', function ($pid, $cid) { 105 | return $pid . $cid; 106 | }); 107 | $router->dispatch(); 108 | 109 | $this->assertEquals($pid . $cid, $this->output($router)); 110 | } 111 | 112 | /** 113 | * @throws Throwable 114 | */ 115 | public function test_with_mixin_parameters() 116 | { 117 | $pid = random_int(1, 100); 118 | $cid = random_int(1, 100); 119 | $this->mockRequest('GET', "https://example.com/products/$pid/comments/$cid"); 120 | 121 | $router = $this->router(); 122 | $router->get('/products/{pid}/comments/{cid?}', function ($pid, $cid = 'default') { 123 | return $pid . $cid; 124 | }); 125 | $router->dispatch(); 126 | 127 | $this->assertEquals($pid . $cid, $this->output($router)); 128 | } 129 | } -------------------------------------------------------------------------------- /tests/Features/PatternsTest.php: -------------------------------------------------------------------------------- 1 | mockRequest('GET', "https://example.com/products/$id"); 18 | 19 | $router = $this->router(); 20 | $router->pattern('id', '[0-9]'); 21 | $router->get('/products/{id}', function ($id) { 22 | return $id; 23 | }); 24 | $router->dispatch(); 25 | 26 | $this->assertEquals($id, $this->output($router)); 27 | } 28 | 29 | /** 30 | * @throws Throwable 31 | */ 32 | public function test_with_a_single_digit_pattern_given_more() 33 | { 34 | $id = random_int(10, 100); 35 | $this->mockRequest('GET', "https://example.com/products/$id"); 36 | 37 | $router = $this->router(); 38 | $router->pattern('id', '[0-9]'); 39 | $router->get('/products/{id}', function ($id) { 40 | return $id; 41 | }); 42 | 43 | $this->expectException(RouteNotFoundException::class); 44 | $router->dispatch(); 45 | } 46 | 47 | /** 48 | * @throws Throwable 49 | */ 50 | public function test_with_a_multi_digits_pattern() 51 | { 52 | $id = random_int(10, 100); 53 | $this->mockRequest('GET', "https://example.com/products/$id"); 54 | 55 | $router = $this->router(); 56 | $router->pattern('id', '[0-9]+'); 57 | $router->get('/products/{id}', function ($id) { 58 | return $id; 59 | }); 60 | $router->dispatch(); 61 | 62 | $this->assertEquals($id, $this->output($router)); 63 | } 64 | 65 | /** 66 | * @throws Throwable 67 | */ 68 | public function test_with_a_multi_digits_pattern_given_string() 69 | { 70 | $this->mockRequest('GET', "https://example.com/products/string"); 71 | 72 | $router = $this->router(); 73 | $router->pattern('id', '[0-9]+'); 74 | $router->get('/products/{id}', function ($id) { 75 | return $id; 76 | }); 77 | 78 | $this->expectException(RouteNotFoundException::class); 79 | $router->dispatch(); 80 | } 81 | 82 | /** 83 | * @throws Throwable 84 | */ 85 | public function test_with_a_alphanumeric_pattern() 86 | { 87 | $id = 'abc123xyz'; 88 | $this->mockRequest('GET', "https://example.com/products/$id"); 89 | 90 | $router = $this->router(); 91 | $router->pattern('id', '[0-9a-z]+'); 92 | $router->get('/products/{id}', function ($id) { 93 | return $id; 94 | }); 95 | $router->dispatch(); 96 | 97 | $this->assertEquals($id, $this->output($router)); 98 | } 99 | 100 | /** 101 | * @throws Throwable 102 | */ 103 | public function test_with_a_alphanumeric_pattern_given_invalid() 104 | { 105 | $id = 'abc$$$'; 106 | $this->mockRequest('GET', "https://example.com/products/$id"); 107 | 108 | $router = $this->router(); 109 | $router->pattern('id', '[0-9a-z]+'); 110 | $router->get('/products/{id}', function ($id) { 111 | return $id; 112 | }); 113 | 114 | $this->expectException(RouteNotFoundException::class); 115 | $router->dispatch(); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /tests/Features/ResponseTest.php: -------------------------------------------------------------------------------- 1 | router(); 21 | $router->get('/', function () { 22 | return new EmptyResponse(204); 23 | }); 24 | $router->dispatch(); 25 | 26 | $this->assertEquals(204, $this->status($router)); 27 | } 28 | 29 | /** 30 | * @throws Throwable 31 | */ 32 | public function test_html_response_with_code_200() 33 | { 34 | $router = $this->router(); 35 | $router->get('/', function () { 36 | return new HtmlResponse('', 200); 37 | }); 38 | $router->dispatch(); 39 | 40 | $this->assertEquals(200, $this->status($router)); 41 | $this->assertEquals('', $this->output($router)); 42 | } 43 | 44 | /** 45 | * @throws Throwable 46 | */ 47 | public function test_json_response_with_code_201() 48 | { 49 | $router = $this->router(); 50 | $router->get('/', function () { 51 | return new JsonResponse(['a' => 'x', 'b' => 'y'], 201); 52 | }); 53 | $router->dispatch(); 54 | 55 | $this->assertEquals(201, $this->status($router)); 56 | $this->assertEquals(json_encode(['a' => 'x', 'b' => 'y']), $this->output($router)); 57 | } 58 | 59 | /** 60 | * @throws Throwable 61 | */ 62 | public function test_text_response_with_code_203() 63 | { 64 | $router = $this->router(); 65 | $router->get('/', function () { 66 | return new TextResponse('Content', 203); 67 | }); 68 | $router->dispatch(); 69 | 70 | $this->assertEquals(203, $this->status($router)); 71 | $this->assertEquals('Content', $this->output($router)); 72 | } 73 | 74 | /** 75 | * @throws Throwable 76 | */ 77 | public function test_redirect_response_with_code_203() 78 | { 79 | $router = $this->router(); 80 | $router->get('/', function () { 81 | return new RedirectResponse('https://miladrahimi.com'); 82 | }); 83 | $router->dispatch(); 84 | 85 | $this->assertEquals(302, $this->status($router)); 86 | $this->assertEquals('', $this->output($router)); 87 | $this->assertContains('location: https://miladrahimi.com', $this->publisher($router)->headerLines); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /tests/Features/RouteTest.php: -------------------------------------------------------------------------------- 1 | mockRequest('POST', 'https://shop.com/admin/profile/666'); 20 | 21 | $router = $this->router(); 22 | 23 | $attributes = [ 24 | Attributes::DOMAIN => 'shop.com', 25 | Attributes::MIDDLEWARE => [SampleMiddleware::class], 26 | Attributes::PREFIX => '/admin', 27 | ]; 28 | 29 | $router->group($attributes, function (Router $router) { 30 | $router->post('/profile/{id}', function (Route $route) { 31 | return $route->__toString(); 32 | }, 'admin.profile'); 33 | }); 34 | 35 | $router->dispatch(); 36 | 37 | $expected = [ 38 | 'method' => 'POST', 39 | 'path' => '/admin/profile/{id}', 40 | 'controller' => function () { 41 | return 'Closure'; 42 | }, 43 | 'name' => 'admin.profile', 44 | 'middleware' => [SampleMiddleware::class], 45 | 'domain' => 'shop.com', 46 | 'uri' => '/admin/profile/666', 47 | 'parameters' => ['id' => '666'], 48 | ]; 49 | 50 | $this->assertEquals(json_encode($expected), $this->output($router)); 51 | } 52 | 53 | /** 54 | * @throws Throwable 55 | */ 56 | public function test_lately_added_attributes_of_route() 57 | { 58 | $this->mockRequest('POST', 'https://shop.com/admin/profile/666'); 59 | 60 | $router = $this->router(); 61 | 62 | $router->post('/admin/profile/{id}', function (Route $route) { 63 | return [ 64 | $route->getParameters(), 65 | $route->getUri(), 66 | ]; 67 | }, 'admin.profile'); 68 | 69 | $router->dispatch(); 70 | 71 | $expected = [['id' => '666'], '/admin/profile/666']; 72 | 73 | $this->assertEquals(json_encode($expected), $this->output($router)); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tests/Features/UrlTest.php: -------------------------------------------------------------------------------- 1 | router(); 18 | $router->get('/', function (Url $url) { 19 | return $url->make('home'); 20 | }, 'home'); 21 | $router->dispatch(); 22 | 23 | $this->assertEquals('/', $this->output($router)); 24 | } 25 | 26 | /** 27 | * @throws Throwable 28 | */ 29 | public function test_generating_url_for_a_page() 30 | { 31 | $this->mockRequest('GET', 'https://web.com/page'); 32 | 33 | $router = $this->router(); 34 | $router->get('/', function (Url $url) { 35 | return $url->make('home'); 36 | }, 'home'); 37 | $router->get('/page', function (Url $url) { 38 | return $url->make('page'); 39 | }, 'page'); 40 | $router->dispatch(); 41 | 42 | $this->assertEquals('/page', $this->output($router)); 43 | } 44 | 45 | /** 46 | * @throws Throwable 47 | */ 48 | public function test_generating_url_for_a_page_with_required_parameter() 49 | { 50 | $this->mockRequest('GET', 'https://web.com/'); 51 | 52 | $router = $this->router(); 53 | $router->get('/', function (Url $url) { 54 | return $url->make('page', ['name' => 'about']); 55 | }); 56 | $router->get('/{name}', function () { 57 | return 'empty'; 58 | }, 'page'); 59 | $router->dispatch(); 60 | 61 | $this->assertEquals('/about', $this->output($router)); 62 | } 63 | 64 | /** 65 | * @throws Throwable 66 | */ 67 | public function test_generating_url_for_a_page_with_optional_parameter() 68 | { 69 | $this->mockRequest('GET', 'https://web.com/'); 70 | 71 | $router = $this->router(); 72 | $router->get('/', function (Url $url) { 73 | return $url->make('post', ['post' => 666]); 74 | }); 75 | $router->get('/blog/{post?}', function () { 76 | return 'empty'; 77 | }, 'post'); 78 | $router->dispatch(); 79 | 80 | $this->assertEquals('/blog/666', $this->output($router)); 81 | } 82 | 83 | /** 84 | * @throws Throwable 85 | */ 86 | public function test_generating_url_for_a_page_with_optional_parameter_ignored() 87 | { 88 | $this->mockRequest('GET', 'https://web.com/'); 89 | 90 | $router = $this->router(); 91 | $router->get('/', function (Url $url) { 92 | return $url->make('page'); 93 | }); 94 | $router->get('/profile/{name?}', function () { 95 | return 'empty'; 96 | }, 'page'); 97 | $router->dispatch(); 98 | 99 | $this->assertEquals('/profile/', $this->output($router)); 100 | } 101 | 102 | /** 103 | * @throws Throwable 104 | */ 105 | public function test_generating_url_for_a_page_with_optional_parameter_and_slash_ignored() 106 | { 107 | $this->mockRequest('GET', 'https://web.com/'); 108 | 109 | $router = $this->router(); 110 | $router->get('/', function (Url $url) { 111 | return $url->make('page'); 112 | }); 113 | $router->get('/profile/?{name?}', function () { 114 | return 'empty'; 115 | }, 'page'); 116 | $router->dispatch(); 117 | 118 | $this->assertEquals('/profile', $this->output($router)); 119 | } 120 | 121 | /** 122 | * @throws Throwable 123 | */ 124 | public function test_generating_url_for_undefined_route() 125 | { 126 | $this->expectException(UndefinedRouteException::class); 127 | $this->expectExceptionMessage("There is no route named `page`."); 128 | 129 | $router = $this->router(); 130 | $router->get('/', function (Url $r) { 131 | return $r->make('page'); 132 | }); 133 | $router->dispatch(); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /tests/Features/ViewTest.php: -------------------------------------------------------------------------------- 1 | setupView(__DIR__ . '/../resources/views'); 21 | 22 | $router->get('/', function(View $view) { 23 | return $view->make('sample', ['user' => 'Milad']); 24 | }); 25 | $router->dispatch(); 26 | 27 | $this->assertEquals('

Hello Milad

', ob_get_clean()); 28 | } 29 | 30 | /** 31 | * @throws Throwable 32 | */ 33 | public function test_with_the_sample_view_and_status_201_and_headers() 34 | { 35 | ob_start(); 36 | 37 | $router = Router::create(); 38 | $router->setupView(__DIR__ . '/../resources/views'); 39 | 40 | $router->get('/', function(View $view) { 41 | return $view->make('sample', ['user' => 'Milad'], 201, [ 42 | 'X-Powered-By' => 'PhpRouter Test', 43 | ]); 44 | }); 45 | $router->dispatch(); 46 | 47 | $this->assertEquals('

Hello Milad

', ob_get_clean()); 48 | $this->assertEquals(201, http_response_code()); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | mockRequest('GET', 'https://example.com/'); 21 | } 22 | 23 | /** 24 | * Manipulate the current HTTP request ($_SERVER) 25 | * 26 | * @param string $method 27 | * @param string $url 28 | */ 29 | protected function mockRequest(string $method, string $url): void 30 | { 31 | $urlParts = parse_url($url); 32 | 33 | $_SERVER['SERVER_NAME'] = $urlParts['host']; 34 | $_SERVER['REQUEST_URI'] = $urlParts['path'] . '?' . ($urlParts['query'] ?? ''); 35 | $_SERVER['REQUEST_METHOD'] = $method; 36 | } 37 | 38 | /** 39 | * Get a router instance for testing purposes 40 | * 41 | * @return Router 42 | * @throws ContainerException 43 | */ 44 | protected function router(): Router 45 | { 46 | $router = Router::create(); 47 | $router->setPublisher(new TrapPublisher()); 48 | 49 | return $router; 50 | } 51 | 52 | /** 53 | * Get the generated output of the dispatched route of the given router 54 | * 55 | * @param Router $router 56 | * @return string 57 | */ 58 | protected function output(Router $router): string 59 | { 60 | return $this->publisher($router)->output; 61 | } 62 | 63 | /** 64 | * Get the given router publisher. 65 | * 66 | * @param Router $router 67 | * @return TrapPublisher|Publisher 68 | */ 69 | protected function publisher(Router $router): TrapPublisher 70 | { 71 | return $router->getPublisher(); 72 | } 73 | 74 | /** 75 | * Get the HTTP status code of the dispatched route of the given router 76 | * 77 | * @param Router $router 78 | * @return int 79 | */ 80 | protected function status(Router $router): int 81 | { 82 | return $this->publisher($router)->responseCode; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/Units/HttpPublisherTest.php: -------------------------------------------------------------------------------- 1 | get('/', function () { 21 | return 'Hello!'; 22 | }); 23 | $router->dispatch(); 24 | 25 | $this->assertEquals('Hello!', ob_get_clean()); 26 | } 27 | 28 | /** 29 | * @throws Throwable 30 | */ 31 | public function test_publish_a_empty_response() 32 | { 33 | ob_start(); 34 | 35 | $router = Router::create(); 36 | $router->get('/', function () { 37 | // 38 | }); 39 | $router->dispatch(); 40 | 41 | $this->assertEmpty(ob_get_clean()); 42 | } 43 | 44 | /** 45 | * @throws Throwable 46 | */ 47 | public function test_publish_a_array_response() 48 | { 49 | ob_start(); 50 | 51 | $router = Router::create(); 52 | $router->get('/', function () { 53 | return ['a', 'b', 'c']; 54 | }); 55 | $router->dispatch(); 56 | 57 | $this->assertEquals('["a","b","c"]', ob_get_clean()); 58 | } 59 | 60 | /** 61 | * @throws Throwable 62 | */ 63 | public function test_publish_a_standard_response() 64 | { 65 | ob_start(); 66 | 67 | $router = Router::create(); 68 | $router->get('/', function () { 69 | return new JsonResponse(['error' => 'failed'], 400); 70 | }); 71 | 72 | $router->dispatch(); 73 | 74 | $this->assertEquals('{"error":"failed"}', ob_get_clean()); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/resources/views/sample.phtml: -------------------------------------------------------------------------------- 1 |

Hello

--------------------------------------------------------------------------------