From e8d8e0e95bffb89812343e074f1b05de0c541d55 Mon Sep 17 00:00:00 2001 From: Maarten Date: Tue, 26 Nov 2024 13:38:35 +0100 Subject: [PATCH 1/2] Add support for params in route with new route dispatcher --- app/Controllers/TestController.php | 22 +++ config/routes.php | 7 +- public/index.php | 2 +- src/Exceptions/Exceptions.php | 2 + src/Exceptions/Exceptions/HttpException.php | 5 + .../Exceptions/InvalidArgumentException.php | 5 + .../Exceptions/NotFoundHttpException.php | 5 + src/Http/Request.php | 23 ++- src/Router/Router.php | 109 ------------ src/Routing/RouteDispatcher.php | 162 ++++++++++++++++++ src/Routing/RouteValidator.php | 89 ++++++++++ src/Routing/Router.php | 77 +++++++++ 12 files changed, 393 insertions(+), 115 deletions(-) create mode 100644 app/Controllers/TestController.php create mode 100644 src/Exceptions/Exceptions/HttpException.php create mode 100644 src/Exceptions/Exceptions/InvalidArgumentException.php create mode 100644 src/Exceptions/Exceptions/NotFoundHttpException.php delete mode 100644 src/Router/Router.php create mode 100644 src/Routing/RouteDispatcher.php create mode 100644 src/Routing/RouteValidator.php create mode 100644 src/Routing/Router.php diff --git a/app/Controllers/TestController.php b/app/Controllers/TestController.php new file mode 100644 index 0000000..8a944a4 --- /dev/null +++ b/app/Controllers/TestController.php @@ -0,0 +1,22 @@ +response->view('subnet'); + } +} \ No newline at end of file diff --git a/config/routes.php b/config/routes.php index 33b550b..659acde 100644 --- a/config/routes.php +++ b/config/routes.php @@ -2,7 +2,10 @@ use App\Controllers\Api\SubnetController; use App\Controllers\HomeController; -use Core\Router\Router; +use App\Controllers\TestController; +use Core\Routing\Router; Router::get('/', HomeController::class, 'index'); -Router::post('/api/subnet', SubnetController::class, 'data'); \ No newline at end of file +Router::post('/api/subnet', SubnetController::class, 'data'); +Router::get('/test/{id}', TestController::class, 'test'); +Router::get('/test/{id}/update', TestController::class, 'test'); \ No newline at end of file diff --git a/public/index.php b/public/index.php index a3c2da1..2bc027d 100644 --- a/public/index.php +++ b/public/index.php @@ -1,7 +1,7 @@ handleException($exception); + + exit(0); } } \ No newline at end of file diff --git a/src/Exceptions/Exceptions/HttpException.php b/src/Exceptions/Exceptions/HttpException.php new file mode 100644 index 0000000..833aeeb --- /dev/null +++ b/src/Exceptions/Exceptions/HttpException.php @@ -0,0 +1,5 @@ +data = $data; + + // Capture all exceptions + Exceptions::catch($this); + } + /** * Get request method * @@ -43,7 +60,7 @@ class Request */ public final function has(string $param): bool { - return isset($_POST[$param]); + return isset($this->data[$param]); } /** @@ -56,11 +73,11 @@ class Request public final function get(string|null $param = null, mixed $default = null): mixed { if($param == null) { - return $_POST; + return $this->data; } if($this->has($param)) { - return $_POST[$param]; + return $this->data[$param]; } return $default; diff --git a/src/Router/Router.php b/src/Router/Router.php deleted file mode 100644 index a8b57a2..0000000 --- a/src/Router/Router.php +++ /dev/null @@ -1,109 +0,0 @@ - $controller, - 'action' => $action, - ]; - } - - /** - * Dispatch router and run application - * - * @return void - */ - public static function dispatch(): void - { - // Init request - $request = new Request(); - $url = $request->url(); - $method = $request->method(); - - // Capture all exceptions - Exceptions::catch($request); - - try { - if (array_key_exists($url, self::$routes[$method])) { - $controllerName = self::$routes[$method][$url]['controller']; - $actionName = self::$routes[$method][$url]['action']; - - // Check for controller existence - if (!class_exists($controllerName)) { - throw new Exception(sprintf("Controller '%s' missing", $controllerName)); - } - - // Create controller object - $controller = new $controllerName($request); - - // Check for method on controller - if (!method_exists($controller, $actionName)) { - throw new Exception(sprintf("Method '%s' not found on '%s'", $actionName, $controllerName)); - } - - // Build response object of action - $response = $controller->$actionName(); - - // Render action - if ($response instanceof Render) { - $response->render(); - } - } else { - throw new Exception(sprintf("No route found for: %s", $url)); - } - } catch (Exception $e) { - Exceptions::catchOne($e); - } - } -} \ No newline at end of file diff --git a/src/Routing/RouteDispatcher.php b/src/Routing/RouteDispatcher.php new file mode 100644 index 0000000..3ca7782 --- /dev/null +++ b/src/Routing/RouteDispatcher.php @@ -0,0 +1,162 @@ +request = $request; + $this->routeCollection = $routeCollection; + + try { + $route = $this->findMatchingRoute(); + $controller = $this->instantiateController($route['controller']); + $action = $this->validateActionExists($controller, $route['action']); + $params = $this->resolveParameters($controller, $action, $route['params'] ?? []); + + $this->executeAction($controller, $action, $params); + } catch (NotFoundHttpException $e) { + $this->handleException($e, 404, 'page not found'); + } catch (Exception $e) { + $this->handleException($e, 500, 'something went wrong'); + } + } + + public static function dispatch(Request $request, array $routeCollection): void + { + new self($request, $routeCollection); + } + + /** + * Locate a matching route for the incoming request. + * + * @return array + * @throws \Core\Exceptions\Exceptions\NotFoundHttpException + */ + private function findMatchingRoute(): array + { + $url = $this->request->url(); + $method = $this->request->method(); + + foreach ($this->routeCollection[$method] ?? [] as $routeRegex => $route) { + if (preg_match($routeRegex, $url, $matches)) { + $params = array_filter( + $matches, + fn($key) => !is_numeric($key), + ARRAY_FILTER_USE_KEY + ); + return array_merge($route, ['params' => $params]); + } + } + + throw new NotFoundHttpException(sprintf("No route found for: %s", $url)); + } + + /** + * Create an instance of the requested controller. + * + * @param string $controllerName + * @return object + * @throws \Core\Exceptions\Exceptions\NotFoundHttpException + */ + private function instantiateController(string $controllerName): object + { + if (!class_exists($controllerName)) { + throw new NotFoundHttpException(sprintf("Controller '%s' missing", $controllerName)); + } + + return new $controllerName($this->request); + } + + /** + * Validate that the action exists in the controller. + * + * @param object $controller + * @param string $actionName + * @return string + * @throws \Core\Exceptions\Exceptions\NotFoundHttpException + */ + private function validateActionExists(object $controller, string $actionName): string + { + if (!method_exists($controller, $actionName)) { + throw new NotFoundHttpException(sprintf("Method '%s' not found on '%s'", $actionName, get_class($controller))); + } + + return $actionName; + } + + /** + * Validate and resolve parameters for the controller action. + * + * @param object $controller + * @param string $action + * @param array $params + * @return array + * @throws \Exception + */ + private function resolveParameters(object $controller, string $action, array $params): array + { + return RouteValidator::resolve($controller, $action, $params); + } + + /** + * Execute the resolved action on the controller with validated parameters. + * + * @param object $controller + * @param string $action + * @param array $params + * @return void + */ + private function executeAction(object $controller, string $action, array $params): void + { + $response = $controller->$action(...$params); + + if ($response instanceof Render) { + $response->render(); + } + } + + /** + * Handle exceptions gracefully. + * + * @param Exception $e + * @param int $statusCode + * @param string $message + * @return void + */ + private function handleException(Exception $e, int $statusCode, string $message): void + { + if (Env::get('debug')) { + Exceptions::catchOne($e); + } + + http_response_code($statusCode); + echo $message; + } +} \ No newline at end of file diff --git a/src/Routing/RouteValidator.php b/src/Routing/RouteValidator.php new file mode 100644 index 0000000..2852407 --- /dev/null +++ b/src/Routing/RouteValidator.php @@ -0,0 +1,89 @@ +getParameters(); + $validatedParams = []; + + foreach ($methodParameters as $methodParameter) { + $paramName = $methodParameter->getName(); + $paramType = $methodParameter->getType(); + $isOptional = $methodParameter->isOptional(); + $defaultValue = $isOptional ? $methodParameter->getDefaultValue() : null; + + if (!array_key_exists($paramName, $params)) { + if ($isOptional) { + $validatedParams[$paramName] = $defaultValue; + continue; + } + throw new NotFoundHttpException(sprintf("Missing parameter '%s' for action '%s'", $paramName, $action)); + } + + $value = $params[$paramName]; + $validatedParams[$paramName] = self::validateType($paramName, $value, $paramType, $action); + } + + return $validatedParams; + } + + /** + * Validate and cast a parameter based on its type. + * + * @param string $paramName + * @param mixed $value + * @param ReflectionNamedType|null $paramType + * @param string $action + * @return mixed + * @throws \Core\Exceptions\Exceptions\NotFoundHttpException + */ + private static function validateType(string $paramName, mixed $value, ?ReflectionNamedType $paramType, string $action): mixed + { + if (!$paramType || $paramType->allowsNull() && $value === null) { + return $value; // No type or allows null, return as-is. + } + + $typeName = $paramType->getName(); + + switch ($typeName) { + case 'int': + if (!ctype_digit((string)$value)) { + throw new NotFoundHttpException(sprintf("Invalid type for parameter '%s'. Expected integer, got '%s'", $paramName, gettype($value))); + } + return (int)$value; + + case 'float': + if (!is_numeric($value)) { + throw new NotFoundHttpException(sprintf("Invalid type for parameter '%s'. Expected float, got '%s'", $paramName, gettype($value))); + } + return (float)$value; + + case 'string': + if (!is_string($value)) { + throw new NotFoundHttpException(sprintf("Invalid type for parameter '%s'. Expected string, got '%s'", $paramName, gettype($value))); + } + return $value; + + default: + throw new NotFoundHttpException(sprintf("Unsupported type '%s' for parameter '%s'", $typeName, $paramName)); + } + } +} \ No newline at end of file diff --git a/src/Routing/Router.php b/src/Routing/Router.php new file mode 100644 index 0000000..e88c095 --- /dev/null +++ b/src/Routing/Router.php @@ -0,0 +1,77 @@ +[a-zA-Z0-9_-]+)', $route); + $routeRegex = '#^' . $routeRegex . '$#'; + + self::$routes[$method][$routeRegex] = [ + 'controller' => $controller, + 'action' => $action, + 'original' => $route, + ]; + } + + /** + * Dispatch router and run application + * + * @return void + */ + public static function dispatch(): void + { + // Create request + $request = new Request($_POST + $_FILES); + + // Dispatch router + RouteDispatcher::dispatch($request, self::$routes); + } +} \ No newline at end of file From d0760ed95cf8515a51248e6e936bb2b8d043652a Mon Sep 17 00:00:00 2001 From: Maarten Date: Tue, 26 Nov 2024 13:44:53 +0100 Subject: [PATCH 2/2] Route collection --- config/routes.php | 10 ++-- src/Routing/Route.php | 32 ++++++++++++ src/Routing/RouteCollection.php | 90 +++++++++++++++++++++++++++++++++ src/Routing/Router.php | 57 +-------------------- 4 files changed, 128 insertions(+), 61 deletions(-) create mode 100644 src/Routing/Route.php create mode 100644 src/Routing/RouteCollection.php diff --git a/config/routes.php b/config/routes.php index 659acde..fd1f717 100644 --- a/config/routes.php +++ b/config/routes.php @@ -3,9 +3,9 @@ use App\Controllers\Api\SubnetController; use App\Controllers\HomeController; use App\Controllers\TestController; -use Core\Routing\Router; +use Core\Routing\Route; -Router::get('/', HomeController::class, 'index'); -Router::post('/api/subnet', SubnetController::class, 'data'); -Router::get('/test/{id}', TestController::class, 'test'); -Router::get('/test/{id}/update', TestController::class, 'test'); \ No newline at end of file +Route::get('/', HomeController::class, 'index'); +Route::post('/api/subnet', SubnetController::class, 'data'); +Route::get('/test/{id}', TestController::class, 'test'); +Route::get('/test/{id}/update', TestController::class, 'test'); \ No newline at end of file diff --git a/src/Routing/Route.php b/src/Routing/Route.php new file mode 100644 index 0000000..86c4a28 --- /dev/null +++ b/src/Routing/Route.php @@ -0,0 +1,32 @@ + $controller, + 'action' => $action, + 'original' => $route, + ]; + } + + /** + * Reveal the treasure trove of routes. + * + * @return array + */ + public static function retrieve(): array + { + return self::$routes; + } + + /** + * Convert a route pattern into a regex for matching requests. + * + * @param string $route + * @return string + */ + protected static function convertToRegex(string $route): string + { + $regex = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '(?P<\1>[a-zA-Z0-9_-]+)', $route); + return '#^' . $regex . '$#'; + } + + /** + * Retrieve a specific route by method and URL. + * + * @param string $method + * @param string $url + * @return array|null + */ + public static function find(string $method, string $url): ?array + { + foreach (self::$routes[$method] ?? [] as $routeRegex => $route) { + if (preg_match($routeRegex, $url, $matches)) { + $route['params'] = array_filter( + $matches, + fn($key) => !is_numeric($key), + ARRAY_FILTER_USE_KEY + ); + return $route; + } + } + + return null; + } + + /** + * Purge all routes from the collection. + * + * @return void + */ + public static function clear(): void + { + self::$routes = []; + } +} \ No newline at end of file diff --git a/src/Routing/Router.php b/src/Routing/Router.php index e88c095..6d90c2a 100644 --- a/src/Routing/Router.php +++ b/src/Routing/Router.php @@ -6,61 +6,6 @@ use Core\Http\Request; class Router { - /** - * List of routes - * - * @var array - */ - protected static array $routes = []; - - /** - * Add GET route - * - * @param string $route - * @param string $controller - * @param string $action - * @return void - */ - public static function get(string $route, string $controller, string $action): void - { - self::register($route, $controller, $action, "GET"); - } - - /** - * Add POST route - * - * @param string $route - * @param string $controller - * @param string $action - * @return void - */ - public static function post(string $route, string $controller, string $action): void - { - self::register($route, $controller, $action, "POST"); - } - - /** - * Register route - * - * @param string $route - * @param string $controller - * @param string $action - * @param string $method - * @return void - */ - public static function register(string $route, string $controller, string $action, string $method): void - { - // Convert route with parameters into regex - $routeRegex = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '(?P<\1>[a-zA-Z0-9_-]+)', $route); - $routeRegex = '#^' . $routeRegex . '$#'; - - self::$routes[$method][$routeRegex] = [ - 'controller' => $controller, - 'action' => $action, - 'original' => $route, - ]; - } - /** * Dispatch router and run application * @@ -72,6 +17,6 @@ class Router $request = new Request($_POST + $_FILES); // Dispatch router - RouteDispatcher::dispatch($request, self::$routes); + RouteDispatcher::dispatch($request, RouteCollection::retrieve()); } } \ No newline at end of file