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 3d2d34a..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()` @@ -222,11 +240,10 @@ Example Usage: $route = Route::delete('delete_item', '/item/{id}', [ItemController::class, 'delete']); ``` -With these static methods, defining routes becomes a breeze, providing a smoother and more efficient way to handle routing in your PHP application. - ### 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()` @@ -324,6 +341,194 @@ Example Usage: $route = (new Route('category', '/category/{name}'))->whereAlpha('name'); ``` +#### Method `whereTwoSegments()` + +This method applies a constraint to match exactly two path segments separated by a slash. + +```php +/** + * Sets a constraint for exactly two path segments separated by a slash. + * + * Example: /{segment1}/{segment2} + * + * @param mixed ...$parameters The route parameters to apply the constraint to. + * @return self The updated Route instance. + */ +public function whereTwoSegments(...$parameters): self +{ + $this->assignExprToParameters($parameters, '[a-zA-Z0-9\-_]+/[a-zA-Z0-9\-_]+'); + foreach ($parameters as $parameter) { + $this->path = str_replace(sprintf('{%s}', $parameter), sprintf('{%s*}', $parameter), $this->path); + } + return $this; +} +``` + +Example Usage: + +```php +$route = (new Route('profile', '/profile/{username}/{id}'))->whereTwoSegments('username', 'id'); +``` + +#### Method `whereAnything()` + +This method applies a constraint to match any characters. + +```php +/** + * Sets a constraint to match any characters. + * + * Example: /{anyPath} + * + * @param mixed ...$parameters The route parameters to apply the constraint to. + * @return self The updated Route instance. + */ +public function whereAnything(...$parameters): self +{ + $this->assignExprToParameters($parameters, '.+'); + foreach ($parameters as $parameter) { + $this->path = str_replace(sprintf('{%s}', $parameter), sprintf('{%s*}', $parameter), $this->path); + } + return $this; +} +``` + +Example Usage: + +```php +$route = (new Route('any', '/{anyPath}'))->whereAnything('anyPath'); +``` + +#### Method `whereDate()` + +This method applies a date constraint to the specified route parameters, expecting a format `YYYY-MM-DD`. + +```php +/** + * Sets a date constraint on the specified route parameters. + * + * Example: /{date} + * + * @param mixed ...$parameters The route parameters to apply the constraint to. + * @return self The updated Route instance. + */ +public function whereDate(...$parameters): self +{ + $this->assignExprToParameters($parameters, '\d{4}-\d{2}-\d{2}'); + return $this; +} +``` + +Example Usage: + +```php +$route = (new Route('date', '/date/{date}'))->whereDate('date'); +``` + +#### Method `whereYearMonth()` + +This method applies a year-month constraint to the specified route parameters, expecting a format `YYYY-MM`. + +```php +/** + * Sets a year/month constraint on the specified route parameters. + * + * Example: /{yearMonth} + * + * @param mixed ...$parameters The route parameters to apply the constraint to. + * @return self The updated Route instance. + */ +public function whereYearMonth(...$parameters): self +{ + $this->assignExprToParameters($parameters, '\d{4}-\d{2}'); + return $this; +} +``` + +Example Usage: + +```php +$route = (new Route('yearMonth', '/yearMonth/{yearMonth}'))->whereYearMonth('yearMonth'); +``` + +#### Method `whereEmail()` + +This method applies an email constraint to the specified route parameters. + +```php +/** + * Sets an email constraint on the specified route parameters. + * + * Example: /{email} + * + * @param mixed ...$parameters The route parameters to apply the constraint to. + * @return self The updated Route instance. + */ +public function whereEmail(...$parameters): self +{ + $this->assignExprToParameters($parameters, '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'); + return $this; +} +``` + +Example Usage: + +```php +$route = (new Route('user', '/user/{email}'))->whereEmail('email'); +``` + +#### Method `whereUuid()` + +This method applies a UUID constraint to the specified route parameters. + +```php +/** + * Sets a UUID constraint on the specified route parameters. + * + * Example: /{uuid} + * + * @param mixed ...$parameters The route parameters to apply the constraint to. + * @return self The updated Route instance. + */ +public function whereUuid(...$parameters): self +{ + $this->assignExprToParameters($parameters, '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}'); + return $this; +} +``` + +Example Usage: + +```php +$route = (new Route('profile', '/profile/{uuid}'))->whereUuid('uuid'); +``` + +#### Method `whereBool()` + +This method applies a boolean constraint to the specified route parameters, accepting `true`, `false`, `1`, and `0`. + +```php +/** + * Sets a boolean constraint on the specified route parameters. + * + * Example: /{isActive} + * + * @param mixed ...$parameters The route parameters to apply the constraint to. + * @return self The updated Route instance. + */ +public function whereBool(...$parameters): self +{ + $this->assignExprToParameters($parameters, 'true|false|1|0'); + return $this; +} +``` + +Example Usage: + +```php +$route = (new Route('status', '/status/{isActive}'))->whereBool('isActive'); +``` + #### Method `where()` This method allows you to define a custom constraint on a specified route parameter. @@ -349,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 @@ -360,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..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 69b464c..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. * @@ -79,9 +81,20 @@ public function __construct(string $name, string $path, $handler, array $methods public function match(string $path): bool { $regex = $this->getPath(); + // This loop replaces all route variables like {var} or {var*} with corresponding regex patterns. + // If the variable name ends with '*', it means the value can contain slashes (e.g. /foo/bar). + // In that case, we use a permissive regex: (?P.+) — matches everything including slashes. + // Otherwise, we use a strict regex: (?P[^/]++), which excludes slashes for standard segments. + // The possessive quantifier '++' is used for better performance (avoids unnecessary backtracking). foreach ($this->getVarsNames() as $variable) { $varName = trim($variable, '{\}'); - $regex = str_replace($variable, '(?P<' . $varName . '>[^/]++)', $regex); + $end = '*'; + if ((@substr_compare($varName, $end, -strlen($end)) == 0)) { + $varName = rtrim($varName, $end); + $regex = str_replace($variable, '(?P<' . $varName . '>.+)', $regex); // allows slashes + continue; + } + $regex = str_replace($variable, '(?P<' . $varName . '>[^/]++)', $regex); // faster, excludes slashes } if (!preg_match('#^' . $regex . '$#sD', Helper::trimPath($path), $matches)) { @@ -93,8 +106,13 @@ public function match(string $path): bool }, ARRAY_FILTER_USE_KEY); foreach ($values as $key => $value) { - if (array_key_exists($key, $this->wheres) && !preg_match('/^'.$this->wheres[$key].'$/', $value)) { - return false; + if (array_key_exists($key, $this->wheres)) { + $pattern = $this->wheres[$key]; + $delimiter = '#'; + $regex = $delimiter . '^' . $pattern . '$' . $delimiter; + if (!preg_match($regex, $value)) { + return false; + } } $this->attributes[$key] = $value; } @@ -156,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. * @@ -204,6 +227,52 @@ public function whereAlpha(...$parameters): self return $this; } + public function whereTwoSegments(...$parameters): self + { + $this->assignExprToParameters($parameters, '[a-zA-Z0-9\-_]+/[a-zA-Z0-9\-_]+'); + foreach ($parameters as $parameter) { + $this->path = str_replace(sprintf('{%s}', $parameter), sprintf('{%s*}', $parameter), $this->path); + } + return $this; + } + + public function whereAnything(string $parameter): self + { + $this->assignExprToParameters([$parameter], '.+'); + $this->path = str_replace(sprintf('{%s}', $parameter), sprintf('{%s*}', $parameter), $this->path); + return $this; + } + + public function whereDate(...$parameters): self + { + $this->assignExprToParameters($parameters, '\d{4}-\d{2}-\d{2}'); + return $this; + } + + public function whereYearMonth(...$parameters): self + { + $this->assignExprToParameters($parameters, '\d{4}-\d{2}'); + return $this; + } + + public function whereEmail(...$parameters): self + { + $this->assignExprToParameters($parameters, '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'); + return $this; + } + + public function whereUuid(...$parameters): self + { + $this->assignExprToParameters($parameters, '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}'); + return $this; + } + + public function whereBool(...$parameters): self + { + $this->assignExprToParameters($parameters, 'true|false|1|0'); + return $this; + } + /** * Sets a custom constraint on the specified route parameter. * @@ -217,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/Router.php b/src/Router.php index 1800966..6e52324 100644 --- a/src/Router.php +++ b/src/Router.php @@ -70,20 +70,26 @@ public function matchFromPath(string $path, string $method): Route /** * @var Route $route */ + $routeMatchedButMethodNotAllowed = false; foreach ($this->routes as $route) { if ($route->match($path) === false) { continue; } if (!in_array($method, $route->getMethods())) { - throw new MethodNotAllowed( - 'Method Not Allowed : ' . $method, - self::METHOD_NOT_ALLOWED - ); + $routeMatchedButMethodNotAllowed = true; + continue; } return $route; } + if ($routeMatchedButMethodNotAllowed) { + throw new MethodNotAllowed( + 'Method Not Allowed : ' . $method, + self::METHOD_NOT_ALLOWED + ); + } + throw new RouteNotFound( 'No route found for ' . $path, self::NO_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']); + } +} diff --git a/tests/RouteTest.php b/tests/RouteTest.php index 45b7ccb..7600942 100755 --- a/tests/RouteTest.php +++ b/tests/RouteTest.php @@ -2,10 +2,13 @@ namespace Test\PhpDevCommunity; +use InvalidArgumentException; use PhpDevCommunity\Route; use PhpDevCommunity\UniTester\TestCase; +use stdClass; -class RouteTest extends TestCase { +class RouteTest extends TestCase +{ protected function setUp(): void { @@ -23,12 +26,18 @@ protected function execute(): void $this->testNotMatchRoute(); $this->testException(); $this->testWheres(); + $this->testWhereDate(); + $this->testWhereYearMonth(); + $this->testWhereEmail(); + $this->testWhereUuid(); + $this->testWhereBool(); + $this->whereAnything(); } public function testNotMatchRoute() { - $routeWithoutAttribute = new Route('view_articles','/view/article/', ['App\\Controller\\HomeController', 'home']); - $routeWithAttribute = new Route('view_article','/view/article/{article}', ['App\\Controller\\HomeController', 'home']); + $routeWithoutAttribute = new Route('view_articles', '/view/article/', ['App\\Controller\\HomeController', 'home']); + $routeWithAttribute = new Route('view_article', '/view/article/{article}', ['App\\Controller\\HomeController', 'home']); $this->assertFalse($routeWithoutAttribute->match('/view/article/1')); $this->assertFalse($routeWithAttribute->match('/view/article/')); @@ -36,9 +45,9 @@ public function testNotMatchRoute() public function testMatchRoute() { - $routeWithAttribute = new Route('view_article','/view/article/{article}', ['App\\Controller\\HomeController', 'home']); - $routeWithAttributes = new Route('view_article_page','/view/article/{article}/{page}', ['App\\Controller\\HomeController', 'home']); - $routeWithoutAttribute = new Route('view_articles','/view/article', ['App\\Controller\\HomeController', 'home']); + $routeWithAttribute = new Route('view_article', '/view/article/{article}', ['App\\Controller\\HomeController', 'home']); + $routeWithAttributes = new Route('view_article_page', '/view/article/{article}/{page}', ['App\\Controller\\HomeController', 'home']); + $routeWithoutAttribute = new Route('view_articles', '/view/article', ['App\\Controller\\HomeController', 'home']); $this->assertTrue($routeWithAttribute->match('/view/article/1')); $this->assertTrue($routeWithAttributes->match('/view/article/1/24')); @@ -47,34 +56,148 @@ public function testMatchRoute() public function testException() { - $this->expectException(\InvalidArgumentException::class, function () { - new Route('view_articles','/view', ['App\\Controller\\HomeController', 'home'], []); + $this->expectException(InvalidArgumentException::class, function () { + new Route('view_articles', '/view', ['App\\Controller\\HomeController', 'home'], []); }); } public function testWheres() { $routes = [ - Route::get('blog.show', '/blog/{id}', function () {})->whereNumber('id'), - Route::get('blog.show', '/blog/{slug}', function () {})->whereSlug('slug'), - Route::get('blog.show', '/blog/{slug}/{id}', function () {}) + Route::get('blog.show', '/blog/{id}', function () { + })->whereNumber('id'), + Route::get('blog.show', '/blog/{slug}', function () { + })->whereSlug('slug'), + Route::get('blog.show', '/blog/{slug}/{id}', function () { + }) ->whereNumber('id') ->whereSlug('slug'), - Route::get('invoice.show', '/invoice/{number}', function () {})->whereAlphaNumeric('number'), - Route::get('invoice.show', '/invoice/{number}', function () {})->whereAlpha('number'), + Route::get('invoice.show', '/invoice/{number}', function () { + })->whereAlphaNumeric('number'), + Route::get('invoice.show', '/invoice/{number}', function () { + })->whereAlpha('number'), + Route::get('invoice.with.slash', '/invoice/{slash*}', function () { + }), + Route::get('invoice.with.slash', '/invoice/{slash}', function () { + })->whereTwoSegments('slash'), ]; - $this->assertTrue($routes[0]->match('/blog/1')); - $this->assertFalse($routes[0]->match('/blog/F1')); - $this->assertTrue($routes[1]->match('/blog/title-of-article')); + + $route = $routes[0]; + $this->assertTrue($route->match('/blog/1')); + $this->assertStrictEquals(['id' => '1'], $route->getAttributes()); + $this->assertFalse($route->match('/blog/F1')); + + $route = $routes[1]; + $this->assertTrue($route->match('/blog/title-of-article')); + $this->assertStrictEquals(['slug' => 'title-of-article'], $route->getAttributes()); $this->assertFalse($routes[1]->match('/blog/title_of_article')); + $route = $routes[2]; $this->assertTrue($routes[2]->match('/blog/title-of-article/12')); + $this->assertStrictEquals(['slug' => 'title-of-article', 'id' => '12'], $route->getAttributes()); - $this->assertTrue($routes[3]->match('/invoice/F0004')); + $route = $routes[3]; + $this->assertTrue($route->match('/invoice/F0004')); + $this->assertStrictEquals(['number' => 'F0004'], $route->getAttributes()); + $route = $routes[4]; $this->assertFalse($routes[4]->match('/invoice/F0004')); $this->assertTrue($routes[4]->match('/invoice/FROUIAUI')); + $this->assertStrictEquals(['number' => 'FROUIAUI'], $route->getAttributes()); + + $route = $routes[5]; + $this->assertTrue($route->match('/invoice/FROUIAUI/12/24-25')); + $this->assertStrictEquals(['slash' => 'FROUIAUI/12/24-25'], $route->getAttributes()); + + $route = $routes[6]; + $this->assertFalse($route->match('/invoice/FROUIAUI/12/24-25')); + $this->assertTrue($route->match('/invoice/FROUIAUI/toto')); + $this->assertStrictEquals(['slash' => 'FROUIAUI/toto'], $route->getAttributes()); + } + + public function testWhereDate() + { + $route = Route::get('example', '/example/{date}', function () { + })->whereDate('date'); + $this->assertTrue($route->match('/example/2022-12-31')); + $this->assertFalse($route->match('/example/12-31-2022')); + $this->assertFalse($route->match('/example/2022-13')); + } + + public function testWhereYearMonth() + { + $route = Route::get('example', '/example/{yearMonth}', function () { + })->whereYearMonth('yearMonth'); + $this->assertTrue($route->match('/example/2022-12')); + $this->assertFalse($route->match('/example/12-31-2022')); + $this->assertFalse($route->match('/example/2022-13-10')); + } + + public function testWhereEmail() + { + $route = Route::get('example', '/example/{email}/{email2}', function () { + })->whereEmail('email', 'email2'); + $this->assertTrue($route->match('/example/0L5yT@example.com/0L5yT@example.com')); + $this->assertFalse($route->match('/example/@example.com/0L5yT@example.com')); + $this->assertFalse($route->match('/example/0L5yT@example.com/toto')); } + public function testWhereUuid() + { + $route = Route::get('example', '/example/{uuid}', function () { + })->whereEmail('uuid'); + $route->whereUuid('uuid'); + + $this->assertTrue($route->match('/example/123e4567-e89b-12d3-a456-426614174000')); + + $this->assertFalse($route->match('/example/123e4567-e89b-12d3-a456-42661417400z')); + $this->assertFalse($route->match('/example/invalid-uuid')); + + $route = Route::get('example', '/example/{uuid}/unused', function () { + })->whereEmail('uuid'); + $route->whereUuid('uuid'); + + $this->assertFalse($route->match('/example/123e4567-e89b-12d3-a456-426614174000')); + $this->assertTrue($route->match('/example/123e4567-e89b-12d3-a456-426614174000/unused')); + + $this->assertFalse($route->match('/example/123e4567-e89b-12d3-a456-42661417400z/unused')); + $this->assertFalse($route->match('/example/invalid-uuid/unused')); + } + + public function testWhereBool() + { + $route = Route::get('example', '/example/{bool}', function () { + })->whereBool('bool'); + $this->assertTrue($route->match('/example/true')); + $this->assertTrue($route->match('/example/1')); + $this->assertTrue($route->match('/example/false')); + $this->assertTrue($route->match('/example/0')); + $this->assertFalse($route->match('/example/invalid')); + + } + + private function whereAnything() + { + $route = Route::get('example', '/example/{anything}', function () { + })->whereAnything('anything'); + $this->assertTrue($route->match('/example/anything')); + $this->assertTrue($route->match('/example/anything/anything')); + $this->assertTrue($route->match('/example/anything/anything/anything')); + $base64 = $this->generateComplexString(); + $this->assertTrue($route->match('/example/' . $base64)); + $this->assertStrictEquals(['anything' => $base64], $route->getAttributes()); + + } + + private function generateComplexString(): string + { + $characters = 'ABCDEFGHIJKLMklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[{]}\\|;:\'",<.>/?`~'; + $complexString = ''; + for ($i = 0; $i < 200; $i++) { + $complexString .= $characters[random_int(0, strlen($characters) - 1)]; + } + $complexString .= '-' . time(); + return $complexString; + } }