chore: complete update

This commit is contained in:
2025-07-17 16:24:20 +02:00
parent 899227b0a4
commit 64a7051137
1300 changed files with 85570 additions and 2756 deletions

View File

@@ -1,20 +1,8 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Http\Status;
final readonly class ActionResult
interface ActionResult
{
public function __construct(
public ResultType $resultType,
public string $template,
public array $data = [],
public Status $status = Status::OK,
public string $layout = '',
public array $slots = [],
public ?string $controllerClass = null
) {}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Framework\Router;
final readonly class CompiledPattern
{
/**
* @param string $regex Kombinierter Regex
* @param array<int, array{route: object, paramMap: array<string, int>}> $routes
*/
public function __construct(
public string $regex,
public array $routes
) {}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Framework\Router;
use App\Framework\Core\Route;
final readonly class CompiledRoutes
{
/**
* @param array<string, array<string, Route>> $staticRoutes
* @param array<string, CompiledPattern|null> $dynamicPatterns
*/
public function __construct(
private array $staticRoutes,
private array $dynamicPatterns,
private array $namedRoutes,
) {}
public function getStaticRoute(string $method, string $path): ?Route
{
return $this->staticRoutes[$method][$path] ?? null;
}
public function getCompiledPattern(string $method): ?CompiledPattern
{
return $this->dynamicPatterns[$method] ?? null;
}
public function getNamedRoute(string $name): ?Route
{
return $this->namedRoutes[$name] ?? null;
}
public function hasNamedRoute(string $name): bool
{
return isset($this->namedRoutes[$name]);
}
public function getAllNamedRoutes(): array
{
return $this->namedRoutes;
}
public function generateUrl(string $name, array $params = []): ?string
{
$route = $this->getNamedRoute($name);
if(!$route) {
return null;
}
return $this->buildUrlFromRoute($route, $params);
}
public function getStats(): array
{
$staticCount = array_sum(array_map('count', $this->staticRoutes));
$dynamicCount = count(array_filter($this->dynamicPatterns));
$namedCount = count($this->namedRoutes);
return [
'static_routes' => $staticCount,
'dynamic_patterns' => $dynamicCount,
'named_routes' => $namedCount,
'total_methods' => count($this->staticRoutes)
];
}
private function buildUrlFromRoute(Route $route, array $params): string
{
$path = $route->path;
foreach ($params as $key => $value) {
$path = str_replace("{{$key}}", (string)$value, $path);
}
return $path;
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\DI\DefaultContainer;
use App\Framework\Http\Request;
use App\Framework\Http\RequestBody;
use App\Framework\Http\Session\Session;
use App\Framework\Validation\Exceptions\ValidationException;
use App\Framework\Validation\Validator;
use Exception;
use ReflectionClass;
use ReflectionNamedType;
use ReflectionProperty;
final readonly class ControllerRequestFactory
{
public function __construct(
private DefaultContainer $container,
private PropertyValueConverter $propertyValueConverter
) {}
/**
* Erstellt und validiert eine ControllerRequest-Instanz
*/
public function createAndValidate(ReflectionClass $requestClass, RequestBody $data): object
{
// Instanz erstellen
$instance = $requestClass->newInstance();
// Eigenschaften befüllen und validieren
foreach ($requestClass->getProperties() as $property) {
$propertyName = $property->getName();
// Wert aus den Daten abrufen
$value = $data->get($propertyName, null);
try {
$this->propertyValueConverter->setPropertyValue($property, $instance, $value);
} catch (\Throwable $e) {
#throw new ValidationException(
# ['Ungültiger Wert für den Typ: ' . $e->getMessage()],
# $propertyName
#);
}
}
$validator = $this->container->get(Validator::class);
$validationResult = $validator->validate($instance);
if ($validationResult->hasErrors()) {
throw new ValidationException($validationResult, 'test');
}
return $instance;
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Framework\Router\Exception;
use App\Framework\Exception\FrameworkException;
use App\Framework\Http\Exception\HttpException;
use App\Framework\Http\Status;
class RouteNotFound extends FrameworkException implements HttpException
{
public Status $status {
get {
return Status::NOT_FOUND;
}
}
protected string $route;
public function __construct(string $route, ?\Throwable $previous = null, int $code = 0, array $context = [])
{
$this->route = $route;
$message = "Route not found: {$route}";
#$context
parent::__construct($message, $code, $previous);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\Exceptions;
use App\Framework\Exception\FrameworkException;
final class ParameterResolutionException extends FrameworkException
{
/**
* @param string $string
* @param int $int
* @param \Exception|ReflectionException $e
*/
public function __construct(string $string, int $int, \Throwable $e) {
parent::__construct($string, $int, $e);
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\Exceptions;
use App\Framework\Exception\FrameworkException;
final class UnknownResultException extends FrameworkException
{
public function __construct(
string $resultClass,
int $code = 0,
?\Throwable $previous = null
) {
parent::__construct(
message: 'Unbekanntes Ergebnisobjekt: ' . $resultClass,
code: $code,
previous: $previous,
context: ['resultClass' => $resultClass]
);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Http\Status;
final readonly class GenericActionResult implements ActionResult
{
private function __construct(
public ResultType $resultType,
public string $template,
public array $data = [],
public Status $status = Status::OK,
#public string $layout = '',
public array $slots = [],
public ?string $controllerClass = null
) {}
}

View File

@@ -4,13 +4,17 @@ declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Attributes\Singleton;
use App\Framework\Core\DynamicRoute;
use App\Framework\Http\HttpMethod;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
#[Singleton]
final readonly class HttpRouter
{
public function __construct(
private RouteCollection $routes
#public RouteCollection $routes,
public CompiledRoutes $optimizedRoutes,
) {}
/**
@@ -19,30 +23,58 @@ final readonly class HttpRouter
* @param string $method HTTP-Methode, zB. GET, POST
* @param string $path URI-Pfad, zB. /user/123
*/
public function match(string $method, string $path): RouteContext
public function match(Request $request): RouteContext
{
$method = HttpMethod::tryFrom(strtoupper($method));
$method = $request->method;
$path = $request->path;
if ($method === null || !$this->routes->has($method->value)) {
if($staticRoute = $this->optimizedRoutes->getStaticRoute($method->value, $path)) {
return new RouteContext(
match: new NoRouteMatch(),
method: $method->value,
match: new RouteMatchSuccess($staticRoute),
method: $method,
path: $path
);
}
$match = $this->matchStatic($method->value, $path)
?? $this->matchDynamic($method->value, $path)
if($dynamicMatch = $this->matchDynamicOptimized($method, $path)) {
return new RouteContext(
match: new RouteMatchSuccess($dynamicMatch),
method: $method,
path: $path
);
}
return new RouteContext(
match: new NoRouteMatch(),
method: $method,
path: $path
);
/*
$method = $request->method;
$path = $request->path;
if (!$this->routes->has($method)) {
return new RouteContext(
match: new NoRouteMatch(),
method: $method,
path: $path
);
}
$match = $this->matchStatic($method, $path)
?? $this->matchDynamic($method, $path)
?? new NoRouteMatch();
return new RouteContext(
match: $match,
method: $method->value,
method: $method,
path: $path
);
);*/
}
private function matchStatic(string $method, string $path): ?RouteMatch
private function matchStatic(Method $method, string $path): ?RouteMatch
{
if (isset($this->routes->getStatic($method)[$path])) {
$handler = $this->routes->getStatic($method)[$path];
@@ -51,25 +83,100 @@ final readonly class HttpRouter
return null;
}
private function matchDynamic(string $method, string $path): ?RouteMatch
private function matchDynamic(Method $method, string $path): ?RouteMatch
{
/* @var $route DynamicRoute */
foreach ($this->routes->getDynamic($method) as $route) {
if (preg_match($route['regex'], $path, $matches)) {
if (preg_match($route->regex, $path, $matches)) {
array_shift($matches); // remove full match
$params = array_combine($route['params'], $matches);
// Überprüfe ob die Anzahl der URL-Parameter stimmt
if (count($route->paramNames) !== count($matches)) {
continue;
}
$paramValues = array_combine($route->paramNames, $matches);
if (!$paramValues) {
continue;
}
$dynamicRoute = new DynamicRoute(
$route["regex"],
$route["params"],
$route['method']['class'],
$route['method']['method'],
$params
$route->regex,
$route->paramNames,
$route->controller,
$route->action,
$route->parameters,
$paramValues,
$route->name,
$route->path,
);
return new RouteMatchSuccess($route);
return new RouteMatchSuccess($dynamicRoute);
}
}
return null;
}
private function matchDynamicOptimized(Method $method, string $path): ?DynamicRoute
{
$pattern = $this->optimizedRoutes->getCompiledPattern($method->value);
if(!$pattern || !preg_match($pattern->regex, $path, $matches)) {
return null;
}
$routeIndex = $this->findMatchingRouteIndex($matches, $method);
$routeData = $pattern->routes[$routeIndex];
$paramValues = $this->extractParameters($matches, $routeData['paramMap']);
$originalRoute = $routeData['route'];
return new DynamicRoute(
regex: $originalRoute->regex,
paramNames: $originalRoute->paramNames,
controller: $originalRoute->controller,
action: $originalRoute->action,
parameters: $originalRoute->parameters,
paramValues: $paramValues,
name: $originalRoute->name,
path: $originalRoute->path,
attributes: $originalRoute->attributes ?? [],
);
}
private function findMatchingRouteIndex(array $matches, $method): int
{
$pattern = $this->optimizedRoutes->getCompiledPattern($method->value);
foreach($pattern->routes as $index => $routeData) {
$routeGroupIndex = $routeData['routeGroupIndex'];
if (isset($matches[$routeGroupIndex]) && $matches[$routeGroupIndex] !== '') {
return $index;
}
}
return 0;
for($i = 1; $i < count($matches); $i++) {
if($matches[$i] !== '') {
return $i - 1;
}
}
return 0;
}
private function extractParameters(array $matches, array $paramMap): array
{
$params = [];
foreach($paramMap as $paramName => $matchIndex) {
if (isset($matches[$matchIndex]) && $matches[$matchIndex] !== '') {
$params[$paramName] = $matches[$matchIndex];
}
}
return $params;
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\DI\DefaultContainer;
use App\Framework\Http\ControllerRequest;
use App\Framework\Http\Request;
use App\Framework\Logging\DefaultLogger;
use App\Framework\Router\Exceptions\ParameterResolutionException;
use ReflectionClass;
use ReflectionException;
final readonly class ParameterProcessor
{
public function __construct(
private DefaultContainer $container,
private ControllerRequestFactory $controllerRequestFactory,
private DefaultLogger $logger
) {}
public function prepareParameters(array $params, array $queryParams): array
{
$parameters = [];
foreach ($params as $param) {
try {
if(in_array($param['name'], array_keys($queryParams))) {
$parameters[] = $queryParams[$param['name']];
continue;
}
if ($param['isBuiltin'] === true) {
$parameters[] = $param['default'] ?? null;
continue;
}
$reflectionClass = new ReflectionClass($param['type']);
if ($reflectionClass->implementsInterface(ControllerRequest::class)) {
$request = $this->container->get(Request::class);
$parsedBody = $request->parsedBody;
$controllerRequest = $this->controllerRequestFactory
->createAndValidate($reflectionClass, $parsedBody);
$this->container->bind($param['type'], $controllerRequest);
$parameters[] = $controllerRequest;
continue;
}
$parameters[] = $this->container->get($param['type']);
} catch (ReflectionException $e) {
$this->logger?->error("Parameter reflection failed", [
'type' => $param['type'],
'error' => $e->getMessage()
]);
throw new ParameterResolutionException(
"Cannot resolve parameter of type '{$param['type']}'",
0,
$e
);
} catch (\Throwable $e) {
$this->logger?->error("Parameter preparation failed", [
'param' => $param,
'error' => $e->getMessage()
]);
throw $e;
}
}
return $parameters;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use ReflectionNamedType;
use ReflectionProperty;
final readonly class PropertyValueConverter
{
/**
* Setzt einen Wert für eine Property mit entsprechender Typenkonvertierung
*/
public function setPropertyValue(ReflectionProperty $property, object $instance, mixed $value): void
{
if ($property->hasDefaultValue()) {
$property->setValue($instance, $property->getDefaultValue());
return;
}
if ($value === null) {
$property->setValue($instance, null);
return;
}
$type = $property->getType();
if ($type instanceof ReflectionNamedType) {
$typeName = $type->getName();
$value = $this->convertValueToType($value, $typeName);
}
$property->setValue($instance, $value);
}
/**
* Konvertiert einen Wert in den angegebenen Typ
*/
private function convertValueToType(mixed $value, string $typeName): mixed
{
return match ($typeName) {
'int' => (int) $value,
'float' => (float) $value,
'bool' => filter_var($value, FILTER_VALIDATE_BOOLEAN),
'string' => (string) $value,
'array' => is_array($value) ? $value : [$value],
default => $value
};
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Framework\Router\Result;
use App\Framework\Router\ActionResult;
final readonly class ContentNegotiationResult implements ActionResult
{
public function __construct(
public array $jsonPayload = [],
public ?string $redirectTo = null,
public ?string $viewTemplate = null,
public array $viewData = [],
) {}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Framework\Router\Result;
use App\Framework\Router\ActionResult;
final readonly class FileResult implements ActionResult
{
public function __construct(
public string $filePath,
public string $variant = 'original',
public string $mimeType = 'application/image',
) {}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\Result;
use App\Framework\Http\Status;
use App\Framework\Router\ActionResult;
final class JsonResult implements ActionResult
{
public function __construct(
public readonly array $data,
public Status $status = Status::OK,
) {}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\Result;
use App\Framework\Http\Status;
use App\Framework\Router\ActionResult;
final class Redirect implements ActionResult
{
public Status $status = Status::FOUND;
public function __construct(
public readonly string $target,
) {}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\Result;
/**
* Repräsentiert ein einzelnes Server-Sent Event
*/
final readonly class SseEvent
{
/**
* @param string $data Der Dateninhalt des Events
* @param string|null $event Der Event-Typ (optional)
* @param string|null $id Die Event-ID (optional)
* @param int|null $retry Retry-Intervall in Millisekunden (optional)
*/
public function __construct(
public string $data,
public ?string $event = null,
public ?string $id = null,
public ?int $retry = null
) {}
/**
* Formatiert das Event in das SSE-Protokollformat
*/
public function format(): string
{
$formatted = [];
if ($this->id !== null) {
$formatted[] = "id: {$this->id}";
}
if ($this->event !== null) {
$formatted[] = "event: {$this->event}";
}
if ($this->retry !== null) {
$formatted[] = "retry: {$this->retry}";
}
// Mehrzeilige Daten unterstützen
foreach (explode("\n", $this->data) as $line) {
$formatted[] = "data: {$line}";
}
$formatted[] = ''; // Leere Zeile am Ende
return implode("\n", $formatted);
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\Result;
use App\Framework\Http\Status;
use App\Framework\Router\ActionResult;
use Closure;
final class SseResult implements ActionResult
{
/**
* @var array<SseEvent> Enthält die SSE-Events, die beim initialen Verbindungsaufbau gesendet werden
*/
private array $initialEvents = [];
/**
* @param Status $status HTTP-Status-Code der Antwort
* @param int|null $retryInterval Zeit in Millisekunden, nach der der Client bei Verbindungsverlust neu verbinden soll
* @param array<string, string> $headers Zusätzliche HTTP-Header für die Response
*/
public function __construct(
public readonly Status $status = Status::OK,
public readonly ?int $retryInterval = 3000,
public readonly array $headers = [],
public readonly ?Closure $callback = null,
public readonly int $maxDuration = 0,
public readonly int $heartbeatInterval = 30,
) {}
/**
* Fügt ein Event hinzu, das beim initialen Verbindungsaufbau gesendet wird
*/
public function addEvent(
string $data,
?string $event = null,
?string $id = null,
?int $retry = null
): self {
$this->initialEvents[] = new SseEvent($data, $event, $id, $retry);
return $this;
}
/**
* Fügt ein JSON-formatiertes Event hinzu
*/
public function addJsonEvent(
array $data,
?string $event = null,
?string $id = null,
?int $retry = null
): self {
return $this->addEvent(json_encode($data), $event, $id, $retry);
}
/**
* Gibt alle initialen Events zurück
*
* @return array<SseEvent>
*/
public function getInitialEvents(): array
{
return $this->initialEvents;
}
public function hasCallback(): bool
{
return $this->callback !== null;
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\Result;
use App\Framework\Http\SseStream;
use App\Framework\Http\Status;
use App\Framework\Router\ActionResult;
/**
* Erweiterte SSE-Klasse, die eine Callback-Funktion für Live-Updates unterstützt
*/
final class SseResultWithCallback implements ActionResult
{
/**
* @var callable|null Callback-Funktion, die während des Streamings ausgeführt wird
*/
private $callback = null;
/**
* @var int Maximale Streaming-Dauer in Sekunden (0 = unbegrenzt)
*/
private int $maxDuration = 0;
/**
* @var int Intervall für Heartbeats in Sekunden
*/
private int $heartbeatInterval = 30;
public function __construct(
public readonly Status $status = Status::OK,
public readonly array $headers = []
) {}
/**
* Setzt die Callback-Funktion, die während des Streamings aufgerufen wird
* Der Callback erhält den SseStream als Parameter
*
* @param callable $callback Funktion mit Signatur: function(SseStream $stream): void
*/
public function setCallback(callable $callback): self
{
$this->callback = $callback;
return $this;
}
/**
* Setzt die maximale Streaming-Dauer
*
* @param int $seconds Maximale Dauer in Sekunden (0 = unbegrenzt)
*/
public function setMaxDuration(int $seconds): self
{
$this->maxDuration = $seconds;
return $this;
}
/**
* Setzt das Intervall für Heartbeats
*
* @param int $seconds Intervall in Sekunden
*/
public function setHeartbeatInterval(int $seconds): self
{
$this->heartbeatInterval = $seconds;
return $this;
}
/**
* Gibt den Callback zurück
*/
public function getCallback(): ?callable
{
return $this->callback;
}
/**
* Gibt die maximale Streaming-Dauer zurück
*/
public function getMaxDuration(): int
{
return $this->maxDuration;
}
/**
* Gibt das Heartbeat-Intervall zurück
*/
public function getHeartbeatInterval(): int
{
return $this->heartbeatInterval;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\Result;
use App\Framework\Http\Status;
use App\Framework\Meta\MetaData;
use App\Framework\Router\ActionResult;
final readonly class ViewResult implements ActionResult
{
public function __construct(
public string $template,
public MetaData $metaData,
public array $data = [],
public Status $status = Status::OK,
#public string $layout = '',
public array $slots = [],
public ?string $controllerClass = null,
public ?object $model = null,
) {}
/*public static function fromStrings():self
{
}*/
}

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\Result;
use App\Framework\Http\Status;
use App\Framework\Router\ActionResult;
final class WebSocketResult implements ActionResult
{
/**
* @var callable|null Handler für neue Verbindungen
*/
private $onConnect = null;
/**
* @var callable|null Handler für eingehende Nachrichten
*/
private $onMessage = null;
/**
* @var callable|null Handler für geschlossene Verbindungen
*/
private $onClose = null;
/**
* @var callable|null Handler für Fehler
*/
private $onError = null;
/**
* @var array Unterstützte Subprotokolle
*/
private array $subprotocols = [];
/**
* @var int Maximale Nachrichtengröße in Bytes
*/
private int $maxMessageSize = 1048576; // 1MB
/**
* @var int Ping-Intervall in Sekunden
*/
private int $pingInterval = 30;
public function __construct(
public readonly Status $status = Status::SWITCHING_PROTOCOLS,
public readonly array $headers = []
) {}
/**
* Setzt den Handler für neue Verbindungen
*
* @param callable $handler function(WebSocketConnection $connection): void
*/
public function onConnect(callable $handler): self
{
$this->onConnect = $handler;
return $this;
}
/**
* Setzt den Handler für eingehende Nachrichten
*
* @param callable $handler function(WebSocketConnection $connection, string $message): void
*/
public function onMessage(callable $handler): self
{
$this->onMessage = $handler;
return $this;
}
/**
* Setzt den Handler für geschlossene Verbindungen
*
* @param callable $handler function(WebSocketConnection $connection, int $code, string $reason): void
*/
public function onClose(callable $handler): self
{
$this->onClose = $handler;
return $this;
}
/**
* Setzt den Handler für Fehler
*
* @param callable $handler function(WebSocketConnection $connection, \Throwable $error): void
*/
public function onError(callable $handler): self
{
$this->onError = $handler;
return $this;
}
/**
* Setzt unterstützte Subprotokolle
*/
public function withSubprotocols(array $subprotocols): self
{
$this->subprotocols = $subprotocols;
return $this;
}
/**
* Setzt die maximale Nachrichtengröße
*/
public function withMaxMessageSize(int $bytes): self
{
$this->maxMessageSize = $bytes;
return $this;
}
/**
* Setzt das Ping-Intervall
*/
public function withPingInterval(int $seconds): self
{
$this->pingInterval = $seconds;
return $this;
}
// Getter
public function getOnConnect(): ?callable { return $this->onConnect; }
public function getOnMessage(): ?callable { return $this->onMessage; }
public function getOnClose(): ?callable { return $this->onClose; }
public function getOnError(): ?callable { return $this->onError; }
public function getSubprotocols(): array { return $this->subprotocols; }
public function getMaxMessageSize(): int { return $this->maxMessageSize; }
public function getPingInterval(): int { return $this->pingInterval; }
}

View File

@@ -2,24 +2,63 @@
namespace App\Framework\Router;
final class RouteCollection
use App\Framework\Core\Route;
use App\Framework\Http\Method;
use Countable;
use Generator;
final readonly class RouteCollection implements Countable
{
/** @var array<string, array{static: array<string, callable>, dynamic: array<array{regex: string, handler: callable}>}> */
private array $routes;
/**
* @param array<string, array{static: array<string, callable>, dynamic: array<array{regex: string, handler: callable}>}> $routes
#* @param array<string, array{path: string, method: Method, handler: callable}> $namedRoutes
*/
public function __construct(
private array $routes,
private array $namedRoutes = []
) {}
public function __construct(array $routes) {
$this->routes = $routes;
public function getStatic(Method $method): array {
return $this->routes[$method->value]['static'] ?? [];
}
public function getStatic(string $method): array {
return $this->routes[$method]['static'] ?? [];
public function getDynamic(Method $method): array {
return $this->routes[$method->value]['dynamic'] ?? [];
}
public function getDynamic(string $method): array {
return $this->routes[$method]['dynamic'] ?? [];
public function has(Method $method): bool {
return isset($this->routes[$method->value]);
}
public function has(string $method): bool {
return isset($this->routes[$method]);
public function getByMethod(Method $method): array {
return ($this->routes[$method->value]['dynamic'] + $this->routes[$method->value]['static']) ?? [];
}
public function getByName(string $name): ?Route
{
return $this->namedRoutes[$name] ?? null;
}
/**
* Gibt alle Named Routes zurück
*
* @return Generator<string, Route>
*/
public function getAllNamedRoutes(): Generator
{
foreach ($this->namedRoutes as $name => $route) {
yield $name => $route;
}
}
public function hasName(string $name): bool {
return isset($this->namedRoutes[$name]);
}
public function count(): int
{
return count($this->routes);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Framework\Router;
use App\Framework\Http\Method;
use Closure;
final class RouteContext
@@ -16,8 +17,8 @@ final class RouteContext
public function __construct(
public readonly RouteMatch $match,
public readonly string $method,
public readonly string $path
public readonly Method $method,
public readonly string $path
) {}
public function isSuccess(): bool

View File

@@ -4,48 +4,58 @@ declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Http\HttpResponse;
use App\Framework\Core\DynamicRoute;
use App\Framework\DI\DefaultContainer;
use App\Framework\Http\Response;
use App\Framework\Http\Status;
use App\Framework\Http\Responses\NotFound;
use App\Framework\Router\Result\ContentNegotiationResult;
class RouteDispatcher
final readonly class RouteDispatcher
{
public function __construct(
private DefaultContainer $container,
private ParameterProcessor $parameterProcessor
) {}
/**
* Verarbeitet eine Route und führt den entsprechenden Controller-Action aus
*/
public function dispatch(RouteContext $routeContext): ActionResult|Response
{
$routeMatch = $routeContext->match;
if ($routeMatch->isMatch()) {
$controller = $routeMatch->route->controller;
$action = $routeMatch->route->action;
$params = $routeMatch->route->params;
$params = $this->prepareParameters(...$params);
$obj = new $controller();
$result = $obj->$action(...$params);
// Hier könntest du z. B. Response-Objekte erwarten oder generieren:
if ($result instanceof Response || $result instanceof ActionResult) {
return $result;
}
return $this->executeController($routeMatch);
}
// Fehlerbehandlung z.B. 404
return new HttpResponse(status: Status::NOT_FOUND, body: 'Nicht gefunden');
return new NotFound();
}
public function prepareParameters(...$params): mixed
/**
* Führt die Controller-Action mit den aufbereiteten Parametern aus
*/
private function executeController(RouteMatch $routeMatch): ActionResult|Response
{
$parameters = [];
foreach ($params as $param) {
if ($param['isBuiltin'] === true) {
$parameters[] = $param['default'];
} else {
#Container!
var_dump($param['isBuiltin']);
}
$controller = $routeMatch->route->controller;
$action = $routeMatch->route->action;
$params = $routeMatch->route->parameters;
$queryParams = [];
if($routeMatch->route instanceof DynamicRoute) {
$queryParams = $routeMatch->route->paramValues;
}
return $parameters;
$preparedParams = $this->parameterProcessor->prepareParameters($params, $queryParams);
$controllerInstance = $this->container->get($controller);
$result = $controllerInstance->$action(...$preparedParams);
if ($result instanceof Response || $result instanceof ActionResult) {
return $result;
}
// Wenn kein gültiges Result zurückgegeben wird
return new NotFound('Ungültige Antwort vom Controller');
}
}

View File

@@ -5,15 +5,13 @@ declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Core\DynamicRoute;
use App\Framework\Core\Route;
use App\Framework\Core\StaticRoute;
final readonly class RouteMatchSuccess implements RouteMatch
{
#public string $controller;
#public string $action;
#public array $params;
public function __construct(
public DynamicRoute|StaticRoute $route
public Route $route
) {
}

View File

@@ -4,70 +4,99 @@ declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Core\PathProvider;
use App\Framework\DI\DefaultContainer;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Response;
use App\Framework\View\Engine;
use App\Framework\Http\Responses\SseResponse;
use App\Framework\Http\Responses\WebSocketResponse;
use App\Framework\Router\Result\FileResult;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\Redirect;
use App\Framework\Router\Result\SseResult;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Router\Result\WebSocketResult;
use App\Framework\View\RenderContext;
use App\Framework\View\Template;
use App\Framework\View\TemplateRenderer;
use App\Framework\View\TemplateRendererInitializer;
readonly class RouteResponder
final readonly class RouteResponder
{
public function __construct(
private TemplateRenderer $templateRenderer = new Engine()
) {
}
private PathProvider $pathProvider,
private DefaultContainer $container,
private TemplateRenderer $templateRenderer,
) {}
public function respond(ActionResult $result): Response
public function respond(Response|ActionResult $result): Response
{
$contentType = "text/html";
switch ($result->resultType) {
case ResultType::Html:
$body = $this->renderTemplate(
$result->template,
$result->data,
$result->layout ?? null,
$result->slots ?? [],
$result->controllerClass
);
$contentType = "text/html";
break;
case ResultType::Json:
$body = json_encode(
$result->data,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE
);
$contentType = "application/json";
break;
case ResultType::Plain:
$body = $result->data['text'] ?? '';
$contentType = "text/plain";
break;
default:
throw new \RuntimeException("Unknown result type: {$result->resultType}");
if($result instanceof Response) {
return $result;
}
return new HttpResponse(
status: $result->status,
headers: new Headers()->with('Content-Type', $contentType), //['Content-Type' => $contentType],
body: $body
);
return match(true) {
$result instanceof ViewResult => new HttpResponse(
status: $result->status,
body: $this->renderTemplate(
new RenderContext(
template: $result->model ? $this->resolveTemplate($result->model) : $result->template,
metaData: $result->metaData,
data: $result->model ? get_object_vars($result->model) + $result->data : $result->data,
layout: '',
slots: $result->slots
)
)
),
$result instanceof JsonResult => new HttpResponse(
status : $result->status,
headers: new Headers()->with('Content-Type', 'application/json'),
body : json_encode($result->data),
),
$result instanceof Redirect => new HttpResponse(
status: $result->status,
headers: new Headers()->with('Location', $result->target)
),
$result instanceof SseResult => new SseResponse($result, $result->callback),
$result instanceof WebSocketResult => $this->createWebSocketResponse($result),
$result instanceof FileResult => new HttpResponse(
#headers: new Headers()->with('Content-Type', $result->mimeType),
body: $result->filePath
),
default => throw new \RuntimeException('Unbekanntes Ergebnisobjekt: ' . get_class($result)),
};
}
private function renderTemplate(string $template, array $data, ?string $layout, array $slots = [], ?string $controllerName = null): string
private function createWebSocketResponse(WebSocketResult $result): WebSocketResponse
{
$context = new RenderContext(
template: $template,
data: $data,
layout: $layout,
slots: $slots,
controllerClass: $controllerName
);
// WebSocket-Key aus Request-Headers abrufen
$websocketKey = $_SERVER['HTTP_SEC_WEBSOCKET_KEY'] ?? '';
return new WebSocketResponse($result, $websocketKey);
}
private function renderTemplate(RenderContext $context): string
{
return $this->templateRenderer->render($context);
}
private function resolveTemplate(?object $model):string
{
if($model === null) {
return 'test';
}
$ref = new \ReflectionClass($model);
$attrs = $ref->getAttributes(Template::class);
if ($attrs === []) {
return 'test';
#throw new \RuntimeException("Fehlendes #[Template] Attribut in {$ref->getName()}");
}
/** @var Template $attr */
$attr = $attrs[0]->newInstance();
return $attr->path;
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Attributes\Route;
use App\Framework\Core\PathProvider;
use App\Framework\Core\RouteCache;
use App\Framework\Core\RouteCompiler;
use App\Framework\DI\DefaultContainer;
use App\Framework\DI\Initializer;
use App\Framework\Discovery\Results\DiscoveryResults;
/**
* Verantwortlich für die Einrichtung des Routers
*/
final readonly class RouterSetup
{
public function __construct(
private DefaultContainer $container,
private PathProvider $pathProvider,
private RouteCompiler $routeCompiler,
private DiscoveryResults $results,
) {}
/**
* Richtet den Router basierend auf den verarbeiteten Attributen ein
*/
#[Initializer]
public function __invoke(): HttpRouter
{
#$routeCache = new RouteCache($this->pathProvider->getCachePath('routes.cache.php'));
if ($this->results->has(Route::class)) {
$discoveryRoutes = $this->results->get(Route::class);
// DIREKT zu optimierten Routen
$optimizedRoutes = $this->routeCompiler->compileOptimized($discoveryRoutes);
// Cache speichern
#$routeCache->save($optimizedRoutes);
// FULL COMMIT: Nur CompiledRoutes
} else {
$optimizedRoutes = $this->routeCompiler->compileOptimized([]);
}
$router = new HttpRouter($optimizedRoutes);
$this->container->bind(CompiledRoutes::class, $optimizedRoutes);
$this->container->bind(HttpRouter::class, $router);
return $router;
}
}

View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Attributes\Singleton;
use App\Framework\Core\Route;
use App\Framework\Http\Request;
use App\Framework\Router\Exception\RouteNotFound;
/**
* Service zur Generierung von URLs basierend auf Named Routes
*/
#[Singleton]
final readonly class UrlGenerator
{
#private RouteCollection $routes;
public function __construct(
HttpRouter $router,
private Request $request,
private CompiledRoutes $routes,
) {
#$this->routes = $router->routes;
}
/**
* Generiert eine URL für eine Named Route
*
* @param string $name Name der Route
* @param array<string, mixed> $parameters Parameter für dynamische Routes
* @param bool $absolute Ob eine absolute URL generiert werden soll
* @return string Generierte URL
* @throws RouteNotFound Wenn die Route nicht gefunden wird
*/
public function route(string $name, array $parameters = [], bool $absolute = false): string
{
$route = $this->routes->getNamedRoute($name);
if ($route === null) {
throw new RouteNotFound("Route with name '{$name}' not found.");
}
$url = $this->buildUrl($route->path, $parameters);
if ($absolute) {
return $this->makeAbsolute($url);
}
return $url;
}
/**
* Generiert eine absolute URL für eine Named Route
*
* @param string $name Name der Route
* @param array<string, mixed> $parameters Parameter für dynamische Routes
* @return string Absolute URL
*/
public function absoluteRoute(string $name, array $parameters = []): string
{
return $this->route($name, $parameters, true);
}
/**
* Erstellt eine URL basierend auf dem aktuellen Request mit geänderten Parametern
*
* @param array<string, mixed> $parameters Query-Parameter
* @param bool $absolute Ob eine absolute URL generiert werden soll
* @return string Generierte URL
*/
public function current(array $parameters = [], bool $absolute = false): string
{
$url = $this->request->path;
if (!empty($parameters)) {
$url .= '?' . http_build_query(array_merge(
$this->request->query->all(),
$parameters
));
} elseif (!empty($this->request->query->all())) {
$url .= '?' . http_build_query($this->request->query->all());
}
if ($absolute) {
return $this->makeAbsolute($url);
}
return $url;
}
/**
* Prüft, ob die aktuelle Route mit dem gegebenen Namen übereinstimmt
*/
public function isCurrentRoute(string $name): bool
{
try {
$route = $this->routes->getByName($name);
return $route !== null && $route->path === $this->request->path;
} catch (RouteNotFound) {
return false;
}
}
/**
* Erstellt eine URL aus einem Pfad-Template und Parametern
*
* @param string $path Pfad-Template (z.B. "/user/{id}/posts/{slug}")
* @param array<string, mixed> $parameters Parameter-Werte
* @return string Fertige URL
*/
private function buildUrl(string $path, array $parameters): string
{
$url = $path;
$queryParams = [];
// Ersetze Pfad-Parameter
foreach ($parameters as $key => $value) {
$placeholder = "{{$key}}";
if (str_contains($url, $placeholder)) {
$url = str_replace($placeholder, (string)$value, $url);
unset($parameters[$key]);
} else {
// Übrige Parameter werden zu Query-Parametern
$queryParams[$key] = $value;
}
}
// Prüfe auf nicht ersetzte Platzhalter
if (preg_match('/\{[^}]+\}/', $url)) {
throw new \InvalidArgumentException(
"Missing required parameters for route. URL: {$url}"
);
}
// Füge Query-Parameter hinzu
if (!empty($queryParams)) {
$url .= '?' . http_build_query($queryParams);
}
return $url;
}
/**
* Macht eine relative URL zu einer absoluten URL
*/
private function makeAbsolute(string $url): string
{
$scheme = $this->request->server->isHttps() ? 'https' : 'http';
$host = $this->request->server->getHttpHost();
return "{$scheme}://{$host}{$url}";
}
/**
* Generiert eine URL für eine Asset-Datei
*
* @param string $asset Pfad zur Asset-Datei
* @param bool $absolute Ob eine absolute URL generiert werden soll
* @return string Asset-URL
*/
public function asset(string $asset, bool $absolute = false): string
{
$url = '/assets/' . ltrim($asset, '/');
if ($absolute) {
return $this->makeAbsolute($url);
}
return $url;
}
}