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..c444820 100755 --- a/README.md +++ b/README.md @@ -1,13 +1,20 @@ -# 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. +> ⚠️ **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 +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 +37,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 +52,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 +63,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 +87,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 +140,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 +247,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 +559,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 +571,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..7aa40a3 --- /dev/null +++ b/src/Attribute/AttributeRouteCollector.php @@ -0,0 +1,147 @@ +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']); + } +}