diff --git a/README.md b/README.md index dc8dc2e..c26f659 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,9 @@ The package could be installed with composer: composer require yiisoft/file-router ``` -## General usage +## Documentation + +- [English](docs/en/README.md) ## Testing diff --git a/docs/en/README.md b/docs/en/README.md new file mode 100644 index 0000000..d85bf95 --- /dev/null +++ b/docs/en/README.md @@ -0,0 +1,196 @@ +# Documentation + +> Note: The router can be used along with the [`yiisoft/router`](https://github.com/yiisoft/router) package. + +> Note: Once the router found a matching route, it will interrupt the middleware queue and execute the controller +> action. + +## General usage + +1. Add `\Yiisoft\FileRouter\FileRouter` to the list of middlewares in your application configuration: + +`web/params.php` + +```php +return [ + 'middlewares' => [ + // ... + \Yiisoft\FileRouter\FileRouter::class, + // or + [ + 'class' => FileRouter::class, + 'withNamespace()' => ['MyApp\\Package1'], + 'withDefaultControllerName()' => ['Default'], + ], + // ... + ] +]; +``` + +2. [Configure the router](#configuration) for your needs. + +## Configuration + +`\Yiisoft\FileRouter\FileRouter` supports the following configuration options: + +### `withBaseControllerDirectory(string $directory): self` + +Sets the directory where controllers are located. + +By default, it is set to `Controller`. + +### `withClassPostfix(string $postfix): self` + +Sets the postfix for controller class names. + +By default, it is set to `Controller`. + +### `withNamespace(string $namespace): self` + +Sets the namespace for controller classes. + +By default, it is set to `App`. + +### `withDefaultControllerName(string $name): self` + +Sets the default controller name. + +By default, it is set to `Index`. + +### `withRoutePrefix(string $prefix): self` + +Sets the route prefix. + +By default, it is empty. + +It could be useful if you want to add a prefix to all routes or to separate routes from different [modules](#modularity). + +## Middlewares + +`\Yiisoft\FileRouter\FileRouter` supports adding middlewares to the routes. + +To add a middleware, add the static property `$middlewares` to the controller class: + +```php +class UserController +{ + public static array $middlewares = [ + 'index' => [ + HeaderMiddleware::class, + ], + ]; + + public function index(): ResponseInterface + { + return new TextResponse('Hello, user/index!'); + } +} +``` + +Where `index` is the method name and the value is an array of middleware class names, or middleware definitions. + +Look at all supported middleware definitions formats in +the [Middleware Dispatcher](https://github.com/yiisoft/middleware-dispatcher#general-usage) package. + +## Matching + +### HTTP methods matching + +The router maps HTTP methods to controller action methods as follows: + +| Method | Action | +|-----------|-------------| +| `HEAD` | `head()` | +| `OPTIONS` | `options()` | +| `GET` | `index()` | +| `POST` | `create()` | +| `PUT` | `update()` | +| `PATCH` | `patch()` | +| `DELETE` | `delete()` | + +> Note: If the controller does not have a method that matches the HTTP method, the router **will not** throw an exception. + +### Custom routes + +To add a custom route, add the static property `$actions` to the controller class: + +```php +class UserController +{ + public static array $actions = [ + 'GET' => 'main', + ]; + + public function main(): ResponseInterface + { + return new TextResponse('Hello, user/main!', 200); + } +} +``` + +> Note: Once you override the actions map, the router will only look for the actions specified in the map. +> In the example above, the router will fail to find the `index` / `delete`, etc. actions + + +### Route collision + +Let's imagine that you have a request `GET /user/index`. + +It has two possible controller and action variations: + +- `src/Controller/User/IndexController::index()` + - `User/IndexController` class has matched by the full route path (`user/index`) + - `index()` method has matched by the [HTTP methods matching](#http-methods-matching) +- `src/Controller/UserController::index()` + - `UserController` class has matched by the first part of the route path (`user`) + - `index()` method has matched by the second part of the route path (`index`) + +For example, if you have a `UserController` and a `User/IndexController`, a `GET` request to `/user` will be handled +by the `UserController`, if it has an `index()` method. + +Otherwise, the `User/IndexController` will be used along with the [HTTP methods matching](#http-methods-matching). + +## Unicode routes + +The router also supports Unicode in routes, controller names, and action names. + +You can use Unicode characters in your URIs, controller class names, and action method names. + +## Modularity + +You can add more than one router to the application. It can help to build a modular application. + +For example, you have two modules with the same controller name: + +```text +- src + - Module1/Controller/ + - UserController.php + - Module2/Controller/ + - UserController.php +``` + +To add the router for each module, add the following code to the application configuration: + +`web/params.php` + +```php +return [ + 'middlewares' => [ + // ... + [ + 'class' => FileRouter::class, + 'withNamespace()' => ['App\\Module1\\'], + 'withRoutePrefix()' => ['module1'], + ], + [ + 'class' => FileRouter::class, + 'withNamespace()' => ['App\\Module2\\'], + 'withRoutePrefix()' => ['module2'], + ], + // ... + ] +]; +``` + +As a usual middleware, each router will be executed one by one. The first router that matches the route will be used. diff --git a/src/FileRouter.php b/src/FileRouter.php index 0d0d5d3..cdc498d 100644 --- a/src/FileRouter.php +++ b/src/FileRouter.php @@ -16,12 +16,18 @@ final class FileRouter implements MiddlewareInterface private string $classPostfix = 'Controller'; private string $namespace = 'App'; private string $defaultControllerName = 'Index'; + private string $routePrefix = ''; public function __construct( private readonly MiddlewareDispatcher $middlewareDispatcher, ) { } + /** + * Sets the directory where controllers are located. + * + * @see withNamespace() if you want to set the namespace for controller classes. + */ public function withBaseControllerDirectory(string $directory): self { $new = clone $this; @@ -30,6 +36,9 @@ public function withBaseControllerDirectory(string $directory): self return $new; } + /** + * Sets the postfix for controller class names. + */ public function withClassPostfix(string $postfix): self { $new = clone $this; @@ -38,6 +47,11 @@ public function withClassPostfix(string $postfix): self return $new; } + /** + * Sets the namespace for controller classes. + * + * @see withBaseControllerDirectory() if you want to set the directory where controllers are located. + */ public function withNamespace(string $namespace): self { $new = clone $this; @@ -46,6 +60,9 @@ public function withNamespace(string $namespace): self return $new; } + /** + * Sets the default controller name. + */ public function withDefaultControllerName(string $name): self { $new = clone $this; @@ -54,6 +71,17 @@ public function withDefaultControllerName(string $name): self return $new; } + /** + * Sets the route prefix. + */ + public function withRoutePrefix(string $prefix): self + { + $new = clone $this; + $new->routePrefix = $prefix; + + return $new; + } + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $possibleEntrypoints = $this->parseRequestPath($request); @@ -75,6 +103,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface 'GET' => 'index', 'POST' => 'create', 'PUT' => 'update', + 'PATCH' => 'patch', 'DELETE' => 'delete', ])[$request->getMethod()] ?? null; @@ -102,6 +131,14 @@ private function parseRequestPath(ServerRequestInterface $request): iterable { $possibleAction = null; $path = urldecode($request->getUri()->getPath()); + + if ($this->routePrefix !== '' && str_starts_with($path, $this->routePrefix)) { + $path = mb_substr($path, strlen($this->routePrefix)); + if ($path === '') { + $path = '/'; + } + } + if ($path === '/') { yield [ $this->cleanClassname( diff --git a/tests/FileRouterTest.php b/tests/FileRouterTest.php index c9fca9a..88df990 100644 --- a/tests/FileRouterTest.php +++ b/tests/FileRouterTest.php @@ -15,6 +15,7 @@ use Yiisoft\FileRouter\Tests\Support\App2; use Yiisoft\FileRouter\Tests\Support\App3; use Yiisoft\FileRouter\Tests\Support\App4; +use Yiisoft\FileRouter\Tests\Support\App5; use Yiisoft\FileRouter\Tests\Support\HeaderMiddleware; use Yiisoft\Middleware\Dispatcher\MiddlewareDispatcher; use Yiisoft\Middleware\Dispatcher\MiddlewareFactory; @@ -319,6 +320,63 @@ public static function dataUnicodeRoutes(): iterable ]; } + #[DataProvider('dataModularity')] + public function testModularity( + string $namespace, + string $routePrefix, + string $method, + string $uri, + string $expectedResult, + ): void { + $router = $this->createRouter(); + $router = $router + ->withNamespace($namespace) + ->withRoutePrefix($routePrefix); + + $handler = $this->createExceptionHandler(); + $request = new ServerRequest( + method: $method, + uri: $uri, + ); + + $response = $router->process($request, $handler); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expectedResult, (string) $response->getBody()); + } + + public static function dataModularity(): iterable + { + yield 'module1 /' => [ + 'Yiisoft\\FileRouter\\Tests\\Support\\App5\\Module1', + '/module1', + 'GET', + '/module1', + 'Hello, module1!', + ]; + yield 'module1 /index' => [ + 'Yiisoft\\FileRouter\\Tests\\Support\\App5\\Module1', + '/module1', + 'GET', + '/module1/', + 'Hello, module1!', + ]; + yield 'module2 /index' => [ + 'Yiisoft\\FileRouter\\Tests\\Support\\App5\\Module2', + '/module2', + 'GET', + '/module2/index', + 'Hello, module2!', + ]; + yield 'm/o/d/u/l/e /index' => [ + 'Yiisoft\\FileRouter\\Tests\\Support\\App5\\Module2', + '/m/o/d/u/l/e', + 'GET', + '/m/o/d/u/l/e/index', + 'Hello, module2!', + ]; + } + private function createRouter(): FileRouter { $container = new SimpleContainer([ @@ -333,7 +391,11 @@ private function createRouter(): FileRouter App3\Controller\UserController::class => new App3\Controller\UserController(), App3\Controller\User\IndexController::class => new App3\Controller\User\IndexController(), - App4\Контроллеры\Пользователь\ГлавныйКонтроллер::class => new App4\Контроллеры\Пользователь\ГлавныйКонтроллер(), + App4\Контроллеры\Пользователь\ГлавныйКонтроллер::class => new App4\Контроллеры\Пользователь\ГлавныйКонтроллер( + ), + + App5\Module1\Controller\IndexController::class => new App5\Module1\Controller\IndexController(), + App5\Module2\Controller\IndexController::class => new App5\Module2\Controller\IndexController(), ]); return new FileRouter( diff --git a/tests/Support/App5/Module1/Controller/IndexController.php b/tests/Support/App5/Module1/Controller/IndexController.php new file mode 100644 index 0000000..1239b0a --- /dev/null +++ b/tests/Support/App5/Module1/Controller/IndexController.php @@ -0,0 +1,16 @@ +