diff --git a/src/Kernel/CoreServiceRegistrar.php b/src/Kernel/CoreServiceRegistrar.php index 7960912..528b9b0 100644 --- a/src/Kernel/CoreServiceRegistrar.php +++ b/src/Kernel/CoreServiceRegistrar.php @@ -525,7 +525,7 @@ private function registerRouteLoader(ContainerBuilder $builder): void RouteLoader::class, (new Definition(RouteLoader::class)) ->setPublic(true) - ->setArguments([null, [], []]) + ->setArguments([null, [], [], []]) ->addTag( HookLoader::TAG, [ diff --git a/src/Routing/RouteCompilerPass.php b/src/Routing/RouteCompilerPass.php index 60612d7..943c97b 100644 --- a/src/Routing/RouteCompilerPass.php +++ b/src/Routing/RouteCompilerPass.php @@ -61,6 +61,7 @@ public function process(ContainerBuilder $container): void $definition->setArgument(0, ServiceLocatorTagPass::register($container, $serviceMap)); $definition->setArgument(1, $frontendRoutes); $definition->setArgument(2, $restRoutes); + $definition->setArgument(3, RouteLoader::compileRoutes($frontendRoutes)); } /** diff --git a/src/Routing/RouteLoader.php b/src/Routing/RouteLoader.php index 545f96e..326dd79 100644 --- a/src/Routing/RouteLoader.php +++ b/src/Routing/RouteLoader.php @@ -10,6 +10,8 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Exception\MethodNotAllowedException; use Symfony\Component\Routing\Exception\ResourceNotFoundException; +use Symfony\Component\Routing\Matcher\CompiledUrlMatcher; +use Symfony\Component\Routing\Matcher\Dumper\CompiledUrlMatcherDumper; use Symfony\Component\Routing\Matcher\UrlMatcher; use Symfony\Component\Routing\RequestContext; use Symfony\Component\Routing\Route; @@ -64,14 +66,39 @@ final class RouteLoader /** * @param list $routes * @param list $restRoutes + * @param list $compiledRoutes Matcher tables from compileRoutes(); empty falls back to UrlMatcher. */ public function __construct( private readonly ContainerInterface $controllers, private array $routes = [], private array $restRoutes = [], + private readonly array $compiledRoutes = [], ) { } + /** + * Precompute the matcher tables at container compile time so requests do not + * run one regular expression per route. Routes with a condition compile to a + * closure that cannot be dumped, so they keep the runtime UrlMatcher. + * + * @param list $routes + * @return list + */ + public static function compileRoutes(array $routes): array + { + if ($routes === []) { + return []; + } + + foreach ($routes as $route) { + if ($route['condition'] !== '') { + return []; + } + } + + return array_values((new CompiledUrlMatcherDumper(self::collection($routes)))->getCompiledRoutes()); + } + private ?RouteCollection $routeCollection = null; public function dispatchFrontendRequest(): void @@ -92,10 +119,10 @@ public function handle(Request $request): ?Response return null; } - $matcher = new UrlMatcher( - $this->routeCollection(), - (new RequestContext())->fromRequest($request), - ); + $context = (new RequestContext())->fromRequest($request); + $matcher = $this->compiledRoutes === [] + ? new UrlMatcher($this->routeCollection(), $context) + : new CompiledUrlMatcher($this->compiledRoutes, $context); try { $attributes = $this->stringKeyMap($matcher->matchRequest($request)); @@ -153,18 +180,24 @@ public function routeCollection(): RouteCollection return $this->routeCollection; } + $this->routeCollection = self::collection($this->routes); + + return $this->routeCollection; + } + + /** @param list $routes */ + private static function collection(array $routes): RouteCollection + { $collection = new RouteCollection(); - foreach ($this->routes as $definition) { + foreach ($routes as $definition) { $collection->add( $definition['name'], - $this->symfonyRoute($definition), + self::symfonyRoute($definition), (int) ($definition['priority'] ?? 0), ); } - $this->routeCollection = $collection; - return $collection; } @@ -373,7 +406,7 @@ private function normalizeResponse(mixed $value): Response } /** @param CompiledRoute|CompiledRestRoute $definition */ - private function symfonyRoute(array $definition): Route + private static function symfonyRoute(array $definition): Route { return new Route( $definition['path'], @@ -396,7 +429,7 @@ private function restPath(array $route): string return str_starts_with($explicitPath, '/') ? $explicitPath : '/' . $explicitPath; } - $regex = $this->symfonyRoute($route)->compile()->getRegex(); + $regex = self::symfonyRoute($route)->compile()->getRegex(); $delimiter = $regex[0] ?? ''; $closingDelimiter = match ($delimiter) { '{' => '}', diff --git a/tests/Routing/RouteAttributeTest.php b/tests/Routing/RouteAttributeTest.php index f07cac6..43d56aa 100644 --- a/tests/Routing/RouteAttributeTest.php +++ b/tests/Routing/RouteAttributeTest.php @@ -65,6 +65,9 @@ public function testFrontendRoutesAreMatchedAndControllersAreResolvedLazily(): v self::assertInstanceOf(RouteLoader::class, $loader); + // The compiled matcher tables are dumped into the container; requests do not rebuild them. + self::assertNotSame([], (new \ReflectionProperty($loader, 'compiledRoutes'))->getValue($loader)); + $response = $loader->handle(Request::create('/kernel/hello/codex', 'GET')); self::assertNotNull($response); @@ -78,6 +81,34 @@ public function testFrontendRoutesAreMatchedAndControllersAreResolvedLazily(): v self::assertSame('GET', $methodMismatch->headers->get('Allow')); } + public function testRoutesWithConditionsKeepTheRuntimeMatcher(): void + { + $route = [ + 'name' => 'conditional', + 'path' => '/conditional', + 'methods' => ['GET'], + 'schemes' => [], + 'host' => '', + 'defaults' => ['_controller_service' => 'demo', '_controller_class' => 'Demo', '_controller_method' => 'run'], + 'requirements' => [], + 'options' => [], + 'condition' => "request.headers.get('X-Demo') == 'yes'", + 'priority' => 0, + 'service' => 'demo', + 'class' => 'Demo', + 'method' => 'run', + ]; + + self::assertSame([], RouteLoader::compileRoutes([$route])); + self::assertSame([], RouteLoader::compileRoutes([])); + + $route['condition'] = ''; + $compiled = RouteLoader::compileRoutes([$route]); + + self::assertNotSame([], $compiled); + self::assertNull($compiled[4]); + } + public function testRestRoutesAreRegisteredWithWordPressRestApi(): void { $loader = $this->compileFixture()->get(RouteLoader::class);