From 4fdc7849cf173df5be182fc2e7898dcd5cd19aed Mon Sep 17 00:00:00 2001 From: phpdevcommunity Date: Fri, 25 Jul 2025 15:25:54 +0000 Subject: [PATCH 1/3] add PHP 8 attributes support and make library PHP 8 compatible --- .gitignore | 2 + README.md | 51 +++++--- src/Attribute/AttributeRouteCollector.php | 143 ++++++++++++++++++++++ src/Attribute/ControllerRoute.php | 28 +++++ src/Attribute/Route.php | 52 ++++++++ src/Route.php | 34 +++++ src/UrlGenerator.php | 3 + tests/AttributeRouteCollectorTest.php | 95 ++++++++++++++ tests/Controller/ApiController.php | 27 ++++ tests/Controller/PingController.php | 16 +++ tests/Controller/ProductController.php | 26 ++++ tests/Controller/UserController.php | 26 ++++ 12 files changed, 488 insertions(+), 15 deletions(-) create mode 100755 src/Attribute/AttributeRouteCollector.php create mode 100755 src/Attribute/ControllerRoute.php create mode 100755 src/Attribute/Route.php create mode 100755 tests/AttributeRouteCollectorTest.php create mode 100755 tests/Controller/ApiController.php create mode 100755 tests/Controller/PingController.php create mode 100755 tests/Controller/ProductController.php create mode 100755 tests/Controller/UserController.php diff --git a/.gitignore b/.gitignore index cac762f..c360515 100755 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /vendor/ /.idea/ +composer.lock +/tests/cache \ No newline at end of file diff --git a/README.md b/README.md index 64ba415..8b55d17 100755 --- a/README.md +++ b/README.md @@ -1,13 +1,15 @@ -# PHP Router - -PHP Router is a simple and efficient routing library designed for PHP applications. It provides a straightforward way to define routes, handle HTTP requests, and generate URLs. Built with PSR-7 message implementation in mind, it seamlessly integrates with PHP applications. +# PHP Router +PHP Router is a simple and efficient routing library designed for PHP applications. It provides a straightforward way to +define routes, handle HTTP requests, and generate URLs. Built with PSR-7 message implementation in mind, it seamlessly +integrates with PHP applications. ## Installation You can install PHP Router via Composer. Just run: ### Composer Require + ``` composer require phpdevcommunity/php-router ``` @@ -30,12 +32,14 @@ composer require phpdevcommunity/php-router 5. **Generate URLs**: Generate URLs for named routes. - ## Example + ```php 8.0 + #[\PhpDevCommunity\Attribute\Route(path: '/', name: 'home_page')] public function __invoke() { return 'Hello world!!'; @@ -43,7 +47,8 @@ class IndexController { } class ArticleController { - + // PHP > 8.0 + #[\PhpDevCommunity\Attribute\Route(path: '/api/articles', name: 'api_articles_collection')] public function getAll() { // db get all post @@ -53,7 +58,8 @@ class ArticleController { ['id' => 3] ]); } - + // PHP > 8.0 + #[\PhpDevCommunity\Attribute\Route(path: '/api/articles/{id}', name: 'api_articles')] public function get(int $id) { // db get post by id @@ -76,11 +82,20 @@ class ArticleController { ```php // Define your routes -$routes = [ - new \PhpDevCommunity\Route('home_page', '/', [IndexController::class]), - new \PhpDevCommunity\Route('api_articles_collection', '/api/articles', [ArticleController::class, 'getAll']), - new \PhpDevCommunity\Route('api_articles', '/api/articles/{id}', [ArticleController::class, 'get']), -]; + +if (PHP_VERSION_ID >= 80000) { + $attributeRouteCollector = new AttributeRouteCollector([ + IndexController::class, + ArticleController::class + ]); + $routes = $attributeRouteCollector->collect(); +}else { + $routes = [ + new \PhpDevCommunity\Route('home_page', '/', [IndexController::class]), + new \PhpDevCommunity\Route('api_articles_collection', '/api/articles', [ArticleController::class, 'getAll']), + new \PhpDevCommunity\Route('api_articles', '/api/articles/{id}', [ArticleController::class, 'get']), + ]; +} // Initialize the router $router = new \PhpDevCommunity\Router($routes, 'http://localhost'); @@ -120,15 +135,18 @@ try { ## Route Definition -Routes can be defined using the `Route` class provided by PHP Router. You can specify HTTP methods, attribute constraints, and handler methods for each route. +Routes can be defined using the `Route` class provided by PHP Router. You can specify HTTP methods, attribute +constraints, and handler methods for each route. ```php $route = new \PhpDevCommunity\Route('api_articles_post', '/api/articles', [ArticleController::class, 'post'], ['POST']); $route = new \PhpDevCommunity\Route('api_articles_put', '/api/articles/{id}', [ArticleController::class, 'put'], ['PUT']); ``` + ### Easier Route Definition with Static Methods -To make route definition even simpler and more intuitive, the `RouteTrait` provides static methods for creating different types of HTTP routes. Here's how to use them: +To make route definition even simpler and more intuitive, the `RouteTrait` provides static methods for creating +different types of HTTP routes. Here's how to use them: #### Method `get()` @@ -224,7 +242,8 @@ $route = Route::delete('delete_item', '/item/{id}', [ItemController::class, 'del ### Using `where` Constraints in the Route Object -The `Route` object allows you to define constraints on route parameters using the `where` methods. These constraints validate and filter parameter values based on regular expressions. Here's how to use them: +The `Route` object allows you to define constraints on route parameters using the `where` methods. These constraints +validate and filter parameter values based on regular expressions. Here's how to use them: #### Method `whereNumber()` @@ -535,7 +554,8 @@ Example Usage: $route = (new Route('product', '/product/{code}'))->where('code', '\d{4}'); ``` -By using these `where` methods, you can apply precise constraints on your route parameters, ensuring proper validation of input values. +By using these `where` methods, you can apply precise constraints on your route parameters, ensuring proper validation +of input values. ## Generating URLs @@ -546,6 +566,7 @@ echo $router->generateUri('home_page'); // / echo $router->generateUri('api_articles', ['id' => 1]); // /api/articles/1 echo $router->generateUri('api_articles', ['id' => 1], true); // http://localhost/api/articles/1 ``` + ## Contributing Contributions are welcome! Feel free to open issues or submit pull requests to help improve the library. diff --git a/src/Attribute/AttributeRouteCollector.php b/src/Attribute/AttributeRouteCollector.php new file mode 100755 index 0000000..add97bb --- /dev/null +++ b/src/Attribute/AttributeRouteCollector.php @@ -0,0 +1,143 @@ +classes = array_unique($classes); + $this->cacheDir = $cacheDir; + if ($this->cacheDir && !is_dir($this->cacheDir)) { + throw new \InvalidArgumentException(sprintf( + 'Cache directory "%s" does not exist', + $this->cacheDir + )); + } + } + + public function generateCache(): void + { + if (!$this->cacheIsEnabled()) { + throw new \LogicException('Cache is not enabled, if you want to enable it, please set the cacheDir on the constructor'); + } + $this->collect(); + } + + public function clearCache(): void + { + if (!$this->cacheIsEnabled()) { + throw new \LogicException('Cache is not enabled, if you want to enable it, please set the cacheDir on the constructor'); + } + + foreach ($this->classes as $class) { + $cacheFile = $this->getCacheFile($class); + if (file_exists($cacheFile)) { + unlink($cacheFile); + } + } + } + + /** + * @return array<\PhpDevCommunity\Route + * @throws \ReflectionException + */ + public function collect(): array + { + $routes = []; + foreach ($this->classes as $class) { + $routes = array_merge($routes, $this->getRoutes($class)); + } + return $routes; + } + + + private function getRoutes(string $class): array + { + if ($this->cacheIsEnabled() && ( $cached = $this->get($class))) { + return $cached; + } + $refClass = new \ReflectionClass($class); + $routes = []; + + $controllerAttr = $refClass->getAttributes( + ControllerRoute::class, + \ReflectionAttribute::IS_INSTANCEOF + )[0] ?? null; + $controllerRoute = $controllerAttr ? $controllerAttr->newInstance() : new ControllerRoute(''); + foreach ($refClass->getMethods() as $method) { + foreach ($method->getAttributes( + Route::class, + \ReflectionAttribute::IS_INSTANCEOF + ) as $attr) { + /** + * @var Route $instance + */ + $instance = $attr->newInstance(); + $route = new \PhpDevCommunity\Route( + $instance->getName(), + $controllerRoute->getPath().$instance->getPath(), + [$class, $method->getName()], + $instance->getMethods() + ); + + $route->format($instance->getFormat() ?: $controllerRoute->getFormat()); + foreach ($instance->getOptions() as $key => $value) { + if (!str_starts_with($key, 'where') || $key === 'where') { + throw new \InvalidArgumentException( + 'Invalid option "' . $key . '". Options must start with "where".' + ); + } + if (is_array($value)) { + $route->$key(...$value); + continue; + } + $route->$key($value); + } + $routes[$instance->getName()] = $route; + } + } + $routes = array_values($routes); + if ($this->cacheIsEnabled()) { + $this->set($class, $routes); + } + + return $routes; + + } + + private function cacheIsEnabled(): bool + { + return $this->cacheDir !== null; + } + + private function get(string $class): ?array + { + $cacheFile = $this->getCacheFile($class); + if (!is_file($cacheFile)) { + return null; + } + + return require $cacheFile; + } + + private function set(string $class, array $routes): void + { + $cacheFile = $this->getCacheFile($class); + $content = "cacheDir . '/' .md5($class) . '.php'; + } +} \ No newline at end of file diff --git a/src/Attribute/ControllerRoute.php b/src/Attribute/ControllerRoute.php new file mode 100755 index 0000000..73f3d25 --- /dev/null +++ b/src/Attribute/ControllerRoute.php @@ -0,0 +1,28 @@ +path = Helper::trimPath($path); + $this->format = $format; + } + + public function getPath(): string + { + return $this->path; + } + + public function getFormat(): ?string + { + return $this->format; + } +} \ No newline at end of file diff --git a/src/Attribute/Route.php b/src/Attribute/Route.php new file mode 100755 index 0000000..e5b48eb --- /dev/null +++ b/src/Attribute/Route.php @@ -0,0 +1,52 @@ +path = Helper::trimPath($path); + $this->name = $name; + $this->methods = $methods; + $this->options = $options; + $this->format = $format; + } + + public function getPath(): string + { + return $this->path; + } + + public function getName(): string + { + return $this->name; + } + + public function getMethods(): array + { + return $this->methods; + } + + public function getOptions(): array + { + return $this->options; + } + + public function getFormat(): ?string + { + return $this->format; + } +} \ No newline at end of file diff --git a/src/Route.php b/src/Route.php index 04b07d9..6d526ef 100644 --- a/src/Route.php +++ b/src/Route.php @@ -41,6 +41,8 @@ final class Route */ private array $wheres = []; + private ?string $format = null; + /** * Constructor for the Route class. * @@ -172,6 +174,11 @@ public function getAttributes(): array return $this->attributes; } + public function getFormat(): ?string + { + return $this->format; + } + /** * Sets a number constraint on the specified route parameters. * @@ -279,10 +286,37 @@ public function where(string $parameter, string $expression): self return $this; } + public function format(?string $format): self + { + $allowedFormats = ['json', 'xml', 'html', null]; + if (!in_array($format, $allowedFormats)) { + throw new \InvalidArgumentException("Invalid format. Allowed formats: " . implode(', ', $allowedFormats)); + } + $this->format = $format; + return $this; + } + private function assignExprToParameters(array $parameters, string $expression): void { foreach ($parameters as $parameter) { $this->where($parameter, $expression); } } + + public static function __set_state(array $state): self + { + $route = new self( + $state['name'], + $state['path'], + $state['handler'], + $state['methods'] + ); + $route->format($state['format'] ?? null); + foreach ($state['wheres'] as $parameter => $expression) { + $route->where($parameter, $expression); + } + return $route; + } } + + diff --git a/src/UrlGenerator.php b/src/UrlGenerator.php index 9cf25ff..72fd939 100644 --- a/src/UrlGenerator.php +++ b/src/UrlGenerator.php @@ -70,6 +70,9 @@ private static function resolveUri(Route $route, array $parameters): string sprintf('%s not found in parameters to generate url', $varName) ); } + if (!is_array($parameters[$varName])) { + $parameters[$varName] = strval($parameters[$varName]); + } $uri = str_replace($variable, $parameters[$varName], $uri); } return $uri; diff --git a/tests/AttributeRouteCollectorTest.php b/tests/AttributeRouteCollectorTest.php new file mode 100755 index 0000000..17040ba --- /dev/null +++ b/tests/AttributeRouteCollectorTest.php @@ -0,0 +1,95 @@ +collect(); + $this->assertStrictEquals(9, count($routes)); + + $attributeRouteCollector = new \PhpDevCommunity\Attribute\AttributeRouteCollector([ + 'Test\PhpDevCommunity\Controller\UserController' + ]); + $routes = $attributeRouteCollector->collect(); + $this->assertStrictEquals(3, count($routes)); + $this->assertStrictEquals('user_list', $routes[0]->getName()); + $this->assertEquals(['GET', 'HEAD'], $routes[0]->getMethods()); + + $this->assertStrictEquals('user_show', $routes[1]->getName()); + $this->assertEquals(['GET', 'HEAD'], $routes[1]->getMethods()); + + $this->assertStrictEquals('user_create', $routes[2]->getName()); + $this->assertEquals(['POST'], $routes[2]->getMethods()); + + + $attributeRouteCollector = new \PhpDevCommunity\Attribute\AttributeRouteCollector([ + 'Test\PhpDevCommunity\Controller\PingController' + ]); + $routes = $attributeRouteCollector->collect(); + $this->assertStrictEquals(1, count($routes)); + $this->assertStrictEquals('/api/ping', $routes[0]->getPath()); + $this->assertEquals(['GET', 'HEAD'], $routes[0]->getMethods()); + $this->assertEquals('json', $routes[0]->getFormat()); + + + $this->testCache(); + } + + private function testCache(): void + { + $controllers = [ + 'Test\PhpDevCommunity\Controller\UserController', + 'Test\PhpDevCommunity\Controller\ProductController', + 'Test\PhpDevCommunity\Controller\ApiController', + 'Test\PhpDevCommunity\Controller\PingController' + ]; + + $cacheDir = dirname(__FILE__) . '/cache'; + if (is_dir($cacheDir)) { + rmdir($cacheDir); + } + mkdir($cacheDir, 0777, true); + + $attributeRouteCollector = new \PhpDevCommunity\Attribute\AttributeRouteCollector($controllers, $cacheDir); + + $attributeRouteCollector->generateCache(); + $this->assertTrue(is_dir($cacheDir)); + foreach ($controllers as $controller) { + $cacheFile = $cacheDir . '/' . md5($controller) . '.php'; + $this->assertTrue($cacheFile); + } + $routes = $attributeRouteCollector->collect(); + $this->assertStrictEquals(9, count($routes)); + foreach ($routes as $route) { + $this->assertInstanceOf(Route::class, $route); + } + + $attributeRouteCollector->clearCache(); + rmdir($cacheDir); + } +} \ No newline at end of file diff --git a/tests/Controller/ApiController.php b/tests/Controller/ApiController.php new file mode 100755 index 0000000..c5f4396 --- /dev/null +++ b/tests/Controller/ApiController.php @@ -0,0 +1,27 @@ + 'John Doe', + ]); + } + + #[Route('/api', name: 'api_post', methods: ['POST'])] + public function post(): string + { + return json_encode([ + 'name' => 'John Doe', + 'status' => 'success' + ]); + } + +} \ No newline at end of file diff --git a/tests/Controller/PingController.php b/tests/Controller/PingController.php new file mode 100755 index 0000000..54e3212 --- /dev/null +++ b/tests/Controller/PingController.php @@ -0,0 +1,16 @@ + true]); + } +} diff --git a/tests/Controller/ProductController.php b/tests/Controller/ProductController.php new file mode 100755 index 0000000..e1fc08a --- /dev/null +++ b/tests/Controller/ProductController.php @@ -0,0 +1,26 @@ + ['Phone', 'Laptop']]); + } + + #[Route('/products/{id}', name: 'product_update', methods: ['PUT'], options: ['whereNumber' => 'id'])] + public function update(): string + { + return json_encode(['status' => 'updated']); + } + + #[Route('/products/{id}', name: 'product_delete', methods: ['DELETE'])] + public function delete(): string + { + return json_encode(['status' => 'deleted']); + } +} diff --git a/tests/Controller/UserController.php b/tests/Controller/UserController.php new file mode 100755 index 0000000..769a847 --- /dev/null +++ b/tests/Controller/UserController.php @@ -0,0 +1,26 @@ + ['Alice', 'Bob']]); + } + + #[Route('/users/{id}', name: 'user_show', methods: ['GET'], options: ['whereNumber' => 'id'])] + public function show(): string + { + return json_encode(['user' => 'Alice']); + } + + #[Route('/users', name: 'user_create', methods: ['POST'])] + public function create(): string + { + return json_encode(['status' => 'created']); + } +} From 0dd371910826a66e7f129fd1a523f29c8f121835 Mon Sep 17 00:00:00 2001 From: phpdevcommunity Date: Wed, 17 Sep 2025 07:07:36 +0000 Subject: [PATCH 2/3] fix cachefilename resolver --- src/Attribute/AttributeRouteCollector.php | 30 +++++++++++++---------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/Attribute/AttributeRouteCollector.php b/src/Attribute/AttributeRouteCollector.php index add97bb..7aa40a3 100755 --- a/src/Attribute/AttributeRouteCollector.php +++ b/src/Attribute/AttributeRouteCollector.php @@ -2,7 +2,11 @@ namespace PhpDevCommunity\Attribute; -use PhpDevCommunity\Helper; +use InvalidArgumentException; +use LogicException; +use ReflectionAttribute; +use ReflectionClass; +use ReflectionException; final class AttributeRouteCollector { @@ -12,12 +16,12 @@ final class AttributeRouteCollector public function __construct(array $classes, ?string $cacheDir = null) { if (PHP_VERSION_ID < 80000) { - throw new \LogicException('Attribute routes are only supported in PHP 8.0+'); + throw new LogicException('Attribute routes are only supported in PHP 8.0+'); } $this->classes = array_unique($classes); $this->cacheDir = $cacheDir; if ($this->cacheDir && !is_dir($this->cacheDir)) { - throw new \InvalidArgumentException(sprintf( + throw new InvalidArgumentException(sprintf( 'Cache directory "%s" does not exist', $this->cacheDir )); @@ -27,7 +31,7 @@ public function __construct(array $classes, ?string $cacheDir = null) public function generateCache(): void { if (!$this->cacheIsEnabled()) { - throw new \LogicException('Cache is not enabled, if you want to enable it, please set the cacheDir on the constructor'); + throw new LogicException('Cache is not enabled, if you want to enable it, please set the cacheDir on the constructor'); } $this->collect(); } @@ -35,7 +39,7 @@ public function generateCache(): void public function clearCache(): void { if (!$this->cacheIsEnabled()) { - throw new \LogicException('Cache is not enabled, if you want to enable it, please set the cacheDir on the constructor'); + throw new LogicException('Cache is not enabled, if you want to enable it, please set the cacheDir on the constructor'); } foreach ($this->classes as $class) { @@ -48,7 +52,7 @@ public function clearCache(): void /** * @return array<\PhpDevCommunity\Route - * @throws \ReflectionException + * @throws ReflectionException */ public function collect(): array { @@ -62,21 +66,21 @@ public function collect(): array private function getRoutes(string $class): array { - if ($this->cacheIsEnabled() && ( $cached = $this->get($class))) { + if ($this->cacheIsEnabled() && ($cached = $this->get($class))) { return $cached; } - $refClass = new \ReflectionClass($class); + $refClass = new ReflectionClass($class); $routes = []; $controllerAttr = $refClass->getAttributes( ControllerRoute::class, - \ReflectionAttribute::IS_INSTANCEOF + ReflectionAttribute::IS_INSTANCEOF )[0] ?? null; $controllerRoute = $controllerAttr ? $controllerAttr->newInstance() : new ControllerRoute(''); foreach ($refClass->getMethods() as $method) { foreach ($method->getAttributes( Route::class, - \ReflectionAttribute::IS_INSTANCEOF + ReflectionAttribute::IS_INSTANCEOF ) as $attr) { /** * @var Route $instance @@ -84,7 +88,7 @@ private function getRoutes(string $class): array $instance = $attr->newInstance(); $route = new \PhpDevCommunity\Route( $instance->getName(), - $controllerRoute->getPath().$instance->getPath(), + $controllerRoute->getPath() . $instance->getPath(), [$class, $method->getName()], $instance->getMethods() ); @@ -92,7 +96,7 @@ private function getRoutes(string $class): array $route->format($instance->getFormat() ?: $controllerRoute->getFormat()); foreach ($instance->getOptions() as $key => $value) { if (!str_starts_with($key, 'where') || $key === 'where') { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( 'Invalid option "' . $key . '". Options must start with "where".' ); } @@ -138,6 +142,6 @@ private function set(string $class, array $routes): void private function getCacheFile(string $class): string { - return $this->cacheDir . '/' .md5($class) . '.php'; + return rtrim($this->cacheDir, '/') . '/' . md5($class) . '.php'; } } \ No newline at end of file From 9f5d40836b9faa0de603d9b86dcad3f63376c2a3 Mon Sep 17 00:00:00 2001 From: "F. Michel" Date: Mon, 15 Dec 2025 17:21:17 +0100 Subject: [PATCH 3/3] Add abandonment notice to README Added notice about package abandonment and alternative suggestion. --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 8b55d17..c444820 100755 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +> ⚠️ **Abandoned package** +> +> This package is abandoned and no longer maintained. +> The author suggests using **[michel/router](https://github.com/michelphp/router)** instead. +> # PHP Router PHP Router is a simple and efficient routing library designed for PHP applications. It provides a straightforward way to