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:
@@ -1,8 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Router;
|
||||
|
||||
interface ActionResult
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
21
src/Framework/Router/EmptyCompiledRoutesInitializer.php
Normal file
21
src/Framework/Router/EmptyCompiledRoutesInitializer.php
Normal 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([], [], []);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,6 @@ final readonly class GenericActionResult implements ActionResult
|
||||
#public string $layout = '',
|
||||
public array $slots = [],
|
||||
public ?string $controllerClass = null
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = [],
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
118
src/Framework/Router/RouteData.php
Normal file
118
src/Framework/Router/RouteData.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
29
src/Framework/Router/RouteParametersInitializer.php
Normal file
29
src/Framework/Router/RouteParametersInitializer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
18
src/Framework/Router/Router.php
Normal file
18
src/Framework/Router/Router.php
Normal 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;
|
||||
}
|
||||
52
src/Framework/Router/RouterPerformanceMonitor.php
Normal file
52
src/Framework/Router/RouterPerformanceMonitor.php
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
68
src/Framework/Router/ValueObjects/MethodParameter.php
Normal file
68
src/Framework/Router/ValueObjects/MethodParameter.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
115
src/Framework/Router/ValueObjects/ParameterCollection.php
Normal file
115
src/Framework/Router/ValueObjects/ParameterCollection.php
Normal 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;
|
||||
}
|
||||
}
|
||||
340
src/Framework/Router/ValueObjects/ParameterConstraints.php
Normal file
340
src/Framework/Router/ValueObjects/ParameterConstraints.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
363
src/Framework/Router/ValueObjects/RouteParameters.php
Normal file
363
src/Framework/Router/ValueObjects/RouteParameters.php
Normal 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);
|
||||
}
|
||||
}
|
||||
88
src/Framework/Router/ValueObjects/SubdomainPattern.php
Normal file
88
src/Framework/Router/ValueObjects/SubdomainPattern.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user