Add support for params in route with new route dispatcher

This commit is contained in:
Maarten 2024-11-26 13:38:35 +01:00
parent 0111ef9525
commit e8d8e0e95b
12 changed files with 393 additions and 115 deletions

View file

@ -0,0 +1,22 @@
<?php
namespace App\Controllers;
use Core\Controllers\Controller;
use Core\View\Render;
class TestController extends Controller
{
/**
* Render index
*
* @return \Core\View\Render
*/
public function test(int $id): Render
{
dd($id);
return $this->response->view('subnet');
}
}

View file

@ -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');
Router::post('/api/subnet', SubnetController::class, 'data');
Router::get('/test/{id}', TestController::class, 'test');
Router::get('/test/{id}/update', TestController::class, 'test');

View file

@ -1,7 +1,7 @@
<?php
use Core\Env\Env;
use Core\Router\Router;
use Core\Routing\Router;
require '../vendor/autoload.php';

View file

@ -77,5 +77,7 @@ class Exceptions
public static function catchOne(Throwable $exception): void
{
self::instance()->handleException($exception);
exit(0);
}
}

View file

@ -0,0 +1,5 @@
<?php
namespace Core\Exceptions\Exceptions;
class HttpException extends \Exception {}

View file

@ -0,0 +1,5 @@
<?php
namespace Core\Exceptions\Exceptions;
class InvalidArgumentException extends \InvalidArgumentException {}

View file

@ -0,0 +1,5 @@
<?php
namespace Core\Exceptions\Exceptions;
class NotFoundHttpException extends HttpException {}

View file

@ -2,8 +2,25 @@
namespace Core\Http;
use Core\Exceptions\Exceptions;
class Request
{
private array $data = [];
/**
* Build request object
*
* @param array $data
*/
public function __construct(array $data)
{
$this->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;

View file

@ -1,109 +0,0 @@
<?php
namespace Core\Router;
use Core\Exceptions\Exceptions;
use Core\Http\Request;
use Core\View\Render;
use Exception;
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
{
self::$routes[$method][$route] = [
'controller' => $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);
}
}
}

View file

@ -0,0 +1,162 @@
<?php
namespace Core\Routing;
use Core\Env\Env;
use Core\Exceptions\Exceptions;
use Core\Exceptions\Exceptions\NotFoundHttpException;
use Core\Http\Request;
use Core\View\Render;
use Exception;
class RouteDispatcher
{
/**
* Current request instance
*
* @var \Core\Http\Request
*/
private Request $request;
/**
* Collection of all routes
*
* @var array
*/
private array $routeCollection;
/**
* @param \Core\Http\Request $request
* @param array $routeCollection
*/
public function __construct(Request $request, array $routeCollection)
{
$this->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;
}
}

View file

@ -0,0 +1,89 @@
<?php
namespace Core\Routing;
use Core\Exceptions\Exceptions\NotFoundHttpException;
use ReflectionMethod;
use ReflectionNamedType;
class RouteValidator
{
/**
* Validate parameters against the method signature.
*
* @param object $controller
* @param string $action
* @param array $params
* @return array
* @throws \Core\Exceptions\Exceptions\NotFoundHttpException
* @throws \ReflectionException
*/
public static function resolve(object $controller, string $action, array $params): array
{
$reflection = new ReflectionMethod($controller, $action);
$methodParameters = $reflection->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));
}
}
}

77
src/Routing/Router.php Normal file
View file

@ -0,0 +1,77 @@
<?php
namespace Core\Routing;
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
*
* @return void
*/
public static function dispatch(): void
{
// Create request
$request = new Request($_POST + $_FILES);
// Dispatch router
RouteDispatcher::dispatch($request, self::$routes);
}
}