From 593c4bbeeaf309dbbeeb1be5a475c192f289c89a Mon Sep 17 00:00:00 2001 From: IngeniozIT Date: Tue, 27 Feb 2024 02:35:40 +0100 Subject: [PATCH] major refactoring of tests and code + remove some features + add better features --- README.md | 241 ++---------------- composer.json | 2 +- index.php | 64 +++++ quality/phpmd.xml.dist | 4 +- src/Route.php | 51 ++-- src/RouteGroup.php | 42 ++- src/RouteNotFound.php | 11 + src/Router.php | 52 ++-- tests/Features/AdditionalAttributesTest.php | 90 +++++++ tests/Features/CallbackTest.php | 82 ++++++ .../ConditionsTest.php} | 31 +-- .../HttpMethodTest.php} | 53 ++-- .../MiddlewaresTest.php} | 42 +-- tests/Features/NameTest.php | 47 ++++ tests/PsrTrait.php | 16 +- tests/RouteTest.php | 92 ------- tests/RouterCase.php | 18 ++ tests/RouterRouteTest.php | 197 -------------- tests/RouterTest.php | 203 +++++++++++++++ tests/SubGroupTest.php | 59 ----- 20 files changed, 696 insertions(+), 701 deletions(-) create mode 100644 index.php create mode 100644 src/RouteNotFound.php create mode 100644 tests/Features/AdditionalAttributesTest.php create mode 100644 tests/Features/CallbackTest.php rename tests/{ConditionTest.php => Features/ConditionsTest.php} (84%) rename tests/{RouteHttpMethodTest.php => Features/HttpMethodTest.php} (53%) rename tests/{MiddlewareTest.php => Features/MiddlewaresTest.php} (76%) create mode 100644 tests/Features/NameTest.php delete mode 100644 tests/RouteTest.php create mode 100644 tests/RouterCase.php delete mode 100644 tests/RouterRouteTest.php create mode 100644 tests/RouterTest.php delete mode 100644 tests/SubGroupTest.php diff --git a/README.md b/README.md index 3e805a6..e14ada6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,14 @@ # 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 +30,28 @@ composer require ingenioz-it/router ## Documentation -### Configuring the Router - -#### Overview - -Here is a quick reference sample of how to configure the routes: - -```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' => '.*'], -); - -$router = new Router($routes, $container); - -$response = $router->handle($request); -``` - -#### Path - -The path can contain parameters, which are enclosed in curly braces: - -```php -new RouteGroup([ - Route::get(path: '/users/{id}', callback: /* handler */), -]); -``` - -By default, the parameters match any character except `/`. - -To match a parameter with a different pattern, use a regular expression: - -```php -new RouteGroup([ - Route::get(path: '/users/{id:[0-9]+}', callback: /* handler */), -]); -``` - -Alternatively, you can define the pattern in the `patterns` parameter: - -```php -new RouteGroup([ - Route::get(path: '/users/{id}', callback: /* handler */, patterns: ['id' => '[0-9]+']), -]); -``` - -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: - -```php -new RouteGroup( - routes: [ - Route::get(path: '/users/{id}/posts/{postId}', callback: /* handler */), - Route::get(path: '/users/{id}/comments/{commentId}', callback: /* handler */), - ], - patterns: ['id' => '[0-9]+'], -); -``` - -#### HTTP Method - -The `Route` class provides static methods to create routes to match each HTTP method: - -```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 -]); -``` - -#### Handlers - -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), -]); -``` - -If the handler is a string, the container will be used to resolve it. +### Overview -If the handler is a middleware, calling the next handler will continue the routing: +Create your routes, instantiate the router and handle the request: ```php -new RouteGroup([ - Route::get(path: '/', callback: ($request, $handler) => $handler->handle($request)), // Will delegate to the next route - Route::get(path: '/', callback: () => 'Hello, world!'), -]); -``` - - -#### Name - -You can name a route: - -```php -new RouteGroup([ - Route::get(path: '/', callback: /* handler */, name: 'home'), - Route::get(path: '/users', callback: /* handler */, name: 'users'), -]); -``` - -#### Middlewares - -You can add middlewares to a route group: - -```php -new RouteGroup( - route: [ - Route::get(path: '/', callback: /* handler */), - ], - middlewares: [ - new MyMiddleware(), - MyMiddleware::class, - ($request, $handler) => $handler->handle($request), - ], -); -``` - -A middleware can be a PSR-15 `MiddlewareInterface`, a string, or a callable. - -If the middleware is a string, the container will be used to resolve it. - -If the middleware is a callable, it will be called with the request and the next handler as arguments. - -#### Subgroups - -You can nest route groups: - -```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 */), - ], - ), - ], -); -``` - -#### Conditions - -You can add conditions to a route group. The conditions are checked before the route group is parsed. - -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. - -```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 */), - ], -); -``` - -If the request does not match the condition, the route group will be skipped. - -If a condition is a string, the container will be used to resolve it. - -### Using the Router - -#### Creating the router - -The `Router` uses a `RouteGroup` to store the routes and a PSR-11 `ContainerInterface` to inject dependencies into the route handlers. - -```php -use IngeniozIT\Router\Router; use IngeniozIT\Router\RouteGroup; +use IngeniozIT\Router\Route; +use IngeniozIT\Router\Router; -$container = /* PSR ContainerInterface */; -$routeGroup = new RouteGroup([/* routes */]); - -$router = new Router($routeGroup, $container); -``` - -#### Routing a request - -The `Router` uses a PSR-7 `ServerRequestInterface` to route the request. +// Create your routes +$routes = new RouteGroup([ + Route::get('/hello', fn() => new Response('Hello, world!')), + Route::get('/bye', fn() => new Response('Goodbye, world!')), +]); -It returns a PSR-7 `ResponseInterface`. +// Instantiate the router +$container = new Container(); +$router = new Router($routes, $container); -```php -$request = /* PSR ServerRequestInterface */; +// Handle the request +$request = new ServerRequest(); $response = $router->handle($request); ``` + +@todo continue working on the documentation (create a wiki ?) \ No newline at end of file diff --git a/composer.json b/composer.json index d8f9918..e0ecace 100644 --- a/composer.json +++ b/composer.json @@ -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/index.php b/index.php new file mode 100644 index 0000000..08c6541 --- /dev/null +++ b/index.php @@ -0,0 +1,64 @@ + 'bar'], + with: ['foo' => 'bar'], + ), + ], + name: 'foo', + where: ['bar' => 'baz'], + with: ['bar' => 'baz'], + conditions: [ + IsLoggedAsAdmin::class, + ], + ), + ], +); + + +class AdminIndexController +{ + +} + +class AdminUserController +{ + +} + +class IsLoggedAsAdmin +{ + +} + +class AdminMiddleware +{ + +} + +print_r($routes); \ No newline at end of file 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/src/Route.php b/src/Route.php index e74b841..5c69879 100644 --- a/src/Route.php +++ b/src/Route.php @@ -38,72 +38,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): self { - return new self(self::GET, $path, $callback, $name, $where, $with); + return new self(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): self { - return new self(self::POST, $path, $callback, $name, $where, $with); + return new self(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): self { - return new self(self::PUT, $path, $callback, $name, $where, $with); + return new self(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): self { - return new self(self::PATCH, $path, $callback, $name, $where, $with); + return new self(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): self { - return new self(self::DELETE, $path, $callback, $name, $where, $with); + return new self(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): self { - return new self(self::HEAD, $path, $callback, $name, $where, $with); + return new self(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): self { - return new self(self::OPTIONS, $path, $callback, $name, $where, $with); + return new self(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): self { - return new self(self::ANY, $path, $callback, $name, $where, $with); + return new self(self::ANY, $path, $callback, $where, $with, $name); } /** @@ -111,14 +111,14 @@ 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): self { $method = 0; foreach ($methods as $methodString) { $method |= self::METHODS[strtoupper($methodString)]; } - return new self($method, $path, $callback, $name, $where, $with); + return new self($method, $path, $callback, $where, $with, $name); } /** @@ -129,17 +129,16 @@ public function __construct( public int $method, public string $path, public mixed $callback, - public ?string $name = null, public array $where = [], public array $with = [], + public ?string $name = null, ) { } /** - * @param array $additionalPatterns * @return false|array */ - public function match(ServerRequestInterface $request, array $additionalPatterns = []): false|array + public function match(ServerRequestInterface $request): false|array { if (!$this->httpMethodMatches($request->getMethod())) { return false; @@ -152,7 +151,7 @@ public function match(ServerRequestInterface $request, array $additionalPatterns return $path === $this->path ? [] : false; } - $extractedParameters = $this->extractParametersValue($parameters, $path, $additionalPatterns); + $extractedParameters = $this->extractParametersValue($parameters, $path); return $extractedParameters === [] ? false : $extractedParameters; } @@ -175,9 +174,9 @@ private function extractParametersFromPath(string $path): array * @param array $additionalPatterns * @return array */ - private function extractParametersValue(array $parameters, string $path, array $additionalPatterns): array + private function extractParametersValue(array $parameters, string $path): array { - preg_match($this->buildRegex($parameters, $additionalPatterns), $path, $parameters); + preg_match($this->buildRegex($parameters), $path, $parameters); return array_filter($parameters, 'is_string', ARRAY_FILTER_USE_KEY); } @@ -185,13 +184,13 @@ private function extractParametersValue(array $parameters, string $path, array $ * @param string[][] $parameters * @param array $additionalPatterns */ - private function buildRegex(array $parameters, array $additionalPatterns): string + private function buildRegex(array $parameters): 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]] ?? '[^/]+') . ')', + '(?<' . $parameter[1] . '>' . ($parameter[2] ?? $this->where[$parameter[1]] ?? '[^/]+') . ')', $regex ); } diff --git a/src/RouteGroup.php b/src/RouteGroup.php index 61c4888..00eb33c 100644 --- a/src/RouteGroup.php +++ b/src/RouteGroup.php @@ -6,17 +6,53 @@ final class RouteGroup { + /** @var Route[]|RouteGroup[] */ + public 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 = [], + array $where = [], + array $with = [], + ?string $name = null, + ?string $path = null, ) { + $this->routes = array_map( + function (RouteGroup|Route $route) use ($with, $where, $name, $path): RouteGroup|Route { + if ($route instanceof RouteGroup) { + return new RouteGroup( + $route->routes, + $route->middlewares, + $route->conditions, + $where, + $with, + $this->concatenatedName($name), + $path, + ); + } + return new Route( + $route->method, + $path . $route->path, + $route->callback, + array_merge($where, $route->where), + array_merge($with, $route->with), + name: $route->name ? $this->concatenatedName($name) . $route->name : null, + ); + }, + $routes, + ); + } + + private function concatenatedName(?string $name): ?string + { + return empty($name) ? $name : $name . '.'; } } diff --git a/src/RouteNotFound.php b/src/RouteNotFound.php new file mode 100644 index 0000000..5c8355f --- /dev/null +++ b/src/RouteNotFound.php @@ -0,0 +1,11 @@ +processResponse($handler($request, $this)); + return $handler($request, $this); } private function executeRoutables(ServerRequestInterface $request): ResponseInterface @@ -103,23 +99,21 @@ 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) { + foreach ($route->with as $key => $value) { $newRequest = $newRequest->withAttribute($key, $value); } - foreach ($route->with as $key => $value) { + foreach ($matchedParams as $key => $value) { $newRequest = $newRequest->withAttribute($key, $value); } @@ -145,7 +139,7 @@ private function callRouteHandler(mixed $callback, ServerRequestInterface $reque throw new InvalidRoute("Route callback is not callable."); } - return $this->processResponse($handler($request, $this)); + return $handler($request, $this); } private function fallback(ServerRequestInterface $request): ResponseInterface @@ -157,21 +151,37 @@ private function fallback(ServerRequestInterface $request): ResponseInterface return $this->callRouteHandler($this->fallback, $request); } - private function processResponse(mixed $response): ResponseInterface + private function resolveCallback(mixed $callback): mixed { - if (is_string($response)) { - $response = $this->responseFactory->createResponse()->withBody($this->streamFactory->createStream($response)); - } + return is_string($callback) ? $this->container->get($callback) : $callback; + } + + public function pathTo(string $routeName): string + { + $route = $this->findNamedRoute($routeName, $this->routeGroup); - if (!$response instanceof ResponseInterface) { - throw new InvalidRoute('Route callback did not return a valid response.'); + if ($route === null) { + throw new RouteNotFound("Route with name '{$routeName}' not found."); } - return $response; + return $route->path; } - private function resolveCallback(mixed $callback): mixed + private function findNamedRoute(string $routeName, RouteGroup $routeGroup): ?Route { - return is_string($callback) ? $this->container->get($callback) : $callback; + foreach ($routeGroup->routes as $route) { + if ($route instanceof RouteGroup) { + $foundRoute = $this->findNamedRoute($routeName, $route); + if ($foundRoute !== null) { + return $foundRoute; + } + } + + if ($route->name === $routeName) { + return $route; + } + } + + return null; } } diff --git a/tests/Features/AdditionalAttributesTest.php b/tests/Features/AdditionalAttributesTest.php new file mode 100644 index 0000000..769377c --- /dev/null +++ b/tests/Features/AdditionalAttributesTest.php @@ -0,0 +1,90 @@ + 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 testAddsRouteGroupAttributesToRequest(): 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/Features/CallbackTest.php b/tests/Features/CallbackTest.php new file mode 100644 index 0000000..5bb3902 --- /dev/null +++ b/tests/Features/CallbackTest.php @@ -0,0 +1,82 @@ +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], + ]; + } + + /** + * @dataProvider providerInvalidHandlers + */ + public function testRouterCannotExecuteAnInvalidCallback(): 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']], + ]; + } +} diff --git a/tests/ConditionTest.php b/tests/Features/ConditionsTest.php similarity index 84% rename from tests/ConditionTest.php rename to tests/Features/ConditionsTest.php index 9856116..0dd5545 100644 --- a/tests/ConditionTest.php +++ b/tests/Features/ConditionsTest.php @@ -1,27 +1,18 @@ router($routeGroup, static fn(): ResponseInterface => - self::response('TEST'))->handle($request); + self::response('TEST'))->handle($request); self::assertEquals($expectedResponse, (string)$response->getBody()); } diff --git a/tests/RouteHttpMethodTest.php b/tests/Features/HttpMethodTest.php similarity index 53% rename from tests/RouteHttpMethodTest.php rename to tests/Features/HttpMethodTest.php index 1af2d69..3838309 100644 --- a/tests/RouteHttpMethodTest.php +++ b/tests/Features/HttpMethodTest.php @@ -1,31 +1,25 @@ self::response('OK')); $request = self::serverRequest($method, '/'); - $result = $route->match($request); + $response = $this->router(new RouteGroup(routes: [$route]))->handle($request); - self::assertSame([], $result); + self::assertSame('OK', (string) $response->getBody()); } /** @@ -49,12 +43,12 @@ public static function providerMethodsAndRoutes(): array */ public function testRouteCanMatchAnyMethod(string $method): void { - $route = Route::any('/', 'foo'); + $route = Route::any('/', fn() => self::response('OK')); $request = self::serverRequest($method, '/'); - $result = $route->match($request); + $response = $this->router(new RouteGroup(routes: [$route]))->handle($request); - self::assertSame([], $result); + self::assertSame('OK', (string) $response->getBody()); } /** @@ -75,27 +69,32 @@ public static function providerRouteMethods(): array public function testCanMatchSomeMethods(): void { - $route = Route::some(['GET', 'POST'], '/', 'foo'); + $routeGroup = new RouteGroup( + routes: [ + Route::some(['GET', 'POST'], '/', fn() => self::response('OK')), + Route::any('/', fn() => self::response('KO')), + ], + ); $getRequest = self::serverRequest('GET', '/'); $postRequest = self::serverRequest('POST', '/'); $putRequest = self::serverRequest('PUT', '/'); - $getResult = $route->match($getRequest); - $postResult = $route->match($postRequest); - $putResult = $route->match($putRequest); + $getResult = $this->router($routeGroup)->handle($getRequest); + $postResult = $this->router($routeGroup)->handle($postRequest); + $putResult = $this->router($routeGroup)->handle($putRequest); - self::assertSame([], $getResult); - self::assertSame([], $postResult); - self::assertSame(false, $putResult); + self::assertSame('OK', (string) $getResult->getBody()); + self::assertSame('OK', (string) $postResult->getBody()); + self::assertSame('KO', (string) $putResult->getBody()); } public function testMethodNameCanBeLowercase(): void { - $route = Route::some(['delete'], '/', 'foo'); - $deleteRequest = self::serverRequest('DELETE', '/'); + $route = Route::some(['delete'], '/', fn() => self::response('OK')); + $request = self::serverRequest('DELETE', '/'); - $deleteResult = $route->match($deleteRequest); + $result = $this->router(new RouteGroup(routes: [$route]))->handle($request); - self::assertSame([], $deleteResult); + self::assertSame('OK', (string) $result->getBody()); } } diff --git a/tests/MiddlewareTest.php b/tests/Features/MiddlewaresTest.php similarity index 76% rename from tests/MiddlewareTest.php rename to tests/Features/MiddlewaresTest.php index d575370..bfbfea3 100644 --- a/tests/MiddlewareTest.php +++ b/tests/Features/MiddlewaresTest.php @@ -1,35 +1,21 @@ 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', @@ -101,7 +83,7 @@ public function testCannotExecuteInvalidMiddlewares(mixed $middleware): void ); $request = self::serverRequest('GET', '/'); - self::expectException(InvalidRoute::class); + self::expectException(Throwable::class); $this->router($routeGroup)->handle($request); } @@ -112,7 +94,7 @@ public static function providerInvalidMiddlewares(): array { return [ 'not a middleware' => [UriFactory::class], - 'value that cannot be converted to a response' => [static fn(): array => ['foo' => 'bar']], + 'callable that does not return a response' => [static fn(): bool => true], ]; } } diff --git a/tests/Features/NameTest.php b/tests/Features/NameTest.php new file mode 100644 index 0000000..acf884e --- /dev/null +++ b/tests/Features/NameTest.php @@ -0,0 +1,47 @@ +router(new RouteGroup([$routeGroup])); + + $result = $router->pathTo('route_name'); + + self::assertSame('/foo', $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); + $router->pathTo('inexisting_route_name'); + } +} diff --git a/tests/PsrTrait.php b/tests/PsrTrait.php index 05615e4..46c040b 100644 --- a/tests/PsrTrait.php +++ b/tests/PsrTrait.php @@ -27,29 +27,29 @@ 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 +58,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(): ContainerInterface { $container = new Container(); 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/RouterCase.php b/tests/RouterCase.php new file mode 100644 index 0000000..ff1d628 --- /dev/null +++ b/tests/RouterCase.php @@ -0,0 +1,18 @@ +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/RouterTest.php b/tests/RouterTest.php new file mode 100644 index 0000000..5fc533e --- /dev/null +++ b/tests/RouterTest.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 testRouteGroupCanHaveAPathPrefix(): 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 testFiltersOutNonMatchingPaths(): 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 testCanHaveSubGroups(): void + { + $routeGroup = new RouteGroup( + routes: [ + new RouteGroup( + routes: [ + Route::get(path: '/sub', callback: static fn() => self::response('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() => self::response('TEST')), + ], + ), + Route::get(path: '/after-sub', callback: static fn() => self::response('TEST2')), + ], + ); + $request = self::serverRequest('GET', '/after-sub'); + + $response = $this->router($routeGroup)->handle($request); + + self::assertEquals('TEST2', (string)$response->getBody()); + } + + public function testCanUsePathParameters(): 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 testCanUseCustomPathParameterPatterns(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() => 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() => self::response('OK'))], + ) + ], + 'pattern defined in route' => [ + new RouteGroup( + routes: [ + Route::get(path: '/{foo}', callback: static fn() => self::response('OK'), where: ['foo' => '\d+']) + ], + ) + ], + 'pattern defined in route group' => [ + new RouteGroup( + routes: [Route::get(path: '/{foo}', callback: static fn() => self::response('OK'))], + where: ['foo' => '\d+'], + ) + ], + 'path pattern takes precedence over route pattern' => [ + new RouteGroup( + routes: [ + Route::get( + path: '/{foo:\d+}', + callback: static fn() => 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() => 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() => self::response('OK'))], + where: ['foo' => '\d+'], + ) + ], + where: ['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', '/baz'); + + self::expectException(EmptyRouteStack::class); + $this->router($routeGroup)->handle($request); + } + + public function testCanHaveAFallbackRoute(): 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()); - } -}