michelphp 1 день назад
Сommit
0dfb9862e9

+ 4 - 0
.gitignore

@@ -0,0 +1,4 @@
+/vendor/
+/.idea/
+composer.lock
+/tests/cache

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 F. Michel
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 576 - 0
README.md

@@ -0,0 +1,576 @@
+# 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 michel/router
+```
+
+## Requirements
+
+* PHP version 7.4 or above
+* Enable URL rewriting on your web server
+* Optional: PSR-7 HTTP Message package (e.g., guzzlehttp/psr7)
+
+## Usage
+
+1. **Define Routes**: Define routes using the `Route` class provided by PHP Router.
+
+2. **Initialize Router**: Initialize the `Router` class with the defined routes.
+
+3. **Match Requests**: Match incoming HTTP requests to defined routes.
+
+4. **Handle Requests**: Handle matched routes by executing appropriate controllers or handlers.
+
+5. **Generate URLs**: Generate URLs for named routes.
+
+## Example
+
+```php
+<?php
+class IndexController {
+
+    // PHP > 8.0
+    #[\Michel\Attribute\Route(path: '/', name: 'home_page')]
+    public function __invoke()
+    {
+        return 'Hello world!!';
+    }
+}
+
+class ArticleController {
+   // PHP > 8.0
+    #[\Michel\Attribute\Route(path: '/api/articles', name: 'api_articles_collection')]
+    public function getAll()
+    {
+        // db get all post
+        return json_encode([
+            ['id' => 1],
+            ['id' => 2],
+            ['id' => 3]
+        ]);
+    }
+    // PHP > 8.0
+    #[\Michel\Attribute\Route(path: '/api/articles/{id}', name: 'api_articles')]
+    public function get(int $id)
+    {
+        // db get post by id
+        return json_encode(['id' => $id]);
+    }
+
+    public function put(int $id)
+    {
+        // db edited post by id
+        return json_encode(['id' => $id]);
+    }
+
+    public function post()
+    {
+        // db create post
+        return json_encode(['id' => 4]);
+    }
+}
+```
+
+```php
+// Define your routes
+
+if (PHP_VERSION_ID >= 80000) {
+    $attributeRouteCollector = new AttributeRouteCollector([
+        IndexController::class,
+        ArticleController::class
+    ]);
+    $routes = $attributeRouteCollector->collect();
+}else {
+    $routes = [
+        new \Michel\Route('home_page', '/', [IndexController::class]),
+        new \Michel\Route('api_articles_collection', '/api/articles', [ArticleController::class, 'getAll']),
+        new \Michel\Route('api_articles', '/api/articles/{id}', [ArticleController::class, 'get']),
+    ];
+}
+
+// Initialize the router
+$router = new \Michel\Router($routes, 'http://localhost');
+
+try {
+    // Match incoming request
+    $route = $router->match(ServerRequestFactory::fromGlobals());
+    
+    // Handle the matched route
+    $handler = $route->getHandler();
+    $attributes = $route->getAttributes();
+    $controllerName = $handler[0];
+    $methodName = $handler[1] ?? null;
+    $controller = new $controllerName();
+    
+    // Invoke the controller method
+    if (!is_callable($controller)) {
+        $controller =  [$controller, $methodName];
+    }
+    echo $controller(...array_values($attributes));
+
+} catch (\Michel\Exception\MethodNotAllowed $exception) {
+    header("HTTP/1.0 405 Method Not Allowed");
+    exit();
+} catch (\Michel\Exception\RouteNotFound $exception) {
+    header("HTTP/1.0 404 Not Found");
+    exit();
+}
+```
+
+## Features
+
+- Lightweight and easy-to-use
+- Supports HTTP method-based routing
+- Flexible route definition with attribute constraints
+- Exception handling for method not allowed and route not found scenarios
+
+## 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.
+
+```php
+$route = new \Michel\Route('api_articles_post', '/api/articles', [ArticleController::class, 'post'], ['POST']);
+$route = new \Michel\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:
+
+#### Method `get()`
+
+```php
+/**
+ * Creates a new GET route with the given name, path, and handler.
+ *
+ * @param string $name The name of the route.
+ * @param string $path The path of the route.
+ * @param mixed $handler The handler for the route.
+ * @return BaseRoute The newly created GET route.
+ */
+public static function get(string $name, string $path, $handler): BaseRoute
+{
+    return new BaseRoute($name, $path, $handler);
+}
+```
+
+Example Usage:
+
+```php
+$route = Route::get('home', '/', [HomeController::class, 'index']);
+```
+
+#### Method `post()`
+
+```php
+/**
+ * Creates a new POST route with the given name, path, and handler.
+ *
+ * @param string $name The name of the route.
+ * @param string $path The path of the route.
+ * @param mixed $handler The handler for the route.
+ * @return BaseRoute The newly created POST route.
+ */
+public static function post(string $name, string $path, $handler): BaseRoute
+{
+    return new BaseRoute($name, $path, $handler, ['POST']);
+}
+```
+
+Example Usage:
+
+```php
+$route = Route::post('submit_form', '/submit', [FormController::class, 'submit']);
+```
+
+#### Method `put()`
+
+```php
+/**
+ * Creates a new PUT route with the given name, path, and handler.
+ *
+ * @param string $name The name of the route.
+ * @param string $path The path of the route.
+ * @param mixed $handler The handler for the route.
+ * @return BaseRoute The newly created PUT route.
+ */
+public static function put(string $name, string $path, $handler): BaseRoute
+{
+    return new BaseRoute($name, $path, $handler, ['PUT']);
+}
+```
+
+Example Usage:
+
+```php
+$route = Route::put('update_item', '/item/{id}', [ItemController::class, 'update']);
+```
+
+#### Method `delete()`
+
+```php
+/**
+ * Creates a new DELETE route with the given name, path, and handler.
+ *
+ * @param string $name The name of the route.
+ * @param string $path The path of the route.
+ * @param mixed $handler The handler for the route.
+ * @return BaseRoute The newly created DELETE route.
+ */
+public static function delete(string $name, string $path, $handler): BaseRoute
+{
+    return new BaseRoute($name, $path, $handler, ['DELETE']);
+}
+```
+
+Example Usage:
+
+```php
+$route = Route::delete('delete_item', '/item/{id}', [ItemController::class, 'delete']);
+```
+
+### 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:
+
+#### Method `whereNumber()`
+
+This method applies a numeric constraint to the specified route parameters.
+
+```php
+/**
+ * Sets a number constraint on the specified route parameters.
+ *
+ * @param mixed ...$parameters The route parameters to apply the constraint to.
+ * @return self The updated Route instance.
+ */
+public function whereNumber(...$parameters): self
+{
+    $this->assignExprToParameters($parameters, '[0-9]+');
+    return $this;
+}
+```
+
+Example Usage:
+
+```php
+$route = (new Route('example', '/example/{id}'))->whereNumber('id');
+```
+
+#### Method `whereSlug()`
+
+This method applies a slug constraint to the specified route parameters, allowing alphanumeric characters and hyphens.
+
+```php
+/**
+ * Sets a slug constraint on the specified route parameters.
+ *
+ * @param mixed ...$parameters The route parameters to apply the constraint to.
+ * @return self The updated Route instance.
+ */
+public function whereSlug(...$parameters): self
+{
+    $this->assignExprToParameters($parameters, '[a-z0-9-]+');
+    return $this;
+}
+```
+
+Example Usage:
+
+```php
+$route = (new Route('article', '/article/{slug}'))->whereSlug('slug');
+```
+
+#### Method `whereAlphaNumeric()`
+
+This method applies an alphanumeric constraint to the specified route parameters.
+
+```php
+/**
+ * Sets an alphanumeric constraint on the specified route parameters.
+ *
+ * @param mixed ...$parameters The route parameters to apply the constraint to.
+ * @return self The updated Route instance.
+ */
+public function whereAlphaNumeric(...$parameters): self
+{
+    $this->assignExprToParameters($parameters, '[a-zA-Z0-9]+');
+    return $this;
+}
+```
+
+Example Usage:
+
+```php
+$route = (new Route('user', '/user/{username}'))->whereAlphaNumeric('username');
+```
+
+#### Method `whereAlpha()`
+
+This method applies an alphabetic constraint to the specified route parameters.
+
+```php
+/**
+ * Sets an alphabetic constraint on the specified route parameters.
+ *
+ * @param mixed ...$parameters The route parameters to apply the constraint to.
+ * @return self The updated Route instance.
+ */
+public function whereAlpha(...$parameters): self
+{
+    $this->assignExprToParameters($parameters, '[a-zA-Z]+');
+    return $this;
+}
+```
+
+Example Usage:
+
+```php
+$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.
+
+```php
+/**
+ * Sets a custom constraint on the specified route parameter.
+ *
+ * @param string $parameter The route parameter to apply the constraint to.
+ * @param string $expression The regular expression constraint.
+ * @return self The updated Route instance.
+ */
+public function where(string $parameter, string $expression): self
+{
+    $this->wheres[$parameter] = $expression;
+    return $this;
+}
+```
+
+Example Usage:
+
+```php
+$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.
+
+## Generating URLs
+
+Generate URLs for named routes using the `generateUri` method.
+
+```php
+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.
+
+## License
+
+This library is open-source software licensed under the [MIT license](https://opensource.org/licenses/MIT).

+ 27 - 0
composer.json

@@ -0,0 +1,27 @@
+{
+    "name": "michel/router",
+    "description": "A versatile and efficient PHP routing solution designed to streamline route management within PHP applications.",
+    "type": "library",
+    "autoload": {
+        "psr-4": {
+            "Michel\\": "src",
+            "Test\\Michel\\": "tests"
+        }
+    },
+    "license": "MIT",
+    "authors": [
+        {
+            "name": "Michel.F"
+        }
+    ],
+    "minimum-stability": "alpha",
+    "require": {
+        "php": ">=7.4",
+        "psr/http-message": "^1.0|^2.0",
+        "psr/http-server-middleware": "^1.0",
+        "psr/http-factory": "^1.0"
+    },
+    "require-dev": {
+        "michel/unitester": "^1.0.0"
+    }
+}

+ 147 - 0
src/Attribute/AttributeRouteCollector.php

@@ -0,0 +1,147 @@
+<?php
+
+namespace Michel\Attribute;
+
+use InvalidArgumentException;
+use LogicException;
+use ReflectionAttribute;
+use ReflectionClass;
+use ReflectionException;
+
+final class AttributeRouteCollector
+{
+    private array $classes;
+    private ?string $cacheDir;
+
+    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+');
+        }
+        $this->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<\Michel\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 \Michel\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 = "<?php\n\nreturn " . var_export($routes, true) . ";\n";
+        file_put_contents($cacheFile, $content);
+    }
+
+    private function getCacheFile(string $class): string
+    {
+        return rtrim($this->cacheDir, '/') . '/' . md5($class) . '.php';
+    }
+}

+ 28 - 0
src/Attribute/ControllerRoute.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace Michel\Attribute;
+
+use Michel\Helper;
+
+#[\Attribute(\Attribute::TARGET_CLASS)]
+final class ControllerRoute
+{
+    private string $path;
+    private ?string $format;
+
+    public function __construct(string $path, string $format = null)
+    {
+        $this->path = Helper::trimPath($path);
+        $this->format = $format;
+    }
+
+    public function getPath(): string
+    {
+        return $this->path;
+    }
+
+    public function getFormat(): ?string
+    {
+        return $this->format;
+    }
+}

+ 52 - 0
src/Attribute/Route.php

@@ -0,0 +1,52 @@
+<?php
+
+namespace Michel\Attribute;
+
+use Michel\Helper;
+
+#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+final class Route
+{
+    private string $path;
+    private string $name;
+    /**
+     * @var array|string[]
+     */
+    private array $methods;
+    private array $options;
+    private ?string $format;
+
+    public function __construct(string $path, string $name, array $methods = ['GET', 'POST'], array $options = [], string $format = null)
+    {
+        $this->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;
+    }
+}

+ 9 - 0
src/Exception/MethodNotAllowed.php

@@ -0,0 +1,9 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Michel\Exception;
+
+final class MethodNotAllowed extends \Exception
+{
+}

+ 9 - 0
src/Exception/RouteNotFound.php

@@ -0,0 +1,9 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Michel\Exception;
+
+final class RouteNotFound extends \Exception
+{
+}

+ 17 - 0
src/Helper.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace Michel;
+
+final class Helper
+{
+    /**
+     * Trim the given path by removing leading and trailing slashes.
+     *
+     * @param string $path The path to be trimmed
+     * @return string The trimmed path
+     */
+    public static function trimPath(string $path): string
+    {
+        return '/' . rtrim(ltrim(trim($path), '/'), '/');
+    }
+}

+ 322 - 0
src/Route.php

@@ -0,0 +1,322 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Michel;
+
+use Michel\Traits\RouteTrait;
+use InvalidArgumentException;
+use function array_filter;
+use function is_string;
+use function preg_match;
+use function preg_match_all;
+use function reset;
+use function str_replace;
+use function trim;
+
+final class Route
+{
+    use RouteTrait;
+
+    private string $name;
+    private string $path;
+
+    /**
+     * @var mixed
+     */
+    private $handler;
+
+    /**
+     * @var array<string>
+     */
+    private array $methods = [];
+
+    /**
+     * @var array<string>
+     */
+    private array $attributes = [];
+
+    /**
+     * @var array<string, string>
+     */
+    private array $wheres = [];
+
+    private ?string $format = null;
+
+    /**
+     * Constructor for the Route class.
+     *
+     * @param string $name The name of the route.
+     * @param string $path The path of the route.
+     * @param mixed $handler The handler for the route.
+     *    $handler = [
+     *      0 => (string) Controller name : HomeController::class.
+     *      1 => (string|null) Method name or null if invoke method
+     *    ]
+     * @param array $methods The HTTP methods for the route. Default is ['GET', 'HEAD'].
+     *
+     * @throws InvalidArgumentException If the HTTP methods argument is empty.
+     */
+    public function __construct(string $name, string $path, $handler, array $methods = ['GET', 'HEAD'])
+    {
+        if ($methods === []) {
+            throw new InvalidArgumentException('HTTP methods argument was empty; must contain at least one method');
+        }
+        $this->name = $name;
+        $this->path = Helper::trimPath($path);
+        $this->handler = $handler;
+        $this->methods = $methods;
+
+        if (in_array('GET', $this->methods) && !in_array('HEAD', $this->methods)) {
+            $this->methods[] = 'HEAD';
+        }
+    }
+
+    /**
+     * Matches a given path against the route's path and extracts attribute values.
+     *
+     * @param string $path The path to match against.
+     * @return bool True if the path matches the route's path, false otherwise.
+     */
+    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<varName>.+) — matches everything including slashes.
+        // Otherwise, we use a strict regex: (?P<varName>[^/]++), 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, '{\}');
+            $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)) {
+            return false;
+        }
+
+        $values = array_filter($matches, static function ($key) {
+            return is_string($key);
+        }, ARRAY_FILTER_USE_KEY);
+
+        foreach ($values as $key => $value) {
+            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;
+        }
+
+        return true;
+    }
+
+    /**
+     * Returns the name of the Route.
+     *
+     * @return string The name of the Route.
+     */
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    /**
+     * Returns the path of the Route.
+     *
+     * @return string The path of the Route.
+     */
+    public function getPath(): string
+    {
+        return $this->path;
+    }
+
+    public function getHandler()
+    {
+        return $this->handler;
+    }
+
+    /**
+     * Returns the HTTP methods for the Route.
+     *
+     * @return array The HTTP methods for the Route.
+     */
+    public function getMethods(): array
+    {
+        return $this->methods;
+    }
+
+    public function getVarsNames(): array
+    {
+        preg_match_all('/{[^}]*}/', $this->path, $matches);
+        return reset($matches) ?? [];
+    }
+
+    public function hasAttributes(): bool
+    {
+        return $this->getVarsNames() !== [];
+    }
+
+    /**
+     * @return array<string>
+     */
+    public function getAttributes(): array
+    {
+        return $this->attributes;
+    }
+
+    public function getFormat(): ?string
+    {
+        return $this->format;
+    }
+
+    /**
+     * Sets a number constraint on the specified route parameters.
+     *
+     * @param mixed ...$parameters The route parameters to apply the constraint to.
+     * @return self The updated Route instance.
+     */
+    public function whereNumber(...$parameters): self
+    {
+        $this->assignExprToParameters($parameters, '[0-9]+');
+        return $this;
+    }
+
+    /**
+     * Sets a slug constraint on the specified route parameters.
+     *
+     * @param mixed ...$parameters The route parameters to apply the constraint to.
+     * @return self The updated Route instance.
+     */
+    public function whereSlug(...$parameters): self
+    {
+        $this->assignExprToParameters($parameters, '[a-z0-9-]+');
+        return $this;
+    }
+
+    /**
+     * Sets an alphanumeric constraint on the specified route parameters.
+     *
+     * @param mixed ...$parameters The route parameters to apply the constraint to.
+     * @return self The updated Route instance.
+     */
+    public function whereAlphaNumeric(...$parameters): self
+    {
+        $this->assignExprToParameters($parameters, '[a-zA-Z0-9]+');
+        return $this;
+    }
+
+    /**
+     * Sets an alphabetic constraint on the specified route parameters.
+     *
+     * @param mixed ...$parameters The route parameters to apply the constraint to.
+     * @return self The updated Route instance.
+     */
+    public function whereAlpha(...$parameters): self
+    {
+        $this->assignExprToParameters($parameters, '[a-zA-Z]+');
+        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.
+     *
+     * @param string $parameter The route parameter to apply the constraint to.
+     * @param string $expression The regular expression constraint.
+     * @return self The updated Route instance.
+     */
+    public function where(string $parameter, string $expression): self
+    {
+        $this->wheres[$parameter] = $expression;
+        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;
+    }
+}
+
+

+ 116 - 0
src/Router.php

@@ -0,0 +1,116 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Michel;
+
+use Michel\Exception\MethodNotAllowed;
+use Michel\Exception\RouteNotFound;
+use Psr\Http\Message\ServerRequestInterface;
+
+final class Router implements RouterInterface
+{
+    private const NO_ROUTE = 404;
+    private const METHOD_NOT_ALLOWED = 405;
+    private \ArrayObject $routes;
+    private UrlGenerator $urlGenerator;
+
+    /**
+     * Router constructor.
+     * @param array<Route> $routes The routes to initialize the Router with.
+     * @param string $defaultUri The default URI for the Router.
+     */
+    public function __construct(array $routes = [], string $defaultUri = 'http://localhost')
+    {
+        $this->routes = new \ArrayObject();
+        $this->urlGenerator = new UrlGenerator($this->routes, $defaultUri);
+        foreach ($routes as $route) {
+            $this->add($route);
+        }
+    }
+
+
+
+    /**
+     * Add a Route to the collection.
+     *
+     * @param Route $route The Route to add
+     * @return self
+     */
+    public function add(Route $route): self
+    {
+        $this->routes->offsetSet($route->getName(), $route);
+        return $this;
+    }
+
+    /**
+     * Matches a server request to a route based on the request's URI and method.
+     *
+     * @param ServerRequestInterface $serverRequest The server request to match.
+     * @return Route The matched route.
+     * @throws MethodNotAllowed Method Not Allowed : $method
+     * * @throws RouteNotFound No route found for $path
+     */
+    public function match(ServerRequestInterface $serverRequest): Route
+    {
+        return $this->matchFromPath($serverRequest->getUri()->getPath(), $serverRequest->getMethod());
+    }
+
+    /**
+     * Match a route from the given path and method.
+     *
+     * @param string $path The path to match
+     * @param string $method The HTTP method
+     * @throws MethodNotAllowed Method Not Allowed : $method
+     * @throws RouteNotFound No route found for $path
+     * @return Route
+     */
+    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())) {
+                $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
+        );
+    }
+
+    /**
+     * Generate a URI based on the provided name, parameters, and settings.
+     *
+     * @param string $name The name used for generating the URI.
+     * @param array $parameters An array of parameters to be included in the URI.
+     * @param bool $absoluteUrl Whether the generated URI should be an absolute URL.
+     * @return string The generated URI.
+     */
+    public function generateUri(string $name, array $parameters = [], bool $absoluteUrl = false): string
+    {
+        return $this->urlGenerator->generate($name, $parameters, $absoluteUrl);
+    }
+
+    public function getUrlGenerator(): UrlGenerator
+    {
+        return $this->urlGenerator;
+    }
+}

+ 37 - 0
src/RouterInterface.php

@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Michel;
+
+use Michel\Exception\MethodNotAllowed;
+use Michel\Exception\RouteNotFound;
+use Psr\Http\Message\ServerRequestInterface;
+
+interface RouterInterface
+{
+    /**
+     * @param ServerRequestInterface $serverRequest
+     * @return Route
+     * @throws RouteNotFound if no found route.
+     * @throws MethodNotAllowed if method not allowed.
+     */
+    public function match(ServerRequestInterface $serverRequest): Route;
+
+    /**
+     * @param string $path
+     * @param string $method
+     * @return Route
+     * @throws RouteNotFound if no found route.
+     * @throws MethodNotAllowed if method not allowed.
+     */
+    public function matchFromPath(string $path, string $method): Route;
+
+    /**
+     * @param string $name
+     * @param array $parameters
+     * @return string
+     * @throws \InvalidArgumentException if unable to generate the given URI.
+     */
+    public function generateUri(string $name, array $parameters = []): string;
+}

+ 54 - 0
src/RouterMiddleware.php

@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Michel;
+
+use Michel\Exception\MethodNotAllowed;
+use Michel\Exception\RouteNotFound;
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+
+final class RouterMiddleware implements MiddlewareInterface
+{
+    public const ATTRIBUTE_KEY = '__route';
+
+    private RouterInterface $router;
+    private ResponseFactoryInterface $responseFactory;
+
+    public function __construct(
+        RouterInterface          $router,
+        ResponseFactoryInterface $responseFactory
+    )
+    {
+        $this->router = $router;
+        $this->responseFactory = $responseFactory;
+    }
+
+
+    /**
+     * Process the request and return a response.
+     *
+     * @param ServerRequestInterface  $request description of request parameter
+     * @param RequestHandlerInterface $handler description of handler parameter
+     * @return ResponseInterface
+     */
+    public function process(
+        ServerRequestInterface  $request,
+        RequestHandlerInterface $handler
+    ): ResponseInterface
+    {
+        try {
+            $route = $this->router->match($request);
+            $request = $request->withAttribute(self::ATTRIBUTE_KEY, $route);
+        } catch (MethodNotAllowed $exception) {
+            return $this->responseFactory->createResponse(405);
+        } catch (RouteNotFound $exception) {
+            return $this->responseFactory->createResponse(404);
+        }
+        return $handler->handle($request);
+    }
+}

+ 60 - 0
src/Traits/RouteTrait.php

@@ -0,0 +1,60 @@
+<?php
+
+namespace Michel\Traits;
+
+use Michel\Route as BaseRoute;
+
+trait RouteTrait
+{
+    /**
+     * Creates a new GET route with the given name, path, and handler.
+     *
+     * @param string $name The name of the route.
+     * @param string $path The path of the route.
+     * @param mixed $handler The handler for the route.
+     * @return BaseRoute The newly created GET route.
+     */
+    public static function get(string $name, string $path, $handler): BaseRoute
+    {
+        return new BaseRoute($name, $path, $handler);
+    }
+
+    /**
+     * Creates a new POST route with the given name, path, and handler.
+     *
+     * @param string $name The name of the route.
+     * @param string $path The path of the route.
+     * @param mixed $handler The handler for the route.
+     * @return BaseRoute The newly created POST route.
+     */
+    public static function post(string $name, string $path, $handler): BaseRoute
+    {
+        return new BaseRoute($name, $path, $handler, ['POST']);
+    }
+
+    /**
+     * Creates a new PUT route with the given name, path, and handler.
+     *
+     * @param string $name The name of the route.
+     * @param string $path The path of the route.
+     * @param mixed $handler The handler for the route.
+     * @return BaseRoute The newly created PUT route.
+     */
+    public static function put(string $name, string $path, $handler): BaseRoute
+    {
+        return new BaseRoute($name, $path, $handler, ['PUT']);
+    }
+
+    /**
+     * Creates a new DELETE route with the given name, path, and handler.
+     *
+     * @param string $name The name of the route.
+     * @param string $path The path of the route.
+     * @param mixed $handler The handler for the route.
+     * @return BaseRoute The newly created DELETE route.
+     */
+    public static function delete(string $name, string $path, $handler): BaseRoute
+    {
+        return new BaseRoute($name, $path, $handler, ['DELETE']);
+    }
+}

+ 80 - 0
src/UrlGenerator.php

@@ -0,0 +1,80 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Michel;
+
+use ArrayAccess;
+use InvalidArgumentException;
+use function array_key_exists;
+use function implode;
+use function sprintf;
+use function str_replace;
+use function trim;
+
+final class UrlGenerator
+{
+    private ArrayAccess $routes;
+    private string $defaultUri;
+
+    /**
+     * Constructor for the UrlGenerator class.
+     *
+     * @param ArrayAccess $routes The routes object.
+     * @param string $defaultUri The default URI.
+     */
+    public function __construct(ArrayAccess $routes, string $defaultUri = '')
+    {
+        $this->routes = $routes;
+        $this->defaultUri = $defaultUri;
+    }
+
+    /**
+     * Generates a URL based on the given route name and parameters.
+     *
+     * @param string $name The name of the route.
+     * @param array $parameters The parameters for the route. Default is an empty array.
+     * @param bool $absoluteUrl Whether to generate an absolute URL. Default is false.
+     * @return string The generated URL.
+     * @throws InvalidArgumentException If the route name is unknown or if the route requires parameters but none are provided.
+     */
+    public function generate(string $name, array $parameters = [], bool $absoluteUrl = false): string
+    {
+        if ($this->routes->offsetExists($name) === false) {
+            throw new InvalidArgumentException(
+                sprintf('Unknown %s name route', $name)
+            );
+        }
+        /*** @var Route $route */
+        $route = $this->routes[$name];
+        if ($route->hasAttributes() === true && $parameters === []) {
+            throw new InvalidArgumentException(
+                sprintf('%s route need parameters: %s', $name, implode(',', $route->getVarsNames()))
+            );
+        }
+
+        $url = self::resolveUri($route, $parameters);
+        if ($absoluteUrl === true) {
+            $url = ltrim(Helper::trimPath($this->defaultUri), '/') . $url;
+        }
+        return $url;
+    }
+
+    private static function resolveUri(Route $route, array $parameters): string
+    {
+        $uri = $route->getPath();
+        foreach ($route->getVarsNames() as $variable) {
+            $varName = trim($variable, '{\}');
+            if (array_key_exists($varName, $parameters) === false) {
+                throw new InvalidArgumentException(
+                    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;
+    }
+}

+ 98 - 0
tests/AttributeRouteCollectorTest.php

@@ -0,0 +1,98 @@
+<?php
+
+namespace Test\Michel;
+
+use Michel\Route;
+use Michel\UniTester\TestCase;
+
+class AttributeRouteCollectorTest extends TestCase
+{
+
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        if (PHP_VERSION_ID < 80000) {
+            return;
+        }
+        $attributeRouteCollector = new \Michel\Attribute\AttributeRouteCollector([
+            'Test\Michel\Controller\UserController',
+            'Test\Michel\Controller\ProductController',
+            'Test\Michel\Controller\ApiController',
+            'Test\Michel\Controller\PingController'
+        ]);
+        $routes = $attributeRouteCollector->collect();
+        $this->assertStrictEquals(9, count($routes));
+
+        $attributeRouteCollector = new \Michel\Attribute\AttributeRouteCollector([
+            'Test\Michel\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 \Michel\Attribute\AttributeRouteCollector([
+            'Test\Michel\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\Michel\Controller\UserController',
+            'Test\Michel\Controller\ProductController',
+            'Test\Michel\Controller\ApiController',
+            'Test\Michel\Controller\PingController'
+        ];
+
+        $cacheDir = dirname(__FILE__) . '/cache';
+        if (is_dir($cacheDir)) {
+            foreach (glob($cacheDir . '/*') as $file) {
+                unlink($file);
+            }
+            rmdir($cacheDir);
+        }
+        mkdir($cacheDir, 0777, true);
+
+        $attributeRouteCollector = new \Michel\Attribute\AttributeRouteCollector($controllers, $cacheDir);
+
+        $attributeRouteCollector->generateCache();
+        $this->assertTrue(is_dir($cacheDir));
+        foreach ($controllers as $controller) {
+            $cacheFile = $cacheDir . '/' . md5($controller) . '.php';
+            $this->assertTrue(file_exists($cacheFile));
+        }
+        $routes = $attributeRouteCollector->collect();
+        $this->assertStrictEquals(9, count($routes));
+        foreach ($routes as $route) {
+            $this->assertInstanceOf(Route::class, $route);
+        }
+
+        $attributeRouteCollector->clearCache();
+        rmdir($cacheDir);
+    }
+}

+ 27 - 0
tests/Controller/ApiController.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace Test\Michel\Controller;
+
+use Michel\Attribute\Route;
+
+class ApiController
+{
+
+    #[Route('/api', name: 'api_index', methods: ['GET'])]
+    public function index(): string
+    {
+        return json_encode([
+            'name' => 'John Doe',
+        ]);
+    }
+
+    #[Route('/api', name: 'api_post', methods: ['POST'])]
+    public function post(): string
+    {
+        return json_encode([
+            'name' => 'John Doe',
+            'status' => 'success'
+        ]);
+    }
+
+}

+ 16 - 0
tests/Controller/PingController.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace Test\Michel\Controller;
+
+use Michel\Attribute\Route;
+use Michel\Attribute\ControllerRoute;
+
+#[ControllerRoute('/api', format: 'json')]
+class PingController
+{
+    #[Route('ping', name: 'ping', methods: ['GET'])]
+    public function ping(): string
+    {
+        return json_encode(['pong' => true]);
+    }
+}

+ 26 - 0
tests/Controller/ProductController.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace Test\Michel\Controller;
+
+use Michel\Attribute\Route;
+
+class ProductController
+{
+    #[Route('/products', name: 'product_index', methods: ['GET'])]
+    public function index(): string
+    {
+        return json_encode(['products' => ['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']);
+    }
+}

+ 26 - 0
tests/Controller/UserController.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace Test\Michel\Controller;
+
+use Michel\Attribute\Route;
+
+class UserController
+{
+    #[Route('/users', name: 'user_list', methods: ['GET'])]
+    public function list(): string
+    {
+        return json_encode(['users' => ['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']);
+    }
+}

+ 203 - 0
tests/RouteTest.php

@@ -0,0 +1,203 @@
+<?php
+
+namespace Test\Michel;
+
+use InvalidArgumentException;
+use Michel\Route;
+use Michel\UniTester\TestCase;
+use stdClass;
+
+class RouteTest extends TestCase
+{
+
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testMatchRoute();
+        $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']);
+
+        $this->assertFalse($routeWithoutAttribute->match('/view/article/1'));
+        $this->assertFalse($routeWithAttribute->match('/view/article/'));
+    }
+
+    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']);
+
+        $this->assertTrue($routeWithAttribute->match('/view/article/1'));
+        $this->assertTrue($routeWithAttributes->match('/view/article/1/24'));
+        $this->assertTrue($routeWithoutAttribute->match('/view/article/'));
+    }
+
+    public function testException()
+    {
+        $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 () {
+            })
+                ->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.with.slash', '/invoice/{slash*}', function () {
+            }),
+            Route::get('invoice.with.slash', '/invoice/{slash}', function () {
+            })->whereTwoSegments('slash'),
+        ];
+
+
+        $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());
+
+        $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;
+    }
+}

+ 98 - 0
tests/RouterTest.php

@@ -0,0 +1,98 @@
+<?php
+
+namespace Test\Michel;
+
+use Michel\Exception\MethodNotAllowed;
+use Michel\Exception\RouteNotFound;
+use Michel\Route;
+use Michel\Router;
+use InvalidArgumentException;
+use Michel\UniTester\TestCase;
+
+class RouterTest extends TestCase
+{
+    private Router $router;
+
+    protected function setUp(): void
+    {
+        $this->router = (new Router())
+            ->add(new Route('home_page', '/home', ['App\\Controller\\HomeController', 'home']))
+            ->add(new Route('article_page', '/view/article', ['App\\Controller\\HomeController', 'article']))
+            ->add(new Route('article_page_by_id', '/view/article/{id}', ['App\\Controller\\HomeController', 'article']))
+            ->add(new Route('article_page_by_id_and_page', '/view/article/{id}/{page}', ['App\\Controller\\HomeController', 'article']));
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testMatchRoute();
+        $this->testNotFoundException();
+        $this->testMethodNotAllowedException();
+        $this->testGenerateUrl();
+        $this->testGenerateAbsoluteUrl();
+    }
+
+    public function testMatchRoute()
+    {
+        $route = $this->router->matchFromPath('/view/article/25', 'GET');
+        $this->assertInstanceOf(Route::class, $route);
+
+        $this->assertNotEmpty($route->getHandler());
+        $this->assertNotEmpty($route->getMethods());
+        $this->assertStrictEquals(['id' => '25'], $route->getAttributes());
+        $this->assertInstanceOf(Route::class, $this->router->matchFromPath('/home', 'GET'));
+    }
+
+    public function testNotFoundException()
+    {
+        $this->expectException(RouteNotFound::class, function () {
+            $this->router->matchFromPath('/homes', 'GET');
+        });
+    }
+
+    public function testMethodNotAllowedException()
+    {
+        $this->expectException(MethodNotAllowed::class, function () {
+            $this->router->matchFromPath('/home', 'PUT');
+        });
+    }
+
+    public function testGenerateUrl()
+    {
+        $urlHome = $this->router->generateUri('home_page');
+        $urlArticle = $this->router->generateUri('article_page');
+        $urlArticleWithParam = $this->router->generateUri('article_page_by_id', ['id' => 25]);
+        $routeArticleWithParams = $this->router->generateUri('article_page_by_id_and_page', ['id' => 25, 'page' => 3]);
+
+        $this->assertStrictEquals($urlHome, '/home');
+        $this->assertStrictEquals($urlArticle, '/view/article');
+        $this->assertStrictEquals($urlArticleWithParam, '/view/article/25');
+        $this->assertStrictEquals($routeArticleWithParams, '/view/article/25/3');
+
+        $this->expectException(InvalidArgumentException::class, function () {
+            $this->router->generateUri('article_page_by_id_and_page', ['id' => 25]);
+        });
+    }
+
+    public function testGenerateAbsoluteUrl()
+    {
+        $urlHome = $this->router->generateUri('home_page', [], true);
+        $urlArticle = $this->router->generateUri('article_page', [], true);
+        $urlArticleWithParam = $this->router->generateUri('article_page_by_id', ['id' => 25], true);
+        $routeArticleWithParams = $this->router->generateUri('article_page_by_id_and_page', ['id' => 25, 'page' => 3], true);
+
+        $this->assertStrictEquals($urlHome, 'http://localhost/home');
+        $this->assertStrictEquals($urlArticle, 'http://localhost/view/article');
+        $this->assertStrictEquals($urlArticleWithParam, 'http://localhost/view/article/25');
+        $this->assertStrictEquals($routeArticleWithParams, 'http://localhost/view/article/25/3');
+
+        $this->expectException(InvalidArgumentException::class, function () {
+            $this->router->generateUri('article_page_by_id_and_page', ['id' => 25], true);
+        });
+    }
+
+}