├── .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 | [](//packagist.org/packages/miladrahimi/phprouter)
2 | [](//packagist.org/packages/miladrahimi/phprouter)
3 | [](https://github.com/miladrahimi/phprouter/actions/workflows/ci.yml)
4 | [](https://codecov.io/gh/miladrahimi/phprouter)
5 | [](https://scrutinizer-ci.com/g/miladrahimi/phprouter/?branch=master)
6 | [](//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
--------------------------------------------------------------------------------