- Move 12 markdown files from root to docs/ subdirectories - Organize documentation by category: • docs/troubleshooting/ (1 file) - Technical troubleshooting guides • docs/deployment/ (4 files) - Deployment and security documentation • docs/guides/ (3 files) - Feature-specific guides • docs/planning/ (4 files) - Planning and improvement proposals Root directory cleanup: - Reduced from 16 to 4 markdown files in root - Only essential project files remain: • CLAUDE.md (AI instructions) • README.md (Main project readme) • CLEANUP_PLAN.md (Current cleanup plan) • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements) This improves: ✅ Documentation discoverability ✅ Logical organization by purpose ✅ Clean root directory ✅ Better maintainability
336 lines
12 KiB
PHP
336 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\Core;
|
|
|
|
use App\Framework\Attributes\Route;
|
|
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
|
|
use App\Framework\Router\CompiledPattern;
|
|
use App\Framework\Router\CompiledRoutes;
|
|
use App\Framework\Router\RouteData;
|
|
use App\Framework\Router\RouteNameInterface;
|
|
use App\Framework\Router\ValueObjects\MethodParameter;
|
|
use App\Framework\Router\ValueObjects\ParameterCollection;
|
|
use App\Framework\Router\ValueObjects\SubdomainPattern;
|
|
|
|
final readonly class RouteCompiler
|
|
{
|
|
/** @var array<string, StaticRoute|DynamicRoute> */
|
|
private array $named;
|
|
|
|
/**
|
|
* Compile routes directly from DiscoveredAttribute objects with subdomain support
|
|
* @return array<string, array<string, array{static: array<string, StaticRoute>, dynamic: array<int, DynamicRoute>}>>
|
|
*/
|
|
public function compile(DiscoveredAttribute ...$discoveredRoutes): array
|
|
{
|
|
$compiled = [];
|
|
$named = [];
|
|
|
|
foreach ($discoveredRoutes as $discoveredAttribute) {
|
|
// Create actual Route attribute instance
|
|
$routeAttribute = $discoveredAttribute->createAttributeInstance();
|
|
if (! $routeAttribute instanceof Route) {
|
|
continue;
|
|
}
|
|
|
|
// Extract route data directly from the Route attribute
|
|
$method = strtoupper($routeAttribute->method->value);
|
|
$path = $routeAttribute->getPathAsString();
|
|
$routeName = $this->extractRouteName($routeAttribute->name);
|
|
|
|
// Process subdomain patterns
|
|
$subdomainPatterns = SubdomainPattern::fromInput($routeAttribute->subdomain);
|
|
|
|
// If no subdomains specified, use default
|
|
if (empty($subdomainPatterns)) {
|
|
$subdomainPatterns = [new SubdomainPattern('')];
|
|
}
|
|
|
|
foreach ($subdomainPatterns as $subdomainPattern) {
|
|
$subdomainKey = $subdomainPattern->getCompilationKey();
|
|
|
|
$compiled[$method] ??= [];
|
|
$compiled[$method][$subdomainKey] ??= ['static' => [], 'dynamic' => []];
|
|
|
|
if (! str_contains($path, '{')) {
|
|
// Static route
|
|
$parameterCollection = $this->createParameterCollection($discoveredAttribute->additionalData['parameters'] ?? []);
|
|
$staticRoute = new StaticRoute(
|
|
controller : $discoveredAttribute->className->getFullyQualified(),
|
|
action : $discoveredAttribute->methodName?->toString() ?? '',
|
|
parameters : $discoveredAttribute->additionalData['parameters'] ?? [],
|
|
name : $routeName ?? '',
|
|
path : $path,
|
|
attributes : $discoveredAttribute->additionalData['attributes'] ?? [],
|
|
parameterCollection: $parameterCollection
|
|
);
|
|
|
|
$compiled[$method][$subdomainKey]['static'][$path] = $staticRoute;
|
|
|
|
if ($routeName) {
|
|
$named[$routeName] = $staticRoute;
|
|
}
|
|
} else {
|
|
// Dynamic route
|
|
$paramNames = [];
|
|
$regex = $this->convertPathToRegex($path, $paramNames);
|
|
$parameterCollection = $this->createParameterCollection($discoveredAttribute->additionalData['parameters'] ?? []);
|
|
|
|
$dynamicRoute = new DynamicRoute(
|
|
regex : $regex,
|
|
paramNames : $paramNames,
|
|
controller : $discoveredAttribute->className->getFullyQualified(),
|
|
action : $discoveredAttribute->methodName?->toString() ?? '',
|
|
parameters : $discoveredAttribute->additionalData['parameters'] ?? [],
|
|
paramValues : [],
|
|
name : $routeName ?? '',
|
|
path : $path,
|
|
attributes : $discoveredAttribute->additionalData['attributes'] ?? [],
|
|
parameterCollection: $parameterCollection
|
|
);
|
|
|
|
$compiled[$method][$subdomainKey]['dynamic'][] = $dynamicRoute;
|
|
|
|
if ($routeName) {
|
|
$named[$routeName] = $dynamicRoute;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (! isset($this->named)) {
|
|
$this->named = $named;
|
|
}
|
|
|
|
return $compiled;
|
|
}
|
|
|
|
/**
|
|
* Convert legacy parameters array to ParameterCollection
|
|
*/
|
|
private function createParameterCollection(array $parameters): ParameterCollection
|
|
{
|
|
$methodParameters = [];
|
|
|
|
foreach ($parameters as $param) {
|
|
$methodParameters[] = new MethodParameter(
|
|
name: $param['name'],
|
|
type: $param['type'],
|
|
isBuiltin: $param['isBuiltin'],
|
|
hasDefault: $param['hasDefault'],
|
|
default: $param['default'] ?? null
|
|
);
|
|
}
|
|
|
|
return new ParameterCollection(...$methodParameters);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array{method: string, path: string, controller: class-string, handler: string}> $routes
|
|
* @return array<string, StaticRoute|DynamicRoute>
|
|
*/
|
|
public function compileNamedRoutes(array $routes): array
|
|
{
|
|
return $this->named;
|
|
}
|
|
|
|
/**
|
|
* Konvertiert zB. /user/{id}/edit → ~^/user/([^/]+)/edit$~ und gibt ['id'] als Parameternamen zurück.
|
|
*
|
|
* @param string $path
|
|
* @param array<string> &$paramNames
|
|
* @return string
|
|
*/
|
|
private function convertPathToRegex(string $path, array &$paramNames): string
|
|
{
|
|
$paramNames = [];
|
|
$regex = preg_replace_callback('#\{(\w+)(\*)?}#', function ($matches) use (&$paramNames) {
|
|
$paramNames[] = $matches[1];
|
|
|
|
// Wenn {id*} dann erlaube Slashes, aber mache es non-greedy
|
|
if (isset($matches[2]) && $matches[2] === '*') {
|
|
return '(.+?)'; // Non-greedy: matcht so wenig wie möglich
|
|
}
|
|
|
|
return '([^/]+)'; // Keine Slashes
|
|
}, $path);
|
|
|
|
return '~^' . $regex . '$~';
|
|
}
|
|
|
|
public function getAttributeClass(): string
|
|
{
|
|
return Route::class;
|
|
}
|
|
|
|
/**
|
|
* Compile optimized routes directly from DiscoveredAttribute objects with subdomain support
|
|
*/
|
|
public function compileOptimized(DiscoveredAttribute ...$discoveredRoutes): CompiledRoutes
|
|
{
|
|
$compiled = $this->compile(...$discoveredRoutes);
|
|
$optimizedStatic = [];
|
|
$optimizedDynamic = [];
|
|
|
|
foreach ($compiled as $method => $subdomainRoutes) {
|
|
foreach ($subdomainRoutes as $subdomainKey => $routes) {
|
|
$optimizedStatic[$method][$subdomainKey] = $routes['static'];
|
|
|
|
if (! empty($routes['dynamic'])) {
|
|
$optimizedDynamic[$method][$subdomainKey] = $this->createCompiledPattern($routes['dynamic']);
|
|
}
|
|
}
|
|
}
|
|
|
|
return new CompiledRoutes($optimizedStatic, $optimizedDynamic, $this->named);
|
|
}
|
|
|
|
/**
|
|
* Creates optimized compiled patterns with route prioritization
|
|
* @param array<int, DynamicRoute> $dynamicRoutes
|
|
*/
|
|
private function createCompiledPattern(array $dynamicRoutes): CompiledPattern
|
|
{
|
|
// Sort routes by priority (specific patterns first, then by path complexity)
|
|
$prioritizedRoutes = $this->prioritizeRoutes($dynamicRoutes);
|
|
|
|
// Group routes into batches for smaller regex patterns
|
|
$routeBatches = $this->groupRoutesByComplexity($prioritizedRoutes);
|
|
|
|
$compiledBatches = [];
|
|
/** @var array<int, RouteData> $routeData */
|
|
$routeData = [];
|
|
$globalIndex = 0;
|
|
|
|
foreach ($routeBatches as $batchName => $batch) {
|
|
$patterns = [];
|
|
$currentIndex = 1; // After full match (Index 0)
|
|
|
|
foreach ($batch as $route) {
|
|
$pattern = $this->stripAnchors($route->regex);
|
|
$patterns[] = "({$pattern})";
|
|
|
|
// This route's main capture group index
|
|
$routeGroupIndex = $currentIndex;
|
|
|
|
// Parameter-Mapping berechnen - parameters are INSIDE the route pattern
|
|
// So their indices are routeGroupIndex + 1, routeGroupIndex + 2, etc.
|
|
$paramMap = [];
|
|
$paramOffset = 1; // Parameters start right after the route's capture group
|
|
foreach ($route->paramNames as $paramName) {
|
|
$paramMap[$paramName] = $routeGroupIndex + $paramOffset++;
|
|
}
|
|
|
|
// Now advance currentIndex past ALL capture groups in this route's pattern
|
|
// That's 1 (the route group) + count(paramNames) (the parameters inside it)
|
|
$currentIndex += 1 + count($route->paramNames);
|
|
|
|
$routeData[$globalIndex] = new RouteData(
|
|
route: $route,
|
|
paramMap: $paramMap,
|
|
routeGroupIndex: $routeGroupIndex,
|
|
batch: $batchName,
|
|
regex: '~^' . $this->stripAnchors($route->regex) . '$~' // Pre-compiled individual regex
|
|
);
|
|
|
|
$globalIndex++;
|
|
}
|
|
|
|
$compiledBatches[$batchName] = [
|
|
'regex' => '~^(?:' . implode('|', $patterns) . ')$~',
|
|
'routes' => array_slice($routeData, $globalIndex - count($batch), count($batch), true),
|
|
];
|
|
}
|
|
|
|
// Use the first batch's regex for backward compatibility, but store all batches
|
|
$primaryRegex = ! empty($compiledBatches) ? reset($compiledBatches)['regex'] : '~^$~';
|
|
|
|
return new CompiledPattern($primaryRegex, $routeData, $compiledBatches);
|
|
}
|
|
|
|
/**
|
|
* Prioritize routes by specificity and expected frequency
|
|
* @param array<DynamicRoute> $routes
|
|
* @return array<DynamicRoute>
|
|
*/
|
|
private function prioritizeRoutes(array $routes): array
|
|
{
|
|
usort($routes, function ($a, $b) {
|
|
// 1. API routes first (typically more frequent)
|
|
$aIsApi = str_starts_with($a->path, '/api/');
|
|
$bIsApi = str_starts_with($b->path, '/api/');
|
|
if ($aIsApi !== $bIsApi) {
|
|
return $bIsApi <=> $aIsApi; // API routes first
|
|
}
|
|
|
|
// 2. Routes with fewer parameters first (more specific)
|
|
$aParamCount = count($a->paramNames);
|
|
$bParamCount = count($b->paramNames);
|
|
if ($aParamCount !== $bParamCount) {
|
|
return $aParamCount <=> $bParamCount;
|
|
}
|
|
|
|
// 3. Shorter paths first (typically more specific)
|
|
$aLength = strlen($a->path);
|
|
$bLength = strlen($b->path);
|
|
|
|
return $aLength <=> $bLength;
|
|
});
|
|
|
|
return $routes;
|
|
}
|
|
|
|
/**
|
|
* Group routes by complexity for optimized batch processing
|
|
* @param array<DynamicRoute> $routes
|
|
* @return array<string, array<DynamicRoute>>
|
|
*/
|
|
private function groupRoutesByComplexity(array $routes): array
|
|
{
|
|
$groups = [
|
|
'simple' => [], // /api/{id}, /user/{id} - 1 parameter
|
|
'medium' => [], // /api/{type}/{id} - 2 parameters
|
|
'complex' => [], // 3+ parameters or wildcards
|
|
];
|
|
|
|
foreach ($routes as $route) {
|
|
$paramCount = count($route->paramNames);
|
|
$hasWildcard = str_contains($route->path, '{*}');
|
|
|
|
if ($hasWildcard || $paramCount >= 3) {
|
|
$groups['complex'][] = $route;
|
|
} elseif ($paramCount === 2) {
|
|
$groups['medium'][] = $route;
|
|
} else {
|
|
$groups['simple'][] = $route;
|
|
}
|
|
}
|
|
|
|
// Remove empty groups
|
|
return array_filter($groups, fn ($group) => ! empty($group));
|
|
}
|
|
|
|
private function stripAnchors(string $regex): string
|
|
{
|
|
if (preg_match('/^~\^(.+)\$~$/', $regex, $matches)) {
|
|
return $matches[1];
|
|
}
|
|
|
|
return $regex;
|
|
}
|
|
|
|
/**
|
|
* Extract route name as string from enum or string
|
|
*/
|
|
private function extractRouteName(RouteNameInterface|string|null $routeName): string
|
|
{
|
|
if ($routeName instanceof RouteNameInterface) {
|
|
return $routeName->value;
|
|
}
|
|
|
|
return (string) ($routeName ?? '');
|
|
}
|
|
}
|