From 16c9bcce8740b71578de52eeadc95bedd1468cd1 Mon Sep 17 00:00:00 2001 From: Ingenioz IT Date: Fri, 12 Apr 2024 23:23:43 +0200 Subject: [PATCH] major refactoring of tests and code + remove some features + add better features (#1) * major refactoring of tests and code + remove some features + add better features * fix code quality * Router::pathTo handles custom parameters * allow pathTo to append extra parameters as the path query * improve tests structure and readability * almost completed the documentation * Indicate data providers as annotations * Improve exceptions * improve project structure * continue updating the README * Allow conditions to return true instead of an array * Update documentation * Add documentation about the exceptions --- README.md | 460 +++++++++++++----- composer.json | 6 +- quality/phpmd.xml.dist | 4 +- quality/rector.php | 7 + src/Condition/ConditionException.php | 11 + src/Condition/ConditionHandler.php | 48 ++ .../Exception/InvalidConditionHandler.php | 18 + .../Exception/InvalidConditionResponse.php | 20 + src/EmptyRouteStack.php | 6 +- src/InvalidRoute.php | 11 - .../Exception/InvalidMiddlewareHandler.php | 20 + .../Exception/InvalidMiddlewareResponse.php | 18 + src/Middleware/MiddlewareException.php | 11 + src/Middleware/MiddlewareHandler.php | 52 ++ src/Route.php | 126 +---- src/Route/Exception/InvalidRouteHandler.php | 22 + src/Route/Exception/InvalidRouteParameter.php | 18 + src/Route/Exception/InvalidRouteResponse.php | 18 + .../Exception/MissingRouteParameters.php | 23 + src/Route/Exception/RouteNotFound.php | 16 + src/Route/RouteElement.php | 174 +++++++ src/Route/RouteException.php | 11 + src/Route/RouteHandler.php | 63 +++ src/RouteGroup.php | 52 +- src/Router.php | 147 +++--- src/RouterException.php | 9 + tests/AdditionalAttributesTest.php | 90 ++++ tests/CallbackTest.php | 99 ++++ .../{ConditionTest.php => ConditionsTest.php} | 81 +-- tests/HttpMethodTest.php | 99 ++++ tests/MiddlewareTest.php | 118 ----- tests/MiddlewaresTest.php | 122 +++++ tests/NameTest.php | 98 ++++ tests/RouteHttpMethodTest.php | 101 ---- tests/RouteTest.php | 92 ---- tests/RouterRouteTest.php | 197 -------- tests/RoutingTest.php | 203 ++++++++ tests/SubGroupTest.php | 59 --- tests/{ => Utils}/PsrTrait.php | 35 +- tests/Utils/RouterCase.php | 18 + tests/{Fakes => Utils}/TestHandler.php | 2 +- tests/{Fakes => Utils}/TestMiddleware.php | 2 +- 42 files changed, 1822 insertions(+), 965 deletions(-) create mode 100644 src/Condition/ConditionException.php create mode 100644 src/Condition/ConditionHandler.php create mode 100644 src/Condition/Exception/InvalidConditionHandler.php create mode 100644 src/Condition/Exception/InvalidConditionResponse.php delete mode 100644 src/InvalidRoute.php create mode 100644 src/Middleware/Exception/InvalidMiddlewareHandler.php create mode 100644 src/Middleware/Exception/InvalidMiddlewareResponse.php create mode 100644 src/Middleware/MiddlewareException.php create mode 100644 src/Middleware/MiddlewareHandler.php create mode 100644 src/Route/Exception/InvalidRouteHandler.php create mode 100644 src/Route/Exception/InvalidRouteParameter.php create mode 100644 src/Route/Exception/InvalidRouteResponse.php create mode 100644 src/Route/Exception/MissingRouteParameters.php create mode 100644 src/Route/Exception/RouteNotFound.php create mode 100644 src/Route/RouteElement.php create mode 100644 src/Route/RouteException.php create mode 100644 src/Route/RouteHandler.php create mode 100644 src/RouterException.php create mode 100644 tests/AdditionalAttributesTest.php create mode 100644 tests/CallbackTest.php rename tests/{ConditionTest.php => ConditionsTest.php} (56%) create mode 100644 tests/HttpMethodTest.php delete mode 100644 tests/MiddlewareTest.php create mode 100644 tests/MiddlewaresTest.php create mode 100644 tests/NameTest.php delete mode 100644 tests/RouteHttpMethodTest.php delete mode 100644 tests/RouteTest.php delete mode 100644 tests/RouterRouteTest.php create mode 100644 tests/RoutingTest.php delete mode 100644 tests/SubGroupTest.php rename tests/{ => Utils}/PsrTrait.php (62%) create mode 100644 tests/Utils/RouterCase.php rename tests/{Fakes => Utils}/TestHandler.php (94%) rename tests/{Fakes => Utils}/TestMiddleware.php (95%) diff --git a/README.md b/README.md index 3e805a6..905cad2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,21 @@ # Router -A PHP router. +A PHP Router. + +## Disclaimer + +In order to ensure that this package is easy to integrate into your app, it is built around the **PHP Standard +Recommendations** : it takes in +a [PSR-7 Server Request](https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterface) and returns +a [PSR-7 Response](https://www.php-fig.org/psr/psr-7/#33-psrhttpmessageresponseinterface). It also uses +a [PSR-11 Container](https://www.php-fig.org/psr/psr-11/) (such +as [EDICT](https://github.com/IngeniozIT/psr-container-edict)) to resolve the route handlers. + +It is inspired by routers from well-known frameworks *(did anyone say Laravel ?)* aswell as some home-made routers used +internally by some major companies. + +It is build with quality in mind : readability, immutability, no global states, 100% code coverage, 100% mutation +testing score, and validation from various static analysis tools at the highest level. ## About @@ -22,227 +37,408 @@ composer require ingenioz-it/router ## Documentation -### Configuring the Router +### Overview -#### Overview +Here is the whole process of using this router : -Here is a quick reference sample of how to configure the routes: +- Create your routes +- Instantiate the router +- Handle the request: ```php -$routes = new RouteGroup( - routes: [ - Route::get(path: '/hello', callback: () => 'Hello, world!', name: 'hello'), - // Users - Route::get(path: '/users', callback: ListUsersHandler::class, name: 'users.list'), - Route::post(path: '/users/{id:[0-9]+}', callback: new CreateUserHandler(), name: 'user.create'), - // Admin - new RouteGroup( - routes: [ - Route::get(path: '/admin', callback: PreviewAllPostsHandler::class, name: 'admin.index'), - Route::get(path: '/admin/logout', callback: AdminLogoutHandler::class, name: 'admin.logout'), - ], - conditions: ['IsAdmin'], - ), - // Web - Route::get(path: '{page}', callback: PageHandler::class, name: 'page'), - Route::get(path: '{page}', callback: PageNotFoundHandler::class, name: 'page.not_found'), - ], - middlewares: [ - ExceptionHandler::class, - RedirectionHandler::class, - ], - patterns: ['page' => '.*'], -); +use IngeniozIT\Router\RouteGroup; +use IngeniozIT\Router\Route; +use IngeniozIT\Router\Router; + +// Create your routes + +$routes = new RouteGroup([ + Route::get('/hello', fn() => new Response('Hello, world!')), + Route::get('/bye', fn() => new Response('Goodbye, world!')), +]); +// Instantiate the router + +/** @var Psr\Container\ContainerInterface $container */ +$container = new Container(); $router = new Router($routes, $container); +// Handle the request + +/** @var Psr\Http\Message\ServerRequestInterface $request */ +$request = new ServerRequest(); +/** @var Psr\Http\Message\ResponseInterface $response */ $response = $router->handle($request); ``` -#### Path +### Basic routing -The path can contain parameters, which are enclosed in curly braces: +The simplest route consists of a path and a handler. + +The path is a string, and the handler is a callable that will be executed when the route is matched. The handler must +return a PSR-7 ResponseInterface. ```php -new RouteGroup([ - Route::get(path: '/users/{id}', callback: /* handler */), -]); +Route::get('/hello', fn() => new Response('Hello, world!')); ``` -By default, the parameters match any character except `/`. +### Organizing routes + +Route groups are used to contain routes definitions. +They also allows you to visually organize your routes according to your application's logic. -To match a parameter with a different pattern, use a regular expression: +This is useful when you want to apply the same conditions, middlewares, or attributes to several routes at once (as we +will see later). ```php new RouteGroup([ - Route::get(path: '/users/{id:[0-9]+}', callback: /* handler */), + Route::get('/hello', fn() => new Response('Hello, world!')), + Route::get('/bye', fn() => new Response('Goodbye, world!')), ]); ``` -Alternatively, you can define the pattern in the `patterns` parameter: +Route groups can be nested to create a hierarchy of routes that will inherit everything from their parent groups. ```php new RouteGroup([ - Route::get(path: '/users/{id}', callback: /* handler */, patterns: ['id' => '[0-9]+']), + Route::get('/', fn() => new Response('Welcome !')), + new RouteGroup([ + Route::get('/hello', fn() => new Response('Hello, world!')), + Route::get('/hello-again', fn() => new Response('Hello again, world!')), + ]), + Route::get('/bye', fn() => new Response('Goodbye, world!')), ]); ``` -If you have a parameter that is used in multiple routes, you can define it inside the `RouteGroup`. It will be used in all the routes of the group: +### HTTP methods + +You can specify the HTTP method that the route should match: + +```php +Route::get('/hello', MyHandler::class); +Route::post('/hello', MyHandler::class); +Route::put('/hello', MyHandler::class); +Route::patch('/hello', MyHandler::class); +Route::delete('/hello', MyHandler::class); +Route::options('/hello', MyHandler::class); +``` + +If you want a route to match multiple HTTP methods, you can use the `some` method: + +```php +Route::some(['GET', 'POST'], '/hello', MyHandler::class); +``` + +You can also use the `any` method to match all HTTP methods: + +```php +Route::any('/hello', MyHandler::class); +``` + +### Path parameters + +#### Basic usage + +You can define route parameters by using the `{}` syntax in the route path. + +```php +Route::get('/hello/{name}', MyHandler::class); +``` + +The matched parameters will be available in the request attributes. ```php -new RouteGroup( - routes: [ - Route::get(path: '/users/{id}/posts/{postId}', callback: /* handler */), - Route::get(path: '/users/{id}/comments/{commentId}', callback: /* handler */), +class MyHandler implements RequestHandlerInterface +{ + public function handle(ServerRequestInterface $request): ResponseInterface + { + $name = $request->getAttribute('name'); + return new Response("Hello, $name!"); + } +} + +Route::get('/hello/{name}', MyHandler::class); +``` + +#### Custom parameter patterns + +By default, the parameters are matched by the `[^/]+` regex (any characters that are not a `/`). + +You can specify a custom pattern by using the `where` parameter: + +```php +// This route will only match if the name contains only letters +Route::get('/hello/{name}', MyHandler::class, where: ['name' => '[a-zA-Z]+']); +``` + +#### Custom parameter patterns in a group + +Parameters patterns can also be defined globally for all routes inside a group: + +```php +$routes = new RouteGroup( + [ + Route::get('/hello/{name}', MyHandler::class), + Route::get('/bye/{name}', MyOtherHandler::class), ], - patterns: ['id' => '[0-9]+'], + where: ['name' => '[a-zA-Z]+'], ); ``` -#### HTTP Method +### Route handlers -The `Route` class provides static methods to create routes to match each HTTP method: +#### Closures + +The simplest way to define a route handler is to use a closure. +The closure must return a PSR-7 ResponseInterface. ```php -new RouteGroup([ - Route::get(/* ... */), - Route::post(/* ... */), - Route::put(/* ... */), - Route::patch(/* ... */), - Route::delete(/* ... */), - Route::head(/* ... */), - Route::options(/* ... */), - Route::any(/* ... */), // mathes all HTTP methods - Route::some(['GET', 'POST'], /* ... */), // matches only GET and POST -]); +Route::get('/hello', fn() => new Response('Hello, world!')); ``` -#### Handlers +Closures can take in parameters: the request and a request handler (the router itself). -The handler can be a callable, a PSR-15 `RequestHandlerInterface`, a PSR-15 `MiddlewareInterface`, or a string. - ```php -new RouteGroup([ - Route::get(path: '/baz', callback: () => 'Hello, world!'), - Route::get(path: '/bar', callback: new Handler()), - Route::get(path: '/foo', callback: Handler::class), -]); +Route::get('/hello', function (ServerRequestInterface $request) { + return new Response('Hello, world!'); +}); + +Route::get('/hello', function (ServerRequestInterface $request, RequestHandlerInterface $router) { + return new Response('Hello, world!'); +}); ``` -If the handler is a string, the container will be used to resolve it. +#### RequestHandlerInterface -If the handler is a middleware, calling the next handler will continue the routing: +A route handler can be a callable, but it can also be a PSR RequestHandlerInterface. ```php -new RouteGroup([ - Route::get(path: '/', callback: ($request, $handler) => $handler->handle($request)), // Will delegate to the next route - Route::get(path: '/', callback: () => 'Hello, world!'), -]); +use Psr\Http\Server\RequestHandlerInterface; +use Psr\Http\Server\ServerRequestInterface; +use Psr\Http\Server\ResponseInterface; + +class MyHandler implements RequestHandlerInterface +{ + public function handle(ServerRequestInterface $request): ResponseInterface + { + return new Response('Hello, world!'); + } +} + +Route::get('/hello', new MyHandler()); ``` +#### MiddlewareInterface -#### Name +Sometimes, you might want a handler to be able to "refuse" to handle the request, and pass it to the next handler in the +chain. -You can name a route: +This is done by using a PSR MiddlewareInterface as a route handler : ```php -new RouteGroup([ - Route::get(path: '/', callback: /* handler */, name: 'home'), - Route::get(path: '/users', callback: /* handler */, name: 'users'), +use Psr\Http\Server\MiddlewareInterface; + +class MyHandler implements MiddlewareInterface +{ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if (resourceDoesNotExist()) { + // We don't want this handler to continue processing the request, + // so we pass the responsability to the next handler + return $handler->handle($request); + } + + /* ... */ + } +} + +$routes = new RouteGroup([ + // This handler will be called first + Route::get('/{ressource}', fn() => new MyHandler()), + // This handler will be called next + Route::get('/{ressource}', fn() => new Response('Hello, world!')), ]); ``` -#### Middlewares +#### Dependency injection -You can add middlewares to a route group: +Instead of using a closure or a class instance, your handler can be a class name. The router will then resolve the class +using the PSR container you injected into the router. ```php -new RouteGroup( - route: [ - Route::get(path: '/', callback: /* handler */), - ], - middlewares: [ - new MyMiddleware(), - MyMiddleware::class, - ($request, $handler) => $handler->handle($request), - ], -); +Route::get('/hello', MyHandler::class); ``` -A middleware can be a PSR-15 `MiddlewareInterface`, a string, or a callable. +*The router will resolve this handler by calling `get(MyHandler::class)` on the container. This means that you can use +any value that the container can resolve into a valid route handler.* -If the middleware is a string, the container will be used to resolve it. +### Additional attributes -If the middleware is a callable, it will be called with the request and the next handler as arguments. +You can add additional attributes to a route by using the `with` method. +Just like path parameters, these attributes will be available in the request attributes. -#### Subgroups +```php +class MyHandler implements RequestHandlerInterface +{ + public function handle(ServerRequestInterface $request): ResponseInterface + { + $name = $request->getAttribute('name'); + return new Response("Hello, $name!"); + } +} + +// Notice there is no name parameter in the route path +Route::get('/hello', MyHandler::class, with: ['name' => 'world']); +``` -You can nest route groups: +Attributes can also be defined globally for all the routes inside a group: ```php -new RouteGroup( - routes: [ - Route::get(path: '/foo', callback: /* handler */), - new RouteGroup( - routes: [ - Route::get(path: '/bar', callback: /* handler */), - Route::get(path: '/baz', callback: /* handler */), - ], - ), +$routes = new RouteGroup( + [ + Route::get('/hello', MyHandler::class), + Route::get('/bye', MyOtherHandler::class), ], + with: ['name' => 'world'], ); ``` -#### Conditions +### Middlewares -You can add conditions to a route group. The conditions are checked before the route group is parsed. +Middlewares are classes that can modify the request and/or the response before and after the route handler is called. -Conditions take the request as argument. They can either return `false` if the request does not match the conditions, or an array of parameters to inject into the request. +They can be applied to a route group. ```php -new RouteGroup( - routes: [ - new RouteGroup( - conditions: [ - // The request must have the header 'X-Is-Admin' - fn ($request) => $request->hasHeader('X-Is-Admin') ? ['IsAdmin' => true] : false, - ], - routes: [ - Route::get(path: '/admin-stuff', callback: /* handler */), - ], - ), - Route::get(path: '/foo', callback: /* handler */), +$routes = new RouteGroup( + [ + Route::get('/hello', MyHandler::class), + ], + middlewares: [ + MyMiddleware::class, + MyOtherMiddleware::class, ], ); ``` -If the request does not match the condition, the route group will be skipped. +The middleware class must implement the PSR `\Psr\Http\Server\MiddlewareInterface` interface. -If a condition is a string, the container will be used to resolve it. +### Conditions -### Using the Router +Conditions are callables that will determine if a route group should be parsed. -#### Creating the router +```php +// This one will be parsed +$routes = new RouteGroup( + [ + Route::get('/hello', MyHandler::class), + ], + conditions: [ + fn(ServerRequestInterface $request) => true, + ], +); -The `Router` uses a `RouteGroup` to store the routes and a PSR-11 `ContainerInterface` to inject dependencies into the route handlers. +// This one will NOT be parsed +$routes = new RouteGroup( + [ + Route::get('/hello', MyHandler::class), + ], + conditions: [ + fn(ServerRequestInterface $request) => false, + ], +); +``` + +Additionally, conditions can return an array of attributes that will be added to the request attributes. ```php -use IngeniozIT\Router\Router; -use IngeniozIT\Router\RouteGroup; +class MyHandler implements RequestHandlerInterface +{ + public function handle(ServerRequestInterface $request): ResponseInterface + { + $name = $request->getAttribute('name'); + return new Response("Hello, $name!"); + } +} + +$routes = new RouteGroup( + [ + Route::get('/hello', MyHandler::class), + ], + conditions: [ + // This condition will add the 'name' attribute to the request + fn(ServerRequestInterface $request) => ['name' => 'world'], + ], +); +``` + +If a condition returns an array, it is assumed that the route group should be parsed. + +If any condition returns `false`, the route group will not be parsed: + +```php +// This one will NOT be parsed +$routes = new RouteGroup( + [ + Route::get('/hello', MyHandler::class), + ], + conditions: [ + fn(ServerRequestInterface $request) => true, + fn(ServerRequestInterface $request) => false, + ], +); +``` + +### Naming routes -$container = /* PSR ContainerInterface */; -$routeGroup = new RouteGroup([/* routes */]); +Routes can be named. -$router = new Router($routeGroup, $container); +```php +Route::get('/hello', MyHandler::class, name: 'hello_route'); ``` -#### Routing a request +Using the router, you can then generate the path to a named route: -The `Router` uses a PSR-7 `ServerRequestInterface` to route the request. +```php +$router->pathTo('hello_route'); // Will return '/hello' +``` -It returns a PSR-7 `ResponseInterface`. +If a route has parameters, you can pass them as the second argument: ```php -$request = /* PSR ServerRequestInterface */; -$response = $router->handle($request); +Route::get('/hello/{name}', MyHandler::class, name: 'hello_route'); + +$router->pathTo('hello_route', ['name' => 'world']); // Will return '/hello/world' ``` + +### Error handling + +This router uses custom exceptions to handle errors. + +Here is the inheritance tree of those exceptions: + +- `IngeniozIT\Router\RouterException` (interface): the base exception, all other exceptions inherit from this one + - `IngeniozIT\Router\EmptyRouteStack`: thrown when no route has been matched by the router + - `IngeniozIT\Router\Route\RouteException`: (interface) the base exception for route errors + - `IngeniozIT\Router\Route\Exception\InvalidRouteHandler`: thrown when the route handler is not a valid request + handler + - `IngeniozIT\Router\Route\Exception\InvalidRouteResponse`: thrown when the route handler does not return a + PSR-7 + ResponseInterface + - `IngeniozIT\Router\Route\Exception\RouteNotFound`: thrown when calling `$router->pathTo` with a route name + that does not + exist + - `IngeniozIT\Router\Route\Exception\InvalidRouteParameter`: thrown when calling `$router->pathTo` with invalid + parameters + - `IngeniozIT\Router\Route\Exception\MissingRouteParameters`: thrown when calling `$router->pathTo` with missing + parameters + - `IngeniozIT\Router\Middleware\MiddlewareException`: (interface) the base exception for middleware errors + - `IngeniozIT\Router\Middleware\Exception\InvalidMiddlewareHandler`: thrown when a middleware is not a valid + middleware handler + - `IngeniozIT\Router\Middleware\Exception\InvalidMiddlewareResponse`: thrown when a middleware does not return a + PSR-7 ResponseInterface + - `IngeniozIT\Router\Condition\ConditionException`: (interface) the base exception for condition errors + - `IngeniozIT\Router\Condition\Exception\InvalidConditionHandler`: thrown when a condition is not a valid + condition handler + - `IngeniozIT\Router\Condition\Exception\InvalidConditionResponse`: thrown when a condition does not return a + valid response \ No newline at end of file diff --git a/composer.json b/composer.json index d8f9918..7d91f4f 100644 --- a/composer.json +++ b/composer.json @@ -25,8 +25,8 @@ "infection/infection": "*", "phpmd/phpmd": "*", "rector/rector": "*", - "ingenioz-it/http-message": "^2.0", - "ingenioz-it/edict": "^3.1" + "ingenioz-it/http-message": "*", + "ingenioz-it/edict": "*" }, "autoload": { "psr-4": { @@ -52,7 +52,7 @@ "quality:psalm": "vendor/bin/psalm --no-cache --config ./quality/psalm.xml.dist", "quality:phan": "vendor/bin/phan --config-file ./quality/phan.php", "quality:phan-silent": "vendor/bin/phan --no-progress-bar --config-file ./quality/phan.php", - "quality:infection": "vendor/bin/infection --configuration=./quality/infection.json.dist", + "quality:infection": "vendor/bin/infection -j$(nproc) --configuration=./quality/infection.json.dist", "quality:phpmd": "vendor/bin/phpmd src/,tests/ text quality/phpmd.xml.dist", "fulltest": [ "@test", diff --git a/quality/phpmd.xml.dist b/quality/phpmd.xml.dist index f731e70..0650738 100644 --- a/quality/phpmd.xml.dist +++ b/quality/phpmd.xml.dist @@ -10,7 +10,9 @@ All default rulesets from PHPMD. - + + + diff --git a/quality/rector.php b/quality/rector.php index 1c6e398..9631139 100644 --- a/quality/rector.php +++ b/quality/rector.php @@ -2,8 +2,10 @@ declare(strict_types=1); +use Rector\CodingStyle\Rector\Encapsed\EncapsedStringsToSprintfRector; use Rector\Config\RectorConfig; use Rector\Set\ValueObject\{LevelSetList, SetList}; +use Rector\Strict\Rector\BooleanNot\BooleanInBooleanNotRuleFixerRector; return static function (RectorConfig $rectorConfig): void { $rectorConfig->paths([ @@ -23,4 +25,9 @@ SetList::EARLY_RETURN, SetList::INSTANCEOF, ]); + + $rectorConfig->skip([ + EncapsedStringsToSprintfRector::class, + BooleanInBooleanNotRuleFixerRector::class, + ]); }; diff --git a/src/Condition/ConditionException.php b/src/Condition/ConditionException.php new file mode 100644 index 0000000..a5354df --- /dev/null +++ b/src/Condition/ConditionException.php @@ -0,0 +1,11 @@ +container->get($callback) : $callback; + + if (!is_callable($handler)) { + throw new InvalidConditionHandler($handler); + } + + $this->handler = $handler(...); + } + + /** + * @return array|false + */ + public function handle(ServerRequestInterface $request): array|false + { + $result = ($this->handler)($request); + + if (!is_bool($result) && !is_array($result)) { + throw new InvalidConditionResponse($result); + } + + return $result === true ? [] : $result; + } +} diff --git a/src/Condition/Exception/InvalidConditionHandler.php b/src/Condition/Exception/InvalidConditionHandler.php new file mode 100644 index 0000000..52dc695 --- /dev/null +++ b/src/Condition/Exception/InvalidConditionHandler.php @@ -0,0 +1,18 @@ +container->get($callback) : $callback; + + if (is_callable($handler)) { + $this->handler = $handler(...); + return; + } + + if ($handler instanceof MiddlewareInterface) { + $this->handler = $handler->process(...); + return; + } + + throw new InvalidMiddlewareHandler($handler); + } + + public function handle(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $result = ($this->handler)($request, $handler); + + if (!$result instanceof ResponseInterface) { + throw new InvalidMiddlewareResponse($result); + } + + return $result; + } +} diff --git a/src/Route.php b/src/Route.php index e74b841..4ef3293 100644 --- a/src/Route.php +++ b/src/Route.php @@ -4,7 +4,10 @@ namespace IngeniozIT\Router; -use Psr\Http\Message\ServerRequestInterface; +use IngeniozIT\Router\Route\RouteElement; + +use function array_reduce; +use function strtoupper; final readonly class Route { @@ -24,7 +27,7 @@ public const ANY = 0b1111111; - private const METHODS = [ + public const METHODS = [ 'GET' => self::GET, 'POST' => self::POST, 'PUT' => self::PUT, @@ -38,72 +41,72 @@ * @param array $where * @param array $with */ - public static function get(string $path, mixed $callback, ?string $name = null, array $where = [], array $with = []): self + public static function get(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): RouteElement { - return new self(self::GET, $path, $callback, $name, $where, $with); + return new RouteElement(self::GET, $path, $callback, $where, $with, $name); } /** * @param array $where * @param array $with */ - public static function post(string $path, mixed $callback, ?string $name = null, array $where = [], array $with = []): self + public static function post(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): RouteElement { - return new self(self::POST, $path, $callback, $name, $where, $with); + return new RouteElement(self::POST, $path, $callback, $where, $with, $name); } /** * @param array $where * @param array $with */ - public static function put(string $path, mixed $callback, ?string $name = null, array $where = [], array $with = []): self + public static function put(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): RouteElement { - return new self(self::PUT, $path, $callback, $name, $where, $with); + return new RouteElement(self::PUT, $path, $callback, $where, $with, $name); } /** * @param array $where * @param array $with */ - public static function patch(string $path, mixed $callback, ?string $name = null, array $where = [], array $with = []): self + public static function patch(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): RouteElement { - return new self(self::PATCH, $path, $callback, $name, $where, $with); + return new RouteElement(self::PATCH, $path, $callback, $where, $with, $name); } /** * @param array $where * @param array $with */ - public static function delete(string $path, mixed $callback, ?string $name = null, array $where = [], array $with = []): self + public static function delete(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): RouteElement { - return new self(self::DELETE, $path, $callback, $name, $where, $with); + return new RouteElement(self::DELETE, $path, $callback, $where, $with, $name); } /** * @param array $where * @param array $with */ - public static function head(string $path, mixed $callback, ?string $name = null, array $where = [], array $with = []): self + public static function head(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): RouteElement { - return new self(self::HEAD, $path, $callback, $name, $where, $with); + return new RouteElement(self::HEAD, $path, $callback, $where, $with, $name); } /** * @param array $where * @param array $with */ - public static function options(string $path, mixed $callback, ?string $name = null, array $where = [], array $with = []): self + public static function options(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): RouteElement { - return new self(self::OPTIONS, $path, $callback, $name, $where, $with); + return new RouteElement(self::OPTIONS, $path, $callback, $where, $with, $name); } /** * @param array $where * @param array $with */ - public static function any(string $path, mixed $callback, ?string $name = null, array $where = [], array $with = []): self + public static function any(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): RouteElement { - return new self(self::ANY, $path, $callback, $name, $where, $with); + return new RouteElement(self::ANY, $path, $callback, $where, $with, $name); } /** @@ -111,91 +114,10 @@ public static function any(string $path, mixed $callback, ?string $name = null, * @param array $where * @param array $with */ - public static function some(array $methods, string $path, mixed $callback, ?string $name = null, array $where = [], array $with = []): self + public static function some(array $methods, string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): RouteElement { - $method = 0; - foreach ($methods as $methodString) { - $method |= self::METHODS[strtoupper($methodString)]; - } - - return new self($method, $path, $callback, $name, $where, $with); - } - - /** - * @param array $where - * @param array $with - */ - public function __construct( - public int $method, - public string $path, - public mixed $callback, - public ?string $name = null, - public array $where = [], - public array $with = [], - ) { - } - - /** - * @param array $additionalPatterns - * @return false|array - */ - public function match(ServerRequestInterface $request, array $additionalPatterns = []): false|array - { - if (!$this->httpMethodMatches($request->getMethod())) { - return false; - } - - $path = $request->getUri()->getPath(); - $parameters = $this->extractParametersFromPath($this->path); - - if ($parameters === []) { - return $path === $this->path ? [] : false; - } - - $extractedParameters = $this->extractParametersValue($parameters, $path, $additionalPatterns); - return $extractedParameters === [] ? false : $extractedParameters; - } + $method = array_reduce($methods, static fn($carry, $methodString): int => $carry | self::METHODS[strtoupper($methodString)], 0); - private function httpMethodMatches(string $method): bool - { - return ($this->method & self::METHODS[$method]) !== 0; - } - - /** - * @return string[][] - */ - private function extractParametersFromPath(string $path): array - { - preg_match_all('/{([^:]+)(?::(.+))?}/U', $path, $matches, PREG_SET_ORDER); - return $matches; - } - - /** - * @param string[][] $parameters - * @param array $additionalPatterns - * @return array - */ - private function extractParametersValue(array $parameters, string $path, array $additionalPatterns): array - { - preg_match($this->buildRegex($parameters, $additionalPatterns), $path, $parameters); - return array_filter($parameters, 'is_string', ARRAY_FILTER_USE_KEY); - } - - /** - * @param string[][] $parameters - * @param array $additionalPatterns - */ - private function buildRegex(array $parameters, array $additionalPatterns): string - { - $regex = '#' . preg_quote($this->path, '#') . '#'; - foreach ($parameters as $parameter) { - $regex = str_replace( - preg_quote($parameter[0], '#'), - '(?<' . $parameter[1] . '>' . ($parameter[2] ?? $this->where[$parameter[1]] ?? $additionalPatterns[$parameter[1]] ?? '[^/]+') . ')', - $regex - ); - } - - return $regex; + return new RouteElement($method, $path, $callback, $where, $with, $name); } } diff --git a/src/Route/Exception/InvalidRouteHandler.php b/src/Route/Exception/InvalidRouteHandler.php new file mode 100644 index 0000000..db9e91a --- /dev/null +++ b/src/Route/Exception/InvalidRouteHandler.php @@ -0,0 +1,22 @@ + */ + public array $where; + + /** @var string[] */ + public array $parameters; + + public ?string $regex; + + /** + * @param array $where + * @param array $with + */ + public function __construct( + public int $method, + string $path, + public mixed $callback, + array $where = [], + public array $with = [], + public ?string $name = null, + ) { + $this->hasParameters = str_contains($path, '{'); + [$this->parameters, $this->where, $this->path] = $this->extractPatterns($where, $path); + $this->regex = $this->buildRegex(); + } + + /** + * @param array $where + * @return array{0: string[], 1: array, 2: string} + */ + private function extractPatterns(array $where, string $path): array + { + $parameters = []; + + if (preg_match_all('#{(\w+)(?::([^}]+))?}#', $path, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $parameters[] = $match[1]; + if (isset($match[2])) { + $path = str_replace($match[0], '{' . $match[1] . '}', $path); + $where[$match[1]] = $match[2]; + } + } + } + + return [$parameters, $where, $path]; + } + + private function buildRegex(): ?string + { + if (!$this->hasParameters) { + return null; + } + + $regex = $this->path; + foreach ($this->parameters as $parameter) { + $regex = str_replace( + '{' . $parameter . '}', + '(?<' . $parameter . '>' . $this->parameterPattern($parameter) . ')', + $regex, + ); + } + + return $regex; + } + + /** + * @return false|array + */ + public function match(ServerRequestInterface $request): false|array + { + if (!$this->httpMethodMatches($request->getMethod())) { + return false; + } + + return $this->pathMatches($request->getUri()->getPath()); + } + + private function httpMethodMatches(string $method): bool + { + return ($this->method & Route::METHODS[$method]) !== 0; + } + + /** + * @return false|array + */ + private function pathMatches(string $path): false|array + { + if (!$this->hasParameters) { + return $path === $this->path ? [] : false; + } + + if (!preg_match('#^' . $this->regex . '$#', $path, $matches)) { + return false; + } + + return array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY); + } + + /** + * @param array $parameters + */ + public function buildPath(array $parameters): string + { + if (!$this->hasParameters) { + return $this->path; + } + + $this->validatePathParameters($parameters); + + $path = $this->path; + $queryParameters = []; + foreach ($parameters as $parameter => $value) { + if (in_array($parameter, $this->parameters)) { + $path = str_replace('{' . $parameter . '}', (string)$value, $path); + continue; + } + + $queryParameters[$parameter] = $value; + } + + return $path . ($queryParameters !== [] ? '?' . http_build_query($queryParameters) : ''); + } + + /** + * @param array $parameters + */ + private function validatePathParameters(array $parameters): void + { + $pathParameters = array_intersect(array_keys($parameters), $this->parameters); + if (count($pathParameters) !== count($this->parameters)) { + $missingParameters = array_diff($this->parameters, $pathParameters); + throw new MissingRouteParameters($this->name ?? '', $missingParameters); + } + + foreach ($this->parameters as $parameter) { + if (!preg_match('#^' . $this->parameterPattern($parameter) . '$#', (string)$parameters[$parameter])) { + throw new InvalidRouteParameter($this->name ?? '', $parameter, $this->parameterPattern($parameter)); + } + } + } + + private function parameterPattern(string $parameterName): string + { + return $this->where[$parameterName] ?? '[^/]+'; + } +} diff --git a/src/Route/RouteException.php b/src/Route/RouteException.php new file mode 100644 index 0000000..a0c8ea1 --- /dev/null +++ b/src/Route/RouteException.php @@ -0,0 +1,11 @@ +container->get($callback) : $callback; + + if ( + !($handler instanceof MiddlewareInterface) + && !($handler instanceof RequestHandlerInterface) + && !is_callable($handler) + ) { + throw new InvalidRouteHandler($handler); + } + + $this->handler = is_callable($handler) ? $handler(...) : $handler; + } + + public function handle(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $result = $this->executeHandler($request, $handler); + + if (!$result instanceof ResponseInterface) { + throw new InvalidRouteResponse($result); + } + + return $result; + } + + private function executeHandler(ServerRequestInterface $request, RequestHandlerInterface $handler): mixed + { + if ($this->handler instanceof RequestHandlerInterface) { + return $this->handler->handle($request); + } + + if ($this->handler instanceof MiddlewareInterface) { + return $this->handler->process($request, $handler); + } + + return ($this->handler)($request, $handler); + } +} diff --git a/src/RouteGroup.php b/src/RouteGroup.php index 61c4888..34b15f8 100644 --- a/src/RouteGroup.php +++ b/src/RouteGroup.php @@ -4,19 +4,63 @@ namespace IngeniozIT\Router; +use IngeniozIT\Router\Route\RouteElement; + +use function array_map; + final class RouteGroup { + /** @var RouteElement[]|RouteGroup[] */ + public array $routes; + /** - * @param array $routes + * @param array $routes * @param mixed[] $middlewares * @param mixed[] $conditions - * @param array $patterns + * @param array $where + * @param array $with */ public function __construct( - public array $routes, + array $routes, public array $middlewares = [], public array $conditions = [], - public array $patterns = [], + public array $where = [], + public array $with = [], + public ?string $name = null, + public ?string $path = null, ) { + $this->routes = array_map($this->addRouteGroupInformationToRoute(...), $routes); + } + + private function addRouteGroupInformationToRoute(RouteGroup|RouteElement $route): RouteGroup|RouteElement + { + return $route instanceof RouteGroup ? + new RouteGroup( + $route->routes, + $route->middlewares, + $route->conditions, + $this->where, + $this->with, + $this->concatenatedNameForRouteGroup(), + $this->path, + ) : + new RouteElement( + $route->method, + $this->path . $route->path, + $route->callback, + [...$this->where, ...$route->where], + [...$this->with, ...$route->with], + $this->concatenatedNameForRouteElement($route->name), + ); + } + + private function concatenatedNameForRouteElement(?string $routeName): ?string + { + return $routeName === null ? null : $this->concatenatedNameForRouteGroup() . $routeName; + } + + private function concatenatedNameForRouteGroup(): ?string + { + return $this->name === null ? null : $this->name . '.'; } } diff --git a/src/Router.php b/src/Router.php index 6251d27..8880828 100644 --- a/src/Router.php +++ b/src/Router.php @@ -4,26 +4,26 @@ namespace IngeniozIT\Router; +use IngeniozIT\Router\Condition\ConditionHandler; +use IngeniozIT\Router\Middleware\MiddlewareHandler; +use IngeniozIT\Router\Route\Exception\RouteNotFound; +use IngeniozIT\Router\Route\RouteElement; +use IngeniozIT\Router\Route\RouteHandler; use Psr\Container\ContainerInterface; -use Psr\Http\Message\{ - ServerRequestInterface, - ResponseInterface, - StreamFactoryInterface, - ResponseFactoryInterface, -}; -use Psr\Http\Server\{RequestHandlerInterface, MiddlewareInterface}; +use Psr\Http\Message\{ResponseInterface, ServerRequestInterface}; +use Psr\Http\Server\RequestHandlerInterface; final class Router implements RequestHandlerInterface { private int $conditionIndex = 0; + private int $middlewareIndex = 0; + private int $routeIndex = 0; public function __construct( private readonly RouteGroup $routeGroup, private readonly ContainerInterface $container, - private readonly ResponseFactoryInterface $responseFactory, - private readonly StreamFactoryInterface $streamFactory, private readonly mixed $fallback = null, ) { } @@ -31,21 +31,23 @@ public function __construct( public function handle(ServerRequestInterface $request): ResponseInterface { if (isset($this->routeGroup->conditions[$this->conditionIndex])) { - return $this->executeConditions($request); + return $this->handleConditions($request); } if (isset($this->routeGroup->middlewares[$this->middlewareIndex])) { - return $this->executeMiddlewares($request); + return $this->handleNextMiddleware($request); } - return $this->executeRoutables($request); + return $this->handleRoutes($request); } - private function executeConditions(ServerRequestInterface $request): ResponseInterface + private function handleConditions(ServerRequestInterface $request): ResponseInterface { $newRequest = $request; while (isset($this->routeGroup->conditions[$this->conditionIndex])) { - $matchedParams = $this->executeCondition($this->routeGroup->conditions[$this->conditionIndex++], $newRequest); + $condition = new ConditionHandler($this->container, $this->routeGroup->conditions[$this->conditionIndex++]); + + $matchedParams = $condition->handle($newRequest); if ($matchedParams === false) { return $this->fallback($request); } @@ -58,43 +60,17 @@ private function executeConditions(ServerRequestInterface $request): ResponseInt return $this->handle($newRequest); } - /** - * @return array|false - */ - private function executeCondition(mixed $callback, ServerRequestInterface $request): array|false - { - $handler = $this->resolveCallback($callback); - - if (!is_callable($handler)) { - throw new InvalidRoute("Condition callback is not callable."); - } - - $result = $handler($request); - - if ($result === false || is_array($result)) { - return $result; - } - - throw new InvalidRoute('Condition handler must return an array or false.'); - } - - private function executeMiddlewares(ServerRequestInterface $request): ResponseInterface + private function handleNextMiddleware(ServerRequestInterface $request): ResponseInterface { - $middleware = $this->routeGroup->middlewares[$this->middlewareIndex++]; - $handler = $this->resolveCallback($middleware); - - if ($handler instanceof MiddlewareInterface) { - return $handler->process($request, $this); - } - - if (!is_callable($handler)) { - throw new InvalidRoute("Middleware callback is not callable."); - } + $middlewaresHandler = new MiddlewareHandler( + $this->container, + $this->routeGroup->middlewares[$this->middlewareIndex++] + ); - return $this->processResponse($handler($request, $this)); + return $middlewaresHandler->handle($request, $this); } - private function executeRoutables(ServerRequestInterface $request): ResponseInterface + private function handleRoutes(ServerRequestInterface $request): ResponseInterface { while (isset($this->routeGroup->routes[$this->routeIndex])) { $route = $this->routeGroup->routes[$this->routeIndex++]; @@ -103,75 +79,78 @@ private function executeRoutables(ServerRequestInterface $request): ResponseInte $newRouter = new Router( $route, $this->container, - $this->responseFactory, - $this->streamFactory, - $this->handle(...) + $this->handle(...), ); return $newRouter->handle($request); } - $matchedParams = $route->match($request, $this->routeGroup->patterns); + $matchedParams = $route->match($request); if ($matchedParams === false) { continue; } - $newRequest = $request; - foreach ($matchedParams as $key => $value) { - $newRequest = $newRequest->withAttribute($key, $value); - } - foreach ($route->with as $key => $value) { - $newRequest = $newRequest->withAttribute($key, $value); - } - - return $this->callRouteHandler($route->callback, $newRequest); + return $this->handleRouteElement($request, $route, $matchedParams); } return $this->fallback($request); } - private function callRouteHandler(mixed $callback, ServerRequestInterface $request): ResponseInterface - { - $handler = $this->resolveCallback($callback); - - if ($handler instanceof MiddlewareInterface) { - return $handler->process($request, $this); - } - - if ($handler instanceof RequestHandlerInterface) { - return $handler->handle($request); + /** + * @param array $matchedParams + */ + private function handleRouteElement( + ServerRequestInterface $request, + RouteElement $route, + array $matchedParams + ): ResponseInterface { + foreach ($route->with as $key => $value) { + $request = $request->withAttribute($key, $value); } - if (!is_callable($handler)) { - throw new InvalidRoute("Route callback is not callable."); + foreach ($matchedParams as $key => $value) { + $request = $request->withAttribute($key, $value); } - return $this->processResponse($handler($request, $this)); + $routeHandler = new RouteHandler($this->container, $route->callback); + return $routeHandler->handle($request, $this); } private function fallback(ServerRequestInterface $request): ResponseInterface { if ($this->fallback === null) { - throw new EmptyRouteStack('No routes left to process.'); + throw new EmptyRouteStack(); } - return $this->callRouteHandler($this->fallback, $request); + $routeHandler = new RouteHandler($this->container, $this->fallback); + return $routeHandler->handle($request, $this); } - private function processResponse(mixed $response): ResponseInterface + /** + * @param array $parameters + */ + public function pathTo(string $routeName, array $parameters = []): string { - if (is_string($response)) { - $response = $this->responseFactory->createResponse()->withBody($this->streamFactory->createStream($response)); - } + $route = $this->findNamedRoute($routeName, $this->routeGroup); - if (!$response instanceof ResponseInterface) { - throw new InvalidRoute('Route callback did not return a valid response.'); + if (!$route instanceof RouteElement) { + throw new RouteNotFound($routeName); } - return $response; + return $route->buildPath($parameters); } - private function resolveCallback(mixed $callback): mixed + private function findNamedRoute(string $routeName, RouteGroup $routeGroup): ?RouteElement { - return is_string($callback) ? $this->container->get($callback) : $callback; + foreach ($routeGroup->routes as $route) { + if ($route instanceof RouteGroup) { + $route = $this->findNamedRoute($routeName, $route); + } + + if ($route?->name === $routeName) { + return $route; + } + } + + return null; } } diff --git a/src/RouterException.php b/src/RouterException.php new file mode 100644 index 0000000..9544805 --- /dev/null +++ b/src/RouterException.php @@ -0,0 +1,9 @@ + self::response( + '' . $request->getAttribute('foo') + ), + with: ['foo' => 'bar'], + ), + ]); + $request = self::serverRequest('GET', '/'); + + $response = $this->router($routeGroup)->handle($request); + + self::assertEquals('bar', (string)$response->getBody()); + } + + public function testRouteGroupAttributesAreAddedToTheRequest(): void + { + $routeGroup = new RouteGroup( + routes: [ + Route::get( + path: '/', + callback: static fn(ServerRequestInterface $request): ResponseInterface => self::response( + $request->getAttribute('foo') . $request->getAttribute('bar') + ), + with: ['foo' => 'bar'], + ), + ], + with: ['bar' => 'baz'], + ); + $request = self::serverRequest('GET', '/'); + + $response = $this->router($routeGroup)->handle($request); + + self::assertEquals('barbaz', (string)$response->getBody()); + } + + public function testRouteAttributesTakePrecedenceOverRouteGroupAttributes(): void + { + $routeGroup = new RouteGroup( + routes: [ + Route::get( + path: '/', + callback: static fn(ServerRequestInterface $request): ResponseInterface => self::response( + '' . $request->getAttribute('foo') + ), + with: ['foo' => 'bar'], + ), + ], + with: ['foo' => 'baz'], + ); + $request = self::serverRequest('GET', '/'); + + $response = $this->router($routeGroup)->handle($request); + + self::assertEquals('bar', (string)$response->getBody()); + } + + public function testPathParametersTakePrecedenceOverRouteAttributes(): void + { + $routeGroup = new RouteGroup(routes: [ + Route::get( + path: '/{foo}', + callback: static fn(ServerRequestInterface $request): ResponseInterface => self::response( + '' . $request->getAttribute('foo') + ), + with: ['foo' => 'baz'], + ), + ]); + $request = self::serverRequest('GET', '/bar'); + + $response = $this->router($routeGroup)->handle($request); + + self::assertEquals('bar', (string)$response->getBody()); + } +} diff --git a/tests/CallbackTest.php b/tests/CallbackTest.php new file mode 100644 index 0000000..247af00 --- /dev/null +++ b/tests/CallbackTest.php @@ -0,0 +1,99 @@ +router($routeGroup)->handle($request); + + self::assertEquals('TEST', (string)$response->getBody()); + } + + /** + * @return array + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public static function providerCallbacks(): array + { + return [ + 'RequestHandler' => [new TestHandler(self::responseFactory(), self::streamFactory())], + 'RequestHandler callable' => [ + static fn(ServerRequestInterface $request): ResponseInterface => self::response('TEST') + ], + 'RequestHandler DI Container name' => [TestHandler::class], + 'Middleware' => [new TestMiddleware(self::responseFactory(), self::streamFactory())], + 'Middleware callable' => [ + static fn( + ServerRequestInterface $request, + RequestHandlerInterface $handler + ): ResponseInterface => self::response('TEST') + ], + 'Middleware DI Container name' => [TestMiddleware::class], + ]; + } + + /** + * @param class-string $expectedException + */ + #[DataProvider('providerInvalidHandlers')] + public function testRouterCannotExecuteAnInvalidCallback( + mixed $callback, + string $expectedException, + string $expectedMessage, + ): void { + $routeGroup = new RouteGroup(routes: [ + Route::get(path: '/', callback: $callback), + ]); + $request = self::serverRequest('GET', '/'); + + self::expectException($expectedException); + self::expectExceptionMessage($expectedMessage); + $this->router($routeGroup)->handle($request); + } + + /** + * @return array, string}> + */ + public static function providerInvalidHandlers(): array + { + return [ + 'not a handler' => [ + UriFactory::class, + InvalidRouteHandler::class, + 'Route handler must be a PSR Middleware, a PSR RequestHandler or a callable, IngeniozIT\Http\Message\UriFactory given.', + ], + 'handler that does not return a PSR response' => [ + static fn(): array => ['foo' => 'bar'], + InvalidRouteResponse::class, + 'Route must return a PSR Response, array given.', + ], + ]; + } +} diff --git a/tests/ConditionTest.php b/tests/ConditionsTest.php similarity index 56% rename from tests/ConditionTest.php rename to tests/ConditionsTest.php index 9856116..d5715ab 100644 --- a/tests/ConditionTest.php +++ b/tests/ConditionsTest.php @@ -1,31 +1,23 @@ router($routeGroup, static fn(): ResponseInterface => - self::response('TEST'))->handle($request); + $response = $this->router($routeGroup, static fn(): ResponseInterface => self::response('TEST'))->handle( + $request + ); self::assertEquals($expectedResponse, (string)$response->getBody()); } @@ -58,7 +51,7 @@ public static function providerConditions(): array ]; } - public function testRouteGroupCanHaveMultipleConditions(): void + public function testRouteGroupsCanHaveMultipleConditions(): void { $routeGroup = new RouteGroup( routes: [ @@ -71,19 +64,22 @@ public function testRouteGroupCanHaveMultipleConditions(): void ); $request = self::serverRequest('GET', '/'); - $response = $this->router($routeGroup, static fn(): ResponseInterface => - self::response('TEST'))->handle($request); + $response = $this->router($routeGroup, static fn(): ResponseInterface => self::response('TEST'))->handle( + $request + ); self::assertEquals('TEST', (string)$response->getBody()); } - public function testConditionCanAddAttributesToARequest(): void + public function testConditionsCanAddAttributesToARequest(): void { $routeGroup = new RouteGroup( routes: [ Route::get( path: '/', - callback: static fn(ServerRequestInterface $request): ResponseInterface => self::response(var_export($request->getAttribute('foo'), true)) + callback: static fn(ServerRequestInterface $request): ResponseInterface => self::response( + var_export($request->getAttribute('foo'), true) + ) ), ], conditions: [ @@ -98,10 +94,14 @@ public function testConditionCanAddAttributesToARequest(): void } /** - * @dataProvider providerInvalidConditions + * @param class-string $expectedException */ - public function testCannotExecuteInvalidConditions(mixed $condition): void - { + #[DataProvider('providerInvalidConditions')] + public function testRouterCannotExecuteInvalidConditions( + mixed $condition, + string $expectedException, + string $expectedMessage, + ): void { $routeGroup = new RouteGroup( routes: [ Route::get(path: '/', callback: static fn(): ResponseInterface => self::response('TEST')), @@ -110,18 +110,27 @@ public function testCannotExecuteInvalidConditions(mixed $condition): void ); $request = self::serverRequest('GET', '/'); - self::expectException(InvalidRoute::class); + self::expectException($expectedException); + self::expectExceptionMessage($expectedMessage); $this->router($routeGroup)->handle($request); } /** - * @return array + * @return array, string}> */ public static function providerInvalidConditions(): array { return [ - 'not a callable' => [UriFactory::class], - 'callable that does not return bool or array' => [static fn(): int => 42], + 'not a callable' => [ + UriFactory::class, + InvalidConditionHandler::class, + 'Condition handler must be a callable, IngeniozIT\Http\Message\UriFactory given.', + ], + 'callable that does not return bool or array' => [ + static fn(): int => 42, + InvalidConditionResponse::class, + 'Condition must either return an array or a boolean, int given.', + ], ]; } } diff --git a/tests/HttpMethodTest.php b/tests/HttpMethodTest.php new file mode 100644 index 0000000..b1d92af --- /dev/null +++ b/tests/HttpMethodTest.php @@ -0,0 +1,99 @@ + self::response('OK')); + $request = self::serverRequest($method, '/'); + + $response = $this->router(new RouteGroup(routes: [$route]))->handle($request); + + self::assertSame('OK', (string) $response->getBody()); + } + + /** + * @return array + */ + public static function providerMethodsAndRoutes(): array + { + return [ + 'GET' => ['GET', Route::get(...)], + 'POST' => ['POST', Route::post(...)], + 'PUT' => ['PUT', Route::put(...)], + 'PATCH' => ['PATCH', Route::patch(...)], + 'DELETE' => ['DELETE', Route::delete(...)], + 'HEAD' => ['HEAD', Route::head(...)], + 'OPTIONS' => ['OPTIONS', Route::options(...)], + ]; + } + + #[DataProvider('providerRouteMethods')] + public function testRoutesCanMatchAnyMethod(string $method): void + { + $route = Route::any('/', static fn(): ResponseInterface => self::response('OK')); + $request = self::serverRequest($method, '/'); + + $response = $this->router(new RouteGroup(routes: [$route]))->handle($request); + + self::assertSame('OK', (string) $response->getBody()); + } + + /** + * @return array + */ + public static function providerRouteMethods(): array + { + return [ + 'GET' => ['GET'], + 'POST' => ['POST'], + 'PUT' => ['PUT'], + 'PATCH' => ['PATCH'], + 'DELETE' => ['DELETE'], + 'HEAD' => ['HEAD'], + 'OPTIONS' => ['OPTIONS'], + ]; + } + + public function testRoutesCanMatchSomeMethods(): void + { + $routeGroup = new RouteGroup( + routes: [ + Route::some(['POST', 'PUT'], '/', static fn(): ResponseInterface => self::response('OK')), + Route::any('/', static fn(): ResponseInterface => self::response('KO')), + ], + ); + $getRequest = self::serverRequest('GET', '/'); + $postRequest = self::serverRequest('POST', '/'); + $putRequest = self::serverRequest('PUT', '/'); + + $getResult = $this->router($routeGroup)->handle($getRequest); + $postResult = $this->router($routeGroup)->handle($postRequest); + $putResult = $this->router($routeGroup)->handle($putRequest); + + self::assertSame('KO', (string) $getResult->getBody()); + self::assertSame('OK', (string) $postResult->getBody()); + self::assertSame('OK', (string) $putResult->getBody()); + } + + public function testMethodNameCanBeLowercase(): void + { + $route = Route::some(['delete'], '/', static fn(): ResponseInterface => self::response('OK')); + $request = self::serverRequest('DELETE', '/'); + + $result = $this->router(new RouteGroup(routes: [$route]))->handle($request); + + self::assertSame('OK', (string) $result->getBody()); + } +} diff --git a/tests/MiddlewareTest.php b/tests/MiddlewareTest.php deleted file mode 100644 index d575370..0000000 --- a/tests/MiddlewareTest.php +++ /dev/null @@ -1,118 +0,0 @@ - self::response('TEST2')), - ], - middlewares: [$middleware], - ); - $request = self::serverRequest('GET', '/'); - - $response = $this->router($routeGroup)->handle($request); - - self::assertEquals($expectedResponse, (string) $response->getBody()); - } - - /** - * @return array - */ - public static function providerMiddlewares(): array - { - return [ - 'middleware that returns a response' => [ - 'middleware' => TestMiddleware::class, - 'expectedResponse' => 'TEST', - ], - 'middleware that returns a string' => [ - 'middleware' => static fn(): string => 'TEST', - 'expectedResponse' => 'TEST', - ], - 'middleware that forwards to handler' => [ - 'middleware' => static fn(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface => $handler->handle($request), - 'expectedResponse' => 'TEST2', - ], - ]; - } - - public function testRouteGroupCanHaveMultipleMiddlewares(): void - { - $routeGroup = new RouteGroup( - routes: [ - Route::get(path: '/', callback: static fn(): ResponseInterface => self::response('TEST2')), - ], - middlewares: [ - static fn(ServerRequestInterface $request, RequestHandlerInterface $handler) => $handler->handle($request), - static fn(ServerRequestInterface $request, RequestHandlerInterface $handler) => throw new Exception(''), - ], - ); - $request = self::serverRequest('GET', '/'); - - self::expectException(Exception::class); - $response = $this->router($routeGroup)->handle($request); - - self::assertEquals('TEST', (string) $response->getBody()); - } - - /** - * @dataProvider providerInvalidMiddlewares - */ - public function testCannotExecuteInvalidMiddlewares(mixed $middleware): void - { - $routeGroup = new RouteGroup( - routes: [ - Route::get(path: '/', callback: static fn(): ResponseInterface => self::response('TEST')), - ], - middlewares: [$middleware], - ); - $request = self::serverRequest('GET', '/'); - - self::expectException(InvalidRoute::class); - $this->router($routeGroup)->handle($request); - } - - /** - * @return array - */ - public static function providerInvalidMiddlewares(): array - { - return [ - 'not a middleware' => [UriFactory::class], - 'value that cannot be converted to a response' => [static fn(): array => ['foo' => 'bar']], - ]; - } -} diff --git a/tests/MiddlewaresTest.php b/tests/MiddlewaresTest.php new file mode 100644 index 0000000..9279d8c --- /dev/null +++ b/tests/MiddlewaresTest.php @@ -0,0 +1,122 @@ + self::response('TEST2')), + ], + middlewares: [$middleware], + ); + $request = self::serverRequest('GET', '/'); + + $response = $this->router($routeGroup)->handle($request); + + self::assertEquals($expectedResponse, (string)$response->getBody()); + } + + /** + * @return array + */ + public static function providerMiddlewares(): array + { + return [ + 'middleware that returns a response' => [ + 'middleware' => TestMiddleware::class, + 'expectedResponse' => 'TEST', + ], + 'middleware that forwards to handler' => [ + 'middleware' => static fn( + ServerRequestInterface $request, + RequestHandlerInterface $handler + ): ResponseInterface => $handler->handle($request), + 'expectedResponse' => 'TEST2', + ], + ]; + } + + public function testRouteGroupsCanHaveMultipleMiddlewares(): void + { + $routeGroup = new RouteGroup( + routes: [ + Route::get(path: '/', callback: static fn(): ResponseInterface => self::response('TEST2')), + ], + middlewares: [ + static fn( + ServerRequestInterface $request, + RequestHandlerInterface $handler + ): ResponseInterface => $handler->handle($request), + static fn(ServerRequestInterface $request, RequestHandlerInterface $handler) => throw new Exception(''), + ], + ); + $request = self::serverRequest('GET', '/'); + + self::expectException(Exception::class); + $response = $this->router($routeGroup)->handle($request); + + self::assertEquals('TEST', (string)$response->getBody()); + } + + /** + * @param class-string $expectedException + */ + #[DataProvider('providerInvalidMiddlewares')] + public function testRouterCannotExecuteInvalidMiddlewares( + mixed $middleware, + string $expectedException, + string $expectedMessage, + ): void { + $routeGroup = new RouteGroup( + routes: [ + Route::get(path: '/', callback: static fn(): ResponseInterface => self::response('TEST')), + ], + middlewares: [$middleware], + ); + $request = self::serverRequest('GET', '/'); + + self::expectException($expectedException); + self::expectExceptionMessage($expectedMessage); + $this->router($routeGroup)->handle($request); + } + + /** + * @return array, string}> + */ + public static function providerInvalidMiddlewares(): array + { + self::container()->set('not_a_callable', value('foo')); + return [ + 'not a middleware' => [ + UriFactory::class, + InvalidMiddlewareHandler::class, + 'Middleware handler must be a PSR Middleware or a callable, IngeniozIT\Http\Message\UriFactory given.', + ], + 'callable that does not return a response' => [ + static fn(): bool => true, + InvalidMiddlewareResponse::class, + 'Middleware must return a PSR Response, bool given.', + ], + ]; + } +} diff --git a/tests/NameTest.php b/tests/NameTest.php new file mode 100644 index 0000000..fb71215 --- /dev/null +++ b/tests/NameTest.php @@ -0,0 +1,98 @@ +router(new RouteGroup([ + new RouteGroup([]), + $routeGroup + ])); + + $result = $router->pathTo('route_name'); + + self::assertSame('/foo', $result); + } + + public function testRouterCanFindARouteWithParametersPathByName(): void + { + $routeGroup = new RouteGroup([ + Route::get('/{foo:\d+}', 'foo', name: 'route_name'), + ]); + $router = $this->router($routeGroup); + + $result = $router->pathTo('route_name', ['foo' => 42]); + + self::assertSame('/42', $result); + } + + public function testAdditionalParametersAreAddedToThePathQuery(): void + { + $routeGroup = new RouteGroup([ + Route::get('/{foo:\d+}', 'foo', name: 'route_name'), + ]); + $router = $this->router($routeGroup); + + $result = $router->pathTo('route_name', ['foo' => '42', 'bar' => 'baz']); + + self::assertSame('/42?bar=baz', $result); + } + + public function testRouteGroupsPassTheirNameToTheirSubRoutes(): void + { + $routeGroup = new RouteGroup( + [ + Route::get('/foo', 'foo', name: 'route_name'), + ], + name: 'group', + ); + $router = $this->router(new RouteGroup([$routeGroup])); + + $result = $router->pathTo('group.route_name'); + + self::assertSame('/foo', $result); + } + + public function testRouterCannotFindAnInexistingRoutePathByName(): void + { + $route = Route::get('/foo', 'foo', name: 'route_name'); + $router = $this->router(new RouteGroup([$route])); + + self::expectException(RouteNotFound::class); + self::expectExceptionMessage("Route inexisting_route_name not found."); + $router->pathTo('inexisting_route_name'); + } + + public function testRouterCannotFindARoutePathWithMissingParameters(): void + { + $route = Route::get('/{foo}/{bar}', 'foo', name: 'route_name'); + $router = $this->router(new RouteGroup([$route])); + + self::expectException(MissingRouteParameters::class); + self::expectExceptionMessage("Missing parameters foo for route route_name."); + $router->pathTo('route_name', ['bar' => '42']); + } + + public function testRouterCannotFindARoutePathWithInvalidParameters(): void + { + $route = Route::get('/{foo:\d+}', 'foo', name: 'route_name'); + $router = $this->router(new RouteGroup([$route])); + + self::expectException(InvalidRouteParameter::class); + self::expectExceptionMessage("Parameter foo for route route_name does not match the pattern \d+."); + $router->pathTo('route_name', ['foo' => 'bar']); + } +} diff --git a/tests/RouteHttpMethodTest.php b/tests/RouteHttpMethodTest.php deleted file mode 100644 index 1af2d69..0000000 --- a/tests/RouteHttpMethodTest.php +++ /dev/null @@ -1,101 +0,0 @@ -match($request); - - self::assertSame([], $result); - } - - /** - * @return array - */ - public static function providerMethodsAndRoutes(): array - { - return [ - 'GET' => ['GET', Route::get(...)], - 'POST' => ['POST', Route::post(...)], - 'PUT' => ['PUT', Route::put(...)], - 'PATCH' => ['PATCH', Route::patch(...)], - 'DELETE' => ['DELETE', Route::delete(...)], - 'HEAD' => ['HEAD', Route::head(...)], - 'OPTIONS' => ['OPTIONS', Route::options(...)], - ]; - } - - /** - * @dataProvider providerRouteMethods - */ - public function testRouteCanMatchAnyMethod(string $method): void - { - $route = Route::any('/', 'foo'); - $request = self::serverRequest($method, '/'); - - $result = $route->match($request); - - self::assertSame([], $result); - } - - /** - * @return array - */ - public static function providerRouteMethods(): array - { - return [ - 'GET' => ['GET'], - 'POST' => ['POST'], - 'PUT' => ['PUT'], - 'PATCH' => ['PATCH'], - 'DELETE' => ['DELETE'], - 'HEAD' => ['HEAD'], - 'OPTIONS' => ['OPTIONS'], - ]; - } - - public function testCanMatchSomeMethods(): void - { - $route = Route::some(['GET', 'POST'], '/', 'foo'); - $getRequest = self::serverRequest('GET', '/'); - $postRequest = self::serverRequest('POST', '/'); - $putRequest = self::serverRequest('PUT', '/'); - - $getResult = $route->match($getRequest); - $postResult = $route->match($postRequest); - $putResult = $route->match($putRequest); - - self::assertSame([], $getResult); - self::assertSame([], $postResult); - self::assertSame(false, $putResult); - } - - public function testMethodNameCanBeLowercase(): void - { - $route = Route::some(['delete'], '/', 'foo'); - $deleteRequest = self::serverRequest('DELETE', '/'); - - $deleteResult = $route->match($deleteRequest); - - self::assertSame([], $deleteResult); - } -} diff --git a/tests/RouteTest.php b/tests/RouteTest.php deleted file mode 100644 index e38e458..0000000 --- a/tests/RouteTest.php +++ /dev/null @@ -1,92 +0,0 @@ -match($matchingRequest); - $nonMatchingResult = $route->match($nonMatchingRequest); - - self::assertSame([], $matchingResult); - self::assertSame(false, $nonMatchingResult); - } - - public function testExtractsParametersFromPath(): void - { - $route = Route::get('/foo/{bar}', 'foo'); - $request = self::serverRequest('GET', '/foo/baz'); - - $result = $route->match($request); - - self::assertSame(['bar' => 'baz'], $result); - } - - /** - * @dataProvider providerRoutePatterns - */ - public function testCanUseCustomParameterPatterns(Route $route): void - { - $matchingRequest = self::serverRequest('GET', '/foo/123/456'); - $nonMatchingRequest = self::serverRequest('GET', '/foo/baz1/baz2'); - - $matchingResult = $route->match($matchingRequest); - $nonMatchingResult = $route->match($nonMatchingRequest); - - self::assertSame(['bar' => '123', 'baz' => '456'], $matchingResult); - self::assertSame(false, $nonMatchingResult); - } - - /** - * @return array - */ - public static function providerRoutePatterns(): array - { - return [ - 'patterns inside the path' => [Route::get( - path: '/foo/{bar:\d+}/{baz:\d+}', - callback: 'foo' - )], - 'patterns as a parameter' => [Route::get( - path: '/foo/{bar}/{baz}', - callback: 'foo', - where: ['bar' => '\d+', 'baz' => '\d+'], - )], - 'path takes precendence over parameters' => [Route::get( - path: '/foo/{bar:\d+}/{baz:\d+}', - callback: 'foo', - where: ['bar' => '[a-z]+', 'baz' => '\d+'], - )], - ]; - } - - public function testCanBeNamed(): void - { - $route = Route::get('/foo', 'foo', 'route name'); - - self::assertEquals('route name', $route->name); - } - - public function testCanHaveAdditionalAttributes(): void - { - $route = Route::get('/foo', 'foo', with: ['foo' => 'bar']); - - self::assertEquals(['foo' => 'bar'], $route->with); - } -} diff --git a/tests/RouterRouteTest.php b/tests/RouterRouteTest.php deleted file mode 100644 index c24dd35..0000000 --- a/tests/RouterRouteTest.php +++ /dev/null @@ -1,197 +0,0 @@ -router($routeGroup)->handle($request); - - self::assertEquals('TEST', (string) $response->getBody()); - } - - /** - * @return array - */ - public static function providerCallbacks(): array - { - return [ - 'RequestHandler' => [new TestHandler(self::responseFactory(), self::streamFactory())], - 'RequestHandler callable' => [static fn(ServerRequestInterface $request): ResponseInterface => self::response('TEST')], - 'RequestHandler DI Container name' => [TestHandler::class], - 'Middleware' => [new TestMiddleware(self::responseFactory(), self::streamFactory())], - 'Middleware callable' => [static fn(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface => self::response('TEST')], - 'Middleware DI Container name' => [TestMiddleware::class], - 'Route that returns a string' => [static fn(): string => 'TEST'], - ]; - } - - /** - * @dataProvider providerInvalidHandlers - */ - public function testCannotExecuteAnInvalidRouteCallback(): void - { - $routeGroup = new RouteGroup(routes: [ - Route::get(path: '/', callback: UriFactory::class), - ]); - $request = self::serverRequest('GET', '/'); - - self::expectException(InvalidRoute::class); - $this->router($routeGroup)->handle($request); - } - - /** - * @return array - */ - public static function providerInvalidHandlers(): array - { - return [ - 'not a handler' => [UriFactory::class], - 'value that cannot be converted to a response' => [static fn(): array => ['foo' => 'bar']], - ]; - } - - public function testFiltersOutNonMatchingRoutes(): void - { - $routeGroup = new RouteGroup(routes: [ - Route::get(path: '/test', callback: static fn(): ResponseInterface => self::response('KO')), - Route::post(path: '/test2', callback: static fn(): ResponseInterface => self::response('KO')), - Route::get(path: '/test2', callback: static fn(): ResponseInterface => self::response('OK')), - ]); - $request = self::serverRequest('GET', '/test2'); - - $response = $this->router($routeGroup)->handle($request); - - self::assertEquals('OK', (string) $response->getBody()); - } - - public function testAddsMatchedParametersToRequest(): void - { - $routeGroup = new RouteGroup(routes: [ - Route::get( - path: '/{foo}', - callback: static fn(ServerRequestInterface $request): ResponseInterface => - self::response(var_export($request->getAttribute('foo'), true)) - ), - ]); - $request = self::serverRequest('GET', '/bar'); - - $response = $this->router($routeGroup)->handle($request); - - self::assertEquals("'bar'", (string) $response->getBody()); - } - - public function testAddsAdditionalAttributesToRequest(): void - { - $routeGroup = new RouteGroup(routes: [ - Route::get( - path: '/', - callback: static fn(ServerRequestInterface $request): ResponseInterface => - self::response(var_export($request->getAttribute('foo'), true)), - with: ['foo' => 'bar'], - ), - ]); - $request = self::serverRequest('GET', '/'); - - $response = $this->router($routeGroup)->handle($request); - - self::assertEquals("'bar'", (string) $response->getBody()); - } - - /** - * @dataProvider providerRouteGroupsWithCustomParameters - */ - public function testCanHaveCustomParameters(RouteGroup $routeGroup): void - { - $matchingRequest = self::serverRequest('GET', '/123'); - $nonMatchingRequest = self::serverRequest('GET', '/abc'); - - $matchingResponse = $this->router($routeGroup)->handle($matchingRequest); - $nonMatchingResponse = $this->router($routeGroup, static fn(): string => 'KO')->handle($nonMatchingRequest); - - self::assertEquals('OK', (string) $matchingResponse->getBody()); - self::assertEquals('KO', (string) $nonMatchingResponse->getBody()); - } - - /** - * @return array - */ - public static function providerRouteGroupsWithCustomParameters(): array - { - return [ - 'pattern defined in route group' => [ - new RouteGroup( - routes: [Route::get(path: '/{foo}', callback: static fn(): string => 'OK')], - patterns: ['foo' => '\d+'], - ) - ], - 'route pattern takes precedence over route group pattern' => [ - new RouteGroup( - routes: [Route::get(path: '/{foo}', callback: static fn(): string => 'OK', where: ['foo' => '\d+'])], - patterns: ['foo' => '[a-z]+'], - ) - ], - ]; - } - - public function testMustFindARouteToProcess(): void - { - $routeGroup = new RouteGroup(routes: [ - Route::get(path: '/foo', callback: static fn(): ResponseInterface => self::response('TEST')), - Route::get(path: '/bar', callback: static fn(): ResponseInterface => self::response('TEST2')), - ]); - $request = self::serverRequest('GET', '/'); - - self::expectException(EmptyRouteStack::class); - $this->router($routeGroup)->handle($request); - } - - public function testCanHaveAFallbackRoute(): void - { - $routeGroup = new RouteGroup(routes: []); - $request = self::serverRequest('GET', '/'); - - $response = $this->router($routeGroup, static fn(): ResponseInterface => self::response('TEST'))->handle($request); - - self::assertEquals('TEST', (string) $response->getBody()); - } -} diff --git a/tests/RoutingTest.php b/tests/RoutingTest.php new file mode 100644 index 0000000..a649027 --- /dev/null +++ b/tests/RoutingTest.php @@ -0,0 +1,203 @@ + self::response('OK')), + ]); + $request = self::serverRequest('GET', '/foo'); + + $response = $this->router($routeGroup)->handle($request); + + self::assertEquals('OK', (string)$response->getBody()); + } + + public function testRouterFiltersOutNonMatchingPaths(): void + { + $routeGroup = new RouteGroup(routes: [ + Route::get(path: '/test2', callback: static fn(): ResponseInterface => self::response('KO')), + Route::get(path: '/test', callback: static fn(): ResponseInterface => self::response('OK')), + ]); + $request = self::serverRequest('GET', '/test'); + + $response = $this->router($routeGroup)->handle($request); + + self::assertEquals('OK', (string)$response->getBody()); + } + + public function testRouterCanHandleARouteAfterASubGroup(): void + { + $routeGroup = new RouteGroup( + routes: [ + new RouteGroup( + routes: [ + Route::get(path: '/sub', callback: static fn(): ResponseInterface => self::response('TEST')), + ], + ), + Route::get(path: '/after-sub', callback: static fn(): ResponseInterface => self::response('TEST2')), + ], + ); + $request = self::serverRequest('GET', '/after-sub'); + + $response = $this->router($routeGroup)->handle($request); + + self::assertEquals('TEST2', (string)$response->getBody()); + } + + public function testRouteGroupsCanHaveSubGroups(): void + { + $routeGroup = new RouteGroup( + routes: [ + new RouteGroup( + routes: [ + Route::get(path: '/sub', callback: static fn(): ResponseInterface => self::response('TEST')), + ], + ), + ], + ); + $request = self::serverRequest('GET', '/sub'); + + $response = $this->router($routeGroup)->handle($request); + + self::assertEquals('TEST', (string)$response->getBody()); + } + + public function testRouteGroupsCanHaveAPathPrefix(): void + { + $routeGroup = new RouteGroup( + routes: [ + Route::get(path: '/bar', callback: static fn(): ResponseInterface => self::response('OK')), + ], + path: '/foo' + ); + $request = self::serverRequest('GET', '/foo/bar'); + + $response = $this->router($routeGroup)->handle($request); + + self::assertEquals('OK', (string)$response->getBody()); + } + + public function testRoutesCanUsePathParameters(): void + { + $routeGroup = new RouteGroup(routes: [ + Route::get(path: '/{foo}/{bar}', callback: static fn(ServerRequestInterface $request + ): ResponseInterface => self::response($request->getAttribute('foo') . $request->getAttribute('bar'))), + ]); + $request = self::serverRequest('GET', '/bar/baz'); + + $response = $this->router($routeGroup)->handle($request); + + self::assertEquals('barbaz', (string)$response->getBody()); + } + + #[DataProvider('providerRouteGroupsWithCustomParameters')] + public function testRoutesCanUseCustomPathParameters(RouteGroup $routeGroup): void + { + $matchingRequest = self::serverRequest('GET', '/123'); + $nonMatchingRequest = self::serverRequest('GET', '/abc'); + + $matchingResponse = $this->router($routeGroup)->handle($matchingRequest); + $nonMatchingResponse = $this->router($routeGroup, static fn(): ResponseInterface => self::response('KO'))->handle($nonMatchingRequest); + + self::assertEquals('OK', (string)$matchingResponse->getBody()); + self::assertEquals('KO', (string)$nonMatchingResponse->getBody()); + } + + /** + * @return array + */ + public static function providerRouteGroupsWithCustomParameters(): array + { + return [ + 'pattern defined in path' => [ + new RouteGroup( + routes: [Route::get(path: '/{foo:\d+}', callback: static fn(): ResponseInterface => self::response('OK'))], + ) + ], + 'pattern defined in route' => [ + new RouteGroup( + routes: [ + Route::get(path: '/{foo}', callback: static fn(): ResponseInterface => self::response('OK'), where: ['foo' => '\d+']) + ], + ) + ], + 'pattern defined in route group' => [ + new RouteGroup( + routes: [Route::get(path: '/{foo}', callback: static fn(): ResponseInterface => self::response('OK'))], + where: ['foo' => '\d+'], + ) + ], + 'path pattern takes precedence over route pattern' => [ + new RouteGroup( + routes: [ + Route::get( + path: '/{foo:\d+}', + callback: static fn(): ResponseInterface => self::response('OK'), + where: ['foo' => '[a-z]+'] + ) + ], + ) + ], + 'route pattern takes precedence over route group pattern' => [ + new RouteGroup( + routes: [ + Route::get(path: '/{foo}', callback: static fn(): ResponseInterface => self::response('OK'), where: ['foo' => '\d+']) + ], + where: ['foo' => '[a-z]+'], + ) + ], + 'group pattern pattern takes precedence over containing route group pattern' => [ + new RouteGroup( + routes: [ + new RouteGroup( + routes: [Route::get(path: '/{foo}', callback: static fn(): ResponseInterface => self::response('OK'))], + where: ['foo' => '\d+'], + ) + ], + where: ['foo' => '[a-z]+'], + ) + ], + ]; + } + + public function testRouterMustFindARouteToProcess(): void + { + $routeGroup = new RouteGroup(routes: [ + Route::get(path: '/foo', callback: static fn(): ResponseInterface => self::response('TEST')), + Route::get(path: '/bar', callback: static fn(): ResponseInterface => self::response('TEST2')), + ]); + $request = self::serverRequest('GET', '/baz'); + + self::expectException(EmptyRouteStack::class); + self::expectExceptionMessage('No routes left to process.'); + $this->router($routeGroup)->handle($request); + } + + public function testRouterCanHaveAFallbackRoute(): void + { + $routeGroup = new RouteGroup(routes: [ + Route::get(path: '/foo', callback: static fn(): ResponseInterface => self::response('TEST')), + Route::get(path: '/bar', callback: static fn(): ResponseInterface => self::response('TEST2')), + ]); + $request = self::serverRequest('GET', '/'); + + $response = $this->router( + routeGroup: $routeGroup, + fallback: static fn(): ResponseInterface => self::response('OK') + )->handle($request); + + self::assertEquals('OK', (string) $response->getBody()); + } +} diff --git a/tests/SubGroupTest.php b/tests/SubGroupTest.php deleted file mode 100644 index a684961..0000000 --- a/tests/SubGroupTest.php +++ /dev/null @@ -1,59 +0,0 @@ - 'TEST'), - ], - ), - ], - ); - $request = self::serverRequest('GET', '/sub'); - - $response = $this->router($routeGroup)->handle($request); - - self::assertEquals('TEST', (string)$response->getBody()); - } - - public function testCanHandleARouteAfterASubGroup(): void - { - $routeGroup = new RouteGroup( - routes: [ - new RouteGroup( - routes: [ - Route::get(path: '/sub', callback: static fn() => 'TEST'), - ], - ), - Route::get(path: '/after-sub', callback: static fn() => 'TEST2'), - ], - ); - $request = self::serverRequest('GET', '/after-sub'); - - $response = $this->router($routeGroup)->handle($request); - - self::assertEquals('TEST2', (string)$response->getBody()); - } -} diff --git a/tests/PsrTrait.php b/tests/Utils/PsrTrait.php similarity index 62% rename from tests/PsrTrait.php rename to tests/Utils/PsrTrait.php index 05615e4..eaff537 100644 --- a/tests/PsrTrait.php +++ b/tests/Utils/PsrTrait.php @@ -2,54 +2,45 @@ declare(strict_types=1); -namespace IngeniozIT\Router\Tests; +namespace IngeniozIT\Router\Tests\Utils; -use Psr\Http\Message\{ - ResponseFactoryInterface, +use IngeniozIT\Edict\Container; +use IngeniozIT\Http\Message\{ResponseFactory, ServerRequestFactory, StreamFactory, UploadedFileFactory, UriFactory,}; +use Psr\Http\Message\{ResponseFactoryInterface, ResponseInterface, ServerRequestFactoryInterface, ServerRequestInterface, StreamFactoryInterface, UploadedFileFactoryInterface, - UriFactoryInterface, -}; -use Psr\Container\ContainerInterface; -use IngeniozIT\Http\Message\{ - ResponseFactory, - ServerRequestFactory, - StreamFactory, - UploadedFileFactory, - UriFactory, -}; -use IngeniozIT\Edict\Container; + UriFactoryInterface,}; use function IngeniozIT\Edict\value; trait PsrTrait { - private static function responseFactory(): ResponseFactoryInterface + protected static function responseFactory(): ResponseFactoryInterface { return new ResponseFactory(self::streamFactory()); } - private static function streamFactory(): StreamFactoryInterface + protected static function streamFactory(): StreamFactoryInterface { return new StreamFactory(); } - private static function uriFactory(): UriFactoryInterface + protected static function uriFactory(): UriFactoryInterface { return new UriFactory(); } - private static function uploadedFileFactory(): UploadedFileFactoryInterface + protected static function uploadedFileFactory(): UploadedFileFactoryInterface { return new UploadedFileFactory( self::streamFactory(), ); } - private static function serverRequestFactory(): ServerRequestFactoryInterface + protected static function serverRequestFactory(): ServerRequestFactoryInterface { return new ServerRequestFactory( self::streamFactory(), @@ -58,19 +49,19 @@ private static function serverRequestFactory(): ServerRequestFactoryInterface ); } - private static function serverRequest(string $method, string $uri): ServerRequestInterface + protected static function serverRequest(string $method, string $uri): ServerRequestInterface { return self::serverRequestFactory()->createServerRequest($method, $uri); } - private static function response(string $content): ResponseInterface + protected static function response(string $content): ResponseInterface { return self::responseFactory()->createResponse()->withBody( self::streamFactory()->createStream($content), ); } - private static function container(): ContainerInterface + protected static function container(): Container { $container = new Container(); diff --git a/tests/Utils/RouterCase.php b/tests/Utils/RouterCase.php new file mode 100644 index 0000000..b3db12d --- /dev/null +++ b/tests/Utils/RouterCase.php @@ -0,0 +1,18 @@ +