From 593c4bbeeaf309dbbeeb1be5a475c192f289c89a Mon Sep 17 00:00:00 2001 From: IngeniozIT Date: Tue, 27 Feb 2024 02:35:40 +0100 Subject: [PATCH 01/13] 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()); - } -} From 44476e6ac360123ccff18f43e3c41b3691b6b9e7 Mon Sep 17 00:00:00 2001 From: IngeniozIT Date: Tue, 27 Feb 2024 21:27:16 +0100 Subject: [PATCH 02/13] fix code quality --- src/{ => Exception}/EmptyRouteStack.php | 2 +- src/{ => Exception}/InvalidRoute.php | 2 +- src/Exception/InvalidRouteCondition.php | 11 +++ src/Exception/InvalidRouteHandler.php | 11 +++ src/Exception/InvalidRouteMiddleware.php | 11 +++ src/Exception/RouteNotFound.php | 11 +++ src/Handler/ConditionHandler.php | 42 +++++++++ src/Handler/MiddlewaresHandler.php | 48 ++++++++++ src/Handler/RouteHandler.php | 59 +++++++++++++ src/Route.php | 2 - src/RouteGroup.php | 9 +- src/RouteNotFound.php | 11 --- src/Router.php | 97 ++++++--------------- tests/Features/AdditionalAttributesTest.php | 8 +- tests/Features/CallbackTest.php | 10 +-- tests/Features/ConditionsTest.php | 8 +- tests/Features/HttpMethodTest.php | 13 +-- tests/Features/MiddlewaresTest.php | 12 +-- tests/Features/NameTest.php | 9 +- tests/PsrTrait.php | 3 +- tests/RouterTest.php | 27 +++--- 21 files changed, 272 insertions(+), 134 deletions(-) rename src/{ => Exception}/EmptyRouteStack.php (75%) rename src/{ => Exception}/InvalidRoute.php (76%) create mode 100644 src/Exception/InvalidRouteCondition.php create mode 100644 src/Exception/InvalidRouteHandler.php create mode 100644 src/Exception/InvalidRouteMiddleware.php create mode 100644 src/Exception/RouteNotFound.php create mode 100644 src/Handler/ConditionHandler.php create mode 100644 src/Handler/MiddlewaresHandler.php create mode 100644 src/Handler/RouteHandler.php delete mode 100644 src/RouteNotFound.php diff --git a/src/EmptyRouteStack.php b/src/Exception/EmptyRouteStack.php similarity index 75% rename from src/EmptyRouteStack.php rename to src/Exception/EmptyRouteStack.php index b11c1bb..a105f4d 100644 --- a/src/EmptyRouteStack.php +++ b/src/Exception/EmptyRouteStack.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace IngeniozIT\Router; +namespace IngeniozIT\Router\Exception; use OutOfRangeException; diff --git a/src/InvalidRoute.php b/src/Exception/InvalidRoute.php similarity index 76% rename from src/InvalidRoute.php rename to src/Exception/InvalidRoute.php index fb8bc62..e0b7045 100644 --- a/src/InvalidRoute.php +++ b/src/Exception/InvalidRoute.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace IngeniozIT\Router; +namespace IngeniozIT\Router\Exception; use InvalidArgumentException; diff --git a/src/Exception/InvalidRouteCondition.php b/src/Exception/InvalidRouteCondition.php new file mode 100644 index 0000000..63bef94 --- /dev/null +++ b/src/Exception/InvalidRouteCondition.php @@ -0,0 +1,11 @@ +container->get($callback) : $callback; + + if (!is_callable($handler)) { + throw new InvalidRouteCondition('Invalid condition handler'); + } + + $this->handler = $handler(...); + } + + /** + * @return array|false + */ + public function handle(ServerRequestInterface $request): array|false + { + $result = ($this->handler)($request); + + if ($result === false || is_array($result)) { + return $result; + } + + throw new InvalidRouteCondition('Condition must either return an array or false.'); + } +} diff --git a/src/Handler/MiddlewaresHandler.php b/src/Handler/MiddlewaresHandler.php new file mode 100644 index 0000000..7567da7 --- /dev/null +++ b/src/Handler/MiddlewaresHandler.php @@ -0,0 +1,48 @@ +container->get($callback) : $callback; + + if (is_callable($handler)) { + $this->handler = $handler(...); + return; + } + + if ($handler instanceof MiddlewareInterface) { + $this->handler = $handler->process(...); + return; + } + + throw new InvalidRouteMiddleware('Middleware must be a PSR Middleware or a callable.'); + } + + public function handle(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $result = ($this->handler)($request, $handler); + + if (!$result instanceof ResponseInterface) { + throw new InvalidRouteMiddleware('Middleware must return a PSR Response.'); + } + + return $result; + } +} diff --git a/src/Handler/RouteHandler.php b/src/Handler/RouteHandler.php new file mode 100644 index 0000000..bf722f6 --- /dev/null +++ b/src/Handler/RouteHandler.php @@ -0,0 +1,59 @@ +container->get($callback) : $callback; + + if ( + !($handler instanceof MiddlewareInterface) + && !($handler instanceof RequestHandlerInterface) + && !is_callable($handler) + ) { + throw new InvalidRouteHandler('Route handler must be a PSR Middleware, a PSR RequestHandler or a callable.'); + } + + $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 InvalidRouteHandler('Route handler must return a PSR Response.'); + } + + 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/Route.php b/src/Route.php index 5c69879..c227eb3 100644 --- a/src/Route.php +++ b/src/Route.php @@ -171,7 +171,6 @@ private function extractParametersFromPath(string $path): array /** * @param string[][] $parameters - * @param array $additionalPatterns * @return array */ private function extractParametersValue(array $parameters, string $path): array @@ -182,7 +181,6 @@ private function extractParametersValue(array $parameters, string $path): array /** * @param string[][] $parameters - * @param array $additionalPatterns */ private function buildRegex(array $parameters): string { diff --git a/src/RouteGroup.php b/src/RouteGroup.php index 00eb33c..c47a9c5 100644 --- a/src/RouteGroup.php +++ b/src/RouteGroup.php @@ -38,13 +38,14 @@ function (RouteGroup|Route $route) use ($with, $where, $name, $path): RouteGroup $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, + [...$where, ...$route->where], + [...$with, ...$route->with], + name: !empty($route->name) ? $this->concatenatedName($name) . $route->name : null, ); }, $routes, @@ -53,6 +54,6 @@ function (RouteGroup|Route $route) use ($with, $where, $name, $path): RouteGroup private function concatenatedName(?string $name): ?string { - return empty($name) ? $name : $name . '.'; + return $name === null || $name === '' ? $name : $name . '.'; } } diff --git a/src/RouteNotFound.php b/src/RouteNotFound.php deleted file mode 100644 index 5c8355f..0000000 --- a/src/RouteNotFound.php +++ /dev/null @@ -1,11 +0,0 @@ -routeGroup->middlewares[$this->middlewareIndex])) { - return $this->executeMiddlewares($request); + $middlewaresHandler = new MiddlewaresHandler($this->container, $this->routeGroup->middlewares[$this->middlewareIndex++]); + + return $middlewaresHandler->handle($request, $this); } return $this->executeRoutables($request); @@ -41,7 +47,9 @@ private function executeConditions(ServerRequestInterface $request): ResponseInt { $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); } @@ -54,42 +62,6 @@ 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 - { - $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."); - } - - return $handler($request, $this); - } - private function executeRoutables(ServerRequestInterface $request): ResponseInterface { while (isset($this->routeGroup->routes[$this->routeIndex])) { @@ -113,55 +85,34 @@ private function executeRoutables(ServerRequestInterface $request): ResponseInte foreach ($route->with as $key => $value) { $newRequest = $newRequest->withAttribute($key, $value); } + foreach ($matchedParams as $key => $value) { $newRequest = $newRequest->withAttribute($key, $value); } - return $this->callRouteHandler($route->callback, $newRequest); + $routeHandler = new RouteHandler($this->container, $route->callback); + return $routeHandler->handle($newRequest, $this); } 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); - } - - if (!is_callable($handler)) { - throw new InvalidRoute("Route callback is not callable."); - } - - return $handler($request, $this); - } - private function fallback(ServerRequestInterface $request): ResponseInterface { if ($this->fallback === null) { throw new EmptyRouteStack('No routes left to process.'); } - return $this->callRouteHandler($this->fallback, $request); - } - - private function resolveCallback(mixed $callback): mixed - { - return is_string($callback) ? $this->container->get($callback) : $callback; + $routeHandler = new RouteHandler($this->container, $this->fallback); + return $routeHandler->handle($request, $this); } public function pathTo(string $routeName): string { $route = $this->findNamedRoute($routeName, $this->routeGroup); - if ($route === null) { - throw new RouteNotFound("Route with name '{$routeName}' not found."); + if (!$route instanceof Route) { + throw new RouteNotFound("Route with name '$routeName' not found."); } return $route->path; @@ -172,9 +123,11 @@ private function findNamedRoute(string $routeName, RouteGroup $routeGroup): ?Rou foreach ($routeGroup->routes as $route) { if ($route instanceof RouteGroup) { $foundRoute = $this->findNamedRoute($routeName, $route); - if ($foundRoute !== null) { + if ($foundRoute instanceof Route) { return $foundRoute; } + + continue; } if ($route->name === $routeName) { diff --git a/tests/Features/AdditionalAttributesTest.php b/tests/Features/AdditionalAttributesTest.php index 769377c..5e714d7 100644 --- a/tests/Features/AdditionalAttributesTest.php +++ b/tests/Features/AdditionalAttributesTest.php @@ -8,7 +8,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -class AdditionalAttributesTest extends RouterCase +final class AdditionalAttributesTest extends RouterCase { public function testAddsRouteAttributesToRequest(): void { @@ -16,7 +16,7 @@ public function testAddsRouteAttributesToRequest(): void Route::get( path: '/', callback: static fn(ServerRequestInterface $request): ResponseInterface => self::response( - $request->getAttribute('foo') + '' . $request->getAttribute('foo') ), with: ['foo' => 'bar'], ), @@ -56,7 +56,7 @@ public function testRouteAttributesTakePrecedenceOverRouteGroupAttributes(): voi Route::get( path: '/', callback: static fn(ServerRequestInterface $request): ResponseInterface => self::response( - $request->getAttribute('foo') + '' . $request->getAttribute('foo') ), with: ['foo' => 'bar'], ), @@ -76,7 +76,7 @@ public function testPathParametersTakePrecedenceOverRouteAttributes(): void Route::get( path: '/{foo}', callback: static fn(ServerRequestInterface $request): ResponseInterface => self::response( - $request->getAttribute('foo') + '' . $request->getAttribute('foo') ), with: ['foo' => 'baz'], ), diff --git a/tests/Features/CallbackTest.php b/tests/Features/CallbackTest.php index 5bb3902..905e2bc 100644 --- a/tests/Features/CallbackTest.php +++ b/tests/Features/CallbackTest.php @@ -4,7 +4,7 @@ use Closure; use IngeniozIT\Http\Message\UriFactory; -use IngeniozIT\Router\InvalidRoute; +use IngeniozIT\Router\Exception\InvalidRouteHandler; use IngeniozIT\Router\Route; use IngeniozIT\Router\RouteGroup; use IngeniozIT\Router\Tests\Fakes\TestHandler; @@ -15,7 +15,7 @@ use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -class CallbackTest extends RouterCase +final class CallbackTest extends RouterCase { /** * @dataProvider providerCallbacks @@ -58,14 +58,14 @@ public static function providerCallbacks(): array /** * @dataProvider providerInvalidHandlers */ - public function testRouterCannotExecuteAnInvalidCallback(): void + public function testRouterCannotExecuteAnInvalidCallback(mixed $callback): void { $routeGroup = new RouteGroup(routes: [ - Route::get(path: '/', callback: UriFactory::class), + Route::get(path: '/', callback: $callback), ]); $request = self::serverRequest('GET', '/'); - self::expectException(InvalidRoute::class); + self::expectException(InvalidRouteHandler::class); $this->router($routeGroup)->handle($request); } diff --git a/tests/Features/ConditionsTest.php b/tests/Features/ConditionsTest.php index 0dd5545..42d6fdf 100644 --- a/tests/Features/ConditionsTest.php +++ b/tests/Features/ConditionsTest.php @@ -4,14 +4,14 @@ use Closure; use IngeniozIT\Http\Message\UriFactory; -use IngeniozIT\Router\InvalidRoute; +use IngeniozIT\Router\Exception\InvalidRouteCondition; use IngeniozIT\Router\Route; use IngeniozIT\Router\RouteGroup; use IngeniozIT\Router\Tests\RouterCase; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -class ConditionsTest extends RouterCase +final class ConditionsTest extends RouterCase { /** * @dataProvider providerConditions @@ -101,7 +101,7 @@ public function testCannotExecuteInvalidConditions(mixed $condition): void ); $request = self::serverRequest('GET', '/'); - self::expectException(InvalidRoute::class); + self::expectException(InvalidRouteCondition::class); $this->router($routeGroup)->handle($request); } @@ -112,7 +112,7 @@ public static function providerInvalidConditions(): array { return [ 'not a callable' => [UriFactory::class], - 'callable that does not return bool or array' => [static fn(): int => 42], + 'callable that does not return bool or array' => [static fn(): bool => true], ]; } } diff --git a/tests/Features/HttpMethodTest.php b/tests/Features/HttpMethodTest.php index 3838309..9e2d402 100644 --- a/tests/Features/HttpMethodTest.php +++ b/tests/Features/HttpMethodTest.php @@ -5,8 +5,9 @@ use IngeniozIT\Router\Route; use IngeniozIT\Router\RouteGroup; use IngeniozIT\Router\Tests\RouterCase; +use Psr\Http\Message\ResponseInterface; -class HttpMethodTest extends RouterCase +final class HttpMethodTest extends RouterCase { /** * @dataProvider providerMethodsAndRoutes @@ -14,7 +15,7 @@ class HttpMethodTest extends RouterCase public function testRouteMatchesRequestsBasedOnMethod(string $method, callable $routeCallable): void { /** @var Route $route */ - $route = $routeCallable('/', fn() => self::response('OK')); + $route = $routeCallable('/', static fn(): ResponseInterface => self::response('OK')); $request = self::serverRequest($method, '/'); $response = $this->router(new RouteGroup(routes: [$route]))->handle($request); @@ -43,7 +44,7 @@ public static function providerMethodsAndRoutes(): array */ public function testRouteCanMatchAnyMethod(string $method): void { - $route = Route::any('/', fn() => self::response('OK')); + $route = Route::any('/', static fn(): ResponseInterface => self::response('OK')); $request = self::serverRequest($method, '/'); $response = $this->router(new RouteGroup(routes: [$route]))->handle($request); @@ -71,8 +72,8 @@ public function testCanMatchSomeMethods(): void { $routeGroup = new RouteGroup( routes: [ - Route::some(['GET', 'POST'], '/', fn() => self::response('OK')), - Route::any('/', fn() => self::response('KO')), + Route::some(['GET', 'POST'], '/', static fn(): ResponseInterface => self::response('OK')), + Route::any('/', static fn(): ResponseInterface => self::response('KO')), ], ); $getRequest = self::serverRequest('GET', '/'); @@ -90,7 +91,7 @@ public function testCanMatchSomeMethods(): void public function testMethodNameCanBeLowercase(): void { - $route = Route::some(['delete'], '/', fn() => self::response('OK')); + $route = Route::some(['delete'], '/', static fn(): ResponseInterface => self::response('OK')); $request = self::serverRequest('DELETE', '/'); $result = $this->router(new RouteGroup(routes: [$route]))->handle($request); diff --git a/tests/Features/MiddlewaresTest.php b/tests/Features/MiddlewaresTest.php index bfbfea3..9826d9a 100644 --- a/tests/Features/MiddlewaresTest.php +++ b/tests/Features/MiddlewaresTest.php @@ -4,7 +4,7 @@ use Exception; use IngeniozIT\Http\Message\UriFactory; -use IngeniozIT\Router\InvalidRoute; +use IngeniozIT\Router\Exception\InvalidRouteMiddleware; use IngeniozIT\Router\Route; use IngeniozIT\Router\RouteGroup; use IngeniozIT\Router\Tests\Fakes\TestMiddleware; @@ -12,9 +12,10 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; -use Throwable; -class MiddlewaresTest extends RouterCase +use function IngeniozIT\Edict\value; + +final class MiddlewaresTest extends RouterCase { /** * @dataProvider providerMiddlewares @@ -58,7 +59,7 @@ public function testRouteGroupCanHaveMultipleMiddlewares(): void 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): \Psr\Http\Message\ResponseInterface => $handler->handle($request), static fn(ServerRequestInterface $request, RequestHandlerInterface $handler) => throw new Exception(''), ], ); @@ -83,7 +84,7 @@ public function testCannotExecuteInvalidMiddlewares(mixed $middleware): void ); $request = self::serverRequest('GET', '/'); - self::expectException(Throwable::class); + self::expectException(InvalidRouteMiddleware::class); $this->router($routeGroup)->handle($request); } @@ -92,6 +93,7 @@ public function testCannotExecuteInvalidMiddlewares(mixed $middleware): void */ public static function providerInvalidMiddlewares(): array { + self::container()->set('not_a_callable', value('foo')); return [ 'not a middleware' => [UriFactory::class], 'callable that does not return a response' => [static fn(): bool => true], diff --git a/tests/Features/NameTest.php b/tests/Features/NameTest.php index acf884e..b1e0fef 100644 --- a/tests/Features/NameTest.php +++ b/tests/Features/NameTest.php @@ -2,19 +2,22 @@ namespace IngeniozIT\Router\Tests\Features; +use IngeniozIT\Router\Exception\RouteNotFound; use IngeniozIT\Router\Route; use IngeniozIT\Router\RouteGroup; -use IngeniozIT\Router\RouteNotFound; use IngeniozIT\Router\Tests\RouterCase; -class NameTest extends RouterCase +final class NameTest extends RouterCase { public function testRouterCanFindARoutePathByName(): void { $routeGroup = new RouteGroup([ Route::get('/foo', 'foo', name: 'route_name'), ]); - $router = $this->router(new RouteGroup([$routeGroup])); + $router = $this->router(new RouteGroup([ + new RouteGroup([]), + $routeGroup + ])); $result = $router->pathTo('route_name'); diff --git a/tests/PsrTrait.php b/tests/PsrTrait.php index 46c040b..6366c2b 100644 --- a/tests/PsrTrait.php +++ b/tests/PsrTrait.php @@ -13,7 +13,6 @@ UploadedFileFactoryInterface, UriFactoryInterface, }; -use Psr\Container\ContainerInterface; use IngeniozIT\Http\Message\{ ResponseFactory, ServerRequestFactory, @@ -70,7 +69,7 @@ protected static function response(string $content): ResponseInterface ); } - protected static function container(): ContainerInterface + protected static function container(): Container { $container = new Container(); diff --git a/tests/RouterTest.php b/tests/RouterTest.php index 5fc533e..a1da19b 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -1,15 +1,14 @@ self::response('TEST')), + Route::get(path: '/sub', callback: static fn(): ResponseInterface => self::response('TEST')), ], ), ], @@ -75,10 +74,10 @@ public function testCanHandleARouteAfterASubGroup(): void routes: [ new RouteGroup( routes: [ - Route::get(path: '/sub', callback: static fn() => self::response('TEST')), + Route::get(path: '/sub', callback: static fn(): ResponseInterface => self::response('TEST')), ], ), - Route::get(path: '/after-sub', callback: static fn() => self::response('TEST2')), + Route::get(path: '/after-sub', callback: static fn(): ResponseInterface => self::response('TEST2')), ], ); $request = self::serverRequest('GET', '/after-sub'); @@ -110,7 +109,7 @@ public function testCanUseCustomPathParameterPatterns(RouteGroup $routeGroup): v $nonMatchingRequest = self::serverRequest('GET', '/abc'); $matchingResponse = $this->router($routeGroup)->handle($matchingRequest); - $nonMatchingResponse = $this->router($routeGroup, static fn() => self::response('KO'))->handle($nonMatchingRequest); + $nonMatchingResponse = $this->router($routeGroup, static fn(): ResponseInterface => self::response('KO'))->handle($nonMatchingRequest); self::assertEquals('OK', (string)$matchingResponse->getBody()); self::assertEquals('KO', (string)$nonMatchingResponse->getBody()); @@ -124,19 +123,19 @@ public static function providerRouteGroupsWithCustomParameters(): array return [ 'pattern defined in path' => [ new RouteGroup( - routes: [Route::get(path: '/{foo:\d+}', callback: static fn() => self::response('OK'))], + 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() => self::response('OK'), where: ['foo' => '\d+']) + 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() => self::response('OK'))], + routes: [Route::get(path: '/{foo}', callback: static fn(): ResponseInterface => self::response('OK'))], where: ['foo' => '\d+'], ) ], @@ -145,7 +144,7 @@ public static function providerRouteGroupsWithCustomParameters(): array routes: [ Route::get( path: '/{foo:\d+}', - callback: static fn() => self::response('OK'), + callback: static fn(): ResponseInterface => self::response('OK'), where: ['foo' => '[a-z]+'] ) ], @@ -154,7 +153,7 @@ public static function providerRouteGroupsWithCustomParameters(): array 'route pattern takes precedence over route group pattern' => [ new RouteGroup( routes: [ - Route::get(path: '/{foo}', callback: static fn() => self::response('OK'), where: ['foo' => '\d+']) + Route::get(path: '/{foo}', callback: static fn(): ResponseInterface => self::response('OK'), where: ['foo' => '\d+']) ], where: ['foo' => '[a-z]+'], ) @@ -163,7 +162,7 @@ public static function providerRouteGroupsWithCustomParameters(): array new RouteGroup( routes: [ new RouteGroup( - routes: [Route::get(path: '/{foo}', callback: static fn() => self::response('OK'))], + routes: [Route::get(path: '/{foo}', callback: static fn(): ResponseInterface => self::response('OK'))], where: ['foo' => '\d+'], ) ], From adeb45b1bf933d8931342b403ab5695c39f06819 Mon Sep 17 00:00:00 2001 From: IngeniozIT Date: Tue, 27 Feb 2024 23:10:59 +0100 Subject: [PATCH 03/13] Router::pathTo handles custom parameters --- index.php | 64 ------------------------- src/Exception/InvalidRouteParameter.php | 23 +++++++++ src/Exception/MissingRouteParameter.php | 22 +++++++++ src/Route.php | 40 ++++++++++++---- src/Router.php | 42 ++++++++++++---- tests/Features/HttpMethodTest.php | 6 +-- tests/Features/NameTest.php | 35 ++++++++++++++ 7 files changed, 145 insertions(+), 87 deletions(-) delete mode 100644 index.php create mode 100644 src/Exception/InvalidRouteParameter.php create mode 100644 src/Exception/MissingRouteParameter.php diff --git a/index.php b/index.php deleted file mode 100644 index 08c6541..0000000 --- a/index.php +++ /dev/null @@ -1,64 +0,0 @@ - '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/src/Exception/InvalidRouteParameter.php b/src/Exception/InvalidRouteParameter.php new file mode 100644 index 0000000..e2e46e5 --- /dev/null +++ b/src/Exception/InvalidRouteParameter.php @@ -0,0 +1,23 @@ + self::OPTIONS, ]; + /** @var array */ + public array $where; + public string $path; + public bool $hasParameters; + /** * @param array $where * @param array $with @@ -113,10 +118,7 @@ public static function any(string $path, mixed $callback, array $where = [], arr */ 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)]; - } + $method = array_reduce($methods, fn($carry, $methodString) => $carry | self::METHODS[strtoupper($methodString)], 0); return new self($method, $path, $callback, $where, $with, $name); } @@ -127,12 +129,25 @@ public static function some(array $methods, string $path, mixed $callback, array */ public function __construct( public int $method, - public string $path, + string $path, public mixed $callback, - public array $where = [], + array $where = [], public array $with = [], public ?string $name = null, ) { + $this->hasParameters = str_contains($path, '{'); + [$this->where, $this->path] = $this->extractPatterns($where, $path); + } + + private function extractPatterns(array $where, string $path): array + { + if ($this->hasParameters && str_contains($path, ':') && preg_match_all('#{(\w+):([^}]+)}#', $path, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $path = str_replace($match[0], '{' . $match[1] . '}', $path); + $where[$match[1]] = $match[2]; + } + } + return [$where, $path]; } /** @@ -145,12 +160,12 @@ public function match(ServerRequestInterface $request): false|array } $path = $request->getUri()->getPath(); - $parameters = $this->extractParametersFromPath($this->path); - if ($parameters === []) { + if (!$this->hasParameters) { return $path === $this->path ? [] : false; } + $parameters = $this->extractParametersFromPath($this->path); $extractedParameters = $this->extractParametersValue($parameters, $path); return $extractedParameters === [] ? false : $extractedParameters; } @@ -165,7 +180,7 @@ private function httpMethodMatches(string $method): bool */ private function extractParametersFromPath(string $path): array { - preg_match_all('/{([^:]+)(?::(.+))?}/U', $path, $matches, PREG_SET_ORDER); + preg_match_all('/{([^:]+)}/U', $path, $matches, PREG_SET_ORDER); return $matches; } @@ -188,11 +203,16 @@ private function buildRegex(array $parameters): string foreach ($parameters as $parameter) { $regex = str_replace( preg_quote($parameter[0], '#'), - '(?<' . $parameter[1] . '>' . ($parameter[2] ?? $this->where[$parameter[1]] ?? '[^/]+') . ')', + '(?<' . $parameter[1] . '>' . $this->parameterPattern($parameter[1]) . ')', $regex ); } return $regex; } + + public function parameterPattern(string $parameterName): string + { + return $this->where[$parameterName] ?? '[^/]+'; + } } diff --git a/src/Router.php b/src/Router.php index 4e18b85..1d4f53c 100644 --- a/src/Router.php +++ b/src/Router.php @@ -5,6 +5,8 @@ namespace IngeniozIT\Router; use IngeniozIT\Router\Exception\EmptyRouteStack; +use IngeniozIT\Router\Exception\InvalidRouteParameter; +use IngeniozIT\Router\Exception\MissingRouteParameter; use IngeniozIT\Router\Exception\RouteNotFound; use IngeniozIT\Router\Handler\ConditionHandler; use IngeniozIT\Router\Handler\MiddlewaresHandler; @@ -107,32 +109,52 @@ private function fallback(ServerRequestInterface $request): ResponseInterface return $routeHandler->handle($request, $this); } - public function pathTo(string $routeName): string + public function pathTo(string $routeName, array $parameters = []): string { - $route = $this->findNamedRoute($routeName, $this->routeGroup); + $route = $this->findNamedRoute($routeName, $parameters, $this->routeGroup); - if (!$route instanceof Route) { + if (!$route) { throw new RouteNotFound("Route with name '$routeName' not found."); } - return $route->path; + return $route; } - private function findNamedRoute(string $routeName, RouteGroup $routeGroup): ?Route + private function findNamedRoute(string $routeName, array $parameters, RouteGroup $routeGroup): ?string { foreach ($routeGroup->routes as $route) { if ($route instanceof RouteGroup) { - $foundRoute = $this->findNamedRoute($routeName, $route); - if ($foundRoute instanceof Route) { - return $foundRoute; + $foundRoute = $this->findNamedRoute($routeName, $parameters, $route); + if ($foundRoute === null) { + continue; } + return $foundRoute; + } + if ($route->name !== $routeName) { continue; } - if ($route->name === $routeName) { - return $route; + if (!$route->hasParameters) { + return $route->path; + } + + $matchedParams = []; + preg_match_all('#{(\w+)}#', $route->path, $matches, PREG_SET_ORDER); + foreach ($matches as $match) { + if (!isset($parameters[$match[1]])) { + throw new MissingRouteParameter($routeName, $match[1]); + } + if (!preg_match('#^' . $route->parameterPattern($match[1]) . '$#', $parameters[$match[1]])) { + throw new InvalidRouteParameter($routeName, $match[1], $route->parameterPattern($match[1])); + } + $matchedParams[$match[1]] = $parameters[$match[1]]; + } + $path = $route->path; + foreach ($matchedParams as $key => $value) { + $path = str_replace('{' . $key . '}', $value, $path); } + return $path; } return null; diff --git a/tests/Features/HttpMethodTest.php b/tests/Features/HttpMethodTest.php index 9e2d402..5e5c974 100644 --- a/tests/Features/HttpMethodTest.php +++ b/tests/Features/HttpMethodTest.php @@ -72,7 +72,7 @@ public function testCanMatchSomeMethods(): void { $routeGroup = new RouteGroup( routes: [ - Route::some(['GET', 'POST'], '/', static fn(): ResponseInterface => self::response('OK')), + Route::some(['POST', 'PUT'], '/', static fn(): ResponseInterface => self::response('OK')), Route::any('/', static fn(): ResponseInterface => self::response('KO')), ], ); @@ -84,9 +84,9 @@ public function testCanMatchSomeMethods(): void $postResult = $this->router($routeGroup)->handle($postRequest); $putResult = $this->router($routeGroup)->handle($putRequest); - self::assertSame('OK', (string) $getResult->getBody()); + self::assertSame('KO', (string) $getResult->getBody()); self::assertSame('OK', (string) $postResult->getBody()); - self::assertSame('KO', (string) $putResult->getBody()); + self::assertSame('OK', (string) $putResult->getBody()); } public function testMethodNameCanBeLowercase(): void diff --git a/tests/Features/NameTest.php b/tests/Features/NameTest.php index b1e0fef..6f9ed59 100644 --- a/tests/Features/NameTest.php +++ b/tests/Features/NameTest.php @@ -2,6 +2,8 @@ namespace IngeniozIT\Router\Tests\Features; +use IngeniozIT\Router\Exception\InvalidRouteParameter; +use IngeniozIT\Router\Exception\MissingRouteParameter; use IngeniozIT\Router\Exception\RouteNotFound; use IngeniozIT\Router\Route; use IngeniozIT\Router\RouteGroup; @@ -12,6 +14,7 @@ final class NameTest extends RouterCase public function testRouterCanFindARoutePathByName(): void { $routeGroup = new RouteGroup([ + Route::get('/bar', 'foo', name: 'not_this_route'), Route::get('/foo', 'foo', name: 'route_name'), ]); $router = $this->router(new RouteGroup([ @@ -24,6 +27,18 @@ public function testRouterCanFindARoutePathByName(): void self::assertSame('/foo', $result); } + public function testRouterCanFindARoutePathByNameWithParameters(): 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 testRouteGroupsPassTheirNameToTheirSubRoutes(): void { $routeGroup = new RouteGroup( @@ -47,4 +62,24 @@ public function testRouterCannotFindAnInexistingRoutePathByName(): void self::expectException(RouteNotFound::class); $router->pathTo('inexisting_route_name'); } + + public function testRouterCannotFindARoutePathWithMissingParameters(): void + { + $route = Route::get('/{foo}', 'foo', name: 'route_name'); + $router = $this->router(new RouteGroup([$route])); + + self::expectException(MissingRouteParameter::class); + self::expectExceptionMessage("Missing parameter 'foo' for route with name 'route_name'."); + $router->pathTo('route_name'); + } + + 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 with name 'route_name' does not match the pattern '\d+'."); + $router->pathTo('route_name', ['foo' => 'bar']); + } } From 782a55a15475ebd7a03bf234459794d669f4f691 Mon Sep 17 00:00:00 2001 From: IngeniozIT Date: Wed, 28 Feb 2024 01:55:35 +0100 Subject: [PATCH 04/13] allow pathTo to append extra parameters as the path query --- src/Exception/InvalidRouteParameter.php | 4 +- ...rameter.php => MissingRouteParameters.php} | 9 +- src/Route.php | 138 +++------------- src/RouteElement.php | 154 ++++++++++++++++++ src/RouteGroup.php | 8 +- src/Router.php | 80 ++++----- tests/Features/HttpMethodTest.php | 3 +- tests/Features/NameTest.php | 24 ++- 8 files changed, 246 insertions(+), 174 deletions(-) rename src/Exception/{MissingRouteParameter.php => MissingRouteParameters.php} (53%) create mode 100644 src/RouteElement.php diff --git a/src/Exception/InvalidRouteParameter.php b/src/Exception/InvalidRouteParameter.php index e2e46e5..fbe0fd1 100644 --- a/src/Exception/InvalidRouteParameter.php +++ b/src/Exception/InvalidRouteParameter.php @@ -11,12 +11,12 @@ final class InvalidRouteParameter extends InvalidArgumentException { public function __construct( string $routeName, - string $parameterName, + string $missingParameters, string $pattern, ?Throwable $previous = null ) { parent::__construct( - "Parameter '$parameterName' for route with name '$routeName' does not match the pattern '$pattern'.", + "Parameter '$missingParameters' for route with name '$routeName' does not match the pattern '$pattern'.", previous: $previous, ); } diff --git a/src/Exception/MissingRouteParameter.php b/src/Exception/MissingRouteParameters.php similarity index 53% rename from src/Exception/MissingRouteParameter.php rename to src/Exception/MissingRouteParameters.php index fe50ed6..0c18700 100644 --- a/src/Exception/MissingRouteParameter.php +++ b/src/Exception/MissingRouteParameters.php @@ -7,15 +7,18 @@ use InvalidArgumentException; use Throwable; -final class MissingRouteParameter extends InvalidArgumentException +final class MissingRouteParameters extends InvalidArgumentException { + /** + * @param string[] $missingParameters + */ public function __construct( string $routeName, - string $parameterName, + array $missingParameters, ?Throwable $previous = null ) { parent::__construct( - "Missing parameter '$parameterName' for route with name '$routeName'.", + "Missing parameters " . implode(', ', $missingParameters) . " for route with name '$routeName'.", previous: $previous, ); } diff --git a/src/Route.php b/src/Route.php index 59dceb3..f8ee9d6 100644 --- a/src/Route.php +++ b/src/Route.php @@ -4,8 +4,6 @@ namespace IngeniozIT\Router; -use Psr\Http\Message\ServerRequestInterface; - final readonly class Route { public const GET = 0b0000001; @@ -24,7 +22,7 @@ public const ANY = 0b1111111; - private const METHODS = [ + public const METHODS = [ 'GET' => self::GET, 'POST' => self::POST, 'PUT' => self::PUT, @@ -34,81 +32,76 @@ 'OPTIONS' => self::OPTIONS, ]; - /** @var array */ - public array $where; - public string $path; - public bool $hasParameters; - /** * @param array $where * @param array $with */ - public static function get(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): self + public static function get(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): RouteElement { - return new self(self::GET, $path, $callback, $where, $with, $name); + return new RouteElement(self::GET, $path, $callback, $where, $with, $name); } /** * @param array $where * @param array $with */ - public static function post(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): self + public static function post(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): RouteElement { - return new self(self::POST, $path, $callback, $where, $with, $name); + return new RouteElement(self::POST, $path, $callback, $where, $with, $name); } /** * @param array $where * @param array $with */ - public static function put(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): self + public static function put(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): RouteElement { - return new self(self::PUT, $path, $callback, $where, $with, $name); + return new RouteElement(self::PUT, $path, $callback, $where, $with, $name); } /** * @param array $where * @param array $with */ - public static function patch(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): self + public static function patch(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): RouteElement { - return new self(self::PATCH, $path, $callback, $where, $with, $name); + return new RouteElement(self::PATCH, $path, $callback, $where, $with, $name); } /** * @param array $where * @param array $with */ - public static function delete(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): self + public static function delete(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): RouteElement { - return new self(self::DELETE, $path, $callback, $where, $with, $name); + return new RouteElement(self::DELETE, $path, $callback, $where, $with, $name); } /** * @param array $where * @param array $with */ - public static function head(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): self + public static function head(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): RouteElement { - return new self(self::HEAD, $path, $callback, $where, $with, $name); + return new RouteElement(self::HEAD, $path, $callback, $where, $with, $name); } /** * @param array $where * @param array $with */ - public static function options(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): self + public static function options(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): RouteElement { - return new self(self::OPTIONS, $path, $callback, $where, $with, $name); + return new RouteElement(self::OPTIONS, $path, $callback, $where, $with, $name); } /** * @param array $where * @param array $with */ - public static function any(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): self + public static function any(string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): RouteElement { - return new self(self::ANY, $path, $callback, $where, $with, $name); + return new RouteElement(self::ANY, $path, $callback, $where, $with, $name); } /** @@ -116,103 +109,10 @@ public static function any(string $path, mixed $callback, array $where = [], arr * @param array $where * @param array $with */ - public static function some(array $methods, string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): self + public static function some(array $methods, string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): RouteElement { $method = array_reduce($methods, fn($carry, $methodString) => $carry | self::METHODS[strtoupper($methodString)], 0); - return new self($method, $path, $callback, $where, $with, $name); - } - - /** - * @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->where, $this->path] = $this->extractPatterns($where, $path); - } - - private function extractPatterns(array $where, string $path): array - { - if ($this->hasParameters && str_contains($path, ':') && preg_match_all('#{(\w+):([^}]+)}#', $path, $matches, PREG_SET_ORDER)) { - foreach ($matches as $match) { - $path = str_replace($match[0], '{' . $match[1] . '}', $path); - $where[$match[1]] = $match[2]; - } - } - return [$where, $path]; - } - - /** - * @return false|array - */ - public function match(ServerRequestInterface $request): false|array - { - if (!$this->httpMethodMatches($request->getMethod())) { - return false; - } - - $path = $request->getUri()->getPath(); - - if (!$this->hasParameters) { - return $path === $this->path ? [] : false; - } - - $parameters = $this->extractParametersFromPath($this->path); - $extractedParameters = $this->extractParametersValue($parameters, $path); - return $extractedParameters === [] ? false : $extractedParameters; - } - - 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 - * @return array - */ - private function extractParametersValue(array $parameters, string $path): array - { - preg_match($this->buildRegex($parameters), $path, $parameters); - return array_filter($parameters, 'is_string', ARRAY_FILTER_USE_KEY); - } - - /** - * @param string[][] $parameters - */ - private function buildRegex(array $parameters): string - { - $regex = '#' . preg_quote($this->path, '#') . '#'; - foreach ($parameters as $parameter) { - $regex = str_replace( - preg_quote($parameter[0], '#'), - '(?<' . $parameter[1] . '>' . $this->parameterPattern($parameter[1]) . ')', - $regex - ); - } - - return $regex; - } - - public function parameterPattern(string $parameterName): string - { - return $this->where[$parameterName] ?? '[^/]+'; + return new RouteElement($method, $path, $callback, $where, $with, $name); } } diff --git a/src/RouteElement.php b/src/RouteElement.php new file mode 100644 index 0000000..120a0c3 --- /dev/null +++ b/src/RouteElement.php @@ -0,0 +1,154 @@ + */ + 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/RouteGroup.php b/src/RouteGroup.php index c47a9c5..f986b07 100644 --- a/src/RouteGroup.php +++ b/src/RouteGroup.php @@ -6,11 +6,11 @@ final class RouteGroup { - /** @var Route[]|RouteGroup[] */ + /** @var RouteElement[]|RouteGroup[] */ public array $routes; /** - * @param array $routes + * @param array $routes * @param mixed[] $middlewares * @param mixed[] $conditions * @param array $where @@ -26,7 +26,7 @@ public function __construct( ?string $path = null, ) { $this->routes = array_map( - function (RouteGroup|Route $route) use ($with, $where, $name, $path): RouteGroup|Route { + function (RouteGroup|RouteElement $route) use ($with, $where, $name, $path): RouteGroup|RouteElement { if ($route instanceof RouteGroup) { return new RouteGroup( $route->routes, @@ -39,7 +39,7 @@ function (RouteGroup|Route $route) use ($with, $where, $name, $path): RouteGroup ); } - return new Route( + return new RouteElement( $route->method, $path . $route->path, $route->callback, diff --git a/src/Router.php b/src/Router.php index 1d4f53c..185af46 100644 --- a/src/Router.php +++ b/src/Router.php @@ -5,8 +5,6 @@ namespace IngeniozIT\Router; use IngeniozIT\Router\Exception\EmptyRouteStack; -use IngeniozIT\Router\Exception\InvalidRouteParameter; -use IngeniozIT\Router\Exception\MissingRouteParameter; use IngeniozIT\Router\Exception\RouteNotFound; use IngeniozIT\Router\Handler\ConditionHandler; use IngeniozIT\Router\Handler\MiddlewaresHandler; @@ -33,19 +31,17 @@ 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])) { - $middlewaresHandler = new MiddlewaresHandler($this->container, $this->routeGroup->middlewares[$this->middlewareIndex++]); - - return $middlewaresHandler->handle($request, $this); + 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])) { @@ -64,7 +60,17 @@ private function executeConditions(ServerRequestInterface $request): ResponseInt return $this->handle($newRequest); } - private function executeRoutables(ServerRequestInterface $request): ResponseInterface + private function handleNextMiddleware(ServerRequestInterface $request): ResponseInterface + { + $middlewaresHandler = new MiddlewaresHandler( + $this->container, + $this->routeGroup->middlewares[$this->middlewareIndex++] + ); + + return $middlewaresHandler->handle($request, $this); + } + + private function handleRoutes(ServerRequestInterface $request): ResponseInterface { while (isset($this->routeGroup->routes[$this->routeIndex])) { $route = $this->routeGroup->routes[$this->routeIndex++]; @@ -83,20 +89,29 @@ private function executeRoutables(ServerRequestInterface $request): ResponseInte continue; } - $newRequest = $request; - foreach ($route->with as $key => $value) { - $newRequest = $newRequest->withAttribute($key, $value); - } + return $this->handleRouteElement($request, $route, $matchedParams); + } - foreach ($matchedParams as $key => $value) { - $newRequest = $newRequest->withAttribute($key, $value); - } + return $this->fallback($request); + } - $routeHandler = new RouteHandler($this->container, $route->callback); - return $routeHandler->handle($newRequest, $this); + /** + * @param array $matchedParams + */ + private function handleRouteElement( + ServerRequestInterface $request, + RouteElement $route, + array $matchedParams + ): ResponseInterface { + foreach ($route->with as $key => $value) { + $request = $request->withAttribute($key, $value); + } + foreach ($matchedParams as $key => $value) { + $request = $request->withAttribute($key, $value); } - return $this->fallback($request); + $routeHandler = new RouteHandler($this->container, $route->callback); + return $routeHandler->handle($request, $this); } private function fallback(ServerRequestInterface $request): ResponseInterface @@ -109,6 +124,9 @@ private function fallback(ServerRequestInterface $request): ResponseInterface return $routeHandler->handle($request, $this); } + /** + * @param array $parameters + */ public function pathTo(string $routeName, array $parameters = []): string { $route = $this->findNamedRoute($routeName, $parameters, $this->routeGroup); @@ -120,6 +138,9 @@ public function pathTo(string $routeName, array $parameters = []): string return $route; } + /** + * @param array $parameters + */ private function findNamedRoute(string $routeName, array $parameters, RouteGroup $routeGroup): ?string { foreach ($routeGroup->routes as $route) { @@ -135,26 +156,7 @@ private function findNamedRoute(string $routeName, array $parameters, RouteGroup continue; } - if (!$route->hasParameters) { - return $route->path; - } - - $matchedParams = []; - preg_match_all('#{(\w+)}#', $route->path, $matches, PREG_SET_ORDER); - foreach ($matches as $match) { - if (!isset($parameters[$match[1]])) { - throw new MissingRouteParameter($routeName, $match[1]); - } - if (!preg_match('#^' . $route->parameterPattern($match[1]) . '$#', $parameters[$match[1]])) { - throw new InvalidRouteParameter($routeName, $match[1], $route->parameterPattern($match[1])); - } - $matchedParams[$match[1]] = $parameters[$match[1]]; - } - $path = $route->path; - foreach ($matchedParams as $key => $value) { - $path = str_replace('{' . $key . '}', $value, $path); - } - return $path; + return $route->buildPath($parameters); } return null; diff --git a/tests/Features/HttpMethodTest.php b/tests/Features/HttpMethodTest.php index 5e5c974..7cf0f19 100644 --- a/tests/Features/HttpMethodTest.php +++ b/tests/Features/HttpMethodTest.php @@ -3,6 +3,7 @@ namespace IngeniozIT\Router\Tests\Features; use IngeniozIT\Router\Route; +use IngeniozIT\Router\RouteElement; use IngeniozIT\Router\RouteGroup; use IngeniozIT\Router\Tests\RouterCase; use Psr\Http\Message\ResponseInterface; @@ -14,7 +15,7 @@ final class HttpMethodTest extends RouterCase */ public function testRouteMatchesRequestsBasedOnMethod(string $method, callable $routeCallable): void { - /** @var Route $route */ + /** @var RouteElement $route */ $route = $routeCallable('/', static fn(): ResponseInterface => self::response('OK')); $request = self::serverRequest($method, '/'); diff --git a/tests/Features/NameTest.php b/tests/Features/NameTest.php index 6f9ed59..5c4092e 100644 --- a/tests/Features/NameTest.php +++ b/tests/Features/NameTest.php @@ -3,7 +3,7 @@ namespace IngeniozIT\Router\Tests\Features; use IngeniozIT\Router\Exception\InvalidRouteParameter; -use IngeniozIT\Router\Exception\MissingRouteParameter; +use IngeniozIT\Router\Exception\MissingRouteParameters; use IngeniozIT\Router\Exception\RouteNotFound; use IngeniozIT\Router\Route; use IngeniozIT\Router\RouteGroup; @@ -34,11 +34,23 @@ public function testRouterCanFindARoutePathByNameWithParameters(): void ]); $router = $this->router($routeGroup); - $result = $router->pathTo('route_name', ['foo' => '42']); + $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( @@ -65,12 +77,12 @@ public function testRouterCannotFindAnInexistingRoutePathByName(): void public function testRouterCannotFindARoutePathWithMissingParameters(): void { - $route = Route::get('/{foo}', 'foo', name: 'route_name'); + $route = Route::get('/{foo}/{bar}', 'foo', name: 'route_name'); $router = $this->router(new RouteGroup([$route])); - self::expectException(MissingRouteParameter::class); - self::expectExceptionMessage("Missing parameter 'foo' for route with name 'route_name'."); - $router->pathTo('route_name'); + self::expectException(MissingRouteParameters::class); + self::expectExceptionMessage("Missing parameters foo for route with name 'route_name'."); + $router->pathTo('route_name', ['bar' => '42']); } public function testRouterCannotFindARoutePathWithInvalidParameters(): void From 9db01df212def2dd854c5321fba67c2df22ec30e Mon Sep 17 00:00:00 2001 From: IngeniozIT Date: Wed, 28 Feb 2024 02:12:43 +0100 Subject: [PATCH 05/13] improve tests structure and readability --- .../AdditionalAttributesTest.php | 8 +-- tests/{Features => }/CallbackTest.php | 10 ++-- tests/{Features => }/ConditionsTest.php | 12 ++-- tests/{Features => }/HttpMethodTest.php | 10 ++-- tests/{Features => }/MiddlewaresTest.php | 12 ++-- tests/{Features => }/NameTest.php | 6 +- tests/{RouterTest.php => RoutingTest.php} | 59 ++++++++++--------- tests/{ => Utils}/PsrTrait.php | 18 ++---- tests/{ => Utils}/RouterCase.php | 2 +- tests/{Fakes => Utils}/TestHandler.php | 2 +- tests/{Fakes => Utils}/TestMiddleware.php | 2 +- 11 files changed, 67 insertions(+), 74 deletions(-) rename tests/{Features => }/AdditionalAttributesTest.php (92%) rename tests/{Features => }/CallbackTest.php (89%) rename tests/{Features => }/ConditionsTest.php (89%) rename tests/{Features => }/HttpMethodTest.php (90%) rename tests/{Features => }/MiddlewaresTest.php (88%) rename tests/{Features => }/NameTest.php (95%) rename tests/{RouterTest.php => RoutingTest.php} (91%) rename tests/{ => Utils}/PsrTrait.php (89%) rename tests/{ => Utils}/RouterCase.php (89%) rename tests/{Fakes => Utils}/TestHandler.php (94%) rename tests/{Fakes => Utils}/TestMiddleware.php (95%) diff --git a/tests/Features/AdditionalAttributesTest.php b/tests/AdditionalAttributesTest.php similarity index 92% rename from tests/Features/AdditionalAttributesTest.php rename to tests/AdditionalAttributesTest.php index 5e714d7..66f7d42 100644 --- a/tests/Features/AdditionalAttributesTest.php +++ b/tests/AdditionalAttributesTest.php @@ -1,16 +1,16 @@ getBody()); } - public function testAddsRouteGroupAttributesToRequest(): void + public function testRouteGroupAttributesAreAddedToTheRequest(): void { $routeGroup = new RouteGroup( routes: [ diff --git a/tests/Features/CallbackTest.php b/tests/CallbackTest.php similarity index 89% rename from tests/Features/CallbackTest.php rename to tests/CallbackTest.php index 905e2bc..4389a8b 100644 --- a/tests/Features/CallbackTest.php +++ b/tests/CallbackTest.php @@ -1,15 +1,15 @@ [UriFactory::class], - 'value that cannot be converted to a response' => [static fn(): array => ['foo' => 'bar']], + 'handler that does not return a PSR response' => [static fn(): array => ['foo' => 'bar']], ]; } } diff --git a/tests/Features/ConditionsTest.php b/tests/ConditionsTest.php similarity index 89% rename from tests/Features/ConditionsTest.php rename to tests/ConditionsTest.php index 42d6fdf..ecee26f 100644 --- a/tests/Features/ConditionsTest.php +++ b/tests/ConditionsTest.php @@ -1,13 +1,13 @@ getBody()); } - public function testConditionCanAddAttributesToARequest(): void + public function testConditionsCanAddAttributesToARequest(): void { $routeGroup = new RouteGroup( routes: [ @@ -91,7 +91,7 @@ public function testConditionCanAddAttributesToARequest(): void /** * @dataProvider providerInvalidConditions */ - public function testCannotExecuteInvalidConditions(mixed $condition): void + public function testRouterCannotExecuteInvalidConditions(mixed $condition): void { $routeGroup = new RouteGroup( routes: [ diff --git a/tests/Features/HttpMethodTest.php b/tests/HttpMethodTest.php similarity index 90% rename from tests/Features/HttpMethodTest.php rename to tests/HttpMethodTest.php index 7cf0f19..5d31d61 100644 --- a/tests/Features/HttpMethodTest.php +++ b/tests/HttpMethodTest.php @@ -1,11 +1,11 @@ self::response('OK')); @@ -43,7 +43,7 @@ public static function providerMethodsAndRoutes(): array /** * @dataProvider providerRouteMethods */ - public function testRouteCanMatchAnyMethod(string $method): void + public function testRoutesCanMatchAnyMethod(string $method): void { $route = Route::any('/', static fn(): ResponseInterface => self::response('OK')); $request = self::serverRequest($method, '/'); @@ -69,7 +69,7 @@ public static function providerRouteMethods(): array ]; } - public function testCanMatchSomeMethods(): void + public function testRoutesCanMatchSomeMethods(): void { $routeGroup = new RouteGroup( routes: [ diff --git a/tests/Features/MiddlewaresTest.php b/tests/MiddlewaresTest.php similarity index 88% rename from tests/Features/MiddlewaresTest.php rename to tests/MiddlewaresTest.php index 9826d9a..2bb8bc1 100644 --- a/tests/Features/MiddlewaresTest.php +++ b/tests/MiddlewaresTest.php @@ -1,14 +1,14 @@ self::response('OK')), @@ -22,22 +23,7 @@ public function testCanHandleAGroupOfRoutes(): void 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 + public function testRouterFiltersOutNonMatchingPaths(): void { $routeGroup = new RouteGroup(routes: [ Route::get(path: '/test2', callback: static fn(): ResponseInterface => self::response('KO')), @@ -50,7 +36,7 @@ public function testFiltersOutNonMatchingPaths(): void self::assertEquals('OK', (string)$response->getBody()); } - public function testCanHaveSubGroups(): void + public function testRouterCanHandleARouteAfterASubGroup(): void { $routeGroup = new RouteGroup( routes: [ @@ -59,16 +45,17 @@ public function testCanHaveSubGroups(): void 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', '/sub'); + $request = self::serverRequest('GET', '/after-sub'); $response = $this->router($routeGroup)->handle($request); - self::assertEquals('TEST', (string)$response->getBody()); + self::assertEquals('TEST2', (string)$response->getBody()); } - public function testCanHandleARouteAfterASubGroup(): void + public function testRouteGroupsCanHaveSubGroups(): void { $routeGroup = new RouteGroup( routes: [ @@ -77,17 +64,31 @@ public function testCanHandleARouteAfterASubGroup(): void 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'); + $request = self::serverRequest('GET', '/sub'); $response = $this->router($routeGroup)->handle($request); - self::assertEquals('TEST2', (string)$response->getBody()); + 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 testCanUsePathParameters(): void + public function testRoutesCanUsePathParameters(): void { $routeGroup = new RouteGroup(routes: [ Route::get(path: '/{foo}/{bar}', callback: static fn(ServerRequestInterface $request @@ -103,7 +104,7 @@ public function testCanUsePathParameters(): void /** * @dataProvider providerRouteGroupsWithCustomParameters */ - public function testCanUseCustomPathParameterPatterns(RouteGroup $routeGroup): void + public function testRoutesCanUseCustomPathParameters(RouteGroup $routeGroup): void { $matchingRequest = self::serverRequest('GET', '/123'); $nonMatchingRequest = self::serverRequest('GET', '/abc'); @@ -172,7 +173,7 @@ public static function providerRouteGroupsWithCustomParameters(): array ]; } - public function testMustFindARouteToProcess(): void + public function testRouterMustFindARouteToProcess(): void { $routeGroup = new RouteGroup(routes: [ Route::get(path: '/foo', callback: static fn(): ResponseInterface => self::response('TEST')), @@ -184,7 +185,7 @@ public function testMustFindARouteToProcess(): void $this->router($routeGroup)->handle($request); } - public function testCanHaveAFallbackRoute(): void + public function testRouterCanHaveAFallbackRoute(): void { $routeGroup = new RouteGroup(routes: [ Route::get(path: '/foo', callback: static fn(): ResponseInterface => self::response('TEST')), diff --git a/tests/PsrTrait.php b/tests/Utils/PsrTrait.php similarity index 89% rename from tests/PsrTrait.php rename to tests/Utils/PsrTrait.php index 6366c2b..eaff537 100644 --- a/tests/PsrTrait.php +++ b/tests/Utils/PsrTrait.php @@ -2,25 +2,17 @@ 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 IngeniozIT\Http\Message\{ - ResponseFactory, - ServerRequestFactory, - StreamFactory, - UploadedFileFactory, - UriFactory, -}; -use IngeniozIT\Edict\Container; + UriFactoryInterface,}; use function IngeniozIT\Edict\value; diff --git a/tests/RouterCase.php b/tests/Utils/RouterCase.php similarity index 89% rename from tests/RouterCase.php rename to tests/Utils/RouterCase.php index ff1d628..b3db12d 100644 --- a/tests/RouterCase.php +++ b/tests/Utils/RouterCase.php @@ -1,6 +1,6 @@ Date: Thu, 29 Feb 2024 01:51:36 +0100 Subject: [PATCH 06/13] almost completed the documentation --- README.md | 244 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 242 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e14ada6..7f6a736 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,10 @@ composer require ingenioz-it/router ### Overview -Create your routes, instantiate the router and handle the request: +Here is the whole process of using this router : +- Create your routes +- Instantiate the router +- Handle the request: ```php use IngeniozIT\Router\RouteGroup; @@ -40,18 +43,255 @@ 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); ``` -@todo continue working on the documentation (create a wiki ?) \ No newline at end of file +### Basic routing + +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 returns a PSR-7 ResponseInterface. + +```php +Route::get('/hello', fn() => new Response('Hello, world!')); +``` + +### Organizing routes + +Route groups allow you to group several routes together. +They also allows you to visually organize your routes according to your application's logic. + +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('/hello', fn() => new Response('Hello, world!')), + Route::get('/bye', fn() => new Response('Goodbye, world!')), +]); +``` + +Route groups can be nested to create a hierarchy of routes that will inherit everything from their parent groups. + +```php +new RouteGroup([ + 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!')), +]); +``` + +### HTTP methods + +You can specify the HTTP method that the route should match by using the corresponding method on the Route class: + +```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 +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), + ], + where: ['name' => '[a-zA-Z]+'], +); +``` + +### Route handlers + +#### Closures + +The simplest way to define a route handler is to use a closure. +The closure should return a PSR-7 ResponseInterface. + +```php +Route::get('/hello', fn() => new Response('Hello, world!')); +``` + +Closures can take in parameters: the request and a request handler (the router itself). + +```php +Route::get('/hello', function (ServerRequestInterface $request) { + return new Response('Hello, world!'); +}); + +Route::get('/hello', function (ServerRequestInterface $request, RequestHandlerInterface $router) { + return new Response('Hello, world!'); +}); +``` + +#### RequestHandlerInterface + +A route handler can be a callable, but it can also be a PSR RequestHandlerInterface. + +```php +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 + +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. + +This is done by using a PSR MiddlewareInterface as a route handler : + +```php +use Psr\Http\Server\MiddlewareInterface; + +class MyHandler implements MiddlewareInterface +{ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if (someResourceDoesNotExist()) { + // 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([ + Route::get('/hello', fn() => new MyHandler()), // This handler will be called first + Route::get('/hello', fn() => new Response('Hello, world!')), // This handler will be called second +]); +``` + +#### Dependency injection + +Instead of using a closure or a class instance as a handler, you can use a class name. The router will then resolve the class using its PSR container. + +```php +Route::get('/hello', MyHandler::class); +``` + +*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.* + +### Additional attributes + +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. + +```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']); +``` + +Attributes can also be defined globally for all routes inside a group: + +```php +$routes = new RouteGroup( + [ + Route::get('/hello', MyHandler::class), + Route::get('/bye', MyOtherHandler::class), + ], + with: ['name' => 'world'], +); +``` + +### Middlewares + +### Conditions + +### Naming routes + +@todo continue working on the documentation From 5fd2b9839f086c7f9125fb18fda883960d7a2e4a Mon Sep 17 00:00:00 2001 From: IngeniozIT Date: Tue, 2 Apr 2024 21:06:34 +0200 Subject: [PATCH 07/13] Indicate data providers as annotations --- composer.json | 4 ++-- tests/CallbackTest.php | 9 +++------ tests/ConditionsTest.php | 9 +++------ tests/HttpMethodTest.php | 9 +++------ tests/MiddlewaresTest.php | 9 +++------ tests/RoutingTest.php | 5 ++--- 6 files changed, 16 insertions(+), 29 deletions(-) diff --git a/composer.json b/composer.json index e0ecace..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": { diff --git a/tests/CallbackTest.php b/tests/CallbackTest.php index 4389a8b..051497e 100644 --- a/tests/CallbackTest.php +++ b/tests/CallbackTest.php @@ -10,6 +10,7 @@ use IngeniozIT\Router\Tests\Utils\RouterCase; use IngeniozIT\Router\Tests\Utils\TestHandler; use IngeniozIT\Router\Tests\Utils\TestMiddleware; +use PHPUnit\Framework\Attributes\DataProvider; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; @@ -17,9 +18,7 @@ final class CallbackTest extends RouterCase { - /** - * @dataProvider providerCallbacks - */ + #[DataProvider('providerCallbacks')] public function testRouterCanExecuteACallback(Closure|MiddlewareInterface|RequestHandlerInterface|string $callback): void { $routeGroup = new RouteGroup(routes: [ @@ -55,9 +54,7 @@ public static function providerCallbacks(): array ]; } - /** - * @dataProvider providerInvalidHandlers - */ + #[DataProvider('providerInvalidHandlers')] public function testRouterCannotExecuteAnInvalidCallback(mixed $callback): void { $routeGroup = new RouteGroup(routes: [ diff --git a/tests/ConditionsTest.php b/tests/ConditionsTest.php index ecee26f..e06ef07 100644 --- a/tests/ConditionsTest.php +++ b/tests/ConditionsTest.php @@ -8,14 +8,13 @@ use IngeniozIT\Router\Route; use IngeniozIT\Router\RouteGroup; use IngeniozIT\Router\Tests\Utils\RouterCase; +use PHPUnit\Framework\Attributes\DataProvider; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; final class ConditionsTest extends RouterCase { - /** - * @dataProvider providerConditions - */ + #[DataProvider('providerConditions')] public function testRouteGroupsCanHaveConditions(Closure $condition, string $expectedResponse): void { $routeGroup = new RouteGroup( @@ -88,9 +87,7 @@ public function testConditionsCanAddAttributesToARequest(): void self::assertEquals("'bar'", (string)$response->getBody()); } - /** - * @dataProvider providerInvalidConditions - */ + #[DataProvider('providerInvalidConditions')] public function testRouterCannotExecuteInvalidConditions(mixed $condition): void { $routeGroup = new RouteGroup( diff --git a/tests/HttpMethodTest.php b/tests/HttpMethodTest.php index 5d31d61..8abe832 100644 --- a/tests/HttpMethodTest.php +++ b/tests/HttpMethodTest.php @@ -6,13 +6,12 @@ use IngeniozIT\Router\RouteElement; use IngeniozIT\Router\RouteGroup; use IngeniozIT\Router\Tests\Utils\RouterCase; +use PHPUnit\Framework\Attributes\DataProvider; use Psr\Http\Message\ResponseInterface; final class HttpMethodTest extends RouterCase { - /** - * @dataProvider providerMethodsAndRoutes - */ + #[DataProvider('providerMethodsAndRoutes')] public function testRoutesMatchRequestsBasedOnMethod(string $method, callable $routeCallable): void { /** @var RouteElement $route */ @@ -40,9 +39,7 @@ public static function providerMethodsAndRoutes(): array ]; } - /** - * @dataProvider providerRouteMethods - */ + #[DataProvider('providerRouteMethods')] public function testRoutesCanMatchAnyMethod(string $method): void { $route = Route::any('/', static fn(): ResponseInterface => self::response('OK')); diff --git a/tests/MiddlewaresTest.php b/tests/MiddlewaresTest.php index 2bb8bc1..663ae05 100644 --- a/tests/MiddlewaresTest.php +++ b/tests/MiddlewaresTest.php @@ -9,6 +9,7 @@ use IngeniozIT\Router\RouteGroup; use IngeniozIT\Router\Tests\Utils\RouterCase; use IngeniozIT\Router\Tests\Utils\TestMiddleware; +use PHPUnit\Framework\Attributes\DataProvider; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -17,9 +18,7 @@ final class MiddlewaresTest extends RouterCase { - /** - * @dataProvider providerMiddlewares - */ + #[DataProvider('providerMiddlewares')] public function testRouteGroupsCanHaveMiddlewares(mixed $middleware, string $expectedResponse): void { $routeGroup = new RouteGroup( @@ -71,9 +70,7 @@ public function testRouteGroupsCanHaveMultipleMiddlewares(): void self::assertEquals('TEST', (string) $response->getBody()); } - /** - * @dataProvider providerInvalidMiddlewares - */ + #[DataProvider('providerInvalidMiddlewares')] public function testRouterCannotExecuteInvalidMiddlewares(mixed $middleware): void { $routeGroup = new RouteGroup( diff --git a/tests/RoutingTest.php b/tests/RoutingTest.php index 57ca366..95c91ec 100644 --- a/tests/RoutingTest.php +++ b/tests/RoutingTest.php @@ -6,6 +6,7 @@ use IngeniozIT\Router\Route; use IngeniozIT\Router\RouteGroup; use IngeniozIT\Router\Tests\Utils\RouterCase; +use PHPUnit\Framework\Attributes\DataProvider; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -101,9 +102,7 @@ public function testRoutesCanUsePathParameters(): void self::assertEquals('barbaz', (string)$response->getBody()); } - /** - * @dataProvider providerRouteGroupsWithCustomParameters - */ + #[DataProvider('providerRouteGroupsWithCustomParameters')] public function testRoutesCanUseCustomPathParameters(RouteGroup $routeGroup): void { $matchingRequest = self::serverRequest('GET', '/123'); From 32d43337fe6786618f3a9ea5f86a3f8000dd00ec Mon Sep 17 00:00:00 2001 From: IngeniozIT Date: Fri, 5 Apr 2024 20:46:34 +0200 Subject: [PATCH 08/13] Improve exceptions --- quality/rector.php | 7 ++ src/Condition/ConditionException.php | 11 +++ .../ConditionHandler.php | 11 ++- src/Condition/InvalidConditionHandler.php | 15 +++++ src/Condition/InvalidConditionResponse.php | 15 +++++ src/EmptyRouteStack.php | 15 +++++ src/Exception/EmptyRouteStack.php | 11 --- src/Exception/InvalidRoute.php | 11 --- src/Exception/InvalidRouteCondition.php | 11 --- src/Exception/InvalidRouteHandler.php | 11 --- src/Exception/InvalidRouteMiddleware.php | 11 --- src/Exception/InvalidRouteParameter.php | 23 ------- src/Exception/MissingRouteParameters.php | 25 ------- src/Exception/RouteNotFound.php | 11 --- src/Middleware/InvalidMiddlewareHandler.php | 15 +++++ src/Middleware/InvalidMiddlewareResponse.php | 15 +++++ src/Middleware/MiddlewareException.php | 11 +++ .../MiddlewareHandler.php} | 9 ++- src/Route.php | 4 +- src/Route/InvalidRouteHandler.php | 15 +++++ src/Route/InvalidRouteParameter.php | 18 +++++ src/Route/InvalidRouteResponse.php | 15 +++++ src/Route/MissingRouteParameters.php | 20 ++++++ src/{ => Route}/RouteElement.php | 16 +++-- src/Route/RouteException.php | 11 +++ src/{Handler => Route}/RouteHandler.php | 7 +- src/Route/RouteNotFound.php | 15 +++++ src/RouteGroup.php | 67 ++++++++++--------- src/Router.php | 40 +++++------ src/RouterException.php | 9 +++ tests/CallbackTest.php | 34 ++++++++-- tests/ConditionsTest.php | 45 +++++++++---- tests/HttpMethodTest.php | 2 +- tests/MiddlewaresTest.php | 45 ++++++++++--- tests/NameTest.php | 11 +-- tests/RoutingTest.php | 3 +- 36 files changed, 388 insertions(+), 227 deletions(-) create mode 100644 src/Condition/ConditionException.php rename src/{Handler => Condition}/ConditionHandler.php (67%) create mode 100644 src/Condition/InvalidConditionHandler.php create mode 100644 src/Condition/InvalidConditionResponse.php create mode 100644 src/EmptyRouteStack.php delete mode 100644 src/Exception/EmptyRouteStack.php delete mode 100644 src/Exception/InvalidRoute.php delete mode 100644 src/Exception/InvalidRouteCondition.php delete mode 100644 src/Exception/InvalidRouteHandler.php delete mode 100644 src/Exception/InvalidRouteMiddleware.php delete mode 100644 src/Exception/InvalidRouteParameter.php delete mode 100644 src/Exception/MissingRouteParameters.php delete mode 100644 src/Exception/RouteNotFound.php create mode 100644 src/Middleware/InvalidMiddlewareHandler.php create mode 100644 src/Middleware/InvalidMiddlewareResponse.php create mode 100644 src/Middleware/MiddlewareException.php rename src/{Handler/MiddlewaresHandler.php => Middleware/MiddlewareHandler.php} (76%) create mode 100644 src/Route/InvalidRouteHandler.php create mode 100644 src/Route/InvalidRouteParameter.php create mode 100644 src/Route/InvalidRouteResponse.php create mode 100644 src/Route/MissingRouteParameters.php rename src/{ => Route}/RouteElement.php (93%) create mode 100644 src/Route/RouteException.php rename src/{Handler => Route}/RouteHandler.php (83%) create mode 100644 src/Route/RouteNotFound.php create mode 100644 src/RouterException.php 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 InvalidRouteCondition('Invalid condition handler'); + throw new InvalidConditionHandler($handler); } $this->handler = $handler(...); @@ -33,10 +32,10 @@ public function handle(ServerRequestInterface $request): array|false { $result = ($this->handler)($request); - if ($result === false || is_array($result)) { - return $result; + if ($result !== false && !is_array($result)) { + throw new InvalidConditionResponse($result); } - throw new InvalidRouteCondition('Condition must either return an array or false.'); + return $result; } } diff --git a/src/Condition/InvalidConditionHandler.php b/src/Condition/InvalidConditionHandler.php new file mode 100644 index 0000000..b9cba50 --- /dev/null +++ b/src/Condition/InvalidConditionHandler.php @@ -0,0 +1,15 @@ +handler)($request, $handler); if (!$result instanceof ResponseInterface) { - throw new InvalidRouteMiddleware('Middleware must return a PSR Response.'); + throw new InvalidMiddlewareResponse($result); } return $result; diff --git a/src/Route.php b/src/Route.php index f8ee9d6..49bab84 100644 --- a/src/Route.php +++ b/src/Route.php @@ -4,6 +4,8 @@ namespace IngeniozIT\Router; +use IngeniozIT\Router\Route\RouteElement; + final readonly class Route { public const GET = 0b0000001; @@ -111,7 +113,7 @@ public static function any(string $path, mixed $callback, array $where = [], arr */ public static function some(array $methods, string $path, mixed $callback, array $where = [], array $with = [], ?string $name = null): RouteElement { - $method = array_reduce($methods, fn($carry, $methodString) => $carry | self::METHODS[strtoupper($methodString)], 0); + $method = array_reduce($methods, static fn($carry, $methodString): int => $carry | self::METHODS[strtoupper($methodString)], 0); return new RouteElement($method, $path, $callback, $where, $with, $name); } diff --git a/src/Route/InvalidRouteHandler.php b/src/Route/InvalidRouteHandler.php new file mode 100644 index 0000000..932917e --- /dev/null +++ b/src/Route/InvalidRouteHandler.php @@ -0,0 +1,15 @@ + */ public array $where; + /** @var string[] */ public array $parameters; + public ?string $regex; /** @@ -42,6 +45,7 @@ public function __construct( 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]; @@ -51,6 +55,7 @@ private function extractPatterns(array $where, string $path): array } } } + return [$parameters, $where, $path]; } @@ -123,10 +128,11 @@ public function buildPath(array $parameters): string $path = str_replace('{' . $parameter . '}', (string)$value, $path); continue; } + $queryParameters[$parameter] = $value; } - return $path . ($queryParameters ? '?' . http_build_query($queryParameters) : ''); + return $path . ($queryParameters !== [] ? '?' . http_build_query($queryParameters) : ''); } /** @@ -141,7 +147,7 @@ private function validatePathParameters(array $parameters): void } foreach ($this->parameters as $parameter) { - if (!preg_match('#^' . $this->parameterPattern($parameter) . '$#', (string)$parameters[$parameter])) { + if (!preg_match('#^' . $this->parameterPattern($parameter) . '$#', (string) $parameters[$parameter])) { throw new InvalidRouteParameter($this->name ?? '', $parameter, $this->parameterPattern($parameter)); } } 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 @@ +handler = is_callable($handler) ? $handler(...) : $handler; @@ -38,7 +37,7 @@ public function handle(ServerRequestInterface $request, RequestHandlerInterface $result = $this->executeHandler($request, $handler); if (!$result instanceof ResponseInterface) { - throw new InvalidRouteHandler('Route handler must return a PSR Response.'); + throw new InvalidRouteResponse($result); } return $result; diff --git a/src/Route/RouteNotFound.php b/src/Route/RouteNotFound.php new file mode 100644 index 0000000..538f6f1 --- /dev/null +++ b/src/Route/RouteNotFound.php @@ -0,0 +1,15 @@ +routes = array_map( - function (RouteGroup|RouteElement $route) use ($with, $where, $name, $path): RouteGroup|RouteElement { - if ($route instanceof RouteGroup) { - return new RouteGroup( - $route->routes, - $route->middlewares, - $route->conditions, - $where, - $with, - $this->concatenatedName($name), - $path, - ); - } - - return new RouteElement( - $route->method, - $path . $route->path, - $route->callback, - [...$where, ...$route->where], - [...$with, ...$route->with], - name: !empty($route->name) ? $this->concatenatedName($name) . $route->name : null, - ); - }, - $routes, - ); + $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 concatenatedName(?string $name): ?string + private function concatenatedNameForRouteGroup(): ?string { - return $name === null || $name === '' ? $name : $name . '.'; + return $this->name === null ? null : $this->name . '.'; } } diff --git a/src/Router.php b/src/Router.php index 185af46..253b989 100644 --- a/src/Router.php +++ b/src/Router.php @@ -4,11 +4,11 @@ namespace IngeniozIT\Router; -use IngeniozIT\Router\Exception\EmptyRouteStack; -use IngeniozIT\Router\Exception\RouteNotFound; -use IngeniozIT\Router\Handler\ConditionHandler; -use IngeniozIT\Router\Handler\MiddlewaresHandler; -use IngeniozIT\Router\Handler\RouteHandler; +use IngeniozIT\Router\Condition\ConditionHandler; +use IngeniozIT\Router\Middleware\MiddlewareHandler; +use IngeniozIT\Router\Route\RouteElement; +use IngeniozIT\Router\Route\RouteHandler; +use IngeniozIT\Router\Route\RouteNotFound; use Psr\Container\ContainerInterface; use Psr\Http\Message\{ResponseInterface, ServerRequestInterface}; use Psr\Http\Server\RequestHandlerInterface; @@ -62,7 +62,7 @@ private function handleConditions(ServerRequestInterface $request): ResponseInte private function handleNextMiddleware(ServerRequestInterface $request): ResponseInterface { - $middlewaresHandler = new MiddlewaresHandler( + $middlewaresHandler = new MiddlewareHandler( $this->container, $this->routeGroup->middlewares[$this->middlewareIndex++] ); @@ -106,6 +106,7 @@ private function handleRouteElement( foreach ($route->with as $key => $value) { $request = $request->withAttribute($key, $value); } + foreach ($matchedParams as $key => $value) { $request = $request->withAttribute($key, $value); } @@ -117,7 +118,7 @@ private function handleRouteElement( private function fallback(ServerRequestInterface $request): ResponseInterface { if ($this->fallback === null) { - throw new EmptyRouteStack('No routes left to process.'); + throw new EmptyRouteStack(); } $routeHandler = new RouteHandler($this->container, $this->fallback); @@ -129,34 +130,25 @@ private function fallback(ServerRequestInterface $request): ResponseInterface */ public function pathTo(string $routeName, array $parameters = []): string { - $route = $this->findNamedRoute($routeName, $parameters, $this->routeGroup); + $route = $this->findNamedRoute($routeName, $this->routeGroup); - if (!$route) { - throw new RouteNotFound("Route with name '$routeName' not found."); + if (!$route instanceof RouteElement) { + throw new RouteNotFound($routeName); } - return $route; + return $route->buildPath($parameters); } - /** - * @param array $parameters - */ - private function findNamedRoute(string $routeName, array $parameters, RouteGroup $routeGroup): ?string + private function findNamedRoute(string $routeName, RouteGroup $routeGroup): ?RouteElement { foreach ($routeGroup->routes as $route) { if ($route instanceof RouteGroup) { - $foundRoute = $this->findNamedRoute($routeName, $parameters, $route); - if ($foundRoute === null) { - continue; - } - return $foundRoute; + $route = $this->findNamedRoute($routeName, $route); } - if ($route->name !== $routeName) { - continue; + if ($route?->name === $routeName) { + return $route; } - - return $route->buildPath($parameters); } 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 @@ + $expectedException + */ #[DataProvider('providerInvalidHandlers')] - public function testRouterCannotExecuteAnInvalidCallback(mixed $callback): void - { + 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(InvalidRouteHandler::class); + self::expectException($expectedException); + self::expectExceptionMessage($expectedMessage); $this->router($routeGroup)->handle($request); } /** - * @return array + * @return array, string}> */ public static function providerInvalidHandlers(): array { return [ - 'not a handler' => [UriFactory::class], - 'handler that does not return a PSR response' => [static fn(): array => ['foo' => 'bar']], + '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/ConditionsTest.php b/tests/ConditionsTest.php index e06ef07..37014f0 100644 --- a/tests/ConditionsTest.php +++ b/tests/ConditionsTest.php @@ -4,13 +4,15 @@ use Closure; use IngeniozIT\Http\Message\UriFactory; -use IngeniozIT\Router\Exception\InvalidRouteCondition; +use IngeniozIT\Router\Condition\InvalidConditionHandler; +use IngeniozIT\Router\Condition\InvalidConditionResponse; use IngeniozIT\Router\Route; use IngeniozIT\Router\RouteGroup; use IngeniozIT\Router\Tests\Utils\RouterCase; use PHPUnit\Framework\Attributes\DataProvider; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use Throwable; final class ConditionsTest extends RouterCase { @@ -25,8 +27,9 @@ public function testRouteGroupsCanHaveConditions(Closure $condition, string $exp ); $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($expectedResponse, (string)$response->getBody()); } @@ -61,8 +64,9 @@ public function testRouteGroupsCanHaveMultipleConditions(): 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()); } @@ -73,7 +77,9 @@ public function testConditionsCanAddAttributesToARequest(): void 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: [ @@ -87,9 +93,15 @@ public function testConditionsCanAddAttributesToARequest(): void self::assertEquals("'bar'", (string)$response->getBody()); } + /** + * @param class-string $expectedException + */ #[DataProvider('providerInvalidConditions')] - public function testRouterCannotExecuteInvalidConditions(mixed $condition): void - { + public function testRouterCannotExecuteInvalidConditions( + mixed $condition, + string $expectedException, + string $expectedMessage, + ): void { $routeGroup = new RouteGroup( routes: [ Route::get(path: '/', callback: static fn(): ResponseInterface => self::response('TEST')), @@ -98,18 +110,27 @@ public function testRouterCannotExecuteInvalidConditions(mixed $condition): void ); $request = self::serverRequest('GET', '/'); - self::expectException(InvalidRouteCondition::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(): bool => true], + '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(): bool => true, + InvalidConditionResponse::class, + 'Condition must either return an array or false, bool given.', + ], ]; } } diff --git a/tests/HttpMethodTest.php b/tests/HttpMethodTest.php index 8abe832..b1d92af 100644 --- a/tests/HttpMethodTest.php +++ b/tests/HttpMethodTest.php @@ -3,7 +3,7 @@ namespace IngeniozIT\Router\Tests; use IngeniozIT\Router\Route; -use IngeniozIT\Router\RouteElement; +use IngeniozIT\Router\Route\RouteElement; use IngeniozIT\Router\RouteGroup; use IngeniozIT\Router\Tests\Utils\RouterCase; use PHPUnit\Framework\Attributes\DataProvider; diff --git a/tests/MiddlewaresTest.php b/tests/MiddlewaresTest.php index 663ae05..f23bceb 100644 --- a/tests/MiddlewaresTest.php +++ b/tests/MiddlewaresTest.php @@ -4,7 +4,8 @@ use Exception; use IngeniozIT\Http\Message\UriFactory; -use IngeniozIT\Router\Exception\InvalidRouteMiddleware; +use IngeniozIT\Router\Middleware\InvalidMiddlewareHandler; +use IngeniozIT\Router\Middleware\InvalidMiddlewareResponse; use IngeniozIT\Router\Route; use IngeniozIT\Router\RouteGroup; use IngeniozIT\Router\Tests\Utils\RouterCase; @@ -13,6 +14,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +use Throwable; use function IngeniozIT\Edict\value; @@ -31,7 +33,7 @@ public function testRouteGroupsCanHaveMiddlewares(mixed $middleware, string $exp $response = $this->router($routeGroup)->handle($request); - self::assertEquals($expectedResponse, (string) $response->getBody()); + self::assertEquals($expectedResponse, (string)$response->getBody()); } /** @@ -45,7 +47,10 @@ public static function providerMiddlewares(): array 'expectedResponse' => 'TEST', ], 'middleware that forwards to handler' => [ - 'middleware' => static fn(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface => $handler->handle($request), + 'middleware' => static fn( + ServerRequestInterface $request, + RequestHandlerInterface $handler + ): ResponseInterface => $handler->handle($request), 'expectedResponse' => 'TEST2', ], ]; @@ -58,7 +63,10 @@ public function testRouteGroupsCanHaveMultipleMiddlewares(): void Route::get(path: '/', callback: static fn(): ResponseInterface => self::response('TEST2')), ], middlewares: [ - static fn(ServerRequestInterface $request, RequestHandlerInterface $handler): \Psr\Http\Message\ResponseInterface => $handler->handle($request), + static fn( + ServerRequestInterface $request, + RequestHandlerInterface $handler + ): ResponseInterface => $handler->handle($request), static fn(ServerRequestInterface $request, RequestHandlerInterface $handler) => throw new Exception(''), ], ); @@ -67,12 +75,18 @@ public function testRouteGroupsCanHaveMultipleMiddlewares(): void self::expectException(Exception::class); $response = $this->router($routeGroup)->handle($request); - self::assertEquals('TEST', (string) $response->getBody()); + self::assertEquals('TEST', (string)$response->getBody()); } + /** + * @param class-string $expectedException + */ #[DataProvider('providerInvalidMiddlewares')] - public function testRouterCannotExecuteInvalidMiddlewares(mixed $middleware): void - { + public function testRouterCannotExecuteInvalidMiddlewares( + mixed $middleware, + string $expectedException, + string $expectedMessage, + ): void { $routeGroup = new RouteGroup( routes: [ Route::get(path: '/', callback: static fn(): ResponseInterface => self::response('TEST')), @@ -81,19 +95,28 @@ public function testRouterCannotExecuteInvalidMiddlewares(mixed $middleware): vo ); $request = self::serverRequest('GET', '/'); - self::expectException(InvalidRouteMiddleware::class); + self::expectException($expectedException); + self::expectExceptionMessage($expectedMessage); $this->router($routeGroup)->handle($request); } /** - * @return array + * @return array, string}> */ public static function providerInvalidMiddlewares(): array { self::container()->set('not_a_callable', value('foo')); return [ - 'not a middleware' => [UriFactory::class], - 'callable that does not return a response' => [static fn(): bool => true], + '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 index 98f5cbb..be1a55e 100644 --- a/tests/NameTest.php +++ b/tests/NameTest.php @@ -2,10 +2,10 @@ namespace IngeniozIT\Router\Tests; -use IngeniozIT\Router\Exception\InvalidRouteParameter; -use IngeniozIT\Router\Exception\MissingRouteParameters; -use IngeniozIT\Router\Exception\RouteNotFound; use IngeniozIT\Router\Route; +use IngeniozIT\Router\Route\InvalidRouteParameter; +use IngeniozIT\Router\Route\MissingRouteParameters; +use IngeniozIT\Router\Route\RouteNotFound; use IngeniozIT\Router\RouteGroup; use IngeniozIT\Router\Tests\Utils\RouterCase; @@ -72,6 +72,7 @@ public function testRouterCannotFindAnInexistingRoutePathByName(): void $router = $this->router(new RouteGroup([$route])); self::expectException(RouteNotFound::class); + self::expectExceptionMessage("Route inexisting_route_name not found."); $router->pathTo('inexisting_route_name'); } @@ -81,7 +82,7 @@ public function testRouterCannotFindARoutePathWithMissingParameters(): void $router = $this->router(new RouteGroup([$route])); self::expectException(MissingRouteParameters::class); - self::expectExceptionMessage("Missing parameters foo for route with name 'route_name'."); + self::expectExceptionMessage("Missing parameters foo for route route_name."); $router->pathTo('route_name', ['bar' => '42']); } @@ -91,7 +92,7 @@ public function testRouterCannotFindARoutePathWithInvalidParameters(): void $router = $this->router(new RouteGroup([$route])); self::expectException(InvalidRouteParameter::class); - self::expectExceptionMessage("Parameter 'foo' for route with name 'route_name' does not match the pattern '\d+'."); + self::expectExceptionMessage("Parameter foo for route route_name does not match the pattern \d+."); $router->pathTo('route_name', ['foo' => 'bar']); } } diff --git a/tests/RoutingTest.php b/tests/RoutingTest.php index 95c91ec..a649027 100644 --- a/tests/RoutingTest.php +++ b/tests/RoutingTest.php @@ -2,7 +2,7 @@ namespace IngeniozIT\Router\Tests; -use IngeniozIT\Router\Exception\EmptyRouteStack; +use IngeniozIT\Router\EmptyRouteStack; use IngeniozIT\Router\Route; use IngeniozIT\Router\RouteGroup; use IngeniozIT\Router\Tests\Utils\RouterCase; @@ -181,6 +181,7 @@ public function testRouterMustFindARouteToProcess(): void $request = self::serverRequest('GET', '/baz'); self::expectException(EmptyRouteStack::class); + self::expectExceptionMessage('No routes left to process.'); $this->router($routeGroup)->handle($request); } From cca6e93ae6e60610c809f6128beed3282705358d Mon Sep 17 00:00:00 2001 From: IngeniozIT Date: Tue, 9 Apr 2024 00:21:49 +0200 Subject: [PATCH 09/13] improve project structure --- src/Condition/ConditionHandler.php | 6 +++++ .../InvalidConditionHandler.php | 3 ++- .../InvalidConditionResponse.php | 5 ++++- .../Exception/InvalidMiddlewareHandler.php | 20 +++++++++++++++++ .../InvalidMiddlewareResponse.php | 5 ++++- src/Middleware/InvalidMiddlewareHandler.php | 15 ------------- src/Middleware/MiddlewareHandler.php | 5 +++++ src/Route.php | 3 +++ src/Route/Exception/InvalidRouteHandler.php | 22 +++++++++++++++++++ .../{ => Exception}/InvalidRouteParameter.php | 2 +- .../{ => Exception}/InvalidRouteResponse.php | 5 ++++- .../MissingRouteParameters.php | 5 ++++- src/Route/{ => Exception}/RouteNotFound.php | 3 ++- src/Route/InvalidRouteHandler.php | 15 ------------- src/Route/RouteElement.php | 16 +++++++++++++- src/Route/RouteHandler.php | 5 +++++ src/RouteGroup.php | 2 ++ src/Router.php | 2 +- tests/CallbackTest.php | 4 ++-- tests/ConditionsTest.php | 4 ++-- tests/MiddlewaresTest.php | 4 ++-- tests/NameTest.php | 6 ++--- 22 files changed, 109 insertions(+), 48 deletions(-) rename src/Condition/{ => Exception}/InvalidConditionHandler.php (77%) rename src/Condition/{ => Exception}/InvalidConditionResponse.php (73%) create mode 100644 src/Middleware/Exception/InvalidMiddlewareHandler.php rename src/Middleware/{ => Exception}/InvalidMiddlewareResponse.php (72%) delete mode 100644 src/Middleware/InvalidMiddlewareHandler.php create mode 100644 src/Route/Exception/InvalidRouteHandler.php rename src/Route/{ => Exception}/InvalidRouteParameter.php (90%) rename src/Route/{ => Exception}/InvalidRouteResponse.php (73%) rename src/Route/{ => Exception}/MissingRouteParameters.php (79%) rename src/Route/{ => Exception}/RouteNotFound.php (75%) delete mode 100644 src/Route/InvalidRouteHandler.php diff --git a/src/Condition/ConditionHandler.php b/src/Condition/ConditionHandler.php index 3672a0f..1bec5ed 100644 --- a/src/Condition/ConditionHandler.php +++ b/src/Condition/ConditionHandler.php @@ -5,9 +5,15 @@ namespace IngeniozIT\Router\Condition; use Closure; +use IngeniozIT\Router\Condition\Exception\InvalidConditionHandler; +use IngeniozIT\Router\Condition\Exception\InvalidConditionResponse; use Psr\Container\ContainerInterface; use Psr\Http\Message\ServerRequestInterface; +use function is_array; +use function is_callable; +use function is_string; + readonly final class ConditionHandler { private Closure $handler; diff --git a/src/Condition/InvalidConditionHandler.php b/src/Condition/Exception/InvalidConditionHandler.php similarity index 77% rename from src/Condition/InvalidConditionHandler.php rename to src/Condition/Exception/InvalidConditionHandler.php index b9cba50..1a79fb0 100644 --- a/src/Condition/InvalidConditionHandler.php +++ b/src/Condition/Exception/InvalidConditionHandler.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace IngeniozIT\Router\Condition; +namespace IngeniozIT\Router\Condition\Exception; +use IngeniozIT\Router\Condition\ConditionException; use InvalidArgumentException; final class InvalidConditionHandler extends InvalidArgumentException implements ConditionException diff --git a/src/Condition/InvalidConditionResponse.php b/src/Condition/Exception/InvalidConditionResponse.php similarity index 73% rename from src/Condition/InvalidConditionResponse.php rename to src/Condition/Exception/InvalidConditionResponse.php index 6e68931..0de93f2 100644 --- a/src/Condition/InvalidConditionResponse.php +++ b/src/Condition/Exception/InvalidConditionResponse.php @@ -2,10 +2,13 @@ declare(strict_types=1); -namespace IngeniozIT\Router\Condition; +namespace IngeniozIT\Router\Condition\Exception; +use IngeniozIT\Router\Condition\ConditionException; use InvalidArgumentException; +use function get_debug_type; + final class InvalidConditionResponse extends InvalidArgumentException implements ConditionException { public function __construct(public mixed $response) diff --git a/src/Middleware/Exception/InvalidMiddlewareHandler.php b/src/Middleware/Exception/InvalidMiddlewareHandler.php new file mode 100644 index 0000000..1576949 --- /dev/null +++ b/src/Middleware/Exception/InvalidMiddlewareHandler.php @@ -0,0 +1,20 @@ +parameters as $parameter) { - if (!preg_match('#^' . $this->parameterPattern($parameter) . '$#', (string) $parameters[$parameter])) { + if (!preg_match('#^' . $this->parameterPattern($parameter) . '$#', (string)$parameters[$parameter])) { throw new InvalidRouteParameter($this->name ?? '', $parameter, $this->parameterPattern($parameter)); } } diff --git a/src/Route/RouteHandler.php b/src/Route/RouteHandler.php index be6b2f8..7f1effa 100644 --- a/src/Route/RouteHandler.php +++ b/src/Route/RouteHandler.php @@ -5,12 +5,17 @@ namespace IngeniozIT\Router\Route; use Closure; +use IngeniozIT\Router\Route\Exception\InvalidRouteHandler; +use IngeniozIT\Router\Route\Exception\InvalidRouteResponse; use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; +use function is_callable; +use function is_string; + readonly final class RouteHandler { private Closure|MiddlewareInterface|RequestHandlerInterface $handler; diff --git a/src/RouteGroup.php b/src/RouteGroup.php index ed53340..34b15f8 100644 --- a/src/RouteGroup.php +++ b/src/RouteGroup.php @@ -6,6 +6,8 @@ use IngeniozIT\Router\Route\RouteElement; +use function array_map; + final class RouteGroup { /** @var RouteElement[]|RouteGroup[] */ diff --git a/src/Router.php b/src/Router.php index 253b989..8880828 100644 --- a/src/Router.php +++ b/src/Router.php @@ -6,9 +6,9 @@ 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 IngeniozIT\Router\Route\RouteNotFound; use Psr\Container\ContainerInterface; use Psr\Http\Message\{ResponseInterface, ServerRequestInterface}; use Psr\Http\Server\RequestHandlerInterface; diff --git a/tests/CallbackTest.php b/tests/CallbackTest.php index 23ebfc2..247af00 100644 --- a/tests/CallbackTest.php +++ b/tests/CallbackTest.php @@ -5,8 +5,8 @@ use Closure; use IngeniozIT\Http\Message\UriFactory; use IngeniozIT\Router\Route; -use IngeniozIT\Router\Route\InvalidRouteHandler; -use IngeniozIT\Router\Route\InvalidRouteResponse; +use IngeniozIT\Router\Route\Exception\InvalidRouteHandler; +use IngeniozIT\Router\Route\Exception\InvalidRouteResponse; use IngeniozIT\Router\RouteGroup; use IngeniozIT\Router\Tests\Utils\RouterCase; use IngeniozIT\Router\Tests\Utils\TestHandler; diff --git a/tests/ConditionsTest.php b/tests/ConditionsTest.php index 37014f0..89c4fed 100644 --- a/tests/ConditionsTest.php +++ b/tests/ConditionsTest.php @@ -4,8 +4,8 @@ use Closure; use IngeniozIT\Http\Message\UriFactory; -use IngeniozIT\Router\Condition\InvalidConditionHandler; -use IngeniozIT\Router\Condition\InvalidConditionResponse; +use IngeniozIT\Router\Condition\Exception\InvalidConditionHandler; +use IngeniozIT\Router\Condition\Exception\InvalidConditionResponse; use IngeniozIT\Router\Route; use IngeniozIT\Router\RouteGroup; use IngeniozIT\Router\Tests\Utils\RouterCase; diff --git a/tests/MiddlewaresTest.php b/tests/MiddlewaresTest.php index f23bceb..9279d8c 100644 --- a/tests/MiddlewaresTest.php +++ b/tests/MiddlewaresTest.php @@ -4,8 +4,8 @@ use Exception; use IngeniozIT\Http\Message\UriFactory; -use IngeniozIT\Router\Middleware\InvalidMiddlewareHandler; -use IngeniozIT\Router\Middleware\InvalidMiddlewareResponse; +use IngeniozIT\Router\Middleware\Exception\InvalidMiddlewareHandler; +use IngeniozIT\Router\Middleware\Exception\InvalidMiddlewareResponse; use IngeniozIT\Router\Route; use IngeniozIT\Router\RouteGroup; use IngeniozIT\Router\Tests\Utils\RouterCase; diff --git a/tests/NameTest.php b/tests/NameTest.php index be1a55e..fb71215 100644 --- a/tests/NameTest.php +++ b/tests/NameTest.php @@ -3,9 +3,9 @@ namespace IngeniozIT\Router\Tests; use IngeniozIT\Router\Route; -use IngeniozIT\Router\Route\InvalidRouteParameter; -use IngeniozIT\Router\Route\MissingRouteParameters; -use IngeniozIT\Router\Route\RouteNotFound; +use IngeniozIT\Router\Route\Exception\InvalidRouteParameter; +use IngeniozIT\Router\Route\Exception\MissingRouteParameters; +use IngeniozIT\Router\Route\Exception\RouteNotFound; use IngeniozIT\Router\RouteGroup; use IngeniozIT\Router\Tests\Utils\RouterCase; From 1595c530a85ea5fee01b6a7c71f48778782e8e83 Mon Sep 17 00:00:00 2001 From: IngeniozIT Date: Tue, 9 Apr 2024 00:22:56 +0200 Subject: [PATCH 10/13] continue updating the README --- README.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7f6a736..6503396 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ $response = $router->handle($request); 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 returns a PSR-7 ResponseInterface. +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 Route::get('/hello', fn() => new Response('Hello, world!')); @@ -75,7 +75,7 @@ Route::get('/hello', fn() => new Response('Hello, world!')); ### Organizing routes -Route groups allow you to group several routes together. +Route groups are used to contain routes definitions. They also allows you to visually organize your routes according to your application's logic. This is useful when you want to apply the same conditions, middlewares, or attributes to several routes at once (as we will see later). @@ -102,7 +102,7 @@ new RouteGroup([ ### HTTP methods -You can specify the HTTP method that the route should match by using the corresponding method on the Route class: +You can specify the HTTP method that the route should match: ```php Route::get('/hello', MyHandler::class); @@ -180,7 +180,7 @@ $routes = new RouteGroup( #### Closures The simplest way to define a route handler is to use a closure. -The closure should return a PSR-7 ResponseInterface. +The closure must return a PSR-7 ResponseInterface. ```php Route::get('/hello', fn() => new Response('Hello, world!')); @@ -231,7 +231,7 @@ class MyHandler implements MiddlewareInterface { public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - if (someResourceDoesNotExist()) { + 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); @@ -242,14 +242,16 @@ class MyHandler implements MiddlewareInterface } $routes = new RouteGroup([ - Route::get('/hello', fn() => new MyHandler()), // This handler will be called first - Route::get('/hello', fn() => new Response('Hello, world!')), // This handler will be called second + // 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!')), ]); ``` #### Dependency injection -Instead of using a closure or a class instance as a handler, you can use a class name. The router will then resolve the class using its PSR container. +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 Route::get('/hello', MyHandler::class); @@ -276,7 +278,7 @@ class MyHandler implements RequestHandlerInterface Route::get('/hello', MyHandler::class, with: ['name' => 'world']); ``` -Attributes can also be defined globally for all routes inside a group: +Attributes can also be defined globally for all the routes inside a group: ```php $routes = new RouteGroup( From 3af3d88ed0925920ffa7b3cef0546aee2b79e5a7 Mon Sep 17 00:00:00 2001 From: IngeniozIT Date: Fri, 12 Apr 2024 21:25:24 +0200 Subject: [PATCH 11/13] Allow conditions to return true instead of an array --- src/Condition/ConditionHandler.php | 5 +++-- src/Condition/Exception/InvalidConditionHandler.php | 2 ++ src/Condition/Exception/InvalidConditionResponse.php | 4 +++- tests/ConditionsTest.php | 4 ++-- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Condition/ConditionHandler.php b/src/Condition/ConditionHandler.php index 1bec5ed..6dd8892 100644 --- a/src/Condition/ConditionHandler.php +++ b/src/Condition/ConditionHandler.php @@ -11,6 +11,7 @@ use Psr\Http\Message\ServerRequestInterface; use function is_array; +use function is_bool; use function is_callable; use function is_string; @@ -38,10 +39,10 @@ public function handle(ServerRequestInterface $request): array|false { $result = ($this->handler)($request); - if ($result !== false && !is_array($result)) { + if (!is_bool($result) && !is_array($result)) { throw new InvalidConditionResponse($result); } - return $result; + return $result === true ? [] : $result; } } diff --git a/src/Condition/Exception/InvalidConditionHandler.php b/src/Condition/Exception/InvalidConditionHandler.php index 1a79fb0..52dc695 100644 --- a/src/Condition/Exception/InvalidConditionHandler.php +++ b/src/Condition/Exception/InvalidConditionHandler.php @@ -7,6 +7,8 @@ use IngeniozIT\Router\Condition\ConditionException; use InvalidArgumentException; +use function get_debug_type; + final class InvalidConditionHandler extends InvalidArgumentException implements ConditionException { public function __construct(public mixed $handler) diff --git a/src/Condition/Exception/InvalidConditionResponse.php b/src/Condition/Exception/InvalidConditionResponse.php index 0de93f2..86b0e5a 100644 --- a/src/Condition/Exception/InvalidConditionResponse.php +++ b/src/Condition/Exception/InvalidConditionResponse.php @@ -13,6 +13,8 @@ final class InvalidConditionResponse extends InvalidArgumentException implements { public function __construct(public mixed $response) { - parent::__construct('Condition must either return an array or false, ' . get_debug_type($response) . ' given.'); + parent::__construct( + 'Condition must either return an array or a boolean, ' . get_debug_type($response) . ' given.' + ); } } diff --git a/tests/ConditionsTest.php b/tests/ConditionsTest.php index 89c4fed..d5715ab 100644 --- a/tests/ConditionsTest.php +++ b/tests/ConditionsTest.php @@ -127,9 +127,9 @@ public static function providerInvalidConditions(): array 'Condition handler must be a callable, IngeniozIT\Http\Message\UriFactory given.', ], 'callable that does not return bool or array' => [ - static fn(): bool => true, + static fn(): int => 42, InvalidConditionResponse::class, - 'Condition must either return an array or false, bool given.', + 'Condition must either return an array or a boolean, int given.', ], ]; } From 58aabee1dcac6113c9bf713f110a91725875f4a3 Mon Sep 17 00:00:00 2001 From: IngeniozIT Date: Fri, 12 Apr 2024 22:14:48 +0200 Subject: [PATCH 12/13] Update documentation --- README.md | 131 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 122 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 6503396..d973d9c 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,18 @@ 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. +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 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. +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 @@ -33,6 +40,7 @@ composer require ingenioz-it/router ### Overview Here is the whole process of using this router : + - Create your routes - Instantiate the router - Handle the request: @@ -67,7 +75,8 @@ $response = $router->handle($request); 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. +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 Route::get('/hello', fn() => new Response('Hello, world!')); @@ -78,7 +87,8 @@ Route::get('/hello', fn() => new Response('Hello, world!')); Route groups are used to contain routes definitions. They also allows you to visually organize your routes according to your application's logic. -This is useful when you want to apply the same conditions, middlewares, or attributes to several routes at once (as we will see later). +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([ @@ -220,7 +230,8 @@ Route::get('/hello', new MyHandler()); #### MiddlewareInterface -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. +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. This is done by using a PSR MiddlewareInterface as a route handler : @@ -251,13 +262,15 @@ $routes = new RouteGroup([ #### Dependency injection -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. +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 Route::get('/hello', MyHandler::class); ``` -*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.* +*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.* ### Additional attributes @@ -292,8 +305,108 @@ $routes = new RouteGroup( ### Middlewares +Middlewares are classes that can modify the request and/or the response before and after the route handler is called. + +They can be applied to a route group. + +```php +$routes = new RouteGroup( + [ + Route::get('/hello', MyHandler::class), + ], + middlewares: [ + MyMiddleware::class, + MyOtherMiddleware::class, + ], +); +``` + +The middleware class must implement the PSR `\Psr\Http\Server\MiddlewareInterface` interface. + ### Conditions +Conditions are callables that will determine if a route group should be parsed. + +```php +// This one will be parsed +$routes = new RouteGroup( + [ + Route::get('/hello', MyHandler::class), + ], + conditions: [ + fn(ServerRequestInterface $request) => true, + ], +); + +// 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 +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 -@todo continue working on the documentation +Routes can be named. + +```php +Route::get('/hello', MyHandler::class, name: 'hello_route'); +``` + +Using the router, you can then generate the path to a named route: + +```php +$router->pathTo('hello_route'); // Will return '/hello' +``` + +If a route has parameters, you can pass them as the second argument: + +```php +Route::get('/hello/{name}', MyHandler::class, name: 'hello_route'); + +$router->pathTo('hello_route', ['name' => 'world']); // Will return '/hello/world' +``` From 0c126026ac3a2dabc3994e5b9f00660ad21d160a Mon Sep 17 00:00:00 2001 From: IngeniozIT Date: Fri, 12 Apr 2024 22:54:23 +0200 Subject: [PATCH 13/13] Add documentation about the exceptions --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index d973d9c..905cad2 100644 --- a/README.md +++ b/README.md @@ -410,3 +410,35 @@ 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