diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml deleted file mode 100644 index a34418c..0000000 --- a/.github/workflows/php.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Run tests - -on: [push] - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v1 - - - name: Validate composer.json and composer.lock - run: composer validate - - - name: Install dependencies - run: composer install --prefer-dist --no-progress --no-suggest - - # Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit" - # Docs: https://getcomposer.org/doc/articles/scripts.md - - - name: Run test suite - run: ./vendor/bin/phpunit ./tests diff --git a/.gitignore b/.gitignore index c0df8ed..e145d12 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /composer.lock /build .DS_Store +.phpunit.result.cache diff --git a/README.md b/README.md index 2e74e0b..0ea3ab2 100644 --- a/README.md +++ b/README.md @@ -6,40 +6,79 @@ # PhpRouter -PhpRouter is a powerful, standalone, and very fast HTTP URL router for PHP projects. +PhpRouter is a powerful, lightweight, and very fast HTTP URL router for PHP projects. -Some of the supported features: +Some of the provided features: * Route parameters * Middleware -* Route groups (URI prefix, namespace prefix, middleware, and domain) -* Route names +* Route groups (by prefix, middleware, and domain) +* Route naming * PSR-7 requests and responses -* Multiple controller types (class, closure, and function) -* Predefined route parameter regex patterns -* Multiple domains or subdomains (regex pattern) +* Multiple (sub)domains (using regex patterns) +* Closure and class controllers +* Predefined route parameter patterns * Custom HTTP methods -* Request, response and router instance injection - -Current version requires PHP `v7.1` or newer versions. +* Integrated with IoC container out of the box +* Auto-injection of request, response, router, etc + +The current version requires PHP `v7.1` or newer versions. + +## Contents +- [Versions](#versions) +- [Documentation](#documentation) + - [Installation](#installation) + - [Configuration](#configuration) + - [Getting Started](#getting-started) + - [HTTP Methods](#http-methods) + - [Controllers](#controllers) + - [Route Parameters](#route-parameters) + - [Requests and Responses](#requests-and-responses) + - [Route Groups](#route-groups) + - [Middleware](#middleware) + - [Domains and Subdomains](#domains-and-subdomains) + - [Route Names](#route-names) + - [Current Route](#current-route) + - [Error Handling](#error-handling) +- [License](#license) ## Versions -* **v4.x.x (LTS)** -* v3.x.x (Unsupported) -* ~~v2.x.x~~ (Unavailable) -* ~~v1.x.x~~ (Unavailable) +Supported versions: + +* v5.x.x +* v4.x.x + +Unsupported versions: + +* v3.x.x + +Unavailable versions: -## Installation +* v2.x.x +* v1.x.x + +## Documentation + +### Installation Install [Composer](https://getcomposer.org) and run following command in your project's root directory: ```bash -composer require miladrahimi/phprouter "4.*" +composer require miladrahimi/phprouter "5.*" ``` -## Configuration +### Configuration -First of all, you need to configure your webserver to handle all the HTTP requests with a single PHP file like `index.php`. Here you can see sample configurations for Apache HTTP Server and NGINX. +First of all, +you need to configure your webserver to handle all the HTTP requests with a single PHP file like the `index.php` file. +Here you can see sample configurations for NGINX and Apache HTTP Server. + +* NGINX configuration sample: + ```nginx + location / { + try_files $uri $uri/ /index.php?$query_string; + } + ``` * Apache `.htaccess` sample: ```apacheconfig @@ -59,194 +98,170 @@ First of all, you need to configure your webserver to handle all the HTTP reques ``` -* NGINX configuration sample: - ```nginx - location / { - try_files $uri $uri/ /index.php?$query_string; - } - ``` - -## Getting Started +### Getting Started -After applying the configurations mentioned above, you can start using PhpRouter in your entry point file (`index.php`) like this example: +It's so easy to work with PhpRouter! Just take a look at the following example. ```php use MiladRahimi\PhpRouter\Router; -$router = new Router(); +$router = Router::create(); $router->get('/', function () { - return '
This is homepage!
'; + return 'This is homepage!'; }); $router->dispatch(); ``` -Chaining methods is also possible, take a look at this example: +### HTTP Methods + +The following example illustrates how to declare different routes for different HTTP methods. ```php use MiladRahimi\PhpRouter\Router; -$router = new Router(); - -$router - ->get('/', function () { - return 'This is homepage!
'; - }) - ->dispatch(); -``` +$router = Router::create(); -There are more examples available [here](https://github.com/miladrahimi/phprouter/tree/master/examples). +$router->get('/', function () { + return 'GET'; +}); +$router->post('/', function () { + return 'POST'; +}); +$router->put('/', function () { + return 'PUT'; +}); +$router->patch('/', function () { + return 'PATCH'; +}); +$router->delete('/', function () { + return 'DELETE'; +}); -## HTTP Methods +$router->dispatch(); +``` -Here you can see how to declare different routes for different HTTP methods: +You can use the `map()` method for other HTTP methods like this example: ```php use MiladRahimi\PhpRouter\Router; -$router = new Router(); - -$router - ->get('/', function () { - return 'GET method'; - }) - ->post('/', function () { - return 'POST method'; - }) - ->patch('/', function () { - return 'PATCH method'; - }) - ->put('/', function () { - return 'PUT method'; - }) - ->delete('/', function () { - return 'DELETE method'; - }) - ->any('/page', function () { - return 'This is the Page! No matter what the HTTP method is!'; - }) - ->dispatch(); -``` +$router = Router::create(); -You may want to use your custom HTTP methods, so take a look at this example: - -```php -use MiladRahimi\PhpRouter\Router; +$router->map('GET', '/', function () { + return 'GET'; +}); +$router->map('OPTIONS', '/', function () { + return 'OPTIONS'; +}); +$router->map('CUSTOM', '/', function () { + return 'CUSTOM'; +}); -$router = new Router(); - -$router - ->map('GET', '/', function () { - return 'GET method'; - }) - ->map('POST', '/', function () { - return 'POST method'; - }) - ->map('CUSTOM', '/', function () { - return 'CUSTOM method'; - }) - ->dispatch(); +$router->dispatch(); ``` -## Controllers - -PhpRouter supports plenty of controller types, just look at the following examples. +If you need to assign multiple HTTP methods to a single controller, there is the `match()` method for you. ```php use MiladRahimi\PhpRouter\Router; -$router = new Router(); +$router = Router::create(); -$router->get('/closure', function () { - return 'Closure as a controller'; +$router->match(['GET', 'POST'], '/', function () { + return 'GET or POST!'; }); -function func() { - return 'Function as a controller'; -} -$router->get('/function', 'func'); - $router->dispatch(); ``` -And class controllers: +If you don't want to care about HTTP verbs, you can use the `any()` method. ```php use MiladRahimi\PhpRouter\Router; -$router = new Router(); +$router = Router::create(); -class Controller -{ - function method() - { - return 'Class method as a controller'; - } -} - -$router->get('/method', 'Controller@method'); +$router->any('/', function () { + return 'This is Home! No matter what the HTTP method is!'; +}); $router->dispatch(); ``` -If your controller class has a namespace: +### Controllers + +#### Closure Controllers ```php -use App\Controllers\TheController; use MiladRahimi\PhpRouter\Router; -$router = new Router(); +$router = Router::create(); -$router->get('/ns', 'App\Controllers\TheController@show'); -// OR -$router->get('/ns', TheController::class . '@show'); +$router->get('/', function () { + return 'This is a closure controller!'; +}); $router->dispatch(); ``` -If your controllers have the same namespace or namespace prefix, you can pass it to the router constructor like this: +#### Class Method Controllers ```php use MiladRahimi\PhpRouter\Router; -$router = new Router('', 'App\Controllers'); +class UsersController +{ + function index() + { + return 'Class: UsersController, Method: index'; + } -$router->get('/', 'TheController@show'); -// PhpRouter looks for App\Controllers\TheController@show + function handle() + { + return 'Class UsersController.'; + } +} + +$router = Router::create(); + +// Controller: Class=UsersController Method=index() +$router->get('/method', [UsersController::class, 'index']); + +// Controller: Class=UsersController Method=handle() +$router->get('/class', UsersController::class); $router->dispatch(); ``` -## Route Parameters +### Route Parameters -A URL might have one or more variable parts like the id in a blog post URL. We call it the route parameter. You can catch them by controller parameters with the same names. +A URL might have one or more variable parts like product IDs on a shopping website. +We call it a route parameter. +You can catch them by controller arguments like the example below. ```php use MiladRahimi\PhpRouter\Router; -$router = new Router(); +$router = Router::create(); // Required parameter $router->get('/post/{id}', function ($id) { return "The content of post $id"; }); - // Optional parameter $router->get('/welcome/{name?}', function ($name = null) { return 'Welcome ' . ($name ?: 'Dear User'); }); - -// Optional parameter, Optional Slash! +// Optional parameter, Optional / (Slash)! $router->get('/profile/?{user?}', function ($user = null) { return ($user ?: 'Your') . ' profile'; }); - // Optional parameter with default value -$router->get('/role/{role?}', function ($role = 'admin') { - return "Role is $role"; +$router->get('/roles/{role?}', function ($role = 'guest') { + return "Your role is $role"; }); - // Multiple parameters $router->get('/post/{pid}/comment/{cid}', function ($pid, $cid) { return "The comment $cid of the post $pid"; @@ -255,141 +270,161 @@ $router->get('/post/{pid}/comment/{cid}', function ($pid, $cid) { $router->dispatch(); ``` -In default, route parameters can be any value, but you can define regex patterns for each of them. +In default, route parameters can have any value, but you can define regex patterns to limit them. ```php use MiladRahimi\PhpRouter\Router; -$router = new Router(); +$router = Router::create(); -$router->define('id', '[0-9]+'); +// "id" must be numeric +$router->pattern('id', '[0-9]+'); -$router->get('/blog/post/{id}', function (int $id) { +$router->get('/post/{id}', function (int $id) { return 'Content of the post: ' . $id; }); $router->dispatch(); ``` -## HTTP Request and Request +### Requests and Responses -PhpRouter uses [zend-diactoros](https://github.com/zendframework/zend-diactoros) package (v2) to provide [PSR-7](https://www.php-fig.org/psr/psr-7) request and response objects to your controllers and middleware. +PhpRouter uses [laminas-diactoros](https://github.com/laminas/laminas-diactoros/) +(formerly known as [zend-diactoros](https://github.com/zendframework/zend-diactoros)) +package (v2) to provide [PSR-7](https://www.php-fig.org/psr/psr-7) +request and response objects to your controllers and middleware. -### Request +#### Requests -You can catch the PSR-7 request object in your controllers like this example: +You can catch the request object in your controllers like this example: ```php use MiladRahimi\PhpRouter\Router; -use Zend\Diactoros\ServerRequest; -use Zend\Diactoros\Response\EmptyResponse; -use Zend\Diactoros\Response\JsonResponse; +use Laminas\Diactoros\ServerRequest; +use Laminas\Diactoros\Response\JsonResponse; -$router = new Router(); +$router = Router::create(); -$router->get('/', function (ServerRequest $request) { +$router->get('/test', function (ServerRequest $request) { return new JsonResponse([ 'method' => $request->getMethod(), - 'uri' => $request->getUri(), - 'body' => $request->getBody(), + 'uri' => $request->getUri()->getPath(), + 'body' => $request->getBody()->getContents(), 'parsedBody' => $request->getParsedBody(), 'headers' => $request->getHeaders(), - 'queryStrings' => $request->getQueryParams(), + 'queryParameters' => $request->getQueryParams(), 'attributes' => $request->getAttributes(), ]); }); -$router->post('/posts', function (ServerRequest $request) { - $post = new PostModel(); - $post->title = $request->getQueryParams()['title']; - $post->content = $request->getQueryParams()['content']; - $post->save(); +$router->dispatch(); +``` + +#### Responses + +The example below illustrates the built-in responses. + +```php +use Laminas\Diactoros\Response\RedirectResponse; +use MiladRahimi\PhpRouter\Router; +use Laminas\Diactoros\Response\EmptyResponse; +use Laminas\Diactoros\Response\HtmlResponse; +use Laminas\Diactoros\Response\JsonResponse; +use Laminas\Diactoros\Response\TextResponse; + +$router = Router::create(); - return new EmptyResponse(); +$router->get('/html/1', function () { + return 'This is an HTML response'; +}); +$router->get('/html/2', function () { + return new HtmlResponse('This is also an HTML response', 200); +}); +$router->get('/json', function () { + return new JsonResponse(['error' => 'Unauthorized!'], 401); +}); +$router->get('/text', function () { + return new TextResponse('This is a plain text...'); +}); +$router->get('/empty', function () { + return new EmptyResponse(204); +}); +$router->get('/redirect', function () { + return new RedirectResponse('https://miladrahimi.com'); }); $router->dispatch(); ``` -### Response +### Route Groups -The example below illustrates the built-in responses. +You can categorize routes into groups. +The groups can have common attributes like middleware, domain, or prefix. +The following example shows how to group routes: ```php use MiladRahimi\PhpRouter\Router; -use Zend\Diactoros\Response\EmptyResponse; -use Zend\Diactoros\Response\HtmlResponse; -use Zend\Diactoros\Response\JsonResponse; -use Zend\Diactoros\Response\TextResponse; - -$router = new Router(); - -$router - ->get('/html/1', function () { - return 'This is an HTML response'; - }) - ->get('/html/2', function () { - return new HtmlResponse('This is also an HTML response', 200); - }) - ->get('/json', function () { - return new JsonResponse(['error' => 'Unauthorized!'], 401); - }) - ->get('/text', function () { - return new TextResponse('This is a plain text...'); - }) - ->get('/empty', function () { - return new EmptyResponse(); // HTTP Status: 204 - }) - ->get('/redirect', function () { - return new RedirectResponse('https://miladrahimi.com'); + +$router = Router::create(); + +// A group with uri prefix +$router->group(['prefix' => '/admin'], function (Router $router) { + // URI: /admin/setting + $router->get('/setting', function () { + return 'Setting Panel'; }); +}); + +// All of group attributes together! +$attributes = [ + 'prefix' => '/admin', + 'domain' => 'shop.example.com', + 'middleware' => [AuthMiddleware::class], +]; + +$router->group($attributes, function (Router $router) { + // URL: http://shop.example.com/admin/users + // Domain: shop.example.com + // Middleware: AuthMiddleware + $router->get('/users', [UsersController::class, 'index']); +}); $router->dispatch(); ``` -## Middleware +The group attributes will be explained later in this documentation. -PhpRouter supports middleware. You can use it for different purposes such as authentication, authorization, throttles and so forth. Middleware runs before controllers and it can check and manipulate the request and response. +You can use [Attributes](src/Routing/Attributes.php) enum, as well. -Here you can see the request lifecycle considering some middleware: +### Middleware -``` -[Request] ↦ Router ↦ Middleware 1 ↦ ... ↦ Middleware N ↦ Controller - ↧ - ↤ Router ↤ Middleware 1 ↤ ... ↤ Middleware N ↤ [Response] -``` +PhpRouter supports middleware. +You can use it for different purposes, such as authentication, authorization, throttles, and so forth. +Middleware runs before controllers, and it can check and manipulate requests and responses. -To declare a middleware, you must implement the `Middleware` interface. Here is the Middleware interface: +Here you can see the request lifecycle considering some middleware: -```php -interface Middleware -{ - /** - * Handle request and response - * - * @param ServerRequestInterface $request - * @param Closure $next - * @return ResponseInterface|mixed|null - */ - public function handle(ServerRequestInterface $request, Closure $next); -} +``` +[Request] ↦ Router ↦ Middleware 1 ↦ ... ↦ Middleware N ↦ Controller + ↧ +[Response] ↤ Router ↤ Middleware 1 ↤ ... ↤ Middleware N ↤ [Response] ``` -As you can see, a middleware must have a `handle()` method that catches the request and a Closure (which is responsible for running the next middleware or the controller). It must return a response, as well. A middleware can break the lifecycle and return a response or it can run the `$next` closure to continue the lifecycle. - -See the following example. In the implemented middelware, if there is an `Authorization` header in the request, it passes the request to the next middleware or the controller (if there is no more middleware left) and if the header is absent, it returns a JSON response with `401 Authorization Failed ` HTTP status code. +To declare a middleware, you can use closures and classes just like controllers. +To use the middleware, you must group the routes and mention the middleware in the group attributes. +Caution! The middleware attribute in groups takes an array of middleware, not a single one. ```php use MiladRahimi\PhpRouter\Router; -use MiladRahimi\PhpRouter\Middleware; use Psr\Http\Message\ServerRequestInterface; -use Zend\Diactoros\Response\JsonResponse; +use Laminas\Diactoros\Response\JsonResponse; -class AuthMiddleware implements Middleware +class AuthMiddleware { public function handle(ServerRequestInterface $request, Closure $next) { if ($request->getHeader('Authorization')) { + // Check the auth header... return $next($request); } @@ -397,158 +432,124 @@ class AuthMiddleware implements Middleware } } -$router = new Router(); +$router = Router::create(); -$router->get('/admin', function () { - return 'This is admin panel!'; -}, AuthMiddleware::class); +// The middleware attribute takes an array of middleware, not a single one! +$router->group(['middleware' => [AuthMiddleware::class]], function(Router $router) { + $router->get('/admin', function () { + return 'Admin Panel'; + }); +}); $router->dispatch(); ``` -Middleware can be implemented using closures but it doesn’t make scense to do so! +As you can see, the middleware catches the request and a closure. +The closure calls the next middleware or the controller if no middleware is left. +The middleware must return a response, as well. +A middleware can break the lifecycle and return a response itself, +or it can call the `$next` closure to continue the lifecycle. -## Domain and Sub-domain +### Domains and Subdomains -Your application may serve different services on different domains or subdomains. In this case, you can specify the domain or subdomain for your routes. See this example: +Your application may serve different services on different domains or subdomains. +In this case, you can specify the domain or subdomain for your routes. +See this example: ```php -$router = new Router(); - -// Domain -$router->get('/', 'Controller@method', [], 'domain2.com'); - -// Sub-domain -$router->get('/', 'Controller@method', [], 'server2.domain.com'); - -// Sub-domain with regex pattern -$router->get('/', 'Controller@method', [], '(.*).domain.com'); - -$router->dispatch(); -``` - -## Route Groups - -Application routes can be categorized into groups if they have common attributes like middleware, domain, or prefix. The following example shows how to group routes: - -```php -use MiladRahimi\PhpRouter\Examples\Samples\SimpleMiddleware; use MiladRahimi\PhpRouter\Router; -use MiladRahimi\PhpRouter\Enums\GroupAttributes; -$router = new Router(); +$router = Router::create(); -// A group with uri prefix -$router->group(['prefix' => '/admin'], function (Router $router) { - // URI: /admin/setting - $router->get('/setting', function () { - return 'Setting.'; +// Domain +$router->group(['domain' => 'shop.com'], function(Router $router) { + $router->get('/', function () { + return 'This is shop.com'; }); }); -// All of group properties together! -$attributes = [ - 'prefix' => '/products', - 'namespace' => 'App\Controllers', - 'domain' => 'shop.example.com', - 'middleware' => SimpleMiddleware::class, -]; - -// A group with many common properties! -$router->group($attributes, function (Router $router) { - // URI: http://shop.example.com/products/{id} - // Controller: App\Controllers\ShopController@getProduct - // Domain: shop.example.com - // Middleware: SampleMiddleware - $router->get('/{id}', 'ShopController@getProduct'); -}); - -$router->dispatch(); -``` - -## URI Prefix - -Your project might be in a subdirectory, or all of your routes might start with the same prefix. You can pass this prefix as the constructor like this example: - -```php -use MiladRahimi\PhpRouter\Router; - -$router = new Router('/shop'); - -// URI: /shop/about -$router->get('/about', function () { - return 'About the shop.'; +// Subdomain +$router->group(['domain' => 'admin.shop.com'], function(Router $router) { + $router->get('/', function () { + return 'This is admin.shop.com'; + }); }); -// URI: /shop/product/{id} -$router->get('/product/{id}', function ($id) { - return 'A product.'; +// Subdomain with regex pattern +$router->group(['domain' => '(.*).example.com'], function(Router $router) { + $router->get('/', function () { + return 'This is a subdomain'; + }); }); $router->dispatch(); ``` -## Route Name +### Route Names -You can define names for your routes and use them in your codes instead of the URLs. See this example: +You can assign names to your routes and use them in your codes instead of the hard-coded URLs. +See this example: ```php use MiladRahimi\PhpRouter\Router; -use Zend\Diactoros\Response\JsonResponse; +use Laminas\Diactoros\Response\JsonResponse; +use MiladRahimi\PhpRouter\Url; -$router = new Router(); +$router = Router::create(); -$router->name('about')->get('/about', function () { - return 'About.'; -}); -$router->name('post')->get('/post/{id}', function ($id) { - return 'Content of the post: ' . $id; -}); -$router->name('home')->get('/', function (Router $router) { +$router->get('/', [HomeController::class, 'show'], 'home'); +$router->get('/post/{id}', [PostController::class, 'show'], 'post'); +$router->get('/links', function (Url $url) { return new JsonResponse([ - 'links' => [ - 'about' => $router->url('about'), /* Result: /about */ - 'post1' => $router->url('post', ['id' => 1]), /* Result: /post/1 */ - 'post2' => $router->url('post', ['id' => 2]) /* Result: /post/2 */ - ] + 'about' => $url->make('home'), /* Result: /about */ + 'post1' => $url->make('post', ['id' => 1]), /* Result: /post/1 */ + 'post2' => $url->make('post', ['id' => 2]) /* Result: /post/2 */ ]); }); $router->dispatch(); ``` -## Current Route +### Current Route -You might want to get information about the current route in your controller. This example shows how to get this information. +You might need to get information about the current route in your controller or middleware. +This example shows how to get this information. ```php use MiladRahimi\PhpRouter\Router; -use Zend\Diactoros\Response\JsonResponse; +use Laminas\Diactoros\Response\JsonResponse; +use MiladRahimi\PhpRouter\Routing\Route; -$router = new Router(); +$router = Router::create(); -$router->name('home')->get('/', function (Router $router) { +$router->get('/{id}', function (Route $route) { return new JsonResponse([ - 'current_page_name' => $router->currentRoute()->getName(), /* Result: home */ - 'current_page_uri' => $router->currentRoute()->getUri(), /* Result: / */ - 'current_page_method' => $router->currentRoute()->getMethod(), /* Result: GET */ - 'current_page_domain' => $router->currentRoute()->getDomain(), /* Result: null */ + 'uri' => $route->getUri(), /* Result: "/1" */ + 'name' => $route->getName(), /* Result: sample */ + 'path' => $route->getPath(), /* Result: "/{id}" */ + 'method' => $route->getMethod(), /* Result: GET */ + 'domain' => $route->getDomain(), /* Result: null */ + 'parameters' => $route->getParameters(), /* Result: {"id": "1"} */ + 'middleware' => $route->getMiddleware(), /* Result: [] */ + 'controller' => $route->getController(), /* Result: {} */ ]); -}); +}, 'sample'); $router->dispatch(); ``` -## Error Handling +### Error Handling -Your application runs through the `Router::dispatch()` method. You should put it in a `try` block and catch exceptions that will be thrown by your application and PhpRouter. +Your application runs through the `Router::dispatch()` method. +You should put it in a `try` block and catch exceptions. +It throws your application and PhpRouter exceptions. ```php use MiladRahimi\PhpRouter\Router; use MiladRahimi\PhpRouter\Exceptions\RouteNotFoundException; -use Zend\Diactoros\Response\HtmlResponse; +use Laminas\Diactoros\Response\HtmlResponse; -$router = new Router(); +$router = Router::create(); $router->get('/', function () { return 'Home.'; @@ -564,17 +565,13 @@ try { } ``` -PhpRouter also throws the following exceptions: +PhpRouter throws the following exceptions: * `RouteNotFoundException` if PhpRouter cannot find any route that matches the user request. -* `InvalidControllerException` if PhpRouter cannot invoke the controller. -* `InvalidMiddlewareException` if PhpRouter cannot invoke the middleware. -* `UndefinedRouteException` if `Router::url()` cannot find any route with the given name. +* `InvalidCallableException` if PhpRouter cannot invoke the controller or middleware. The `RouteNotFoundException` should be considered `404 Not found` error. -The `InvalidControllerException` and `InvalidMiddlewareException` exceptions should never be thrown normally, so they should be considered `500 Internal Error`. - ## License PhpRouter is initially created by [Milad Rahimi](https://miladrahimi.com) and released under the [MIT License](http://opensource.org/licenses/mit-license.php). diff --git a/composer.json b/composer.json index 650874e..3de002b 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "miladrahimi/phprouter", - "description": "A powerful, lightweight, and very fast HTTP URL router.", + "description": "A powerful, lightweight, and very fast HTTP URL router for PHP projects.", "keywords": [ "Route", "Router", @@ -29,14 +29,19 @@ "php": ">=7.1", "ext-json": "*", "ext-mbstring": "*", - "zendframework/zend-diactoros": "^2.1" + "laminas/laminas-diactoros": "^2.2", + "miladrahimi/phpcontainer": "^5.0" }, "require-dev": { - "phpunit/phpunit": "^7" + "phpunit/phpunit": "^7|^8" }, "autoload": { "psr-4": { - "MiladRahimi\\PhpRouter\\": "src/", + "MiladRahimi\\PhpRouter\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { "MiladRahimi\\PhpRouter\\Tests\\": "tests/", "MiladRahimi\\PhpRouter\\Examples\\": "examples/" } diff --git a/examples/example-01/index.php b/examples/01/index.php similarity index 68% rename from examples/example-01/index.php rename to examples/01/index.php index 90a6f87..34fc123 100644 --- a/examples/example-01/index.php +++ b/examples/01/index.php @@ -4,10 +4,10 @@ use MiladRahimi\PhpRouter\Router; -$router = new Router(); +$router = Router::create(); $router->get('/', function () { - return 'This is homepage!
'; + return 'This is homepage!'; }); $router->dispatch(); diff --git a/examples/02/index.php b/examples/02/index.php new file mode 100644 index 0000000..982ed21 --- /dev/null +++ b/examples/02/index.php @@ -0,0 +1,25 @@ +get('/', function () { + return 'GET'; +}); +$router->post('/', function () { + return 'POST'; +}); +$router->put('/', function () { + return 'PUT'; +}); +$router->patch('/', function () { + return 'PATCH'; +}); +$router->delete('/', function () { + return 'DELETE'; +}); + +$router->dispatch(); diff --git a/examples/03/index.php b/examples/03/index.php new file mode 100644 index 0000000..74dbea7 --- /dev/null +++ b/examples/03/index.php @@ -0,0 +1,19 @@ +map('GET', '/', function () { + return 'GET'; +}); +$router->map('OPTIONS', '/', function () { + return 'OPTIONS'; +}); +$router->map('CUSTOM', '/', function () { + return 'CUSTOM'; +}); + +$router->dispatch(); diff --git a/examples/04/index.php b/examples/04/index.php new file mode 100644 index 0000000..1272872 --- /dev/null +++ b/examples/04/index.php @@ -0,0 +1,13 @@ +match(['GET', 'POST'], '/', function () { + return 'GET or POST!'; +}); + +$router->dispatch(); diff --git a/examples/example-04/index.php b/examples/05/index.php similarity index 87% rename from examples/example-04/index.php rename to examples/05/index.php index 58e7c3b..2803abb 100644 --- a/examples/example-04/index.php +++ b/examples/05/index.php @@ -4,7 +4,7 @@ use MiladRahimi\PhpRouter\Router; -$router = new Router(); +$router = Router::create(); $router->any('/', function () { return 'This is Home! No matter what the HTTP method is!'; diff --git a/examples/06/index.php b/examples/06/index.php new file mode 100644 index 0000000..689041d --- /dev/null +++ b/examples/06/index.php @@ -0,0 +1,13 @@ +get('/', function () { + return 'This is a closure controller!'; +}); + +$router->dispatch(); \ No newline at end of file diff --git a/examples/07/index.php b/examples/07/index.php new file mode 100644 index 0000000..a550de1 --- /dev/null +++ b/examples/07/index.php @@ -0,0 +1,28 @@ +get('/method', [UsersController::class, 'index']); + +// Controller: Class=UsersController Method=handle() +$router->get('/class', UsersController::class); + +$router->dispatch(); diff --git a/examples/example-09/index.php b/examples/08/index.php similarity index 79% rename from examples/example-09/index.php rename to examples/08/index.php index dc7ba80..ed6b632 100644 --- a/examples/example-09/index.php +++ b/examples/08/index.php @@ -4,28 +4,24 @@ use MiladRahimi\PhpRouter\Router; -$router = new Router(); +$router = Router::create(); // Required parameter $router->get('/post/{id}', function ($id) { return "The content of post $id"; }); - // Optional parameter $router->get('/welcome/{name?}', function ($name = null) { return 'Welcome ' . ($name ?: 'Dear User'); }); - -// Optional parameter, Optional Slash! +// Optional parameter, Optional / (Slash)! $router->get('/profile/?{user?}', function ($user = null) { return ($user ?: 'Your') . ' profile'; }); - // Optional parameter with default value -$router->get('/role/{role?}', function ($role = 'admin') { - return "Role is $role"; +$router->get('/roles/{role?}', function ($role = 'guest') { + return "Your role is $role"; }); - // Multiple parameters $router->get('/post/{pid}/comment/{cid}', function ($pid, $cid) { return "The comment $cid of the post $pid"; diff --git a/examples/example-10/index.php b/examples/09/index.php similarity index 69% rename from examples/example-10/index.php rename to examples/09/index.php index 5c09ad6..9d56012 100644 --- a/examples/example-10/index.php +++ b/examples/09/index.php @@ -4,9 +4,10 @@ use MiladRahimi\PhpRouter\Router; -$router = new Router(); +$router = Router::create(); -$router->define('id', '[0-9]+'); +// "id" must be numeric +$router->pattern('id', '[0-9]+'); $router->get('/post/{id}', function (int $id) { return 'Content of the post: ' . $id; diff --git a/examples/10/index.php b/examples/10/index.php new file mode 100644 index 0000000..9fe62b9 --- /dev/null +++ b/examples/10/index.php @@ -0,0 +1,23 @@ +get('/test', function (ServerRequest $request) { + return new JsonResponse([ + 'method' => $request->getMethod(), + 'uri' => $request->getUri()->getPath(), + 'body' => $request->getBody()->getContents(), + 'parsedBody' => $request->getParsedBody(), + 'headers' => $request->getHeaders(), + 'queryParameters' => $request->getQueryParams(), + 'attributes' => $request->getAttributes(), + ]); +}); + +$router->dispatch(); diff --git a/examples/11/index.php b/examples/11/index.php new file mode 100644 index 0000000..608d7f2 --- /dev/null +++ b/examples/11/index.php @@ -0,0 +1,33 @@ +get('/html/1', function () { + return 'This is an HTML response'; +}); +$router->get('/html/2', function () { + return new HtmlResponse('This is also an HTML response', 200); +}); +$router->get('/json', function () { + return new JsonResponse(['error' => 'Unauthorized!'], 401); +}); +$router->get('/text', function () { + return new TextResponse('This is a plain text...'); +}); +$router->get('/empty', function () { + return new EmptyResponse(204); +}); +$router->get('/redirect', function () { + return new RedirectResponse('https://miladrahimi.com'); +}); + +$router->dispatch(); diff --git a/examples/example-16/index.php b/examples/12/index.php similarity index 58% rename from examples/example-16/index.php rename to examples/12/index.php index a52aacb..0272d2f 100644 --- a/examples/example-16/index.php +++ b/examples/12/index.php @@ -2,36 +2,32 @@ require('../../vendor/autoload.php'); +use MiladRahimi\PhpRouter\Examples\Shared\SimpleController; use MiladRahimi\PhpRouter\Examples\Shared\SimpleMiddleware; use MiladRahimi\PhpRouter\Router; -$router = new Router(); +$router = Router::create(); // A group with uri prefix $router->group(['prefix' => '/admin'], function (Router $router) { // URI: /admin/setting $router->get('/setting', function () { - return 'Setting.'; + return 'Setting Panel'; }); }); -// All of group properties together! +// All of group attributes together! $attributes = [ 'prefix' => '/products', - 'namespace' => 'App\Controllers', 'domain' => 'shop.example.com', - 'middleware' => SimpleMiddleware::class, + 'middleware' => [SimpleMiddleware::class], ]; -// A group with many common properties! $router->group($attributes, function (Router $router) { - // URI: http://shop.example.com/products/{id} - // Controller: App\Controllers\ShopController@getProduct + // URL: http://shop.example.com/products/{id} // Domain: shop.example.com // Middleware: SampleMiddleware - $router->get('/{id}', function ($id) { - return 'Wow.'; - }); + $router->get('/{id}', [SimpleController::class, 'show']); }); $router->dispatch(); diff --git a/examples/example-14/index.php b/examples/13/index.php similarity index 54% rename from examples/example-14/index.php rename to examples/13/index.php index cba4afb..2d60849 100644 --- a/examples/example-14/index.php +++ b/examples/13/index.php @@ -3,15 +3,15 @@ require('../../vendor/autoload.php'); use MiladRahimi\PhpRouter\Router; -use MiladRahimi\PhpRouter\Middleware; use Psr\Http\Message\ServerRequestInterface; -use Zend\Diactoros\Response\JsonResponse; +use Laminas\Diactoros\Response\JsonResponse; -class AuthMiddleware implements Middleware +class AuthMiddleware { public function handle(ServerRequestInterface $request, Closure $next) { if ($request->getHeader('Authorization')) { + // Check the auth header... return $next($request); } @@ -19,10 +19,12 @@ public function handle(ServerRequestInterface $request, Closure $next) } } -$router = new Router(); +$router = Router::create(); -$router->get('/admin', function () { - return 'This is admin panel!'; -}, AuthMiddleware::class); +$router->group(['middleware' => [AuthMiddleware::class]], function(Router $router) { + $router->get('/admin', function () { + return 'Admin Panel'; + }); +}); -$router->dispatch(); \ No newline at end of file +$router->dispatch(); diff --git a/examples/14/index.php b/examples/14/index.php new file mode 100644 index 0000000..dd04f22 --- /dev/null +++ b/examples/14/index.php @@ -0,0 +1,30 @@ +group(['domain' => 'shop.com'], function(Router $router) { + $router->get('/', function () { + return 'This is shop.com'; + }); +}); + +// Subdomain +$router->group(['domain' => 'admin.shop.com'], function(Router $router) { + $router->get('/', function () { + return 'This is admin.shop.com'; + }); +}); + +// Subdomain with regex pattern +$router->group(['domain' => '(.*).example.com'], function(Router $router) { + $router->get('/', function () { + return 'This is a subdomain'; + }); +}); + +$router->dispatch(); diff --git a/examples/15/index.php b/examples/15/index.php new file mode 100644 index 0000000..97ac1ee --- /dev/null +++ b/examples/15/index.php @@ -0,0 +1,22 @@ +get('/', [SimpleController::class, 'show'], 'home'); +$router->get('/post/{id}', [SimpleController::class, 'show'], 'post'); +$router->get('/links', function (Url $url) { + return new JsonResponse([ + 'about' => $url->make('home'), /* Result: /about */ + 'post1' => $url->make('post', ['id' => 1]), /* Result: /post/1 */ + 'post2' => $url->make('post', ['id' => 2]) /* Result: /post/2 */ + ]); +}); + +$router->dispatch(); diff --git a/examples/16/index.php b/examples/16/index.php new file mode 100644 index 0000000..a0b6361 --- /dev/null +++ b/examples/16/index.php @@ -0,0 +1,24 @@ +get('/{id}', function (Route $route) { + return new JsonResponse([ + 'uri' => $route->getUri(), /* Result: "/1" */ + 'name' => $route->getName(), /* Result: sample */ + 'path' => $route->getPath(), /* Result: "/{id}" */ + 'method' => $route->getMethod(), /* Result: GET */ + 'domain' => $route->getDomain(), /* Result: null */ + 'parameters' => $route->getParameters(), /* Result: {"id": "1"} */ + 'middleware' => $route->getMiddleware(), /* Result: [] */ + 'controller' => $route->getController(), /* Result: {} */ + ]); +}, 'sample'); + +$router->dispatch(); diff --git a/examples/example-20/index.php b/examples/17/index.php similarity index 86% rename from examples/example-20/index.php rename to examples/17/index.php index ccc2a04..136d60e 100644 --- a/examples/example-20/index.php +++ b/examples/17/index.php @@ -4,9 +4,9 @@ use MiladRahimi\PhpRouter\Router; use MiladRahimi\PhpRouter\Exceptions\RouteNotFoundException; -use Zend\Diactoros\Response\HtmlResponse; +use Laminas\Diactoros\Response\HtmlResponse; -$router = new Router(); +$router = Router::create(); $router->get('/', function () { return 'Home.'; diff --git a/examples/Shared/PostModel.php b/examples/Shared/PostModel.php deleted file mode 100644 index 65f4893..0000000 --- a/examples/Shared/PostModel.php +++ /dev/null @@ -1,14 +0,0 @@ -get('/', function () { - return 'GET method'; - }) - ->post('/', function () { - return 'POST method'; - }) - ->patch('/', function () { - return 'PATCH method'; - }) - ->put('/', function () { - return 'PUT method'; - }) - ->delete('/', function () { - return 'DELETE method'; - }) - ->dispatch(); diff --git a/examples/example-03/index.php b/examples/example-03/index.php deleted file mode 100644 index 7e615bd..0000000 --- a/examples/example-03/index.php +++ /dev/null @@ -1,19 +0,0 @@ -map('GET', '/', function () { - return 'GET method'; - }) - ->map('POST', '/', function () { - return 'POST method'; - }) - ->map('CUSTOM', '/', function () { - return 'CUSTOM method'; - }) - ->dispatch(); diff --git a/examples/example-05/index.php b/examples/example-05/index.php deleted file mode 100644 index a029051..0000000 --- a/examples/example-05/index.php +++ /dev/null @@ -1,18 +0,0 @@ -get('/closure', function () { - return 'Closure as a controller'; -}); - -function func() { - return 'Function as a controller'; -} -$router->get('/function', 'func'); - -$router->dispatch(); \ No newline at end of file diff --git a/examples/example-06/index.php b/examples/example-06/index.php deleted file mode 100644 index 2c9066a..0000000 --- a/examples/example-06/index.php +++ /dev/null @@ -1,20 +0,0 @@ -get('/method', 'Controller@method'); - -$router->dispatch(); \ No newline at end of file diff --git a/examples/example-07/index.php b/examples/example-07/index.php deleted file mode 100644 index 41619b0..0000000 --- a/examples/example-07/index.php +++ /dev/null @@ -1,14 +0,0 @@ -get('/ns', 'MiladRahimi\PhpRouter\Examples\Samples\SimpleController@show'); -// OR -$router->get('/ns', SimpleController::class . '@show'); - -$router->dispatch(); diff --git a/examples/example-08/index.php b/examples/example-08/index.php deleted file mode 100644 index 4a99228..0000000 --- a/examples/example-08/index.php +++ /dev/null @@ -1,12 +0,0 @@ -get('/', 'SimpleController@show'); -// PhpRouter looks for MiladRahimi\PhpRouter\Examples\Samples\SimpleController@show - -$router->dispatch(); diff --git a/examples/example-11/index.php b/examples/example-11/index.php deleted file mode 100644 index 5fb4600..0000000 --- a/examples/example-11/index.php +++ /dev/null @@ -1,34 +0,0 @@ -get('/', function (ServerRequest $request) { - return new JsonResponse([ - 'method' => $request->getMethod(), - 'uri' => $request->getUri(), - 'body' => $request->getBody(), - 'parsedBody' => $request->getParsedBody(), - 'headers' => $request->getHeaders(), - 'queryParameters' => $request->getQueryParams(), - 'attributes' => $request->getAttributes(), - ]); -}); - -$router->post('/posts', function (ServerRequest $request) { - $post = new PostModel(); - $post->title = $request->getQueryParams()['title']; - $post->content = $request->getQueryParams()['content']; - $post->save(); - - return new EmptyResponse(201); -}); - -$router->dispatch(); diff --git a/examples/example-12/index.php b/examples/example-12/index.php deleted file mode 100644 index 775fe4d..0000000 --- a/examples/example-12/index.php +++ /dev/null @@ -1,30 +0,0 @@ -get('/html/1', function () { - return 'This is an HTML response'; - }) - ->get('/html/2', function () { - return new HtmlResponse('This is also an HTML response', 200); - }) - ->get('/json', function () { - return new JsonResponse(['error' => 'Unauthorized!'], 401); - }) - ->get('/text', function () { - return new TextResponse('This is a plain text...'); - }) - ->get('/empty', function () { - return new EmptyResponse(); - }); - -$router->dispatch(); diff --git a/examples/example-13/index.php b/examples/example-13/index.php deleted file mode 100644 index fa0657e..0000000 --- a/examples/example-13/index.php +++ /dev/null @@ -1,14 +0,0 @@ -get('/redirect', function () { - return new RedirectResponse('https://miladrahimi.com'); - }) - ->dispatch(); diff --git a/examples/example-15/index.php b/examples/example-15/index.php deleted file mode 100644 index 2c1f3a7..0000000 --- a/examples/example-15/index.php +++ /dev/null @@ -1,18 +0,0 @@ -get('/', 'Controller@method', [], 'domain2.com'); - -// Sub-domain -$router->get('/', 'Controller@method', [], 'server2.domain.com'); - -// Sub-domain with regex pattern -$router->get('/', 'Controller@method', [], '(.*).domain.com'); - -$router->dispatch(); \ No newline at end of file diff --git a/examples/example-17/index.php b/examples/example-17/index.php deleted file mode 100644 index 8c6382e..0000000 --- a/examples/example-17/index.php +++ /dev/null @@ -1,19 +0,0 @@ -get('/about', function () { - return 'About the shop.'; -}); - -// URI: /shop/product/{id} -$router->get('/product/{id}', function ($id) { - return 'A product.'; -}); - -$router->dispatch(); diff --git a/examples/example-18/index.php b/examples/example-18/index.php deleted file mode 100644 index b4cdf89..0000000 --- a/examples/example-18/index.php +++ /dev/null @@ -1,26 +0,0 @@ -name('about')->get('/about', function () { - return 'About.'; -}); -$router->name('post')->get('/post/{id}', function ($id) { - return 'Content of the post: ' . $id; -}); -$router->name('home')->get('/', function (Router $router) { - return new JsonResponse([ - 'links' => [ - 'about' => $router->url('about'), /* Result: /about */ - 'post1' => $router->url('post', ['id' => 1]), /* Result: /post/1 */ - 'post2' => $router->url('post', ['id' => 2]) /* Result: /post/2 */ - ] - ]); -}); - -$router->dispatch(); diff --git a/examples/example-19/index.php b/examples/example-19/index.php deleted file mode 100644 index 4107bd8..0000000 --- a/examples/example-19/index.php +++ /dev/null @@ -1,19 +0,0 @@ -name('home')->get('/', function (Router $router) { - return new JsonResponse([ - 'current_page_name' => $router->currentRoute()->getName(), /* Result: home */ - 'current_page_uri' => $router->currentRoute()->getUri(), /* Result: / */ - 'current_page_method' => $router->currentRoute()->getMethod(), /* Result: GET */ - 'current_page_domain' => $router->currentRoute()->getDomain(), /* Result: null */ - ]); -}); - -$router->dispatch(); diff --git a/src/Dispatching/Caller.php b/src/Dispatching/Caller.php new file mode 100644 index 0000000..3d4c76c --- /dev/null +++ b/src/Dispatching/Caller.php @@ -0,0 +1,107 @@ +container = $container; + } + + /** + * Call the given callable stack + * + * @param string[] $callables + * @param ServerRequestInterface $request + * @param int $index + * @return mixed + * @throws ContainerException + * @throws InvalidCallableException + */ + public function stack(array $callables, ServerRequestInterface $request, int $index = 0) + { + $this->container->singleton(ServerRequest::class, $request); + $this->container->singleton(ServerRequestInterface::class, $request); + + if (isset($callables[$index + 1])) { + $this->container->closure('$next', function (ServerRequestInterface $request) use ($callables, $index) { + return $this->stack($callables, $request, $index + 1); + }); + } else { + $this->container->delete('$next'); + } + + return $this->call($callables[$index]); + } + + /** + * Run the given callable + * + * @param Closure|callable|string $callable + * @return mixed + * @throws InvalidCallableException + * @throws ContainerException + */ + public function call($callable) + { + if (is_array($callable)) { + if (count($callable) != 2) { + throw new InvalidCallableException('Invalid callable: ' . implode(',', $callable)); + } + + [$class, $method] = $callable; + + if (class_exists($class) == false) { + throw new InvalidCallableException("Class `$class` not found."); + } + + $object = $this->container->instantiate($class); + + if (method_exists($object, $method) == false) { + throw new InvalidCallableException("Method `$class::$method` is not declared."); + } + + $callable = [$object, $method]; + } else { + if (is_string($callable)) { + if (class_exists($callable)) { + $callable = new $callable(); + } else { + throw new InvalidCallableException("Class `$callable` not found."); + } + } + + if (is_object($callable) && !$callable instanceof Closure) { + if (method_exists($callable, 'handle')) { + $callable = [$callable, 'handle']; + } else { + throw new InvalidCallableException("Method `handle` is not declared."); + } + } + } + + if (is_callable($callable) == false) { + throw new InvalidCallableException('Invalid callable.'); + } + + return $this->container->call($callable); + } +} diff --git a/src/Dispatching/Matcher.php b/src/Dispatching/Matcher.php new file mode 100644 index 0000000..f71c52d --- /dev/null +++ b/src/Dispatching/Matcher.php @@ -0,0 +1,142 @@ +repository = $repository; + } + + /** + * Find the right route for the given request and defined patterns + * + * @param ServerRequestInterface $request + * @param string[] $patterns + * @return Route + * @throws RouteNotFoundException + */ + public function find(ServerRequestInterface $request, array $patterns) + { + foreach ($this->repository->findByMethod($request->getMethod()) as $route) { + $parameters = []; + + if ($this->compare($route, $request, $parameters, $patterns)) { + $route->setUri($request->getUri()->getPath()); + $route->setParameters($this->pruneRouteParameters($parameters)); + + return $route; + } + } + + throw new RouteNotFoundException(); + } + + /** + * Prune route parameters (remove unnecessary parameters) + * + * @param string[] $parameters + * @return string[] + * @noinspection PhpUnusedParameterInspection + */ + private function pruneRouteParameters(array $parameters): array + { + return array_filter($parameters, function ($value, $name) { + return is_numeric($name) == false; + }, ARRAY_FILTER_USE_BOTH); + } + + /** + * Compare given route with the given http request + * + * @param Route $route + * @param ServerRequestInterface $request + * @param string[] $parameters + * @param string[] $patterns + * @return bool + */ + private function compare(Route $route, ServerRequestInterface $request, array &$parameters, array $patterns): bool + { + return ( + $this->compareDomain($route->getDomain(), $request->getUri()->getHost()) && + $this->compareUri($route->getPath(), $request->getUri()->getPath(), $parameters, $patterns) + ); + } + + /** + * Check if given request domain matches given route domain + * + * @param string|null $routeDomain + * @param string $requestDomain + * @return bool + */ + private function compareDomain(?string $routeDomain, string $requestDomain): bool + { + return !$routeDomain || preg_match('@^' . $routeDomain . '$@', $requestDomain); + } + + /** + * Check if given request uri matches given uri method + * + * @param string $path + * @param string $uri + * @param string[] $parameters + * @param string[] $patterns + * @return bool + */ + private function compareUri(string $path, string $uri, array &$parameters, array $patterns): bool + { + return preg_match('@^' . $this->regexUri($path, $patterns) . '$@', $uri, $parameters); + } + + /** + * Convert route to regex + * + * @param string $path + * @param string[] $patterns + * @return string + */ + private function regexUri(string $path, array $patterns): string + { + return preg_replace_callback('@{([^}]+)}@', function (array $match) use ($patterns) { + return $this->regexParameter($match[1], $patterns); + }, $path); + } + + /** + * Convert route parameter to regex + * + * @param string $name + * @param array $patterns + * @return string + */ + private function regexParameter(string $name, array $patterns): string + { + if ($name[-1] == '?') { + $name = substr($name, 0, -1); + $suffix = '?'; + } else { + $suffix = ''; + } + + $pattern = $patterns[$name] ?? '[^/]+'; + + return '(?<' . $name . '>' . $pattern . ')' . $suffix; + } +} diff --git a/src/Enums/GroupAttributes.php b/src/Enums/GroupAttributes.php deleted file mode 100644 index bc4e937..0000000 --- a/src/Enums/GroupAttributes.php +++ /dev/null @@ -1,16 +0,0 @@ -container = $container; + $this->storekeeper = $storekeeper; + $this->matcher = $matcher; + $this->caller = $caller; + $this->publisher = $publisher; + } /** - * Router constructor. + * Create a new router instance * - * @param string $uriPrefix - * @param string $namespacePrefix + * @return static */ - public function __construct(string $uriPrefix = '', string $namespacePrefix = '') + public static function create(): self { - $this->prefix = $uriPrefix; - $this->namespace = $namespacePrefix; + $container = new Container(); + $container->singleton(Container::class, $container); + $container->singleton(ContainerInterface::class, $container); + $container->singleton(Repository::class, new Repository()); + $container->singleton(Publisher::class, HttpPublisher::class); + + return $container->instantiate(Router::class); } /** * Group routes with the given attributes * * @param array $attributes - * @param Closure $routes - * @return self + * @param Closure $body */ - public function group(array $attributes, Closure $routes): self + public function group(array $attributes, Closure $body): void { - // Backup current properties - $oldName = $this->name; - $oldMiddleware = $this->middleware; - $oldNamespace = $this->namespace; - $oldPrefix = $this->prefix; - $oldDomain = $this->domain; - - $this->name = null; - - // Set middleware for the group - if (isset($attributes[GroupAttributes::MIDDLEWARE])) { - if (is_array($attributes[GroupAttributes::MIDDLEWARE]) == false) { - $attributes[GroupAttributes::MIDDLEWARE] = [$attributes[GroupAttributes::MIDDLEWARE]]; - } - - $this->middleware = array_merge($attributes[GroupAttributes::MIDDLEWARE], $this->middleware); - } - - // Set namespace for the group - if (isset($attributes[GroupAttributes::NAMESPACE])) { - $this->namespace = $attributes[GroupAttributes::NAMESPACE]; - } - - // Set prefix for the group - if (isset($attributes[GroupAttributes::PREFIX])) { - $this->prefix = $this->prefix . $attributes[GroupAttributes::PREFIX]; - } - - // Set domain for the group - if (isset($attributes[GroupAttributes::DOMAIN])) { - $this->domain = $attributes[GroupAttributes::DOMAIN]; - } + $oldState = clone $this->storekeeper->getState(); - // Run the group body closure - call_user_func($routes, $this); + $this->storekeeper->getState()->append($attributes); - // Restore properties - $this->name = $oldName; - $this->domain = $oldDomain; - $this->prefix = $oldPrefix; - $this->middleware = $oldMiddleware; - $this->namespace = $oldNamespace; + call_user_func($body, $this); - return $this; + $this->storekeeper->setState($oldState); } /** - * Map a controller to a route and set basic attributes + * Map a controller to a route * * @param string $method - * @param string $route - * @param Closure|callable|string $controller - * @param string|callable|Closure|Middleware|string[]|callable[]|Closure[]|Middleware[] $middleware - * @param string|null $domain + * @param string $path + * @param Closure|array $controller * @param string|null $name - * @return self */ - public function map( - ?string $method, - string $route, - $controller, - $middleware = [], - ?string $domain = null, - ?string $name = null - ): self + public function map(string $method, string $path, $controller, ?string $name = null): void { - $name = $name ?: $this->name; - $uri = $this->prefix . $route; - $middleware = is_array($middleware) ? $middleware : [$middleware]; - - if (is_string($controller) && is_callable($controller) == false) { - $controller = $this->namespace . "\\" . $controller; - } - - $route = new Route( - $name, - $uri, - $method, - $controller, - array_merge($this->middleware, $middleware), - $domain ?: $this->domain - ); - - $this->routes[] = $route; - - if ($name) { - $this->names[$name] = $route; - $this->name = null; - } - - return $this; + $this->storekeeper->add($method, $path, $controller, $name); } /** * Dispatch routes and run the application * - * @return self + * @throws ContainerException + * @throws InvalidCallableException * @throws RouteNotFoundException - * @throws InvalidControllerException - * @throws InvalidMiddlewareException - * @throws Throwable (the controller might throw any kind of exception) - */ - public function dispatch(): self - { - $this->prepare(); - - $method = $this->request->getMethod(); - $domain = $this->request->getUri()->getHost(); - $uri = $this->request->getUri()->getPath(); - - sort($this->routes, SORT_DESC); - - foreach ($this->routes as $route) { - $parameters = []; - - if ( - $this->compareMethod($route->getMethod(), $method) && - $this->compareDomain($route->getDomain(), $domain) && - $this->compareUri($route->getUri(), $uri, $parameters) - ) { - $this->currentRoute = $route; - - $this->publisher->publish($this->run($route, $parameters)); - - return $this; - } - } - - throw new RouteNotFoundException(); - } - - /** - * Check if given request method matches given route method - * - * @param string|null $routeMethod - * @param string $requestMethod - * @return bool */ - private function compareMethod(?string $routeMethod, string $requestMethod): bool + public function dispatch() { - return $routeMethod == null || $routeMethod == $requestMethod; - } + $request = ServerRequestFactory::fromGlobals(); - /** - * Check if given request domain matches given route domain - * - * @param string|null $routeDomain - * @param string $requestDomain - * @return bool - */ - private function compareDomain(?string $routeDomain, string $requestDomain): bool - { - return $routeDomain == null || preg_match('@^' . $routeDomain . '$@', $requestDomain); - } + $route = $this->matcher->find($request, $this->patterns); - /** - * Check if given request uri matches given uri method - * - * @param string $routeUri - * @param string $requestUri - * @param array $parameters - * @return bool - */ - private function compareUri(string $routeUri, string $requestUri, array &$parameters): bool - { - $pattern = '@^' . $this->regexUri($routeUri) . '$@'; + $this->container->singleton(Route::class, $route); - return preg_match($pattern, $requestUri, $parameters); - } - - /** - * Run the controller of the given route - * - * @param Route $route - * @param array $parameters - * @return ResponseInterface|mixed|null - * @throws InvalidControllerException - * @throws InvalidMiddlewareException - * @throws Throwable - */ - private function run(Route $route, array $parameters) - { - $controller = $route->getController(); - - if (count($middleware = $route->getMiddleware()) > 0) { - $controllerRunner = function (ServerRequest $request) use ($controller, $parameters) { - return $this->runController($controller, $parameters, $request); - }; - - return $this->runControllerThroughMiddleware($middleware, $this->request, $controllerRunner); + foreach ($route->getParameters() as $key => $value) { + $this->container->singleton('$' . $key, $value); } - return $this->runController($controller, $parameters, $this->request); + $this->publisher->publish($this->caller->stack( + array_merge($route->getMiddleware(), [$route->getController()]), + $request + )); } /** - * Run the controller through the middleware (list) + * Define a parameter pattern * - * @param string|callable|Closure|Middleware|string[]|callable[]|Closure[]|Middleware[] $middleware - * @param ServerRequestInterface $request - * @param Closure $controllerRunner - * @param int $i - * @return ResponseInterface|mixed|null - * @throws InvalidMiddlewareException + * @param string $name + * @param string $pattern */ - private function runControllerThroughMiddleware( - array $middleware, - ServerRequestInterface $request, - Closure $controllerRunner, - $i = 0 - ) + public function pattern(string $name, string $pattern) { - if (isset($middleware[$i + 1])) { - $next = function (ServerRequestInterface $request) use ($middleware, $controllerRunner, $i) { - return $this->runControllerThroughMiddleware($middleware, $request, $controllerRunner, $i + 1); - }; - } else { - $next = $controllerRunner; - } - - if (is_callable($middleware[$i])) { - return $middleware[$i]($request, $next); - } - - if (is_subclass_of($middleware[$i], Middleware::class)) { - if (is_string($middleware[$i])) { - $middleware[$i] = new $middleware[$i]; - } - - return $middleware[$i]->handle($request, $next); - } - - throw new InvalidMiddlewareException('Invalid middleware for route: ' . $this->currentRoute); + $this->patterns[$name] = $pattern; } /** - * Run the controller + * Map a controller to given route with multiple http methods * + * @param array $methods + * @param string $path * @param Closure|callable|string $controller - * @param array $parameters - * @param ServerRequestInterface $request - * @return ResponseInterface|mixed|null - * @throws InvalidControllerException - * @throws ReflectionException - */ - private function runController($controller, array $parameters, ServerRequestInterface $request) - { - if (is_string($controller) && strpos($controller, '@')) { - list($className, $methodName) = explode('@', $controller); - - if (class_exists($className) == false) { - throw new InvalidControllerException("Controller class `$controller` not found."); - } - - $classObject = new $className(); - - if (method_exists($classObject, $methodName) == false) { - throw new InvalidControllerException("Controller method `$methodName` not found."); - } - - $parameters = $this->arrangeMethodParameters($className, $methodName, $parameters, $request); - - $controller = [$classObject, $methodName]; - } elseif (is_callable($controller)) { - $parameters = $this->arrangeFunctionParameters($controller, $parameters, $request); - } else { - throw new InvalidControllerException('Invalid controller: ' . $controller); - } - - return call_user_func_array($controller, $parameters); - } - - /** - * Arrange parameters for given function - * - * @param Closure|callable $function - * @param array $parameters - * @param ServerRequestInterface $request - * @return array - * @throws ReflectionException - */ - private function arrangeFunctionParameters($function, array $parameters, ServerRequestInterface $request): array - { - return $this->arrangeParameters(new ReflectionFunction($function), $parameters, $request); - } - - /** - * Arrange parameters for given method - * - * @param string $class - * @param string $method - * @param array $parameters - * @param ServerRequestInterface $request - * @return array - * @throws ReflectionException - */ - private function arrangeMethodParameters( - string $class, - string $method, - array $parameters, - ServerRequestInterface $request - ): array - { - return $this->arrangeParameters(new ReflectionMethod($class, $method), $parameters, $request); - } - - /** - * Arrange parameters for given method/function - * - * @param ReflectionFunctionAbstract $reflection - * @param array $parameters - * @param ServerRequestInterface $request - * @return array - */ - private function arrangeParameters( - ReflectionFunctionAbstract $reflection, - array $parameters, - ServerRequestInterface $request - ): array - { - return array_map( - function (ReflectionParameter $parameter) use ($parameters, $request) { - if (isset($parameters[$parameter->getName()])) { - return $parameters[$parameter->getName()]; - } - - /** @noinspection PhpPossiblePolymorphicInvocationInspection */ - if ( - ($parameter->getType() && $parameter->getType()->getName() == ServerRequestInterface::class) || - ($parameter->getType() && $parameter->getType()->getName() == ServerRequest::class) || - ($parameter->getName() == 'request') - ) { - return $request; - } - - /** @noinspection PhpPossiblePolymorphicInvocationInspection */ - if ( - ($parameter->getType() && $parameter->getType()->getName() == Router::class) || - ($parameter->getName() == 'router') - ) { - return $this; - } - - if ($parameter->isOptional()) { - return $parameter->getDefaultValue(); - } - - return null; - }, - - $reflection->getParameters() - ); - } - - /** - * Convert route to regex - * - * @param string $route - * @return string - */ - private function regexUri(string $route): string - { - return preg_replace_callback('@{([^}]+)}@', function (array $match) { - return $this->regexParameter($match[1]); - }, $route); - } - - /** - * Convert route parameter to regex - * - * @param string $name - * @return string + * @param string|null $name */ - private function regexParameter(string $name): string + public function match(array $methods, string $path, $controller, ?string $name = null): void { - if ($name[-1] == '?') { - $name = substr($name, 0, -1); - $suffix = '?'; - } else { - $suffix = ''; + foreach ($methods as $method) { + $this->map($method, $path, $controller, $name); } - - $pattern = $this->parameters[$name] ?? '[^/]+'; - - return '(?<' . $name . '>' . $pattern . ')' . $suffix; } /** * Map a controller to given route for all the http methods * - * @param string $route + * @param string $path * @param Closure|callable|string $controller - * @param string|callable|Closure|Middleware|string[]|callable[]|Closure[]|Middleware[] $middleware - * @param string|null $domain * @param string|null $name - * @return self */ - public function any( - string $route, - $controller, - $middleware = [], - ?string $domain = null, - ?string $name = null - ): self + public function any(string $path, $controller, ?string $name = null): void { - return $this->map(null, $route, $controller, $middleware, $domain, $name); + $this->map('*', $path, $controller, $name); } /** * Map a controller to given GET route * - * @param string $route + * @param string $path * @param Closure|callable|string $controller - * @param string|callable|Closure|Middleware|string[]|callable[]|Closure[]|Middleware[] $middleware - * @param string|null $domain * @param string|null $name - * @return self */ - public function get( - string $route, - $controller, - $middleware = [], - ?string $domain = null, - ?string $name = null - ): self + public function get(string $path, $controller, ?string $name = null): void { - return $this->map(HttpMethods::GET, $route, $controller, $middleware, $domain, $name); + $this->map('GET', $path, $controller, $name); } /** * Map a controller to given POST route * - * @param string $route + * @param string $path * @param Closure|callable|string $controller - * @param string|callable|Closure|Middleware|string[]|callable[]|Closure[]|Middleware[] $middleware - * @param string|null $domain * @param string|null $name - * @return self */ - public function post( - string $route, - $controller, - $middleware = [], - ?string $domain = null, - ?string $name = null - ): self + public function post(string $path, $controller, ?string $name = null): void { - return $this->map(HttpMethods::POST, $route, $controller, $middleware, $domain, $name); + $this->map('POST', $path, $controller, $name); } /** * Map a controller to given PUT route * - * @param string $route + * @param string $path * @param Closure|callable|string $controller - * @param string|callable|Closure|Middleware|string[]|callable[]|Closure[]|Middleware[] $middleware - * @param string|null $domain * @param string|null $name - * @return self */ - public function put( - string $route, - $controller, - $middleware = [], - ?string $domain = null, - ?string $name = null - ): self + public function put(string $path, $controller, ?string $name = null): void { - return $this->map(HttpMethods::PUT, $route, $controller, $middleware, $domain, $name); + $this->map('PUT', $path, $controller, $name); } /** * Map a controller to given PATCH route * - * @param string $route + * @param string $path * @param Closure|callable|string $controller - * @param string|callable|Closure|Middleware|string[]|callable[]|Closure[]|Middleware[] $middleware - * @param string|null $domain * @param string|null $name - * @return self */ - public function patch( - string $route, - $controller, - $middleware = [], - ?string $domain = null, - ?string $name = null - ): self + public function patch(string $path, $controller, ?string $name = null): void { - return $this->map(HttpMethods::PATCH, $route, $controller, $middleware, $domain, $name); + $this->map('PATCH', $path, $controller, $name); } /** * Map a controller to given DELETE route * - * @param string $route + * @param string $path * @param Closure|callable|string $controller - * @param string|callable|Closure|Middleware|string[]|callable[]|Closure[]|Middleware[] $middleware - * @param string|null $domain * @param string|null $name - * @return self */ - public function delete( - string $route, - $controller, - $middleware = [], - ?string $domain = null, - ?string $name = null - ): self + public function delete(string $path, $controller, ?string $name = null): void { - return $this->map(HttpMethods::DELETE, $route, $controller, $middleware, $domain, $name); + $this->map('DELETE', $path, $controller, $name); } /** - * Use given name for the next route mapping - * - * @param string $name - * @return self - */ - public function name(string $name): self - { - $this->name = $name; - - return $this; - } - - /** - * Define a route parameter pattern - * - * @param string $name - * @param string $pattern - * @return self - */ - public function define(string $name, string $pattern): self - { - $this->parameters[$name] = $pattern; - - return $this; - } - - /** - * Generate URL for given route name - * - * @param string $routeName - * @param string[] $parameters - * @return string - * @throws UndefinedRouteException - */ - public function url(string $routeName, array $parameters = []): string - { - if (isset($this->names[$routeName]) == false) { - throw new UndefinedRouteException("There is no route with name `$routeName`."); - } - - $uri = $this->names[$routeName]->getUri(); - - foreach ($parameters as $name => $value) { - $uri = preg_replace('/\??{' . $name . '\??}/', $value, $uri); - } - - $uri = preg_replace('/{[^}]+\?}/', '', $uri); - $uri = str_replace('/?', '', $uri); - - return $uri; - } - - /** - * @return Route|null - */ - public function currentRoute(): ?Route - { - return $this->currentRoute; - } - - /** - * Prepare router to dispatch routes - */ - private function prepare(): void - { - $this->request = $this->request ?: ServerRequestFactory::fromGlobals(); - $this->publisher = $this->publisher ?: new HttpPublisher(); - } - - /** - * Get current http request instance - * - * @return ServerRequestInterface - */ - public function getRequest(): ServerRequestInterface - { - return $this->request; - } - - /** - * Set my own http request instance - * - * @param ServerRequestInterface $request + * @return Container */ - public function setRequest(ServerRequestInterface $request): void + public function getContainer(): Container { - $this->request = $request; + return $this->container; } /** diff --git a/src/Routing/Attributes.php b/src/Routing/Attributes.php new file mode 100644 index 0000000..2e40729 --- /dev/null +++ b/src/Routing/Attributes.php @@ -0,0 +1,10 @@ +routes['method'][$method][] = $route; + + if ($name) { + $this->routes['name'][$name] = $route; + } + } + + /** + * Find routes by given method + * + * @param string $method + * @return Route[] + */ + public function findByMethod(string $method): array + { + $routes = array_merge( + $this->routes['method']['*'] ?? [], + $this->routes['method'][$method] ?? [] + ); + + krsort($routes); + + return $routes; + } + + /** + * Find route by given name + * + * @param string $name + * @return Route|null + */ + public function findByName(string $name): ?Route + { + return $this->routes['name'][$name] ?? null; + } +} diff --git a/src/Values/Route.php b/src/Routing/Route.php similarity index 53% rename from src/Values/Route.php rename to src/Routing/Route.php index 2d95a8e..37c2386 100644 --- a/src/Values/Route.php +++ b/src/Routing/Route.php @@ -1,15 +1,9 @@ name = $name; - $this->uri = $uri; + ) + { $this->method = $method; + $this->path = $path; $this->controller = $controller; + $this->name = $name; $this->middleware = $middleware; $this->domain = $domain; } @@ -74,12 +79,14 @@ public function __construct( public function toArray(): array { return [ - 'name' => $this->name, - 'uri' => $this->uri, - 'method' => $this->method, - 'controller' => $this->controller, - 'middleware' => $this->middleware, - 'domain' => $this->domain, + 'method' => $this->getMethod(), + 'path' => $this->getPath(), + 'controller' => $this->getController(), + 'name' => $this->getName(), + 'middleware' => $this->getMiddleware(), + 'domain' => $this->getDomain(), + 'uri' => $this->getUri(), + 'parameters' => $this->getParameters(), ]; } @@ -110,21 +117,21 @@ public function getName(): ?string /** * @return string */ - public function getUri(): string + public function getPath(): string { - return $this->uri; + return $this->path; } /** - * @return string|null + * @return string */ - public function getMethod(): ?string + public function getMethod(): string { return $this->method; } /** - * @return Closure|callable|string + * @return array|Closure */ public function getController() { @@ -132,9 +139,9 @@ public function getController() } /** - * @return string[]|callable[]|Closure[]|Middleware[] + * @return array[]|Closure[] */ - public function getMiddleware() + public function getMiddleware(): array { return $this->middleware; } @@ -146,4 +153,36 @@ public function getDomain(): ?string { return $this->domain; } + + /** + * @return string[] + */ + public function getParameters(): array + { + return $this->parameters; + } + + /** + * @param string[] $parameters + */ + public function setParameters(array $parameters): void + { + $this->parameters = $parameters; + } + + /** + * @return string + */ + public function getUri(): string + { + return $this->uri; + } + + /** + * @param string $uri + */ + public function setUri(string $uri): void + { + $this->uri = $uri; + } } diff --git a/src/Routing/State.php b/src/Routing/State.php new file mode 100644 index 0000000..7d9a17b --- /dev/null +++ b/src/Routing/State.php @@ -0,0 +1,71 @@ +prefix = $prefix; + $this->middleware = $middleware; + $this->domain = $domain; + } + + /** + * Append new attributes to the existing ones + * + * @param array $attributes + */ + public function append(array $attributes): void + { + $this->domain = $attributes[Attributes::DOMAIN] ?? null; + $this->prefix .= $attributes[Attributes::PREFIX] ?? ''; + $this->middleware = array_merge($this->middleware, $attributes[Attributes::MIDDLEWARE] ?? []); + } + + /** + * @return string + */ + public function getPrefix(): string + { + return $this->prefix; + } + + /** + * @return array + */ + public function getMiddleware(): array + { + return $this->middleware; + } + + /** + * @return string|null + */ + public function getDomain(): ?string + { + return $this->domain; + } +} diff --git a/src/Routing/Storekeeper.php b/src/Routing/Storekeeper.php new file mode 100644 index 0000000..079713d --- /dev/null +++ b/src/Routing/Storekeeper.php @@ -0,0 +1,64 @@ +repository = $repository; + $this->state = $state; + } + + /** + * Add a route to the collection + * + * @param string $method + * @param string $path + * @param $controller + * @param string|null $name + */ + public function add(string $method, string $path, $controller, ?string $name = null): void + { + $this->repository->save( + $method, + $this->state->getPrefix() . $path, + $controller, + $name, + $this->state->getMiddleware(), + $this->state->getDomain() + ); + } + + /** + * @return State + */ + public function getState(): State + { + return $this->state; + } + + /** + * @param State $state + */ + public function setState(State $state): void + { + $this->state = $state; + } +} diff --git a/src/Services/HttpPublisher.php b/src/Services/HttpPublisher.php index 2d20f51..8650f35 100644 --- a/src/Services/HttpPublisher.php +++ b/src/Services/HttpPublisher.php @@ -4,13 +4,6 @@ use Psr\Http\Message\ResponseInterface; -/** - * Class HttpPublisher - * HttpPublisher publishes controller responses as over HTTP. - * - * @package MiladRahimi\PhpRouter\Services - * @codeCoverageIgnore - */ class HttpPublisher implements Publisher { /** @@ -18,7 +11,7 @@ class HttpPublisher implements Publisher */ public function publish($content): void { - $content = empty($content) ? '' : $content; + $content = empty($content) ? null : $content; $output = fopen('php://output', 'a'); @@ -26,12 +19,14 @@ public function publish($content): void http_response_code($content->getStatusCode()); foreach ($content->getHeaders() as $name => $values) { - header($name . ': ' . $content->getHeaderLine($name)); + @header($name . ': ' . $content->getHeaderLine($name)); } fwrite($output, $content->getBody()); } elseif (is_scalar($content)) { fwrite($output, $content); + } elseif ($content === null) { + fwrite($output, ''); } else { fwrite($output, json_encode($content)); } diff --git a/src/Services/Publisher.php b/src/Services/Publisher.php index 385dcaf..873fc28 100644 --- a/src/Services/Publisher.php +++ b/src/Services/Publisher.php @@ -2,12 +2,6 @@ namespace MiladRahimi\PhpRouter\Services; -/** - * Interface Publisher - * Publishers are responsible to publish the response provided by controllers - * - * @package MiladRahimi\PhpRouter\Services - */ interface Publisher { /** diff --git a/src/Url.php b/src/Url.php new file mode 100644 index 0000000..5e3c364 --- /dev/null +++ b/src/Url.php @@ -0,0 +1,50 @@ +repository = $repository; + } + + /** + * Generate URL for given route name + * + * @param string $routeName + * @param string[] $parameters + * @return string + * @throws UndefinedRouteException + */ + public function make(string $routeName, array $parameters = []): string + { + if (!($route = $this->repository->findByName($routeName))) { + throw new UndefinedRouteException("There is no route named `$routeName`."); + } + + $uri = $route->getPath(); + + foreach ($parameters as $name => $value) { + $uri = preg_replace('/\??{' . $name . '\??}/', $value, $uri); + } + + $uri = preg_replace('/{[^}]+\?}/', '', $uri); + $uri = str_replace('/?', '', $uri); + + return $uri; + } +} diff --git a/tests/Common/SampleController.php b/tests/Common/SampleController.php new file mode 100644 index 0000000..d30f806 --- /dev/null +++ b/tests/Common/SampleController.php @@ -0,0 +1,21 @@ +content = $content ?: 'empty'; } - /** - * @inheritdoc - */ - public function handle(ServerRequestInterface $request, Closure $next) + public function handle(ServerRequestInterface $request, $next) { static::$output[] = $this->content; diff --git a/tests/Testing/StopperMiddleware.php b/tests/Common/StopperMiddleware.php similarity index 78% rename from tests/Testing/StopperMiddleware.php rename to tests/Common/StopperMiddleware.php index 55b12cb..8903072 100644 --- a/tests/Testing/StopperMiddleware.php +++ b/tests/Common/StopperMiddleware.php @@ -1,13 +1,12 @@ router(); + $router->getContainer()->singleton('name', 'Pink Floyd'); + + $router->get('/', function (Container $container) { + return $container->get('name'); + }); + + $router->dispatch(); + + $this->assertEquals('Pink Floyd', $this->output($router)); + } +} diff --git a/tests/ControllerTest.php b/tests/ControllerTest.php new file mode 100644 index 0000000..7194d3b --- /dev/null +++ b/tests/ControllerTest.php @@ -0,0 +1,114 @@ +router(); + $router->get('/', function () { + return 'Closure'; + }); + $router->dispatch(); + + $this->assertEquals('Closure', $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_with_a_method_controller() + { + $router = $this->router(); + $router->get('/', [SampleController::class, 'home']); + $router->dispatch(); + + $this->assertEquals('Home', $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_with_an_invalid_array_as_controller_it_should_fail() + { + $router = $this->router(); + $router->get('/', ['invalid', 'array', 'controller']); + + $this->expectException(InvalidCallableException::class); + $this->expectExceptionMessage('Invalid callable: invalid,array,controller'); + $router->dispatch(); + } + + /** + * @throws Throwable + */ + public function test_with_an_invalid_class_as_controller_it_should_fail() + { + $router = $this->router(); + $router->get('/', ['InvalidController', 'show']); + + $this->expectException(InvalidCallableException::class); + $this->expectExceptionMessage('Class `InvalidController` not found.'); + $router->dispatch(); + } + + /** + * @throws Throwable + */ + public function test_with_an_int_as_controller_it_should_fail() + { + $router = $this->router(); + $router->get('/', 666); + + $this->expectException(InvalidCallableException::class); + $this->expectExceptionMessage('Invalid callable.'); + $router->dispatch(); + } + + /** + * @throws Throwable + */ + public function test_with_an_handle_less_class_as_controller_it_should_fail() + { + $router = $this->router(); + $router->get('/', SampleController::class); + + $this->expectException(InvalidCallableException::class); + $this->expectExceptionMessage('Method `handle` is not declared.'); + $router->dispatch(); + } + + /** + * @throws Throwable + */ + public function test_with_an_invalid_method_as_controller_it_should_fail() + { + $router = $this->router(); + $router->get('/', [SampleController::class, 'invalid']); + + $this->expectException(InvalidCallableException::class); + $this->expectExceptionMessage('Method `' . SampleController::class . '::invalid` is not declared.'); + $router->dispatch(); + } + + /** + * @throws Throwable + */ + public function test_with_multiple_controller_for_the_same_route_it_should_call_the_last_one() + { + $router = $this->router(); + $router->get('/', [SampleController::class, 'home']); + $router->get('/', [SampleController::class, 'page']); + $router->dispatch(); + + $this->assertEquals('Page', $this->output($router)); + } +} diff --git a/tests/Enums/HttpMethodsTest.php b/tests/Enums/HttpMethodsTest.php deleted file mode 100644 index 63e2e27..0000000 --- a/tests/Enums/HttpMethodsTest.php +++ /dev/null @@ -1,16 +0,0 @@ -assertEquals('APPEND', $actual); - } -} diff --git a/tests/GroupingTest.php b/tests/GroupingTest.php index d80e2a8..466d556 100644 --- a/tests/GroupingTest.php +++ b/tests/GroupingTest.php @@ -2,9 +2,9 @@ namespace MiladRahimi\PhpRouter\Tests; -use MiladRahimi\PhpRouter\Enums\HttpMethods; use MiladRahimi\PhpRouter\Router; -use MiladRahimi\PhpRouter\Tests\Testing\SampleMiddleware; +use MiladRahimi\PhpRouter\Tests\Common\SampleController; +use MiladRahimi\PhpRouter\Tests\Common\SampleMiddleware; use Throwable; class GroupingTest extends TestCase @@ -14,10 +14,11 @@ class GroupingTest extends TestCase */ public function test_with_no_attribute() { - $router = $this->router() - ->group([], function (Router $router) { - $router->get('/', $this->OkController()); - })->dispatch(); + $router = $this->router(); + $router->group([], function (Router $router) { + $router->get('/', [SampleController::class, 'ok']); + }); + $router->dispatch(); $this->assertEquals('OK', $this->output($router)); } @@ -29,33 +30,16 @@ public function test_with_a_middleware() { $middleware = new SampleMiddleware(666); - $router = $this->router() - ->group(['middleware' => $middleware], function (Router $router) { - $router->get('/', $this->OkController()); - })->dispatch(); + $router = $this->router(); + $router->group(['middleware' => [$middleware]], function (Router $router) { + $router->get('/', [SampleController::class, 'ok']); + }); + $router->dispatch(); $this->assertEquals('OK', $this->output($router)); $this->assertContains($middleware->content, SampleMiddleware::$output); } - /** - * @throws Throwable - */ - public function test_with_route_and_group_middleware() - { - $groupMiddleware = new SampleMiddleware(13); - $routeMiddleware = new SampleMiddleware(666); - - $router = $this->router() - ->group(['middleware' => $groupMiddleware], function (Router $router) use ($routeMiddleware) { - $router->get('/', $this->OkController(), $routeMiddleware); - })->dispatch(); - - $this->assertEquals('OK', $this->output($router)); - $this->assertContains($groupMiddleware->content, SampleMiddleware::$output); - $this->assertContains($routeMiddleware->content, SampleMiddleware::$output); - } - /** * @throws Throwable */ @@ -64,12 +48,13 @@ public function test_nested_groups_with_middleware() $group1Middleware = new SampleMiddleware(mt_rand(1, 9999999)); $group2Middleware = new SampleMiddleware(mt_rand(1, 9999999)); - $router = $this->router() - ->group(['middleware' => $group1Middleware], function (Router $router) use ($group2Middleware) { - $router->group(['middleware' => $group2Middleware], function (Router $router) { - $router->get('/', $this->OkController()); - }); - })->dispatch(); + $router = $this->router(); + $router->group(['middleware' => [$group1Middleware]], function (Router $router) use ($group2Middleware) { + $router->group(['middleware' => [$group2Middleware]], function (Router $router) { + $router->get('/', [SampleController::class, 'ok']); + }); + }); + $router->dispatch(); $this->assertEquals('OK', $this->output($router)); $this->assertContains($group1Middleware->content, SampleMiddleware::$output); @@ -81,12 +66,13 @@ public function test_nested_groups_with_middleware() */ public function test_with_a_prefix() { - $this->mockRequest(HttpMethods::GET, 'http://example.com/group/page'); + $this->mockRequest('GET', 'http://example.com/group/page'); - $router = $this->router() - ->group(['prefix' => '/group'], function (Router $router) { - $router->get('/page', $this->OkController()); - })->dispatch(); + $router = $this->router(); + $router->group(['prefix' => '/group'], function (Router $router) { + $router->get('/page', [SampleController::class, 'ok']); + }); + $router->dispatch(); $this->assertEquals('OK', $this->output($router)); } @@ -96,59 +82,31 @@ public function test_with_a_prefix() */ public function test_nested_groups_with_prefix() { - $this->mockRequest(HttpMethods::GET, 'http://example.com/group1/group2/page'); + $this->mockRequest('GET', 'http://example.com/group1/group2/page'); - $router = $this->router() - ->group(['prefix' => '/group1'], function (Router $router) { - $router->group(['prefix' => '/group2'], function (Router $router) { - $router->get('/page', $this->OkController()); - }); - })->dispatch(); + $router = $this->router(); + $router->group(['prefix' => '/group1'], function (Router $router) { + $router->group(['prefix' => '/group2'], function (Router $router) { + $router->get('/page', [SampleController::class, 'ok']); + }); + }); + $router->dispatch(); $this->assertEquals('OK', $this->output($router)); } - /** - * @throws Throwable - */ - public function test_with_namespace() - { - $namespace = 'MiladRahimi\PhpRouter\Tests\Testing'; - - $router = $this->router() - ->group(['namespace' => $namespace], function (Router $router) { - $router->get('/', 'SampleController@home'); - })->dispatch(); - - $this->assertEquals('Home', $this->output($router)); - } - /** * @throws Throwable */ public function test_with_domain() { - $this->mockRequest(HttpMethods::GET, 'http://sub.domain.tld/'); + $this->mockRequest('GET', 'http://sub.domain.tld/'); - $router = $this->router() - ->group(['domain' => 'sub.domain.tld'], function (Router $router) { - $router->get('/', $this->OkController()); - })->dispatch(); - - $this->assertEquals('OK', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_with_group_and_route_domain_it_should_only_consider_route_domain() - { - $this->mockRequest(HttpMethods::GET, 'http://sub2.domain.com/'); - - $router = $this->router() - ->group(['domain' => 'sub1.domain.com'], function (Router $router) { - $router->get('/', $this->OkController(), [], 'sub2.domain.com'); - })->dispatch(); + $router = $this->router(); + $router->group(['domain' => 'sub.domain.tld'], function (Router $router) { + $router->get('/', [SampleController::class, 'ok']); + }); + $router->dispatch(); $this->assertEquals('OK', $this->output($router)); } @@ -158,31 +116,16 @@ public function test_with_group_and_route_domain_it_should_only_consider_route_d */ public function test_nested_groups_with_domain_it_should_consider_the_inner_group_domain() { - $this->mockRequest(HttpMethods::GET, 'http://sub2.domain.com/'); - - $router = $this->router() - ->group(['domain' => 'sub1.domain.com'], function (Router $router) { - $router->group(['domain' => 'sub2.domain.com'], function (Router $router) { - $router->get('/', $this->OkController()); + $this->mockRequest('GET', 'http://sub2.domain.com/'); - }); - })->dispatch(); - - $this->assertEquals('OK', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_naming_it_should_remove_existing_name_before_the_group() - { - $router = $this->router() - ->name('NameForNothing') - ->group([], function (Router $router) { - $router->get('/', $this->OkController()); - })->dispatch(); + $router = $this->router(); + $router->group(['domain' => 'sub1.domain.com'], function (Router $router) { + $router->group(['domain' => 'sub2.domain.com'], function (Router $router) { + $router->get('/', [SampleController::class, 'ok']); + }); + }); + $router->dispatch(); $this->assertEquals('OK', $this->output($router)); - $this->assertFalse($router->currentRoute()->getName() == 'NameForNothing'); } } diff --git a/tests/HttpMethodTest.php b/tests/HttpMethodTest.php new file mode 100644 index 0000000..41a9692 --- /dev/null +++ b/tests/HttpMethodTest.php @@ -0,0 +1,159 @@ +mockRequest('GET', 'http://example.com/'); + + $router = $this->router(); + $router->get('/', [SampleController::class, 'home']); + $router->dispatch(); + + $this->assertEquals('Home', $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_a_post_route() + { + $this->mockRequest('POST', 'http://example.com/'); + + $router = $this->router(); + $router->post('/', [SampleController::class, 'home']); + $router->dispatch(); + + $this->assertEquals('Home', $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_a_put_route() + { + $this->mockRequest('PUT', 'http://example.com/'); + + $router = $this->router(); + $router->put('/', [SampleController::class, 'home']); + $router->dispatch(); + + $this->assertEquals('Home', $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_a_patch_route() + { + $this->mockRequest('PATCH', 'http://example.com/'); + + $router = $this->router(); + $router->patch('/', [SampleController::class, 'home']); + $router->dispatch(); + + $this->assertEquals('Home', $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_a_delete_route() + { + $this->mockRequest('DELETE', 'http://example.com/'); + + $router = $this->router(); + $router->delete('/', [SampleController::class, 'home']); + $router->dispatch(); + + $this->assertEquals('Home', $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_map_a_post_route() + { + $this->mockRequest('POST', 'http://example.com/'); + + $router = $this->router(); + $router->map('POST', '/', [SampleController::class, 'home']); + $router->dispatch(); + + $this->assertEquals('Home', $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_map_a_custom_method() + { + $this->mockRequest('CUSTOM', 'http://example.com/'); + + $router = $this->router(); + $router->map('CUSTOM', '/', [SampleController::class, 'home']); + $router->dispatch(); + + $this->assertEquals('Home', $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_match_multiple_routes() + { + $router = $this->router(); + $router->match(['PUT', 'PATCH'], '/', [SampleController::class, 'home']); + + $this->mockRequest('PUT', 'http://example.com/'); + $router->dispatch(); + $this->assertEquals('Home', $this->output($router)); + + $this->mockRequest('PATCH', 'http://example.com/'); + $router->dispatch(); + $this->assertEquals('Home', $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_match_no_route() + { + $this->mockRequest('GET', 'http://example.com/'); + + $router = $this->router(); + $router->match([], '/', [SampleController::class, 'home']); + + $this->expectException(RouteNotFoundException::class); + $router->dispatch(); + } + + /** + * @throws Throwable + */ + public function test_any_with_some_methods() + { + $router = $this->router(); + $router->any('/', function (ServerRequest $request) { + return $request->getMethod(); + }); + + $this->mockRequest('GET', 'http://example.com/'); + $router->dispatch(); + $this->assertEquals('GET', $this->output($router)); + + $this->mockRequest('POST', 'http://example.com/'); + $router->dispatch(); + $this->assertEquals('POST', $this->output($router)); + } +} diff --git a/tests/InjectionTest.php b/tests/InjectionTest.php new file mode 100644 index 0000000..1992a09 --- /dev/null +++ b/tests/InjectionTest.php @@ -0,0 +1,83 @@ +router(); + $router->get('/', function (ServerRequest $request) { + return get_class($request); + }); + $router->dispatch(); + + $this->assertEquals(ServerRequest::class, $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_injecting_request_by_interface() + { + $router = $this->router(); + $router->get('/', function (ServerRequestInterface $request) { + return get_class($request); + }); + $router->dispatch(); + + $this->assertEquals(ServerRequest::class, $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_injecting_route() + { + $router = $this->router(); + $router->get('/', function (Route $route) { + return $route->getPath(); + }); + $router->dispatch(); + + $this->assertEquals('/', $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_injecting_container() + { + $router = $this->router(); + $router->get('/', function (Container $container) { + return get_class($container); + }); + $router->dispatch(); + + $this->assertEquals(Container::class, $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_injecting_container_by_interface() + { + $router = $this->router(); + $router->get('/', function (ContainerInterface $container) { + return get_class($container); + }); + $router->dispatch(); + + $this->assertEquals(Container::class, $this->output($router)); + } +} diff --git a/tests/MiddlewareTest.php b/tests/MiddlewareTest.php index e28cf88..9bfb388 100644 --- a/tests/MiddlewareTest.php +++ b/tests/MiddlewareTest.php @@ -2,12 +2,12 @@ namespace MiladRahimi\PhpRouter\Tests; -use Closure; -use MiladRahimi\PhpRouter\Exceptions\InvalidMiddlewareException; -use MiladRahimi\PhpRouter\Tests\Testing\SampleMiddleware; -use MiladRahimi\PhpRouter\Tests\Testing\StopperMiddleware; +use MiladRahimi\PhpRouter\Exceptions\InvalidCallableException; +use MiladRahimi\PhpRouter\Router; +use MiladRahimi\PhpRouter\Tests\Common\SampleController; +use MiladRahimi\PhpRouter\Tests\Common\SampleMiddleware; +use MiladRahimi\PhpRouter\Tests\Common\StopperMiddleware; use Throwable; -use Zend\Diactoros\ServerRequest; class MiddlewareTest extends TestCase { @@ -18,9 +18,11 @@ public function test_with_a_single_middleware_as_an_object() { $middleware = new SampleMiddleware(666); - $router = $this->router() - ->get('/', $this->OkController(), $middleware) - ->dispatch(); + $router = $this->router(); + $router->group(['middleware' => [$middleware]], function (Router $r) { + $r->get('/', [SampleController::class, 'ok']); + }); + $router->dispatch(); $this->assertEquals('OK', $this->output($router)); $this->assertContains($middleware->content, SampleMiddleware::$output); @@ -33,32 +35,16 @@ public function test_with_a_single_middleware_as_a_string() { $middleware = SampleMiddleware::class; - $router = $this->router() - ->get('/', $this->OkController(), $middleware) - ->dispatch(); + $router = $this->router(); + $router->group(['middleware' => [$middleware]], function (Router $r) { + $r->get('/', [SampleController::class, 'ok']); + }); + $router->dispatch(); $this->assertEquals('OK', $this->output($router)); $this->assertEquals('empty', SampleMiddleware::$output[0]); } - /** - * @throws Throwable - */ - public function test_with_a_single_middleware_as_a_closure() - { - $middleware = function (ServerRequest $request, Closure $next) { - return $next($request->withAttribute('Middleware', 666)); - }; - - $router = $this->router() - ->get('/', function (ServerRequest $request) { - return $request->getAttribute('Middleware'); - }, $middleware) - ->dispatch(); - - $this->assertEquals('666', $this->output($router)); - } - /** * @throws Throwable */ @@ -66,48 +52,27 @@ public function test_with_a_stopper_middleware() { $middleware = new StopperMiddleware(666); - $router = $this->router() - ->get('/', $this->OkController(), $middleware) - ->dispatch(); + $router = $this->router(); + $router->group(['middleware' => [$middleware]], function (Router $r) { + $r->get('/', [SampleController::class, 'ok']); + }); + $router->dispatch(); $this->assertEquals('Stopped in middleware.', $this->output($router)); $this->assertContains($middleware->content, StopperMiddleware::$output); } - /** - * @throws Throwable - */ - public function test_with_multiple_middleware() - { - $middleware = [ - function (ServerRequest $request, $next) { - $request = $request->withAttribute('a', 'It'); - return $next($request); - }, - function (ServerRequest $request, $next) { - $request = $request->withAttribute('b', 'works!'); - return $next($request); - }, - ]; - - $router = $this->router() - ->get('/', function (ServerRequest $request) { - return $request->getAttribute('a') . ' ' . $request->getAttribute('b'); - }, $middleware) - ->dispatch(); - - $this->assertEquals('It works!', $this->output($router)); - } - /** * @throws Throwable */ public function test_with_invalid_middleware() { - $this->expectException(InvalidMiddlewareException::class); + $this->expectException(InvalidCallableException::class); - $this->router() - ->get('/', $this->OkController(), 'UnknownMiddleware') - ->dispatch(); + $router = $this->router(); + $router->group(['middleware' => ['UnknownMiddleware']], function (Router $r) { + $r->get('/', [SampleController::class, 'ok']); + }); + $router->dispatch(); } } diff --git a/tests/NamingTest.php b/tests/NamingTest.php index 262dd1f..a4a2653 100644 --- a/tests/NamingTest.php +++ b/tests/NamingTest.php @@ -2,7 +2,7 @@ namespace MiladRahimi\PhpRouter\Tests; -use MiladRahimi\PhpRouter\Enums\HttpMethods; +use MiladRahimi\PhpRouter\Routing\Route; use Throwable; class NamingTest extends TestCase @@ -12,49 +12,12 @@ class NamingTest extends TestCase */ public function test_a_named_route() { - $router = $this->router() - ->get('/', $this->OkController(), [], null, 'Home') - ->dispatch(); + $router = $this->router(); + $router->get('/', function (Route $route) { + return $route->getName(); + }, 'home'); + $router->dispatch(); - $this->assertEquals('OK', $this->output($router)); - $this->assertTrue($router->currentRoute()->getName() == 'Home'); - } - - /** - * @throws Throwable - */ - public function test_the_name_method() - { - $router = $this->router() - ->name('Home') - ->get('/', $this->OkController()) - ->dispatch(); - - $this->assertEquals('OK', $this->output($router)); - $this->assertTrue($router->currentRoute()->getName() == 'Home'); - - $this->mockRequest(HttpMethods::POST, 'http://example.com/666'); - - $router = $this->router() - ->post('/{id}', function ($id) { - return $id; - }) - ->dispatch(); - - $this->assertEquals('666', $this->output($router)); - $this->assertFalse($router->currentRoute()->getName() == 'Home'); - } - - /** - * @throws Throwable - */ - public function test_duplicate_naming_it_should_set_the_name_for_all_routes() - { - $router = $this->router() - ->get('/', $this->OkController(), [], null, 'Home') - ->get('/home', $this->OkController(), [], null, 'Home') - ->dispatch(); - - $this->assertTrue($router->currentRoute()->getName() == 'Home'); + $this->assertEquals('home', $this->output($router)); } } diff --git a/tests/ParametersTest.php b/tests/ParametersTest.php new file mode 100644 index 0000000..c42259f --- /dev/null +++ b/tests/ParametersTest.php @@ -0,0 +1,128 @@ +mockRequest('GET', "http://example.com/products/$id"); + + $router = $this->router(); + $router->get('/products/{id}', function ($id) { + return $id; + }); + $router->dispatch(); + + $this->assertEquals($id, $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_with_some_required_parameters() + { + $pid = random_int(1, 100); + $cid = random_int(1, 100); + $this->mockRequest('GET', "http://example.com/products/$pid/comments/$cid"); + + $router = $this->router(); + $router->get('/products/{pid}/comments/{cid}', function ($pid, $cid) { + return $pid . $cid; + }); + $router->dispatch(); + + $this->assertEquals($pid . $cid, $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_with_a_provided_optional_parameter() + { + $id = random_int(1, 100); + $this->mockRequest('GET', "http://example.com/products/$id"); + + $router = $this->router(); + $router->get('/products/{id?}', function ($id = 'default') { + return $id; + }); + $router->dispatch(); + + $this->assertEquals($id, $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_with_a_unprovided_optional_parameter() + { + $this->mockRequest('GET', "http://example.com/products/"); + + $router = $this->router(); + $router->get('/products/{id?}', function ($id = 'default') { + return $id; + }); + $router->dispatch(); + + $this->assertEquals('default', $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_with_a_unprovided_optional_parameter_and_slash() + { + $this->mockRequest('GET', "http://example.com/products"); + + $router = $this->router(); + $router->get('/products/?{id?}', function ($id = 'default') { + return $id; + }); + $router->dispatch(); + + $this->assertEquals('default', $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_with_some_optional_parameters() + { + $pid = random_int(1, 100); + $cid = random_int(1, 100); + $this->mockRequest('GET', "http://example.com/products/$pid/comments/$cid"); + + $router = $this->router(); + $router->get('/products/{pid?}/comments/{cid?}', function ($pid, $cid) { + return $pid . $cid; + }); + $router->dispatch(); + + $this->assertEquals($pid . $cid, $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_with_mixin_parameters() + { + $pid = random_int(1, 100); + $cid = random_int(1, 100); + $this->mockRequest('GET', "http://example.com/products/$pid/comments/$cid"); + + $router = $this->router(); + $router->get('/products/{pid}/comments/{cid?}', function ($pid, $cid = 'default') { + return $pid . $cid; + }); + $router->dispatch(); + + $this->assertEquals($pid . $cid, $this->output($router)); + } +} \ No newline at end of file diff --git a/tests/PatternsTest.php b/tests/PatternsTest.php new file mode 100644 index 0000000..094a0a7 --- /dev/null +++ b/tests/PatternsTest.php @@ -0,0 +1,116 @@ +mockRequest('GET', "http://example.com/products/$id"); + + $router = $this->router(); + $router->pattern('id', '[0-9]'); + $router->get('/products/{id}', function ($id) { + return $id; + }); + $router->dispatch(); + + $this->assertEquals($id, $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_with_a_single_digit_pattern_given_more() + { + $id = random_int(10, 100); + $this->mockRequest('GET', "http://example.com/products/$id"); + + $router = $this->router(); + $router->pattern('id', '[0-9]'); + $router->get('/products/{id}', function ($id) { + return $id; + }); + + $this->expectException(RouteNotFoundException::class); + $router->dispatch(); + } + + /** + * @throws Throwable + */ + public function test_with_a_multi_digits_pattern() + { + $id = random_int(10, 100); + $this->mockRequest('GET', "http://example.com/products/$id"); + + $router = $this->router(); + $router->pattern('id', '[0-9]+'); + $router->get('/products/{id}', function ($id) { + return $id; + }); + $router->dispatch(); + + $this->assertEquals($id, $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_with_a_multi_digits_pattern_given_string() + { + $this->mockRequest('GET', "http://example.com/products/string"); + + $router = $this->router(); + $router->pattern('id', '[0-9]+'); + $router->get('/products/{id}', function ($id) { + return $id; + }); + + $this->expectException(RouteNotFoundException::class); + $router->dispatch(); + } + + /** + * @throws Throwable + */ + public function test_with_a_alphanumeric_pattern() + { + $id = 'abc123xyz'; + $this->mockRequest('GET', "http://example.com/products/$id"); + + $router = $this->router(); + $router->pattern('id', '[0-9a-z]+'); + $router->get('/products/{id}', function ($id) { + return $id; + }); + $router->dispatch(); + + $this->assertEquals($id, $this->output($router)); + } + + /** + * @throws Throwable + */ + public function test_with_a_alphanumeric_pattern_given_invalid() + { + $id = 'abc$$$'; + $this->mockRequest('GET', "http://example.com/products/$id"); + + $router = $this->router(); + $router->pattern('id', '[0-9a-z]+'); + $router->get('/products/{id}', function ($id) { + return $id; + }); + + $this->expectException(RouteNotFoundException::class); + $router->dispatch(); + } +} diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 0af269e..1cb1912 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -3,11 +3,11 @@ namespace MiladRahimi\PhpRouter\Tests; use Throwable; -use Zend\Diactoros\Response\EmptyResponse; -use Zend\Diactoros\Response\HtmlResponse; -use Zend\Diactoros\Response\JsonResponse; -use Zend\Diactoros\Response\RedirectResponse; -use Zend\Diactoros\Response\TextResponse; +use Laminas\Diactoros\Response\EmptyResponse; +use Laminas\Diactoros\Response\HtmlResponse; +use Laminas\Diactoros\Response\JsonResponse; +use Laminas\Diactoros\Response\RedirectResponse; +use Laminas\Diactoros\Response\TextResponse; class ResponseTest extends TestCase { @@ -16,11 +16,11 @@ class ResponseTest extends TestCase */ public function test_empty_response_with_code_204() { - $router = $this->router() - ->get('/', function () { - return new EmptyResponse(204); - }) - ->dispatch(); + $router = $this->router(); + $router->get('/', function () { + return new EmptyResponse(204); + }); + $router->dispatch(); $this->assertEquals(204, $this->status($router)); } @@ -30,11 +30,11 @@ public function test_empty_response_with_code_204() */ public function test_html_response_with_code_200() { - $router = $this->router() - ->get('/', function () { - return new HtmlResponse('', 200); - }) - ->dispatch(); + $router = $this->router(); + $router->get('/', function () { + return new HtmlResponse('', 200); + }); + $router->dispatch(); $this->assertEquals(200, $this->status($router)); $this->assertEquals('', $this->output($router)); @@ -45,11 +45,11 @@ public function test_html_response_with_code_200() */ public function test_json_response_with_code_201() { - $router = $this->router() - ->get('/', function () { - return new JsonResponse(['a' => 'x', 'b' => 'y'], 201); - }) - ->dispatch(); + $router = $this->router(); + $router->get('/', function () { + return new JsonResponse(['a' => 'x', 'b' => 'y'], 201); + }); + $router->dispatch(); $this->assertEquals(201, $this->status($router)); $this->assertEquals(json_encode(['a' => 'x', 'b' => 'y']), $this->output($router)); @@ -60,11 +60,11 @@ public function test_json_response_with_code_201() */ public function test_text_response_with_code_203() { - $router = $this->router() - ->get('/', function () { - return new TextResponse('Content', 203); - }) - ->dispatch(); + $router = $this->router(); + $router->get('/', function () { + return new TextResponse('Content', 203); + }); + $router->dispatch(); $this->assertEquals(203, $this->status($router)); $this->assertEquals('Content', $this->output($router)); @@ -75,11 +75,11 @@ public function test_text_response_with_code_203() */ public function test_redirect_response_with_code_203() { - $router = $this->router() - ->get('/', function () { - return new RedirectResponse('https://miladrahimi.com'); - }) - ->dispatch(); + $router = $this->router(); + $router->get('/', function () { + return new RedirectResponse('https://miladrahimi.com'); + }); + $router->dispatch(); $this->assertEquals(302, $this->status($router)); $this->assertEquals('', $this->output($router)); diff --git a/tests/RouteTest.php b/tests/RouteTest.php new file mode 100644 index 0000000..c56bd6a --- /dev/null +++ b/tests/RouteTest.php @@ -0,0 +1,71 @@ +mockRequest('POST', 'http://shop.com/admin/profile/666'); + + $router = $this->router(); + + $attributes = [ + Attributes::DOMAIN => 'shop.com', + Attributes::MIDDLEWARE => [SampleMiddleware::class], + Attributes::PREFIX => '/admin', + ]; + + $router->group($attributes, function (Router $router) { + $router->post('/profile/{id}', function (Route $route) { + return $route->__toString(); + }, 'admin.profile'); + }); + + $router->dispatch(); + + $expected = [ + 'method' => 'POST', + 'path' => '/admin/profile/{id}', + 'controller' => function () { + return 'Closure'; + }, + 'name' => 'admin.profile', + 'middleware' => [SampleMiddleware::class], + 'domain' => 'shop.com', + 'uri' => '/admin/profile/666', + 'parameters' => ['id' => '666'], + ]; + + $this->assertEquals(json_encode($expected), $this->output($router)); + } + + public function test_lately_added_attributes_of_route() + { + $this->mockRequest('POST', 'http://shop.com/admin/profile/666'); + + $router = $this->router(); + + $router->post('/admin/profile/{id}', function (Route $route) { + return [ + $route->getParameters(), + $route->getUri(), + ]; + }, 'admin.profile'); + + $router->dispatch(); + + $expected = [['id' => '666'], '/admin/profile/666']; + + $this->assertEquals(json_encode($expected), $this->output($router)); + } +} diff --git a/tests/RoutingTest.php b/tests/RoutingTest.php deleted file mode 100644 index 31193fa..0000000 --- a/tests/RoutingTest.php +++ /dev/null @@ -1,480 +0,0 @@ -mockRequest(HttpMethods::GET, 'http://example.com/'); - - $router = $this->router()->map('GET', '/', $this->OkController())->dispatch(); - - $this->assertEquals('OK', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_a_simple_post_route() - { - $this->mockRequest(HttpMethods::POST, 'http://example.com/'); - - $router = $this->router()->post('/', $this->OkController())->dispatch(); - - $this->assertEquals('OK', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_a_simple_put_route() - { - $this->mockRequest(HttpMethods::PUT, 'http://example.com/'); - - $router = $this->router()->put('/', $this->OkController())->dispatch(); - - $this->assertEquals('OK', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_a_simple_patch_route() - { - $this->mockRequest(HttpMethods::PATCH, 'http://example.com/'); - - $router = $this->router()->patch('/', $this->OkController())->dispatch(); - - $this->assertEquals('OK', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_a_simple_delete_route() - { - $this->mockRequest(HttpMethods::DELETE, 'http://example.com/'); - - $router = $this->router()->delete('/', $this->OkController())->dispatch(); - - $this->assertEquals('OK', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_a_route_with_custom_method() - { - $method = "SCREW"; - - $this->mockRequest($method, 'http://example.com/'); - - $router = $this->router()->map($method, '/', $this->OkController())->dispatch(); - - $this->assertEquals('OK', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_the_any_method() - { - $this->mockRequest(HttpMethods::GET, 'http://example.com/'); - - $router = $this->router()->any('/', function () { - return 'Test any for get'; - })->dispatch(); - - $this->assertEquals('Test any for get', $this->output($router)); - - $this->mockRequest(HttpMethods::POST, 'http://example.com/'); - - $router = $this->router() - ->any('/', function () { - return 'Test any for post'; - }) - ->dispatch(); - - $this->assertEquals('Test any for post', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_multiple_routes() - { - $this->mockRequest(HttpMethods::POST, 'http://example.com/666'); - - $router = $this->router() - ->get('/', function () { - return 'Home'; - }) - ->post('/{id}', function ($id) { - return $id; - }) - ->dispatch(); - - $this->assertEquals('666', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_duplicate_routes_with_different_controllers() - { - $router = $this->router() - ->get('/', function () { - return 'Home'; - }) - ->get('/', function () { - return 'Home again!'; - }) - ->dispatch(); - - $this->assertEquals('Home again!', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_multiple_http_methods() - { - $this->mockRequest(HttpMethods::POST, 'http://example.com/'); - - $router = $this->router() - ->get('/', function () { - return 'Get'; - }) - ->post('/', function () { - return 'Post'; - }) - ->delete('/', function () { - return 'Delete'; - }) - ->dispatch(); - - $this->assertEquals('Post', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_initial_prefix() - { - $this->mockRequest(HttpMethods::POST, 'http://example.com/app/page'); - - $router = $this->router('/app') - ->post('/page', $this->OkController()) - ->dispatch(); - - $this->assertEquals('OK', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_with_a_required_parameter() - { - $this->mockRequest(HttpMethods::GET, 'http://web.com/666'); - - $router = $this->router() - ->get('/{id}', function ($id) { - return $id; - }) - ->dispatch(); - - $this->assertEquals('666', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_with_a_optional_parameter_when_it_is_present() - { - $this->mockRequest(HttpMethods::GET, 'http://web.com/666'); - - $router = $this->router() - ->get('/{id?}', function ($id) { - return $id; - }) - ->dispatch(); - - $this->assertEquals('666', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_with_a_optional_parameter_when_it_is_not_present() - { - $this->mockRequest(HttpMethods::GET, 'http://web.com/'); - - $router = $this->router() - ->get('/{id?}', function ($id) { - return $id ?: 'Default'; - }) - ->dispatch(); - - $this->assertEquals('Default', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_with_a_static_domain() - { - $this->mockRequest(HttpMethods::GET, 'http://server.domain.ext/'); - - $router = $this->router() - ->get('/', $this->OkController(), [], 'server.domain.ext') - ->dispatch(); - - $this->assertEquals('OK', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_with_a_domain_pattern() - { - $this->mockRequest(HttpMethods::GET, 'http://something.domain.ext/'); - - $router = $this->router() - ->get('/', $this->OkController(), [], '(.*).domain.ext') - ->dispatch(); - - $this->assertEquals('OK', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_with_defined_parameters() - { - $this->mockRequest(HttpMethods::GET, 'http://example.com/666'); - - $router = $this->router() - ->define('id', '[0-9]+') - ->get('/{id}', $this->OkController()) - ->dispatch(); - - $this->assertEquals('OK', $this->output($router)); - - $this->mockRequest(HttpMethods::GET, 'http://example.com/abc'); - - $this->expectException(RouteNotFoundException::class); - - $this->router() - ->define('id', '[0-9]+') - ->get('/{id}', $this->OkController()) - ->dispatch(); - } - - /** - * @throws Throwable - */ - public function test_injection_of_request_by_name() - { - $router = $this->router() - ->get('/', function ($request) { - /** @var ServerRequest $request */ - return $request->getMethod(); - }) - ->dispatch(); - - $this->assertEquals('GET', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_injection_of_request_by_interface() - { - $router = $this->router() - ->get('/', function (ServerRequestInterface $r) { - return $r->getMethod(); - }) - ->dispatch(); - - $this->assertEquals('GET', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_injection_of_request_by_type() - { - $router = $this->router() - ->get('/', function (ServerRequest $r) { - return $r->getMethod(); - }) - ->dispatch(); - - $this->assertEquals('GET', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_injection_of_router_by_name() - { - $router = $this->router() - ->name('home') - ->get('/', function ($router) { - /** @var Router $router */ - return $router->currentRoute()->getName(); - }) - ->dispatch(); - - $this->assertEquals('home', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_injection_of_router_by_type() - { - $router = $this->router() - ->name('home') - ->get('/', function (Router $r) { - return $r->currentRoute()->getName(); - }) - ->dispatch(); - - $this->assertEquals('home', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_injection_of_default_value() - { - $router = $this->router() - ->get('/', function ($default = "Default") { - return $default; - }) - ->dispatch(); - - $this->assertEquals('Default', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_set_and_get_request() - { - $router = $this->router(); - - $router->get('/', function () use ($router) { - $newRequest = $router->getRequest()->withMethod('CUSTOM'); - $router->setRequest($newRequest); - - return $router->getRequest()->getMethod(); - })->dispatch(); - - $this->assertEquals('CUSTOM', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_default_publisher() - { - ob_start(); - - $router = new Router(); - - $router->get('/', function () { - return 'home'; - })->dispatch(); - - $this->assertEquals('home', ob_get_contents()); - - ob_end_clean(); - } - - /** - * @throws Throwable - */ - public function test_with_fully_namespaced_controller() - { - $c = 'MiladRahimi\PhpRouter\Tests\Testing\SampleController@home'; - - $router = $this->router() - ->get('/', $c) - ->dispatch(); - - $this->assertEquals('Home', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_with_preserved_namespaced_controller() - { - $namespace = 'MiladRahimi\PhpRouter\Tests\Testing'; - - $router = $this->router('', $namespace) - ->get('/', 'SampleController@home') - ->dispatch(); - - $this->assertEquals('Home', $this->output($router)); - } - - /** - * @throws Throwable - */ - public function test_not_found_error() - { - $this->mockRequest(HttpMethods::GET, 'http://example.com/unknowon'); - - $this->expectException(RouteNotFoundException::class); - - $this->router()->get('/', $this->OkController())->dispatch(); - } - - /** - * @throws Throwable - */ - public function test_with_class_method_but_invalid_controller_class() - { - $this->expectException(InvalidControllerException::class); - - $this->router()->get('/', 'UnknownController@method')->dispatch(); - } - - /** - * @throws Throwable - */ - public function test_with_invalid_controller_class() - { - $this->expectException(InvalidControllerException::class); - - $this->router()->get('/', 666)->dispatch(); - } - - /** - * @throws Throwable - */ - public function test_with_invalid_controller_method() - { - $this->expectException(InvalidControllerException::class); - - $namespace = 'MiladRahimi\PhpRouter\Tests\Testing'; - $this->router('', $namespace) - ->get('/', 'SampleController@invalidMethod') - ->dispatch(); - } -} diff --git a/tests/Services/HttpPublisherTest.php b/tests/Services/HttpPublisherTest.php new file mode 100644 index 0000000..708f34b --- /dev/null +++ b/tests/Services/HttpPublisherTest.php @@ -0,0 +1,76 @@ +get('/', function () { + return 'Hello!'; + }); + $router->dispatch(); + + $this->assertEquals('Hello!', ob_get_clean()); + } + + /** + * @throws Throwable + */ + public function test_publish_a_empty_response() + { + ob_start(); + + $router = Router::create(); + $router->get('/', function () { + // + }); + $router->dispatch(); + + $this->assertEmpty(ob_get_clean()); + } + + /** + * @throws Throwable + */ + public function test_publish_a_array_response() + { + ob_start(); + + $router = Router::create(); + $router->get('/', function () { + return ['a', 'b', 'c']; + }); + $router->dispatch(); + + $this->assertEquals('["a","b","c"]', ob_get_clean()); + } + + /** + * @throws Throwable + */ + public function test_publish_a_standard_response() + { + ob_start(); + + $router = Router::create(); + $router->get('/', function () { + return new JsonResponse(['error' => 'failed'], 400); + }); + + $router->dispatch(); + + $this->assertEquals('{"error":"failed"}', ob_get_clean()); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 72a514f..913aeda 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,11 +2,10 @@ namespace MiladRahimi\PhpRouter\Tests; -use Closure; -use MiladRahimi\PhpRouter\Enums\HttpMethods; +use MiladRahimi\PhpContainer\Exceptions\ContainerException; use MiladRahimi\PhpRouter\Router; use MiladRahimi\PhpRouter\Services\Publisher; -use MiladRahimi\PhpRouter\Tests\Testing\TestPublisher; +use MiladRahimi\PhpRouter\Tests\Common\TrapPublisher; use PHPUnit\Framework\TestCase as BaseTestCase; class TestCase extends BaseTestCase @@ -14,11 +13,11 @@ class TestCase extends BaseTestCase /** * @inheritDoc */ - protected function setUp() + protected function setUp(): void { parent::setUp(); - $this->mockRequest(HttpMethods::GET, 'http://example.com/'); + $this->mockRequest('GET', 'http://example.com/'); } /** @@ -39,30 +38,17 @@ protected function mockRequest(string $method, string $url): void /** * Get a router instance for testing purposes * - * @param string $prefix - * @param string $namespace * @return Router + * @throws ContainerException */ - protected function router(string $prefix = '', string $namespace = ''): Router + protected function router(): Router { - $router = new Router($prefix, $namespace); - $router->setPublisher(new TestPublisher()); + $router = Router::create(); + $router->setPublisher(new TrapPublisher()); return $router; } - /** - * Get a sample controller that returns an 'OK' string - * - * @return Closure - */ - protected function OkController(): Closure - { - return function () { - return 'OK'; - }; - } - /** * Get the generated output of the dispatched route of the given router * @@ -71,16 +57,16 @@ protected function OkController(): Closure */ protected function output(Router $router) { - return $router->getPublisher()->output; + return $this->publisher($router)->output; } /** * Get the given router publisher. * * @param Router $router - * @return TestPublisher|Publisher + * @return TrapPublisher|Publisher */ - protected function publisher(Router $router): TestPublisher + protected function publisher(Router $router): TrapPublisher { return $router->getPublisher(); } diff --git a/tests/Testing/SampleController.php b/tests/Testing/SampleController.php deleted file mode 100644 index 0e18ea8..0000000 --- a/tests/Testing/SampleController.php +++ /dev/null @@ -1,14 +0,0 @@ -router() - ->name('home') - ->get('/', function (Router $r) { - return $r->url('home'); - }) - ->dispatch(); + $router = $this->router(); + $router->get('/', function (Url $url) { + return $url->make('home'); + }, 'home'); + $router->dispatch(); $this->assertEquals('/', $this->output($router)); } @@ -29,16 +27,16 @@ public function test_generating_url_for_the_homepage() */ public function test_generating_url_for_a_page() { - $this->mockRequest(HttpMethods::GET, 'http://web.com/page'); + $this->mockRequest('GET', 'http://web.com/page'); - $router = $this->router() - ->name('home')->get('/', function (Router $r) { - return $r->url('home'); - }) - ->name('page')->get('/page', function (Router $r) { - return $r->url('page'); - }) - ->dispatch(); + $router = $this->router(); + $router->get('/', function (Url $url) { + return $url->make('home'); + }, 'home'); + $router->get('/page', function (Url $url) { + return $url->make('page'); + }, 'page'); + $router->dispatch(); $this->assertEquals('/page', $this->output($router)); } @@ -48,14 +46,16 @@ public function test_generating_url_for_a_page() */ public function test_generating_url_for_a_page_with_required_parameter() { - $this->mockRequest(HttpMethods::GET, 'http://web.com/contact'); + $this->mockRequest('GET', 'http://web.com/'); - $router = $this->router() - ->name('page') - ->get('/{name}', function (Router $r) { - return $r->url('page', ['name' => 'about']); - }) - ->dispatch(); + $router = $this->router(); + $router->get('/', function (Url $url) { + return $url->make('page', ['name' => 'about']); + }); + $router->get('/{name}', function () { + return 'empty'; + }, 'page'); + $router->dispatch(); $this->assertEquals('/about', $this->output($router)); } @@ -65,50 +65,56 @@ public function test_generating_url_for_a_page_with_required_parameter() */ public function test_generating_url_for_a_page_with_optional_parameter() { - $this->mockRequest(HttpMethods::GET, 'http://web.com/contact'); - - $router = $this->router() - ->name('page') - ->get('/{name?}', function (Router $r) { - return $r->url('page', ['name' => 'about']); - }) - ->dispatch(); - - $this->assertEquals('/about', $this->output($router)); + $this->mockRequest('GET', 'http://web.com/'); + + $router = $this->router(); + $router->get('/', function (Url $url) { + return $url->make('post', ['post' => 666]); + }); + $router->get('/blog/{post?}', function () { + return 'empty'; + }, 'post'); + $router->dispatch(); + + $this->assertEquals('/blog/666', $this->output($router)); } /** * @throws Throwable */ - public function test_generating_url_for_a_page_with_optional_parameter_2() + public function test_generating_url_for_a_page_with_optional_parameter_ignored() { - $this->mockRequest(HttpMethods::GET, 'http://web.com/contact'); - - $router = $this->router() - ->name('page') - ->get('/{name?}', function (Router $r) { - return $r->url('page'); - }) - ->dispatch(); - - $this->assertEquals('/', $this->output($router)); + $this->mockRequest('GET', 'http://web.com/'); + + $router = $this->router(); + $router->get('/', function (Url $url) { + return $url->make('page'); + }); + $router->get('/profile/{name?}', function () { + return 'empty'; + }, 'page'); + $router->dispatch(); + + $this->assertEquals('/profile/', $this->output($router)); } /** * @throws Throwable */ - public function test_generating_url_for_a_page_with_optional_parameter_3() + public function test_generating_url_for_a_page_with_optional_parameter_and_slash_ignored() { - $this->mockRequest(HttpMethods::GET, 'http://web.com/page/contact'); - - $router = $this->router() - ->name('page') - ->get('/page/?{name?}', function (Router $r) { - return $r->url('page'); - }) - ->dispatch(); - - $this->assertEquals('/page', $this->output($router)); + $this->mockRequest('GET', 'http://web.com/'); + + $router = $this->router(); + $router->get('/', function (Url $url) { + return $url->make('page'); + }); + $router->get('/profile/?{name?}', function () { + return 'empty'; + }, 'page'); + $router->dispatch(); + + $this->assertEquals('/profile', $this->output($router)); } /** @@ -117,12 +123,12 @@ public function test_generating_url_for_a_page_with_optional_parameter_3() public function test_generating_url_for_undefined_route() { $this->expectException(UndefinedRouteException::class); - $this->expectExceptionMessage("There is no route with name `home`."); + $this->expectExceptionMessage("There is no route named `page`."); - $this->router() - ->get('/', function (Router $r) { - return $r->url('home'); - }) - ->dispatch(); + $router = $this->router(); + $router->get('/', function (Url $r) { + return $r->make('page'); + }); + $router->dispatch(); } }