Enable Discovery debug logging for production troubleshooting

- Add DISCOVERY_LOG_LEVEL=debug
- Add DISCOVERY_SHOW_PROGRESS=true
- Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View File

@@ -1,8 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
interface ActionResult
{
}

View File

@@ -1,15 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
final readonly class CompiledPattern
{
/**
* @param string $regex Kombinierter Regex
* @param array<int, array{route: object, paramMap: array<string, int>}> $routes
* @param string $regex Primary regex (for backward compatibility)
* @param array<int, RouteData> $routes Compiled route data objects
* @param array<string, array{regex: string, routes: array<mixed>}> $batches Optimized route batches
*/
public function __construct(
public string $regex,
public array $routes
) {}
public array $routes,
public array $batches = []
) {
}
public function getRouteData(int $routeIndex): RouteData
{
return $this->routes[$routeIndex];
}
/**
* Get routes grouped by complexity for optimized matching
* @return array<string, array{regex: string, routes: array<mixed>}>
*/
public function getBatches(): array
{
return $this->batches;
}
/**
* Check if this pattern uses optimized batches
*/
public function hasOptimizedBatches(): bool
{
return ! empty($this->batches);
}
}

View File

@@ -1,29 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Core\Route;
use App\Framework\Http\Method;
final readonly class CompiledRoutes
{
/**
* @param array<string, array<string, Route>> $staticRoutes
* @param array<string, CompiledPattern|null> $dynamicPatterns
* @param array<string, array<string, array<string, Route>>> $staticRoutes - [method][subdomain][path] => Route
* @param array<string, array<string, CompiledPattern|null>> $dynamicPatterns - [method][subdomain] => CompiledPattern
*/
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
public function getStaticRoute(Method $method, string $path, string $subdomainKey = 'default'): ?Route
{
return $this->dynamicPatterns[$method] ?? null;
return $this->staticRoutes[$method->value][$subdomainKey][$path] ?? null;
}
public function getCompiledPattern(Method $method, string $subdomainKey = 'default'): ?CompiledPattern
{
return $this->dynamicPatterns[$method->value][$subdomainKey] ?? null;
}
/**
* Get static routes for a specific subdomain
*/
public function getStaticRoutesForSubdomain(Method $method, string $subdomainKey): array
{
return $this->staticRoutes[$method->value][$subdomainKey] ?? [];
}
/**
* Get all subdomain keys for a method
*/
public function getSubdomainKeys(Method $method): array
{
return array_keys($this->staticRoutes[$method->value] ?? []);
}
public function getNamedRoute(string $name): ?Route
@@ -41,27 +61,58 @@ final readonly class CompiledRoutes
return $this->namedRoutes;
}
/**
* Generator version for memory-efficient iteration over named routes
*
* @return \Generator<string, Route>
*/
public function getAllNamedRoutesGenerator(): \Generator
{
foreach ($this->namedRoutes as $name => $route) {
yield $name => $route;
}
}
public function getStaticRoutes(): array
{
return $this->staticRoutes;
}
public function generateUrl(string $name, array $params = []): ?string
{
$route = $this->getNamedRoute($name);
if(!$route) {
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));
$staticCount = 0;
$dynamicCount = 0;
$subdomainCount = 0;
foreach ($this->staticRoutes as $methodRoutes) {
foreach ($methodRoutes as $subdomainRoutes) {
$staticCount += count($subdomainRoutes);
$subdomainCount++;
}
}
foreach ($this->dynamicPatterns as $methodPatterns) {
$dynamicCount += count(array_filter($methodPatterns));
}
$namedCount = count($this->namedRoutes);
return [
'static_routes' => $staticCount,
'dynamic_patterns' => $dynamicCount,
'named_routes' => $namedCount,
'total_methods' => count($this->staticRoutes)
'subdomain_groups' => $subdomainCount,
'total_methods' => count($this->staticRoutes),
];
}
@@ -72,6 +123,7 @@ final readonly class CompiledRoutes
foreach ($params as $key => $value) {
$path = str_replace("{{$key}}", (string)$value, $path);
}
return $path;
}
}

View File

@@ -4,46 +4,48 @@ declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\DI\DefaultContainer;
use App\Framework\Http\Request;
use App\Framework\Http\RequestBody;
use App\Framework\Http\Session\Session;
use App\Framework\Reflection\ReflectionProvider;
use App\Framework\Reflection\WrappedReflectionClass;
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
) {}
private PropertyValueConverter $propertyValueConverter,
private ReflectionProvider $reflectionProvider
) {
}
/**
* Erstellt und validiert eine ControllerRequest-Instanz
*/
public function createAndValidate(ReflectionClass $requestClass, RequestBody $data): object
public function createAndValidate(WrappedReflectionClass $requestClass, RequestBody $data): object
{
// Instanz erstellen
$instance = $requestClass->newInstance();
// Native ReflectionClass für Instanziierung holen
$className = ClassName::create($requestClass->getName());
// Eigenschaften befüllen und validieren
foreach ($requestClass->getProperties() as $property) {
$class = $this->reflectionProvider->getClass($className);
// Erst die Instanz erstellen
$instance = $class->newInstance();
$properties = $requestClass->getProperties();
foreach ($properties as $property) {
$propertyName = $property->getName();
// Wert aus den Daten abrufen
$value = $data->get($propertyName, null);
try {
// Jetzt auf die tatsächliche Instanz setzen
$this->propertyValueConverter->setPropertyValue($property, $instance, $value);
} catch (\Throwable $e) {
#throw new ValidationException(
# ['Ungültiger Wert für den Typ: ' . $e->getMessage()],
# $propertyName
#);
// Fehler beim Setzen ignorieren
}
}
@@ -52,7 +54,6 @@ final readonly class ControllerRequestFactory
$validationResult = $validator->validate($instance);
if ($validationResult->hasErrors()) {
throw new ValidationException($validationResult, 'test');
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Context\ContextType;
use App\Framework\DI\Initializer;
final readonly class EmptyCompiledRoutesInitializer
{
/**
* Stellt leere CompiledRoutes für Non-Web-Contexts bereit
* MCP Tools und andere Services benötigen CompiledRoutes auch in CLI
*/
#[Initializer(ContextType::CLI_SCRIPT, ContextType::CONSOLE, ContextType::WORKER, ContextType::TEST)]
public function __invoke(): CompiledRoutes
{
return new CompiledRoutes([], [], []);
}
}

View File

@@ -1,7 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\Exception;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
use App\Framework\Http\Exception\HttpException;
use App\Framework\Http\Status;
@@ -21,7 +24,12 @@ class RouteNotFound extends FrameworkException implements HttpException
$this->route = $route;
$message = "Route not found: {$route}";
#$context
parent::__construct($message, $code, $previous);
$exceptionContext = new ExceptionContext(
operation: 'route_resolution',
component: 'router',
data: array_merge(['route' => $route], $context)
);
parent::__construct($message, $exceptionContext, $code, $previous);
}
}

View File

@@ -1,19 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\Exceptions;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
final class ParameterResolutionException extends FrameworkException
{
/**
* @param string $string
* @param int $int
* @param \Exception|ReflectionException $e
* @param string $message
* @param int $code
* @param \Throwable $previous
*/
public function __construct(string $string, int $int, \Throwable $e) {
parent::__construct($string, $int, $e);
public function __construct(string $message, int $code = 0, ?\Throwable $previous = null)
{
parent::__construct(
message: $message,
context: ExceptionContext::forOperation('parameter_resolution', 'Router')
->withData([
'error_type' => 'parameter_resolution_failed',
'previous_error' => $previous?->getMessage(),
]),
code: $code,
previous: $previous
);
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Framework\Router\Exceptions;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
final class UnknownResultException extends FrameworkException
@@ -15,9 +16,10 @@ final class UnknownResultException extends FrameworkException
) {
parent::__construct(
message: 'Unbekanntes Ergebnisobjekt: ' . $resultClass,
context: ExceptionContext::forOperation('result_processing', 'Router')
->withData(['resultClass' => $resultClass]),
code: $code,
previous: $previous,
context: ['resultClass' => $resultClass]
previous: $previous
);
}
}

View File

@@ -16,5 +16,6 @@ final readonly class GenericActionResult implements ActionResult
#public string $layout = '',
public array $slots = [],
public ?string $controllerClass = null
) {}
) {
}
}

View File

@@ -10,38 +10,80 @@ use App\Framework\Http\Method;
use App\Framework\Http\Request;
#[Singleton]
final readonly class HttpRouter
final readonly class HttpRouter implements Router
{
public function __construct(
#public RouteCollection $routes,
public CompiledRoutes $optimizedRoutes,
) {}
) {
}
/**
* Versucht, eine Route zu finden.
*
* @param string $method HTTP-Methode, zB. GET, POST
* @param string $path URI-Pfad, zB. /user/123
*/
public function match(Request $request): RouteContext
{
$method = $request->method;
$path = $request->path;
$host = $request->server->getHttpHost();
$subdomain = $this->extractSubdomain($host);
if($staticRoute = $this->optimizedRoutes->getStaticRoute($method->value, $path)) {
return new RouteContext(
match: new RouteMatchSuccess($staticRoute),
method: $method,
path: $path
);
// 1. Try subdomain-specific routes first
if ($subdomain) {
$subdomainKey = 'exact:' . $subdomain;
if ($staticRoute = $this->optimizedRoutes->getStaticRoute($method, $path, $subdomainKey)) {
return new RouteContext(
match: new RouteMatchSuccess($staticRoute),
method: $method,
path: $path
);
}
if ($dynamicMatch = $this->matchDynamicOptimized($method, $path, $subdomainKey)) {
return new RouteContext(
match: new RouteMatchSuccess($dynamicMatch),
method: $method,
path: $path
);
}
// 2. Try wildcard subdomain patterns
foreach ($this->getWildcardSubdomainKeys($method) as $wildcardKey) {
$pattern = substr($wildcardKey, strlen('wildcard:'));
if (fnmatch($pattern, $subdomain)) {
if ($staticRoute = $this->optimizedRoutes->getStaticRoute($method, $path, $wildcardKey)) {
return new RouteContext(
match: new RouteMatchSuccess($staticRoute),
method: $method,
path: $path
);
}
if ($dynamicMatch = $this->matchDynamicOptimized($method, $path, $wildcardKey)) {
return new RouteContext(
match: new RouteMatchSuccess($dynamicMatch),
method: $method,
path: $path
);
}
}
}
}
if($dynamicMatch = $this->matchDynamicOptimized($method, $path)) {
return new RouteContext(
match: new RouteMatchSuccess($dynamicMatch),
method: $method,
path: $path
);
// 3. Try default routes only if NO subdomain is present
if (! $subdomain) {
if ($staticRoute = $this->optimizedRoutes->getStaticRoute($method, $path, 'default')) {
return new RouteContext(
match: new RouteMatchSuccess($staticRoute),
method: $method,
path: $path
);
}
if ($dynamicMatch = $this->matchDynamicOptimized($method, $path, 'default')) {
return new RouteContext(
match: new RouteMatchSuccess($dynamicMatch),
method: $method,
path: $path
);
}
}
return new RouteContext(
@@ -49,90 +91,22 @@ final readonly class HttpRouter
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,
path: $path
);*/
}
private function matchStatic(Method $method, string $path): ?RouteMatch
private function matchDynamicOptimized(Method $method, string $path, string $subdomainKey = 'default'): ?DynamicRoute
{
if (isset($this->routes->getStatic($method)[$path])) {
$handler = $this->routes->getStatic($method)[$path];
return new RouteMatchSuccess($handler);
}
return null;
}
$pattern = $this->optimizedRoutes->getCompiledPattern($method, $subdomainKey);
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)) {
array_shift($matches); // remove full match
// Ü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->paramNames,
$route->controller,
$route->action,
$route->parameters,
$paramValues,
$route->name,
$route->path,
);
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)) {
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']);
$routeIndex = $this->findMatchingRouteIndex($matches, $method, $subdomainKey);
$routeData = $pattern->getRouteData($routeIndex);
$paramValues = $routeData->extractParameterValues($matches);
$originalRoute = $routeData->route;
$originalRoute = $routeData['route'];
return new DynamicRoute(
regex: $originalRoute->regex,
paramNames: $originalRoute->paramNames,
@@ -143,40 +117,69 @@ final readonly class HttpRouter
name: $originalRoute->name,
path: $originalRoute->path,
attributes: $originalRoute->attributes ?? [],
parameterCollection: $originalRoute->parameterCollection
);
}
private function findMatchingRouteIndex(array $matches, $method): int
/**
* @param array<int|string, string> $matches
*/
private function findMatchingRouteIndex(array $matches, Method $method, string $subdomainKey = 'default'): int
{
$pattern = $this->optimizedRoutes->getCompiledPattern($method->value);
$pattern = $this->optimizedRoutes->getCompiledPattern($method, $subdomainKey);
foreach($pattern->routes as $index => $routeData) {
$routeGroupIndex = $routeData['routeGroupIndex'];
if (! $pattern) {
return 0;
}
if (isset($matches[$routeGroupIndex]) && $matches[$routeGroupIndex] !== '') {
foreach ($pattern->routes as $index => $routeData) {
if ($routeData->shouldMatch($matches)) {
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
/**
* Extract subdomain from host
*/
private function extractSubdomain(string $host): string
{
$params = [];
foreach($paramMap as $paramName => $matchIndex) {
if (isset($matches[$matchIndex]) && $matches[$matchIndex] !== '') {
$params[$paramName] = $matches[$matchIndex];
}
$parts = explode('.', $host);
// Special case for development domains
if (count($parts) === 2 && $parts[1] === 'localhost') {
// test.localhost -> 'test'
return $parts[0] === 'localhost' ? '' : $parts[0];
}
return $params;
// Skip if no subdomain or common cases
if (count($parts) <= 2 || in_array($parts[0], ['www'])) {
return '';
}
// For multi-level subdomains like tenant1.app.example.com,
// extract the full subdomain part (tenant1.app)
// Assume the main domain is the last two parts
if (count($parts) > 3) {
// Extract everything except the last two parts (domain.tld)
$subdomainParts = array_slice($parts, 0, -2);
return implode('.', $subdomainParts);
}
return $parts[0];
}
/**
* Get wildcard subdomain keys for a method
* @return string[]
*/
private function getWildcardSubdomainKeys(Method $method): array
{
$allKeys = $this->optimizedRoutes->getSubdomainKeys($method);
return array_filter($allKeys, fn ($key) => str_starts_with($key, 'wildcard:'));
}
}

View File

@@ -4,12 +4,17 @@ declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\DI\DefaultContainer;
use App\Framework\Http\ControllerRequest;
use App\Framework\Http\Request;
use App\Framework\Logging\DefaultLogger;
use App\Framework\Reflection\ReflectionProvider;
use App\Framework\Router\Exceptions\ParameterResolutionException;
use ReflectionClass;
use App\Framework\Router\ValueObjects\MethodParameter;
use App\Framework\Router\ValueObjects\ParameterCollection;
use App\Framework\Router\ValueObjects\RouteParameters;
use App\Framework\Validation\Exceptions\ValidationException;
use ReflectionException;
final readonly class ParameterProcessor
@@ -17,60 +22,211 @@ final readonly class ParameterProcessor
public function __construct(
private DefaultContainer $container,
private ControllerRequestFactory $controllerRequestFactory,
private DefaultLogger $logger
) {}
private DefaultLogger $logger,
private ReflectionProvider $reflectionProvider
) {
}
/**
* Legacy-kompatible Methode - behält bestehende API
*/
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']];
// Für RouteParameters - verwende den Container
if ($param['type'] === 'App\\Framework\\Router\\ValueObjects\\RouteParameters') {
$parameters[] = $this->container->get('App\\Framework\\Router\\ValueObjects\\RouteParameters');
continue;
}
// Route-Parameter aus queryParams extrahieren
if (in_array($param['name'], array_keys($queryParams))) {
$parameters[] = $queryParams[$param['name']];
continue;
}
// Builtin-Typen mit Default-Werten
if ($param['isBuiltin'] === true) {
$parameters[] = $param['default'] ?? null;
continue;
}
$reflectionClass = new ReflectionClass($param['type']);
// ControllerRequest-Typen
if ($this->isControllerRequestType($param['type'])) {
$parameters[] = $this->createControllerRequest($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;
}
// Dependency Injection
$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()
'error' => $e->getMessage(),
]);
throw $e;
}
}
return $parameters;
}
/**
* Type-safe parameter preparation using Value Objects
*/
public function prepareParametersFromCollection(ParameterCollection $parameterCollection, array $queryParams): array
{
$parameters = [];
foreach ($parameterCollection as $parameter) {
try {
$parameters[] = $this->resolveTypedParameter($parameter, $queryParams);
} catch (\Throwable $e) {
$this->logger?->error("Parameter preparation failed", [
'parameter' => $parameter->name,
'type' => $parameter->type,
'error' => $e->getMessage(),
]);
throw $e;
}
}
return $parameters;
}
/**
* Resolve single typed parameter - type-safe version
*/
private function resolveTypedParameter(MethodParameter $parameter, array $queryParams): mixed
{
// Für RouteParameters - verwende den Container
if ($parameter->type === 'App\\Framework\\Router\\ValueObjects\\RouteParameters') {
return $this->container->get('App\\Framework\\Router\\ValueObjects\\RouteParameters');
}
// Route-Parameter aus queryParams extrahieren
if (array_key_exists($parameter->name, $queryParams)) {
return $queryParams[$parameter->name];
}
// Builtin-Typen mit Default-Werten
if ($parameter->isBuiltin) {
return $parameter->default;
}
// ControllerRequest-Typen (now type-safe!)
if ($parameter->type !== null && $this->isControllerRequestType($parameter->type)) {
return $this->createControllerRequest($parameter->type);
}
// Dependency Injection
if ($parameter->type !== null) {
return $this->container->get($parameter->type);
}
return null;
}
/**
* Löst einzelnen Parameter auf
*/
private function resolveParameter(array $param, RouteParameters $routeParams): mixed
{
// Route-Parameter haben Priorität
if ($routeParams->has($param['name'])) {
return $this->convertParameterValue(
$routeParams->get($param['name']),
$param['type'] ?? 'string'
);
}
// Builtin-Typen mit Default-Werten
if ($param['isBuiltin'] === true) {
return $param['default'] ?? null;
}
// ControllerRequest-Typen
if ($this->isControllerRequestType($param['type'])) {
return $this->createControllerRequest($param['type']);
}
// Dependency Injection
return $this->container->get($param['type']);
}
/**
* Prüft ob Parameter ein ControllerRequest ist
*/
private function isControllerRequestType(string $type): bool
{
try {
return $this->reflectionProvider->implementsInterface(
ClassName::create($type),
ControllerRequest::class
);
} catch (ReflectionException) {
return false;
}
}
/**
* Erstellt ControllerRequest-Instanz
*/
private function createControllerRequest(string $type): object
{
try {
$request = $this->container->get(Request::class);
$parsedBody = $request->parsedBody;
$reflectionClass = $this->reflectionProvider->getClass(ClassName::create($type));
$controllerRequest = $this->controllerRequestFactory->createAndValidate(
$reflectionClass,
$parsedBody
);
$this->container->bind($type, $controllerRequest);
return $controllerRequest;
} catch (\Throwable $e) {
// ValidationExceptions direkt durchreichen, da sie speziell behandelt werden müssen
if ($e instanceof ValidationException) {
throw $e;
}
throw new ParameterResolutionException(
"Cannot create ControllerRequest of type '{$type}': " . $e->getMessage(),
0,
$e
);
}
}
/**
* Konvertiert Parameter-Wert zum gewünschten Typ
*/
private function convertParameterValue(mixed $value, string $targetType): mixed
{
if ($value === null) {
return null;
}
return match($targetType) {
'int', 'integer' => (int) $value,
'float', 'double' => (float) $value,
'bool', 'boolean' => filter_var($value, FILTER_VALIDATE_BOOLEAN),
'string' => (string) $value,
'array' => is_array($value) ? $value : [$value],
default => $value
};
}
}

View File

@@ -14,13 +14,16 @@ final readonly class PropertyValueConverter
*/
public function setPropertyValue(ReflectionProperty $property, object $instance, mixed $value): void
{
if ($property->hasDefaultValue()) {
// Nur Default-Wert setzen wenn kein anderer Wert bereitgestellt wurde
if ($value === null && $property->hasDefaultValue()) {
$property->setValue($instance, $property->getDefaultValue());
return;
}
if ($value === null) {
$property->setValue($instance, null);
return;
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\Result;
use App\Framework\Router\ActionResult;
@@ -11,5 +13,6 @@ final readonly class ContentNegotiationResult implements ActionResult
public ?string $redirectTo = null,
public ?string $viewTemplate = null,
public array $viewData = [],
) {}
) {
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\Result;
use App\Framework\Router\ActionResult;
@@ -10,5 +12,6 @@ final readonly class FileResult implements ActionResult
public string $filePath,
public string $variant = 'original',
public string $mimeType = 'application/image',
) {}
) {
}
}

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\Result;
@@ -19,7 +20,8 @@ final readonly class SseEvent
public ?string $event = null,
public ?string $id = null,
public ?int $retry = null
) {}
) {
}
/**
* Formatiert das Event in das SSE-Protokollformat

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\Result;
@@ -23,12 +24,11 @@ final class SseResult implements ActionResult
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
@@ -40,6 +40,7 @@ final class SseResult implements ActionResult
?int $retry = null
): self {
$this->initialEvents[] = new SseEvent($data, $event, $id, $retry);
return $this;
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\Result;
@@ -30,7 +31,8 @@ final class SseResultWithCallback implements ActionResult
public function __construct(
public readonly Status $status = Status::OK,
public readonly array $headers = []
) {}
) {
}
/**
* Setzt die Callback-Funktion, die während des Streamings aufgerufen wird
@@ -41,6 +43,7 @@ final class SseResultWithCallback implements ActionResult
public function setCallback(callable $callback): self
{
$this->callback = $callback;
return $this;
}
@@ -52,6 +55,7 @@ final class SseResultWithCallback implements ActionResult
public function setMaxDuration(int $seconds): self
{
$this->maxDuration = $seconds;
return $this;
}
@@ -63,6 +67,7 @@ final class SseResultWithCallback implements ActionResult
public function setHeartbeatInterval(int $seconds): self
{
$this->heartbeatInterval = $seconds;
return $this;
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\Result;
@@ -18,7 +19,8 @@ final readonly class ViewResult implements ActionResult
public array $slots = [],
public ?string $controllerClass = null,
public ?object $model = null,
) {}
) {
}
/*public static function fromStrings():self
{

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\Result;
@@ -46,7 +47,8 @@ final class WebSocketResult implements ActionResult
public function __construct(
public readonly Status $status = Status::SWITCHING_PROTOCOLS,
public readonly array $headers = []
) {}
) {
}
/**
* Setzt den Handler für neue Verbindungen
@@ -56,6 +58,7 @@ final class WebSocketResult implements ActionResult
public function onConnect(callable $handler): self
{
$this->onConnect = $handler;
return $this;
}
@@ -67,6 +70,7 @@ final class WebSocketResult implements ActionResult
public function onMessage(callable $handler): self
{
$this->onMessage = $handler;
return $this;
}
@@ -78,6 +82,7 @@ final class WebSocketResult implements ActionResult
public function onClose(callable $handler): self
{
$this->onClose = $handler;
return $this;
}
@@ -89,6 +94,7 @@ final class WebSocketResult implements ActionResult
public function onError(callable $handler): self
{
$this->onError = $handler;
return $this;
}
@@ -98,6 +104,7 @@ final class WebSocketResult implements ActionResult
public function withSubprotocols(array $subprotocols): self
{
$this->subprotocols = $subprotocols;
return $this;
}
@@ -107,6 +114,7 @@ final class WebSocketResult implements ActionResult
public function withMaxMessageSize(int $bytes): self
{
$this->maxMessageSize = $bytes;
return $this;
}
@@ -116,15 +124,43 @@ final class WebSocketResult implements ActionResult
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; }
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

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Core\Route;
@@ -16,21 +18,26 @@ final readonly class RouteCollection implements Countable
public function __construct(
private array $routes,
private array $namedRoutes = []
) {}
) {
}
public function getStatic(Method $method): array {
public function getStatic(Method $method): array
{
return $this->routes[$method->value]['static'] ?? [];
}
public function getDynamic(Method $method): array {
public function getDynamic(Method $method): array
{
return $this->routes[$method->value]['dynamic'] ?? [];
}
public function has(Method $method): bool {
public function has(Method $method): bool
{
return isset($this->routes[$method->value]);
}
public function getByMethod(Method $method): array {
public function getByMethod(Method $method): array
{
return ($this->routes[$method->value]['dynamic'] + $this->routes[$method->value]['static']) ?? [];
}
@@ -52,8 +59,8 @@ final readonly class RouteCollection implements Countable
}
}
public function hasName(string $name): bool {
public function hasName(string $name): bool
{
return isset($this->namedRoutes[$name]);
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Http\Method;
@@ -19,7 +21,8 @@ final class RouteContext
public readonly RouteMatch $match,
public readonly Method $method,
public readonly string $path
) {}
) {
}
public function isSuccess(): bool
{

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Core\DynamicRoute;
/**
* Value Object representing compiled route data for optimized routing
*/
final readonly class RouteData
{
/**
* @param DynamicRoute $route The original dynamic route
* @param array<string, int> $paramMap Parameter name to regex group index mapping
* @param int $routeGroupIndex Index of the route's capture group in combined regex
* @param string $batch Batch name for complexity grouping ('simple', 'medium', 'complex')
* @param string $regex Pre-compiled individual regex for this route
*/
public function __construct(
public DynamicRoute $route,
public array $paramMap,
public int $routeGroupIndex,
public string $batch,
public string $regex
) {
}
/**
* Create RouteData from array (for backward compatibility)
* @param array{route: DynamicRoute, paramMap: array<string, int>, routeGroupIndex: int, batch: string, regex: string} $data
*/
public static function fromArray(array $data): self
{
return new self(
route: $data['route'],
paramMap: $data['paramMap'],
routeGroupIndex: $data['routeGroupIndex'],
batch: $data['batch'],
regex: $data['regex']
);
}
/**
* Convert to array (for serialization/caching)
* @return array{route: DynamicRoute, paramMap: array<string, int>, routeGroupIndex: int, batch: string, regex: string}
*/
public function toArray(): array
{
return [
'route' => $this->route,
'paramMap' => $this->paramMap,
'routeGroupIndex' => $this->routeGroupIndex,
'batch' => $this->batch,
'regex' => $this->regex,
];
}
/**
* Extract parameter values from regex matches
* @param array<int|string, string> $matches
* @return array<string, string>
*/
public function extractParameterValues(array $matches): array
{
$params = [];
foreach ($this->paramMap as $paramName => $matchIndex) {
if (isset($matches[$matchIndex]) && $matches[$matchIndex] !== '') {
$params[$paramName] = $matches[$matchIndex];
}
}
return $params;
}
/**
* Check if this route should match based on the regex matches
* @param array<int|string, string> $matches
*/
public function shouldMatch(array $matches): bool
{
return isset($matches[$this->routeGroupIndex]) && $matches[$this->routeGroupIndex] !== '';
}
/**
* Get the route's complexity level
*/
public function getComplexity(): string
{
return $this->batch;
}
/**
* Get the number of parameters this route expects
*/
public function getParameterCount(): int
{
return count($this->paramMap);
}
/**
* Get parameter names
* @return array<string>
*/
public function getParameterNames(): array
{
return array_keys($this->paramMap);
}
/**
* Check if this route has a specific parameter
*/
public function hasParameter(string $paramName): bool
{
return isset($this->paramMap[$paramName]);
}
}

View File

@@ -8,23 +8,27 @@ use App\Framework\Core\DynamicRoute;
use App\Framework\DI\DefaultContainer;
use App\Framework\Http\Response;
use App\Framework\Http\Responses\NotFound;
use App\Framework\Router\Result\ContentNegotiationResult;
use App\Framework\Performance\Contracts\PerformanceServiceInterface;
use App\Framework\Performance\PerformanceCategory;
final readonly class RouteDispatcher
{
public function __construct(
private DefaultContainer $container,
private ParameterProcessor $parameterProcessor
) {}
private ParameterProcessor $parameterProcessor,
private PerformanceServiceInterface $performanceService
) {
}
/**
* Verarbeitet eine Route und führt den entsprechenden Controller-Action aus
* Verarbeitet eine Route und führt die entsprechende Controller-Action aus
*/
public function dispatch(RouteContext $routeContext): ActionResult|Response
{
$routeMatch = $routeContext->match;
if ($routeMatch->isMatch()) {
/** @var RouteMatchSuccess $routeMatch */
return $this->executeController($routeMatch);
}
@@ -33,23 +37,50 @@ final readonly class RouteDispatcher
/**
* Führt die Controller-Action mit den aufbereiteten Parametern aus
* @param RouteMatchSuccess $routeMatch
* @return ActionResult|Response
*/
private function executeController(RouteMatch $routeMatch): ActionResult|Response
{
$controller = $routeMatch->route->controller;
$action = $routeMatch->route->action;
$params = $routeMatch->route->parameters;
$queryParams = [];
if($routeMatch->route instanceof DynamicRoute) {
if ($routeMatch->route instanceof DynamicRoute) {
$queryParams = $routeMatch->route->paramValues;
}
$preparedParams = $this->parameterProcessor->prepareParameters($params, $queryParams);
// Use type-safe parameter collection if available, fallback to legacy array
$parameterCollection = $routeMatch->route->getParameterCollection();
$preparedParams = $this->performanceService->measure(
'parameter_preparation',
fn () => $this->parameterProcessor->prepareParametersFromCollection($parameterCollection, $queryParams),
PerformanceCategory::CONTROLLER,
[
'controller' => $controller,
'action' => $action,
'param_count' => $parameterCollection->count(),
]
);
$controllerInstance = $this->container->get($controller);
$result = $controllerInstance->$action(...$preparedParams);
// Measure controller instantiation
$controllerInstance = $this->performanceService->measure(
'controller_instantiation',
fn () => $this->container->get($controller), /** @phpstan-ignore argument.type */
PerformanceCategory::CONTROLLER,
['controller' => $controller]
);
// Measure actual controller method execution
$result = $this->performanceService->measure(
'controller_execution',
fn () => $controllerInstance->$action(...$preparedParams),
PerformanceCategory::CONTROLLER,
[
'controller' => $controller,
'action' => $action,
]
);
if ($result instanceof Response || $result instanceof ActionResult) {
return $result;

View File

@@ -4,9 +4,7 @@ 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
{

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\Http\Request;
use App\Framework\Router\ValueObjects\RouteParameters;
final readonly class RouteParametersInitializer
{
#[Initializer]
public function __invoke(Container $container): RouteParameters
{
$request = $container->get(Request::class);
// Kombiniere alle Parameter-Quellen in Prioritäts-Reihenfolge:
// 1. Query-Parameter
// 2. POST/Request-Body Parameter
$allParams = array_merge(
$request->parsedBody->all(), // POST data (niedrigste Priorität)
$request->queryParams // Query parameters (höchste Priorität)
);
return RouteParameters::fromArray($allParams);
}
}

View File

@@ -8,9 +8,13 @@ use App\Framework\Core\PathProvider;
use App\Framework\DI\DefaultContainer;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Request;
use App\Framework\Http\Response;
use App\Framework\Http\Responses\JsonResponse;
use App\Framework\Http\Responses\RedirectResponse;
use App\Framework\Http\Responses\SseResponse;
use App\Framework\Http\Responses\WebSocketResponse;
use App\Framework\Http\Uri;
use App\Framework\Router\Result\FileResult;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\Redirect;
@@ -20,7 +24,6 @@ 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;
final readonly class RouteResponder
{
@@ -28,36 +31,42 @@ final readonly class RouteResponder
private PathProvider $pathProvider,
private DefaultContainer $container,
private TemplateRenderer $templateRenderer,
) {}
private Request $request,
) {
}
public function getContext(ViewResult $result): RenderContext
{
return 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,
isPartial: $this->isSpaRequest(),
);
}
public function respond(Response|ActionResult $result): Response
{
if($result instanceof Response) {
if ($result instanceof Response) {
return $result;
}
if ($this->isSpaRequest() && $result instanceof ViewResult) {
return $this->createSpaResponse($result);
}
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
)
)
body: $this->renderTemplate($this->getContext($result))
),
$result instanceof JsonResult => new HttpResponse(
$result instanceof JsonResult => new JsonResponse(
body : $result->data,
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 Redirect => new RedirectResponse(new Uri($result->target)),
$result instanceof SseResult => new SseResponse($result, $result->callback),
$result instanceof WebSocketResult => $this->createWebSocketResponse($result),
$result instanceof FileResult => new HttpResponse(
@@ -82,9 +91,40 @@ final readonly class RouteResponder
return $this->templateRenderer->render($context);
}
private function resolveTemplate(?object $model):string
private function isSpaRequest(): bool
{
if($model === null) {
$xmlHttpRequest = $this->request->headers->getFirst('X-Requested-With');
$hasSpaRequest = $this->request->headers->has('X-SPA-Request');
// Fallback: Direkt aus $_SERVER lesen (für Nginx FastCGI)
if (empty($xmlHttpRequest)) {
$xmlHttpRequest = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? '';
}
if (! $hasSpaRequest) {
$hasSpaRequest = ! empty($_SERVER['HTTP_X_SPA_REQUEST']);
}
return $xmlHttpRequest === 'XMLHttpRequest' && $hasSpaRequest;
}
private function createSpaResponse(ViewResult $result): JsonResponse
{
$context = $this->getContext($result);
return new JsonResponse([
'html' => $this->templateRenderer->renderPartial($context),
'title' => $result->metaData->title ?? '',
'meta' => [
'description' => $result->metaData->description ?? '',
// Add other meta data as needed
],
]);
}
private function resolveTemplate(?object $model): string
{
if ($model === null) {
return 'test';
}
@@ -97,6 +137,7 @@ final readonly class RouteResponder
/** @var Template $attr */
$attr = $attrs[0]->newInstance();
return $attr->path;
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Http\Request;
interface Router
{
/**
* Match a request to a route
*
* @param Request $request
* @return RouteContext
*/
public function match(Request $request): RouteContext;
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router;
final class RouterPerformanceMonitor
{
/** @var array<string, array{count: int, total_time: float, avg_time: float}> */
private array $routeStats = [];
public function trackRouteMatch(string $route, float $duration): void
{
if (! isset($this->routeStats[$route])) {
$this->routeStats[$route] = [
'count' => 0,
'total_time' => 0.0,
'avg_time' => 0.0,
];
}
$this->routeStats[$route]['count']++;
$this->routeStats[$route]['total_time'] += $duration;
$this->routeStats[$route]['avg_time'] =
$this->routeStats[$route]['total_time'] / $this->routeStats[$route]['count'];
}
/**
* @param int $thresholdMs
* @return array<string, array{count: int, total_time: float, avg_time: float}>
*/
public function getSlowRoutes(int $thresholdMs = 10): array
{
return array_filter(
$this->routeStats,
fn (array $stats) => $stats['avg_time'] > $thresholdMs
);
}
/**
* @return array<string, array{count: int, total_time: float, avg_time: float}>
*/
public function getAllStats(): array
{
return $this->routeStats;
}
public function reset(): void
{
$this->routeStats = [];
}
}

View File

@@ -5,12 +5,13 @@ declare(strict_types=1);
namespace App\Framework\Router;
use App\Framework\Attributes\Route;
use App\Framework\Context\ContextType;
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;
use App\Framework\Discovery\Results\DiscoveryRegistry;
/**
* Verantwortlich für die Einrichtung des Routers
@@ -19,24 +20,35 @@ final readonly class RouterSetup
{
public function __construct(
private DefaultContainer $container,
private PathProvider $pathProvider,
private RouteCompiler $routeCompiler,
private DiscoveryResults $results,
) {}
private DiscoveryRegistry $results,
) {
}
/**
* Richtet den Router basierend auf den verarbeiteten Attributen ein
* Nur für Web-Context - CLI braucht keine HTTP-Routes
*/
#[Initializer]
public function __invoke(): HttpRouter
#[Initializer(ContextType::WEB)]
public function __invoke(): Router
{
#$routeCache = new RouteCache($this->pathProvider->getCachePath('routes.cache.php'));
if ($this->results->has(Route::class)) {
$discoveryRoutes = $this->results->get(Route::class);
error_log("DEBUG RouterSetup: Starting route setup");
// DIREKT zu optimierten Routen
$optimizedRoutes = $this->routeCompiler->compileOptimized($discoveryRoutes);
// Debug: Check what keys are available in DiscoveryRegistry
error_log("DEBUG RouterSetup: Checking for Route::class = " . Route::class);
$routeCount = $this->results->attributes->getCount(Route::class);
error_log("DEBUG RouterSetup: Found $routeCount routes in DiscoveryRegistry");
if ($routeCount > 0) {
$discoveredRoutes = $this->results->attributes->get(Route::class);
error_log("DEBUG RouterSetup: Found " . count($discoveredRoutes) . " routes in discovery results");
// Direct DiscoveredAttribute API
$optimizedRoutes = $this->routeCompiler->compileOptimized(...$discoveredRoutes);
#error_log("DEBUG RouterSetup: Routes compiled successfully - CompiledRoutes object created");
// Cache speichern
#$routeCache->save($optimizedRoutes);
@@ -44,13 +56,17 @@ final readonly class RouterSetup
// FULL COMMIT: Nur CompiledRoutes
} else {
$optimizedRoutes = $this->routeCompiler->compileOptimized([]);
error_log("DEBUG RouterSetup: No routes found in discovery registry");
$optimizedRoutes = $this->routeCompiler->compileOptimized();
}
$router = new HttpRouter($optimizedRoutes);
$this->container->bind(CompiledRoutes::class, $optimizedRoutes);
$this->container->bind(HttpRouter::class, $router);
$this->container->bind(Router::class, $router); // Interface binding
error_log("DEBUG RouterSetup: Router setup completed successfully");
return $router;
}

View File

@@ -15,14 +15,10 @@ use App\Framework\Router\Exception\RouteNotFound;
#[Singleton]
final readonly class UrlGenerator
{
#private RouteCollection $routes;
public function __construct(
HttpRouter $router,
private Request $request,
private CompiledRoutes $routes,
) {
#$this->routes = $router->routes;
}
/**
@@ -74,12 +70,12 @@ final readonly class UrlGenerator
{
$url = $this->request->path;
if (!empty($parameters)) {
if (! empty($parameters)) {
$url .= '?' . http_build_query(array_merge(
$this->request->query->all(),
$parameters
));
} elseif (!empty($this->request->query->all())) {
} elseif (! empty($this->request->query->all())) {
$url .= '?' . http_build_query($this->request->query->all());
}
@@ -96,7 +92,8 @@ final readonly class UrlGenerator
public function isCurrentRoute(string $name): bool
{
try {
$route = $this->routes->getByName($name);
$route = $this->routes->getNamedRoute($name);
return $route !== null && $route->path === $this->request->path;
} catch (RouteNotFound) {
return false;
@@ -128,14 +125,14 @@ final readonly class UrlGenerator
}
// Prüfe auf nicht ersetzte Platzhalter
if (preg_match('/\{[^}]+\}/', $url)) {
if (preg_match('/\{[^}]+}/', $url)) {
throw new \InvalidArgumentException(
"Missing required parameters for route. URL: {$url}"
);
}
// Füge Query-Parameter hinzu
if (!empty($queryParams)) {
if (! empty($queryParams)) {
$url .= '?' . http_build_query($queryParams);
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\ValueObjects;
use App\Framework\Reflection\WrappedReflectionParameter;
/**
* Type-safe representation of a method parameter
*/
final readonly class MethodParameter
{
public function __construct(
public string $name,
public ?string $type,
public bool $isBuiltin,
public bool $hasDefault,
public mixed $default = null
) {
}
/**
* Create from Framework Reflection Parameter
*/
public static function fromWrappedParameter(WrappedReflectionParameter $parameter): self
{
return new self(
name: $parameter->getName(),
type: $parameter->getTypeName(), // Use getTypeName() for string type instead of getType()
isBuiltin: $parameter->isBuiltin(),
hasDefault: $parameter->hasDefaultValue(),
default: $parameter->hasDefaultValue() ? $parameter->getDefaultValue() : null
);
}
/**
* Check if this parameter is required (no default value)
*/
public function isRequired(): bool
{
return ! $this->hasDefault;
}
/**
* Check if this is a specific type
*/
public function isOfType(string $expectedType): bool
{
return $this->type === $expectedType;
}
/**
* Convert to legacy array format for backward compatibility
* @deprecated Use object properties instead
*/
public function toArray(): array
{
return [
'name' => $this->name,
'type' => $this->type,
'isBuiltin' => $this->isBuiltin,
'hasDefault' => $this->hasDefault,
'default' => $this->default,
'required' => $this->isRequired(),
];
}
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\ValueObjects;
use ArrayIterator;
use Countable;
use IteratorAggregate;
/**
* Type-safe collection of method parameters
*/
final readonly class ParameterCollection implements IteratorAggregate, Countable
{
/** @var array<MethodParameter> */
private array $parameters;
public function __construct(MethodParameter ...$parameters)
{
$this->parameters = $parameters;
}
/**
* @return ArrayIterator<int, MethodParameter>
*/
public function getIterator(): ArrayIterator
{
return new ArrayIterator($this->parameters);
}
public function count(): int
{
return count($this->parameters);
}
public function isEmpty(): bool
{
return empty($this->parameters);
}
/**
* Get parameter by name
*/
public function getByName(string $name): ?MethodParameter
{
foreach ($this->parameters as $parameter) {
if ($parameter->name === $name) {
return $parameter;
}
}
return null;
}
/**
* Get all required parameters
*/
public function getRequired(): self
{
$required = array_filter(
$this->parameters,
fn (MethodParameter $param) => $param->isRequired()
);
return new self(...$required);
}
/**
* Get all builtin type parameters
*/
public function getBuiltins(): self
{
$builtins = array_filter(
$this->parameters,
fn (MethodParameter $param) => $param->isBuiltin
);
return new self(...$builtins);
}
/**
* Filter by type
*/
public function filterByType(string $type): self
{
$filtered = array_filter(
$this->parameters,
fn (MethodParameter $param) => $param->isOfType($type)
);
return new self(...$filtered);
}
/**
* Convert to legacy array format for backward compatibility
* @deprecated Use object methods instead
* @return array<array<string, mixed>>
*/
public function toLegacyArray(): array
{
return array_map(
fn (MethodParameter $param) => $param->toArray(),
$this->parameters
);
}
/**
* @return array<MethodParameter>
*/
public function toArray(): array
{
return $this->parameters;
}
}

View File

@@ -0,0 +1,340 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\ValueObjects;
/**
* Value Object für Parameter-Constraints und Validation Rules
*/
final readonly class ParameterConstraints
{
/**
* @param array<string, array{
* type?: string,
* required?: bool,
* min?: int|float,
* max?: int|float,
* minLength?: int,
* maxLength?: int,
* pattern?: string,
* enum?: array<mixed>,
* default?: mixed
* }> $constraints
*/
public function __construct(
private array $constraints = []
) {
}
/**
* Factory method für häufige Constraint-Typen
*/
public static function create(): self
{
return new self();
}
/**
* String-Parameter mit optionaler Längen-Beschränkung
*/
public function string(string $name, bool $required = true, ?int $minLength = null, ?int $maxLength = null, ?string $pattern = null): self
{
$constraint = [
'type' => 'string',
'required' => $required,
];
if ($minLength !== null) {
$constraint['minLength'] = $minLength;
}
if ($maxLength !== null) {
$constraint['maxLength'] = $maxLength;
}
if ($pattern !== null) {
$constraint['pattern'] = $pattern;
}
return $this->with($name, $constraint);
}
/**
* Integer-Parameter mit optionaler Range-Beschränkung
*/
public function int(string $name, bool $required = true, ?int $min = null, ?int $max = null): self
{
$constraint = [
'type' => 'int',
'required' => $required,
];
if ($min !== null) {
$constraint['min'] = $min;
}
if ($max !== null) {
$constraint['max'] = $max;
}
return $this->with($name, $constraint);
}
/**
* Float-Parameter mit optionaler Range-Beschränkung
*/
public function float(string $name, bool $required = true, ?float $min = null, ?float $max = null): self
{
$constraint = [
'type' => 'float',
'required' => $required,
];
if ($min !== null) {
$constraint['min'] = $min;
}
if ($max !== null) {
$constraint['max'] = $max;
}
return $this->with($name, $constraint);
}
/**
* Boolean-Parameter
*/
public function bool(string $name, bool $required = true): self
{
return $this->with($name, [
'type' => 'bool',
'required' => $required,
]);
}
/**
* Array-Parameter
*/
public function array(string $name, bool $required = true): self
{
return $this->with($name, [
'type' => 'array',
'required' => $required,
]);
}
/**
* Enum-Parameter (erlaubte Werte)
*/
public function enum(string $name, array $allowedValues, bool $required = true): self
{
return $this->with($name, [
'enum' => $allowedValues,
'required' => $required,
]);
}
/**
* ID-Parameter (positive Integer)
*/
public function id(string $name, bool $required = true): self
{
return $this->int($name, $required, 1);
}
/**
* Slug-Parameter (alphanumeric + hyphens)
*/
public function slug(string $name, bool $required = true): self
{
return $this->string($name, $required, 1, 255, '/^[a-z0-9\-]+$/');
}
/**
* Email-Parameter
*/
public function email(string $name, bool $required = true): self
{
return $this->string($name, $required, 3, 254, '/^[^\s@]+@[^\s@]+\.[^\s@]+$/');
}
/**
* UUID-Parameter
*/
public function uuid(string $name, bool $required = true): self
{
return $this->string($name, $required, 36, 36, '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i');
}
/**
* URL-Parameter
*/
public function url(string $name, bool $required = true): self
{
return $this->string($name, $required, 3, 2048, '/^https?:\/\/.+/');
}
/**
* Datum-Parameter (YYYY-MM-DD)
*/
public function date(string $name, bool $required = true): self
{
return $this->string($name, $required, 10, 10, '/^\d{4}-\d{2}-\d{2}$/');
}
/**
* DateTime-Parameter (ISO 8601)
*/
public function dateTime(string $name, bool $required = true): self
{
return $this->string($name, $required, 19, 25, '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/');
}
/**
* Page-Parameter für Pagination
*/
public function page(string $name = 'page', int $maxPage = 9999): self
{
return $this->int($name, false, 1, $maxPage);
}
/**
* Limit-Parameter für Pagination
*/
public function limit(string $name = 'limit', int $defaultLimit = 20, int $maxLimit = 100): self
{
return $this->int($name, false, 1, $maxLimit)->withDefault($name, $defaultLimit);
}
/**
* Sort-Parameter für Sortierung
*/
public function sort(string $name = 'sort', array $allowedFields = []): self
{
if (! empty($allowedFields)) {
return $this->enum($name, $allowedFields, false);
}
return $this->string($name, false, 1, 50);
}
/**
* Search-Parameter
*/
public function search(string $name = 'q', int $minLength = 2, int $maxLength = 255): self
{
return $this->string($name, false, $minLength, $maxLength);
}
/**
* Custom Constraint hinzufügen
*/
public function custom(string $name, array $constraint): self
{
return $this->with($name, $constraint);
}
/**
* Default-Wert setzen
*/
public function withDefault(string $name, mixed $defaultValue): self
{
if (! isset($this->constraints[$name])) {
throw new \InvalidArgumentException("Parameter '{$name}' not found in constraints");
}
$constraints = $this->constraints;
$constraints[$name]['default'] = $defaultValue;
return new self($constraints);
}
/**
* Neue Instanz mit zusätzlichem Constraint
*/
private function with(string $name, array $constraint): self
{
$constraints = $this->constraints;
$constraints[$name] = $constraint;
return new self($constraints);
}
/**
* Alle Constraints als Array
*/
public function toArray(): array
{
return $this->constraints;
}
/**
* Prüft ob Constraint für Parameter existiert
*/
public function has(string $name): bool
{
return isset($this->constraints[$name]);
}
/**
* Holt Constraint für Parameter
*/
public function get(string $name): ?array
{
return $this->constraints[$name] ?? null;
}
/**
* Anzahl der Constraints
*/
public function count(): int
{
return count($this->constraints);
}
/**
* Prüft ob leer
*/
public function isEmpty(): bool
{
return empty($this->constraints);
}
/**
* Parameter-Namen mit Constraints
*/
public function getParameterNames(): array
{
return array_keys($this->constraints);
}
/**
* Required Parameter-Namen
*/
public function getRequiredParameters(): array
{
return array_keys(array_filter($this->constraints, fn ($constraint) => $constraint['required'] ?? false));
}
/**
* Optional Parameter-Namen
*/
public function getOptionalParameters(): array
{
return array_keys(array_filter($this->constraints, fn ($constraint) => ! ($constraint['required'] ?? false)));
}
/**
* Debug-Informationen
*/
public function getDebugInfo(): array
{
return [
'total_constraints' => $this->count(),
'required_parameters' => $this->getRequiredParameters(),
'optional_parameters' => $this->getOptionalParameters(),
'parameter_types' => array_map(fn ($constraint) => $constraint['type'] ?? 'mixed', $this->constraints),
];
}
}

View File

@@ -0,0 +1,363 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\ValueObjects;
use App\Framework\Router\Exceptions\ParameterResolutionException;
/**
* Value Object für Route-Parameter mit Type Safety und Validation
*/
final readonly class RouteParameters
{
private array $parameters;
/**
* @param array<string, mixed> $parameters
*/
public function __construct(array $parameters = [])
{
$this->parameters = $this->validateAndNormalize($parameters);
}
/**
* Factory method aus Array
*/
public static function fromArray(array $parameters): self
{
return new self($parameters);
}
/**
* Factory method für leere Parameter
*/
public static function empty(): self
{
return new self([]);
}
/**
* Prüft ob Parameter existiert
*/
public function has(string $name): bool
{
return array_key_exists($name, $this->parameters);
}
/**
* Holt Parameter-Wert mit Type Safety
*/
public function get(string $name, mixed $default = null): mixed
{
return $this->parameters[$name] ?? $default;
}
/**
* Holt String-Parameter mit Validation
*/
public function getString(string $name, ?string $default = null): string
{
$value = $this->get($name, $default);
if ($value === null) {
throw new ParameterResolutionException("Parameter '{$name}' is required but not provided");
}
return (string) $value;
}
/**
* Holt Integer-Parameter mit Validation
*/
public function getInt(string $name, ?int $default = null): int
{
$value = $this->get($name, $default);
if ($value === null) {
throw new ParameterResolutionException("Parameter '{$name}' is required but not provided");
}
if (! is_numeric($value)) {
throw new ParameterResolutionException("Parameter '{$name}' must be numeric, got: " . gettype($value));
}
return (int) $value;
}
/**
* Holt Boolean-Parameter mit Validation
*/
public function getBool(string $name, ?bool $default = null): bool
{
$value = $this->get($name, $default);
if ($value === null) {
throw new ParameterResolutionException("Parameter '{$name}' is required but not provided");
}
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
}
/**
* Holt Float-Parameter mit Validation
*/
public function getFloat(string $name, ?float $default = null): float
{
$value = $this->get($name, $default);
if ($value === null) {
throw new ParameterResolutionException("Parameter '{$name}' is required but not provided");
}
if (! is_numeric($value)) {
throw new ParameterResolutionException("Parameter '{$name}' must be numeric, got: " . gettype($value));
}
return (float) $value;
}
/**
* Holt Array-Parameter mit Validation
*/
public function getArray(string $name, ?array $default = null): array
{
$value = $this->get($name, $default);
if ($value === null) {
throw new ParameterResolutionException("Parameter '{$name}' is required but not provided");
}
if (! is_array($value)) {
throw new ParameterResolutionException("Parameter '{$name}' must be array, got: " . gettype($value));
}
return $value;
}
/**
* Alle Parameter als Array
*/
public function all(): array
{
return $this->parameters;
}
/**
* Anzahl der Parameter
*/
public function count(): int
{
return count($this->parameters);
}
/**
* Prüft ob Parameter leer sind
*/
public function isEmpty(): bool
{
return empty($this->parameters);
}
/**
* Parameter-Namen
*/
public function keys(): array
{
return array_keys($this->parameters);
}
/**
* Parameter-Werte
*/
public function values(): array
{
return array_values($this->parameters);
}
/**
* Erstellt neue Instanz mit zusätzlichen Parametern
*/
public function with(string $name, mixed $value): self
{
$parameters = $this->parameters;
$parameters[$name] = $value;
return new self($parameters);
}
/**
* Erstellt neue Instanz ohne bestimmten Parameter
*/
public function without(string $name): self
{
$parameters = $this->parameters;
unset($parameters[$name]);
return new self($parameters);
}
/**
* Erstellt neue Instanz mit mehreren Parametern
*/
public function merge(array $parameters): self
{
return new self(array_merge($this->parameters, $parameters));
}
/**
* Filtert Parameter nach Callback
*/
public function filter(callable $callback): self
{
return new self(array_filter($this->parameters, $callback, ARRAY_FILTER_USE_BOTH));
}
/**
* Nur bestimmte Parameter
*/
public function only(array $keys): self
{
return new self(array_intersect_key($this->parameters, array_flip($keys)));
}
/**
* Alle Parameter außer bestimmten
*/
public function except(array $keys): self
{
return new self(array_diff_key($this->parameters, array_flip($keys)));
}
/**
* Parameter mit Constraints validieren
*/
public function validate(ParameterConstraints $constraints): self
{
foreach ($constraints->toArray() as $name => $constraint) {
if (! $this->has($name)) {
if ($constraint['required'] ?? false) {
throw new ParameterResolutionException("Required parameter '{$name}' is missing");
}
continue;
}
$value = $this->get($name);
// Type validation
if (isset($constraint['type'])) {
$this->validateType($name, $value, $constraint['type']);
}
// Range validation for numbers
if (isset($constraint['min']) && is_numeric($value) && $value < $constraint['min']) {
throw new ParameterResolutionException("Parameter '{$name}' must be >= {$constraint['min']}, got: {$value}");
}
if (isset($constraint['max']) && is_numeric($value) && $value > $constraint['max']) {
throw new ParameterResolutionException("Parameter '{$name}' must be <= {$constraint['max']}, got: {$value}");
}
// Length validation for strings
if (isset($constraint['minLength']) && is_string($value) && strlen($value) < $constraint['minLength']) {
throw new ParameterResolutionException("Parameter '{$name}' must be at least {$constraint['minLength']} characters long");
}
if (isset($constraint['maxLength']) && is_string($value) && strlen($value) > $constraint['maxLength']) {
throw new ParameterResolutionException("Parameter '{$name}' must be at most {$constraint['maxLength']} characters long");
}
// Pattern validation
if (isset($constraint['pattern']) && is_string($value) && ! preg_match($constraint['pattern'], $value)) {
throw new ParameterResolutionException("Parameter '{$name}' does not match required pattern");
}
// Enum validation
if (isset($constraint['enum']) && ! in_array($value, $constraint['enum'], true)) {
throw new ParameterResolutionException("Parameter '{$name}' must be one of: " . implode(', ', $constraint['enum']));
}
}
return $this;
}
/**
* Debug-Informationen
*/
public function getDebugInfo(): array
{
return [
'count' => $this->count(),
'keys' => $this->keys(),
'types' => array_map('gettype', $this->parameters),
'isEmpty' => $this->isEmpty(),
];
}
/**
* Validiert und normalisiert Parameter
*/
private function validateAndNormalize(array $parameters): array
{
$normalized = [];
foreach ($parameters as $name => $value) {
if (! is_string($name)) {
throw new ParameterResolutionException("Parameter names must be strings, got: " . gettype($name));
}
if (trim($name) === '') {
throw new ParameterResolutionException("Parameter names cannot be empty");
}
$normalized[trim($name)] = $value;
}
return $normalized;
}
/**
* Validiert Parameter-Typ
*/
private function validateType(string $name, mixed $value, string $expectedType): void
{
$actualType = gettype($value);
$isValid = match($expectedType) {
'string' => is_string($value),
'int', 'integer' => is_int($value) || (is_numeric($value) && (int) $value == $value),
'float', 'double' => is_float($value) || is_numeric($value),
'bool', 'boolean' => is_bool($value) || in_array(strtolower((string) $value), ['true', 'false', '1', '0'], true),
'array' => is_array($value),
'null' => $value === null,
default => true // Unknown types pass through
};
if (! $isValid) {
throw new ParameterResolutionException("Parameter '{$name}' must be of type '{$expectedType}', got: {$actualType}");
}
}
/**
* Konvertiert zu JSON
*/
public function toJson(): string
{
return json_encode($this->parameters, JSON_THROW_ON_ERROR);
}
/**
* Factory method aus JSON
*/
public static function fromJson(string $json): self
{
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (! is_array($data)) {
throw new ParameterResolutionException("JSON must decode to an array");
}
return new self($data);
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Framework\Router\ValueObjects;
/**
* Represents a subdomain pattern for route matching
*/
final readonly class SubdomainPattern
{
public function __construct(
public string $pattern,
public bool $isWildcard = false,
public bool $isOptional = false
) {
}
/**
* Check if this pattern matches the given subdomain
*/
public function matches(string $subdomain): bool
{
if ($this->isWildcard) {
return fnmatch($this->pattern, $subdomain);
}
return $this->pattern === $subdomain;
}
/**
* Create SubdomainPattern from string
*/
public static function fromString(string $pattern): self
{
$isWildcard = str_contains($pattern, '*');
$isOptional = str_starts_with($pattern, '?');
return new self(
pattern: trim($pattern, '?'),
isWildcard: $isWildcard,
isOptional: $isOptional
);
}
/**
* Create array of SubdomainPattern from mixed input
*/
public static function fromInput(array|string $input): array
{
if (is_string($input)) {
return $input ? [self::fromString($input)] : [];
}
return array_map(fn (string $pattern) => self::fromString($pattern), $input);
}
/**
* Check if this is a default pattern (no subdomain restriction)
*/
public function isDefault(): bool
{
return empty($this->pattern);
}
/**
* Get the key for this pattern in route compilation
*/
public function getCompilationKey(): string
{
if ($this->isDefault()) {
return 'default';
}
if ($this->isWildcard) {
return 'wildcard:' . $this->pattern;
}
return 'exact:' . $this->pattern;
}
public function toString(): string
{
$prefix = $this->isOptional ? '?' : '';
return $prefix . $this->pattern;
}
}