Initial Version
This commit is contained in:
30
core/lib/Routing/Route.php
Normal file
30
core/lib/Routing/Route.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Routing;
|
||||
|
||||
/**
|
||||
* Value object representing a resolved route.
|
||||
*/
|
||||
class Route
|
||||
{
|
||||
/** @var array<string, string> Route parameters extracted from path */
|
||||
public array $params = [];
|
||||
|
||||
public function __construct(
|
||||
public readonly string $name,
|
||||
public readonly string $method,
|
||||
public readonly string $path,
|
||||
public readonly bool $authenticated,
|
||||
public readonly string $className,
|
||||
public readonly string $classMethodName,
|
||||
public readonly array $classMethodParameters = [],
|
||||
public readonly array $permissions = [],
|
||||
) {}
|
||||
|
||||
public function withParams(array $params): self
|
||||
{
|
||||
$clone = clone $this;
|
||||
$clone->params = $params;
|
||||
return $clone;
|
||||
}
|
||||
}
|
||||
244
core/lib/Routing/Router.php
Normal file
244
core/lib/Routing/Router.php
Normal file
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Routing;
|
||||
|
||||
use DI\Attribute\Inject;
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\Injection\Container;
|
||||
use KTXC\Module\ModuleManager;
|
||||
use KTXF\Routing\Attributes\AnonymousRoute;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use ReflectionClass;
|
||||
use ReflectionMethod;
|
||||
use KTXF\Json\JsonDeserializable;
|
||||
|
||||
class Router
|
||||
{
|
||||
private Container $container;
|
||||
/** @var array<string,array<string,Route>> */
|
||||
private array $routes = []; // [method][path] => Route
|
||||
private bool $initialized = false;
|
||||
private string $cacheFile;
|
||||
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly ModuleManager $moduleManager,
|
||||
Container $container,
|
||||
#[Inject('rootDir')] private readonly string $rootDir,
|
||||
#[Inject('moduleDir')] private readonly string $moduleDir,
|
||||
#[Inject('environment')] private readonly string $environment
|
||||
)
|
||||
{
|
||||
$this->container = $container;
|
||||
$this->cacheFile = $rootDir . '/var/cache/routes.cache.php';
|
||||
}
|
||||
|
||||
private function initialize(): void
|
||||
{
|
||||
// load cached routes in production
|
||||
if ($this->environment === 'prod' && file_exists($this->cacheFile)) {
|
||||
$data = include $this->cacheFile;
|
||||
if (is_array($data)) {
|
||||
$this->routes = $data;
|
||||
$this->initialized = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// otherwise scan for routes
|
||||
$this->scan();
|
||||
$this->initialized = true;
|
||||
// write cache
|
||||
$dir = dirname($this->cacheFile);
|
||||
if (!is_dir($dir)) @mkdir($dir, 0775, true);
|
||||
file_put_contents($this->cacheFile, '<?php return ' . var_export($this->routes, true) . ';');
|
||||
}
|
||||
|
||||
|
||||
private function scan(): void
|
||||
{
|
||||
// load core controllers
|
||||
foreach (glob($this->rootDir . '/core/lib/Controllers/*.php') as $file) {
|
||||
$this->extract($file);
|
||||
}
|
||||
|
||||
// load module controllers
|
||||
foreach ($this->moduleManager->list(true, true) as $module) {
|
||||
$path = $this->moduleDir . '/' . $module->handle() . '/lib/Controllers';
|
||||
if (is_dir($path)) {
|
||||
foreach (glob($path . '/*.php') as $file) {
|
||||
$this->extract($file, '/m/' . $module->handle());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function extract(string $file, string $routePrefix = ''): void
|
||||
{
|
||||
$contents = file_get_contents($file);
|
||||
if ($contents === false) return;
|
||||
// extract namespace
|
||||
if (!preg_match('#namespace\\s+([^;]+);#', $contents, $nsM)) return;
|
||||
$ns = trim($nsM[1]);
|
||||
// extract class names
|
||||
if (!preg_match_all('#class\\s+(\\w+)#', $contents, $cM)) return;
|
||||
foreach ($cM[1] as $class) {
|
||||
$fqcn = $ns . '\\' . $class;
|
||||
try {
|
||||
if (!class_exists($fqcn)) {
|
||||
continue;
|
||||
}
|
||||
require_once $file;
|
||||
$reflectionClass = new ReflectionClass($fqcn);
|
||||
if ($reflectionClass->isAbstract()) continue;
|
||||
foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $reflectionMethod) {
|
||||
$attributes = array_merge(
|
||||
$reflectionMethod->getAttributes(AnonymousRoute::class),
|
||||
$reflectionMethod->getAttributes(AuthenticatedRoute::class)
|
||||
);
|
||||
foreach ($attributes as $attribute) {
|
||||
$route = $attribute->newInstance();
|
||||
$httpPath = $routePrefix . $route->path;
|
||||
foreach ($route->methods as $httpMethod) {
|
||||
$this->routes[$httpMethod][$httpPath] = new Route(
|
||||
method: $httpMethod,
|
||||
path: $httpPath,
|
||||
name: $route->name,
|
||||
authenticated: $route instanceof AuthenticatedRoute,
|
||||
className: $reflectionClass->getName(),
|
||||
classMethodName: $reflectionMethod->getName(),
|
||||
classMethodParameters: $reflectionMethod->getParameters(),
|
||||
permissions: $route instanceof AuthenticatedRoute ? $route->permissions : [],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Route collection failed', ['file' => $file, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a Request to a Route, or return null if no match.
|
||||
* Supports exact matches and simple {param} patterns.
|
||||
* Prioritizes: 1) exact matches, 2) specific patterns, 3) catch-all patterns
|
||||
*/
|
||||
public function match(Request $request): ?Route
|
||||
{
|
||||
if (!$this->initialized) {
|
||||
$this->initialize();
|
||||
}
|
||||
$method = $request->getMethod();
|
||||
$path = $request->getPathInfo();
|
||||
// Exact match first
|
||||
if (isset($this->routes[$method][$path])) {
|
||||
return $this->routes[$method][$path];
|
||||
}
|
||||
// Pattern matching - separate catch-all from specific patterns
|
||||
$specificPatterns = [];
|
||||
$catchAllPattern = null;
|
||||
foreach ($this->routes[$method] ?? [] as $routePath => $routeObj) {
|
||||
if (str_contains($routePath, '{')) {
|
||||
// Check if this is a catch-all pattern (e.g., /{path})
|
||||
if (preg_match('#^/\{[^/]+\}$#', $routePath)) {
|
||||
$catchAllPattern = [$routePath, $routeObj];
|
||||
} else {
|
||||
$specificPatterns[] = [$routePath, $routeObj];
|
||||
}
|
||||
}
|
||||
}
|
||||
// Try specific patterns first
|
||||
foreach ($specificPatterns as [$routePath, $routeObj]) {
|
||||
$pattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>[^/]+)', $routePath);
|
||||
$pattern = '#^' . $pattern . '$#';
|
||||
if (preg_match($pattern, $path, $m)) {
|
||||
$params = [];
|
||||
foreach ($m as $k => $v) {
|
||||
if (is_string($k)) { $params[$k] = $v; }
|
||||
}
|
||||
return $routeObj->withParams($params);
|
||||
}
|
||||
}
|
||||
// Try catch-all pattern last
|
||||
if ($catchAllPattern !== null) {
|
||||
[$routePath, $routeObj] = $catchAllPattern;
|
||||
$pattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>.*)', $routePath);
|
||||
$pattern = '#^' . $pattern . '$#';
|
||||
if (preg_match($pattern, $path, $m)) {
|
||||
$params = [];
|
||||
foreach ($m as $k => $v) {
|
||||
if (is_string($k)) { $params[$k] = $v; }
|
||||
}
|
||||
return $routeObj->withParams($params);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a matched route meta and return a Response (or null if controller does not return one).
|
||||
* Performs light argument resolution: Request object, route params, body fields, full body for array params.
|
||||
*/
|
||||
public function dispatch(Route $route, Request $request): ?Response
|
||||
{
|
||||
// extract controller and method
|
||||
$routeControllerName = $route->className;
|
||||
$routeControllerMethod = $route->classMethodName;
|
||||
$routeControllerParameters = $route->classMethodParameters;
|
||||
// instantiate controller
|
||||
if ($this->container->has($routeControllerName)) {
|
||||
$instance = $this->container->get($routeControllerName);
|
||||
} else {
|
||||
$instance = new $routeControllerName();
|
||||
}
|
||||
try {
|
||||
$requestParameters = $request->getPayload();
|
||||
} catch (\Throwable) {
|
||||
// ignore payload errors
|
||||
}
|
||||
$reflectionMethod = new \ReflectionMethod($routeControllerName, $routeControllerMethod);
|
||||
$routeParams = $route->params ?? [];
|
||||
$callArgs = [];
|
||||
foreach ($reflectionMethod->getParameters() as $reflectionParameter) {
|
||||
$reflectionParameterName = $reflectionParameter->getName();
|
||||
$reflectionParameterType = $reflectionParameter->getType();
|
||||
// if parameter matches request class, use current request
|
||||
if ($reflectionParameterType && $reflectionParameterType instanceof \ReflectionNamedType && !$reflectionParameterType->isBuiltin() && is_a($reflectionParameterType->getName(), Request::class, true)) {
|
||||
$callArgs[] = $request;
|
||||
continue;
|
||||
}
|
||||
// if method parameter matches a route path param, use that (highest priority)
|
||||
if (array_key_exists($reflectionParameterName, $routeParams)) {
|
||||
$callArgs[] = $routeParams[$reflectionParameterName];
|
||||
continue;
|
||||
}
|
||||
// if method parameter matches a request param, use that
|
||||
if ($requestParameters->has($reflectionParameterName)) {
|
||||
// if parameter is a class implementing JsonDeserializable, call jsonDeserialize on it
|
||||
if ($reflectionParameterType && $reflectionParameterType instanceof \ReflectionNamedType && !$reflectionParameterType->isBuiltin() && is_a($reflectionParameterType->getName(), JsonDeserializable::class, true)) {
|
||||
$type = $reflectionParameterType->getName();
|
||||
$object = new $type();
|
||||
if ($object instanceof JsonDeserializable) {
|
||||
$object->jsonDeserialize($requestParameters->get($reflectionParameterName));
|
||||
$callArgs[] = $object;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// otherwise, use the raw value
|
||||
$callArgs[] = $requestParameters->get($reflectionParameterName);
|
||||
continue;
|
||||
}
|
||||
// if method parameter did not match, but has a default value, use that
|
||||
if ($reflectionParameter->isDefaultValueAvailable()) {
|
||||
$callArgs[] = $reflectionParameter->getDefaultValue();
|
||||
continue;
|
||||
}
|
||||
$callArgs[] = null;
|
||||
}
|
||||
$result = $instance->$routeControllerMethod(...$callArgs);
|
||||
return $result instanceof Response ? $result : null;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user