diff --git a/.gitignore b/.gitignore index 8c81ab2..7fc4a65 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,4 @@ phpunit.phar /phpunit.xml # phpunit cache .phpunit.result.cache -/phpunit.cache +.phpunit.cache diff --git a/README.md b/README.md index 8e5b84f..c258eaf 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,18 @@ -

Yii _____

+

File Router


-[![Latest Stable Version](https://poser.pugx.org/yiisoft/_____/v/stable.png)](https://packagist.org/packages/yiisoft/_____) -[![Total Downloads](https://poser.pugx.org/yiisoft/_____/downloads.png)](https://packagist.org/packages/yiisoft/_____) -[![Build status](https://github.com/yiisoft/_____/workflows/build/badge.svg)](https://github.com/yiisoft/_____/actions?query=workflow%3Abuild) -[![Code Coverage](https://codecov.io/gh/yiisoft/_____/branch/master/graph/badge.svg)](https://codecov.io/gh/yiisoft/_____) -[![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2F_____%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/_____/master) -[![static analysis](https://github.com/yiisoft/_____/workflows/static%20analysis/badge.svg)](https://github.com/yiisoft/_____/actions?query=workflow%3A%22static+analysis%22) -[![type-coverage](https://shepherd.dev/github/yiisoft/_____/coverage.svg)](https://shepherd.dev/github/yiisoft/_____) -[![psalm-level](https://shepherd.dev/github/yiisoft/_____/level.svg)](https://shepherd.dev/github/yiisoft/_____) +[![Latest Stable Version](https://poser.pugx.org/yiisoft/File Router/v/stable.png)](https://packagist.org/packages/yiisoft/File Router) +[![Total Downloads](https://poser.pugx.org/yiisoft/File Router/downloads.png)](https://packagist.org/packages/yiisoft/File Router) +[![Build status](https://github.com/yiisoft/File Router/workflows/build/badge.svg)](https://github.com/yiisoft/File Router/actions?query=workflow%3Abuild) +[![Code Coverage](https://codecov.io/gh/yiisoft/File Router/branch/master/graph/badge.svg)](https://codecov.io/gh/yiisoft/File Router) +[![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2FFile Router%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/File Router/master) +[![static analysis](https://github.com/yiisoft/File Router/workflows/static%20analysis/badge.svg)](https://github.com/yiisoft/File Router/actions?query=workflow%3A%22static+analysis%22) +[![type-coverage](https://shepherd.dev/github/yiisoft/File Router/coverage.svg)](https://shepherd.dev/github/yiisoft/File Router) +[![psalm-level](https://shepherd.dev/github/yiisoft/File Router/level.svg)](https://shepherd.dev/github/yiisoft/File Router) The package ... @@ -26,7 +26,7 @@ The package ... The package could be installed with composer: ```shell -composer require yiisoft/_____ +composer require yiisoft/File Router ``` ## General usage @@ -74,7 +74,7 @@ Use [ComposerRequireChecker](https://github.com/maglnet/ComposerRequireChecker) ## License -The Yii _____ is free software. It is released under the terms of the BSD License. +The File Router is free software. It is released under the terms of the BSD License. Please see [`LICENSE`](./LICENSE.md) for more information. Maintained by [Yii Software](https://www.yiiframework.com/). diff --git a/composer.json b/composer.json index 62faeea..48aa138 100644 --- a/composer.json +++ b/composer.json @@ -1,19 +1,23 @@ { - "name": "yiisoft/_____", + "name": "yiisoft/file-router", "type": "library", - "description": "_____", + "description": "File based router", "keywords": [ - "_____" + "file-router", + "router", + "routing", + "convention-router", + "structure-router" ], "homepage": "https://www.yiiframework.com/", "license": "BSD-3-Clause", "support": { - "issues": "https://github.com/yiisoft/_____/issues?state=open", + "issues": "https://github.com/yiisoft/file-router/issues?state=open", "forum": "https://www.yiiframework.com/forum/", "wiki": "https://www.yiiframework.com/wiki/", "irc": "ircs://irc.libera.chat:6697/yii", "chat": "https://t.me/yii3en", - "source": "https://github.com/yiisoft/_____" + "source": "https://github.com/yiisoft/file-router" }, "funding": [ { @@ -28,24 +32,31 @@ "minimum-stability": "dev", "prefer-stable": true, "require": { - "php": "^8.1" + "php": "^8.1", + "psr/http-message": "^2.0", + "psr/http-server-middleware": "^1.0", + "yiisoft/middleware-dispatcher": "^5.2" }, "require-dev": { + "httpsoft/http-message": "^1.1", + "httpsoft/http-response": "^1.1", "maglnet/composer-require-checker": "^4.7", "phpunit/phpunit": "^10.5", "rector/rector": "^0.18.11", "roave/infection-static-analysis-plugin": "^1.34", "spatie/phpunit-watcher": "^1.23", - "vimeo/psalm": "^5.16" + "vimeo/psalm": "^5.16", + "yiisoft/strings": "^2.4", + "yiisoft/test-support": "^3.0" }, "autoload": { "psr-4": { - "Yiisoft\\_____\\": "src" + "Yiisoft\\FileRouter\\": "src" } }, "autoload-dev": { "psr-4": { - "Yiisoft\\_____\\Tests\\": "tests" + "Yiisoft\\FileRouter\\Tests\\": "tests" } }, "config": { diff --git a/config/web.php b/config/web.php new file mode 100644 index 0000000..69d387a --- /dev/null +++ b/config/web.php @@ -0,0 +1,21 @@ + function (ContainerInterface $container) { + $eventDispatcher = $container->has(EventDispatcherInterface::class) + ? $container->get(EventDispatcherInterface::class) + : null; + + $middlewareFactory = $container->get(MiddlewareFactory::class); + + return new FileRouter(new MiddlewareDispatcher($middlewareFactory, $eventDispatcher)); + }, +]; diff --git a/src/.gitkeep b/src/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/FileRouter.php b/src/FileRouter.php new file mode 100644 index 0000000..4e8c033 --- /dev/null +++ b/src/FileRouter.php @@ -0,0 +1,136 @@ +baseControllerDirectory = $directory; + + return $new; + } + + public function withClassPostfix(string $postfix): self + { + $new = clone $this; + $new->classPostfix = $postfix; + + return $new; + } + + public function withNamespace(string $namespace): self + { + $new = clone $this; + $new->namespace = $namespace; + + return $new; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $controllerClass = $this->parseController($request); + if ($controllerClass === null) { + return $handler->handle($request); + } + $action = $this->parseAction($request); + + if (!method_exists($controllerClass, $action)) { + return $handler->handle($request); + } + + $middlewares = $controllerClass::$middlewares[$action] ?? []; + $middlewares[] = [$controllerClass, $action]; + + $middlewareDispatcher = $this->middlewareDispatcher->withMiddlewares($middlewares); + + return $middlewareDispatcher->dispatch($request, $handler); + } + + private function parseAction(ServerRequestInterface $request): ?string + { + switch ($request->getMethod()) { + case 'HEAD': + case 'GET': + $action = 'index'; + break; + case 'POST': + $action = 'create'; + break; + case 'PUT': + $action = 'update'; + break; + case 'DELETE': + $action = 'delete'; + break; + default: + throw new Exception('Not implemented.'); + } + return $action; + } + + private function parseController(ServerRequestInterface $request): mixed + { + $path = $request->getUri()->getPath(); + if ($path === '/') { + $controllerName = 'Index'; + $directoryPath = ''; + } else { + $controllerName = preg_replace_callback( + '#(/.)#', + fn(array $matches) => strtoupper($matches[1]), + str_replace('/', DIRECTORY_SEPARATOR, $path) + ); + $directoryPath = StringHelper::directoryName($controllerName); + + $controllerName = StringHelper::basename($controllerName); + } + + $controller = $controllerName . $this->classPostfix; + $className = str_replace( + ['/', '\\\\'], + ['\\', '\\'], + $this->namespace . '\\' . $this->baseControllerDirectory . '\\' . $directoryPath . '\\' . $controller + ); + + if (class_exists($className)) { + return $className; + } + + // alternative version finding namespace by file + + + //$controllerDirectory = $this->aliases->get('src') . DIRECTORY_SEPARATOR . $this->baseControllerDirectory; + //$classPath = $controllerDirectory . DIRECTORY_SEPARATOR . $directoryPath . DIRECTORY_SEPARATOR . $controller; + //$filename = $classPath . '.php'; + //if (file_exists($filename)) { + // $content = file_get_contents($filename); + // $namespace = preg_match('#namespace\s+(.+?);#', $content, $matches) ? $matches[1] : ''; + // if (class_exists($namespace . '\\' . $controller)) { + // return $namespace . '\\' . $controller; + // } + //} + + return null; + } +} diff --git a/tests/.gitkeep b/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/FileRouterTest.php b/tests/FileRouterTest.php new file mode 100644 index 0000000..9769798 --- /dev/null +++ b/tests/FileRouterTest.php @@ -0,0 +1,172 @@ +createRouter(); + $router = $router + ->withNamespace('Yiisoft\FileRouter\Tests\Support\App1') + ->withBaseControllerDirectory('Controller'); + + $handler = $this->createExceptionHandler(); + $request = new ServerRequest( + method: 'GET', + uri: '/user/blog', + ); + + $response = $router->process($request, $handler); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('Hello, index!', (string) $response->getBody()); + $this->assertEquals('x-header-value', $response->getHeaderLine('X-Header-Name')); + } + + #[DataProvider('dataRouter')] + public function testRouter(string $method, string $uri, string $expectedResponse): void + { + /** + * @var FileRouter $router + */ + $router = $this->createRouter(); + $router = $router + ->withNamespace('Yiisoft\FileRouter\Tests\Support\App1'); + + $handler = $this->createExceptionHandler(); + $request = new ServerRequest( + method: $method, + uri: $uri, + ); + + $response = $router->process($request, $handler); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($expectedResponse, (string) $response->getBody()); + } + + public static function dataRouter(): iterable + { + yield 'GET /user' => [ + 'GET', + '/user', + 'Hello, index!', + ]; + yield 'POST /user' => [ + 'POST', + '/user', + 'Hello, create!', + ]; + yield 'PUT /user' => [ + 'PUT', + '/user', + 'Hello, update!', + ]; + yield 'DELETE /user' => [ + 'DELETE', + '/user', + 'Hello, delete!', + ]; + } + + public function testUnsupportedMethod(): void + { + $router = $this->createRouter(); + $router = $router + ->withNamespace('Yiisoft\FileRouter\Tests\Support\App1'); + + $handler = $this->createExceptionHandler(); + $request = new ServerRequest( + method: 'DELETE', + uri: '/', + ); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Not implemented from tests.'); + $router->process($request, $handler); + } + + public function testBaseController(): void + { + $router = $this->createRouter(); + $router = $router + ->withNamespace('Yiisoft\FileRouter\Tests\Support\App1'); + + $handler = $this->createExceptionHandler(); + $request = new ServerRequest( + method: 'GET', + uri: '/', + ); + + $response = $router->process($request, $handler); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('Hello, index!', (string) $response->getBody()); + } + + public function testUnusualControllerDirectory(): void + { + $router = $this->createRouter(); + $router = $router + ->withNamespace('Yiisoft\FileRouter\Tests\Support\App2') + ->withBaseControllerDirectory('Action') + ->withClassPostfix('Action'); + + $handler = $this->createExceptionHandler(); + $request = new ServerRequest( + method: 'GET', + uri: '/user', + ); + + $response = $router->process($request, $handler); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('Hello, user action!', (string) $response->getBody()); + } + + private function createRouter(): FileRouter + { + $container = new SimpleContainer([ + HeaderMiddleware::class => new HeaderMiddleware(), + BlogController::class => new BlogController(), + UserController::class => new UserController(), + IndexController::class => new IndexController(), + UserAction::class => new UserAction(), + ]); + + return new FileRouter( + new MiddlewareDispatcher(new MiddlewareFactory($container), null) + ); + } + + private function createExceptionHandler(): RequestHandlerInterface + { + return new class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new \Exception('Not implemented from tests.'); + } + }; + { + } + } +} diff --git a/tests/Support/App1/Controller/IndexController.php b/tests/Support/App1/Controller/IndexController.php new file mode 100644 index 0000000..88abb86 --- /dev/null +++ b/tests/Support/App1/Controller/IndexController.php @@ -0,0 +1,16 @@ + 'X-Header-Value']); + } +} diff --git a/tests/Support/App1/Controller/User/BlogController.php b/tests/Support/App1/Controller/User/BlogController.php new file mode 100644 index 0000000..5215cda --- /dev/null +++ b/tests/Support/App1/Controller/User/BlogController.php @@ -0,0 +1,23 @@ + [ + HeaderMiddleware::class, + ], + ]; + + public function index(): ResponseInterface + { + return new TextResponse('Hello, index!'); + } +} diff --git a/tests/Support/App1/Controller/UserController.php b/tests/Support/App1/Controller/UserController.php new file mode 100644 index 0000000..1cc4418 --- /dev/null +++ b/tests/Support/App1/Controller/UserController.php @@ -0,0 +1,38 @@ + [ + HeaderMiddleware::class, + ], + ]; + + public function index(): ResponseInterface + { + return new TextResponse('Hello, index!'); + } + + public function create(): ResponseInterface + { + return new TextResponse('Hello, create!'); + } + + public function update(): ResponseInterface + { + return new TextResponse('Hello, update!'); + } + + public function delete(): ResponseInterface + { + return new TextResponse('Hello, delete!'); + } +} diff --git a/tests/Support/App2/Action/UserAction.php b/tests/Support/App2/Action/UserAction.php new file mode 100644 index 0000000..72cf4f9 --- /dev/null +++ b/tests/Support/App2/Action/UserAction.php @@ -0,0 +1,23 @@ + [ + HeaderMiddleware::class, + ], + ]; + + public function index(): ResponseInterface + { + return new TextResponse('Hello, user action!'); + } +} diff --git a/tests/Support/HeaderMiddleware.php b/tests/Support/HeaderMiddleware.php new file mode 100644 index 0000000..59dbdb5 --- /dev/null +++ b/tests/Support/HeaderMiddleware.php @@ -0,0 +1,23 @@ +handle($request); + + return $response->withHeader(self::X_HEADER_NAME, self::X_HEADER_VALUE); + } +}