diff --git a/src/Tempest/Http/src/RouteConfig.php b/src/Tempest/Http/src/RouteConfig.php index e050ab0a4..84a44aca3 100644 --- a/src/Tempest/Http/src/RouteConfig.php +++ b/src/Tempest/Http/src/RouteConfig.php @@ -4,81 +4,22 @@ namespace Tempest\Http; -use Tempest\Http\Routing\Construction\MarkedRoute; -use Tempest\Http\Routing\Construction\RoutingTree; -use Tempest\Reflection\MethodReflector; - final class RouteConfig { - /** @var string The mark to give the next route in the matching Regex */ - private string $regexMark = 'a'; - - /** @var array */ - public array $matchingRegexes = []; - - public RoutingTree $routingTree; - public function __construct( - /** @var array> */ + /** @var array> */ public array $staticRoutes = [], - /** @var array> */ + /** @var array> */ public array $dynamicRoutes = [], + /** @var array */ + public array $matchingRegexes = [], ) { - $this->routingTree = new RoutingTree(); - } - - public function addRoute(MethodReflector $handler, Route $route): self - { - $route->setHandler($handler); - - if ($route->isDynamic) { - $this->regexMark = str_increment($this->regexMark); - $this->dynamicRoutes[$route->method->value][$this->regexMark] = $route; - - $this->routingTree->add( - new MarkedRoute( - mark: $this->regexMark, - route: $route, - ) - ); - - } else { - $uriWithTrailingSlash = rtrim($route->uri, '/'); - - $this->staticRoutes[$route->method->value][$uriWithTrailingSlash] = $route; - $this->staticRoutes[$route->method->value][$uriWithTrailingSlash . '/'] = $route; - } - - return $this; - } - - public function prepareMatchingRegexes(): void - { - if (! empty($this->matchingRegexes)) { - return; - } - - $this->matchingRegexes = $this->routingTree->toMatchingRegexes(); - } - - /** - * __sleep is called before serialize and returns the public properties to serialize. We do not want the routingTree - * to be serialized, but we do want the result to be serialized. Thus prepareMatchingRegexes is called and the - * resulting matchingRegexes are stored. - */ - public function __sleep(): array - { - $this->prepareMatchingRegexes(); - - return ['staticRoutes', 'dynamicRoutes', 'matchingRegexes']; } - /** - * __wakeup is called after unserialize. We do not serialize the routingTree thus we need to provide some default - * for it. Otherwise, it will be uninitialized and cause issues when tempest expects it to be defined. - */ - public function __wakeup(): void + public function apply(RouteConfig $newConfig): void { - $this->routingTree = new RoutingTree(); + $this->staticRoutes = $newConfig->staticRoutes; + $this->dynamicRoutes = $newConfig->dynamicRoutes; + $this->matchingRegexes = $newConfig->matchingRegexes; } } diff --git a/src/Tempest/Http/src/RouteDiscovery.php b/src/Tempest/Http/src/RouteDiscovery.php index ba0965d5e..b4fb0596c 100644 --- a/src/Tempest/Http/src/RouteDiscovery.php +++ b/src/Tempest/Http/src/RouteDiscovery.php @@ -6,11 +6,15 @@ use Tempest\Container\Container; use Tempest\Core\Discovery; +use Tempest\Core\KernelEvent; +use Tempest\EventBus\EventHandler; +use Tempest\Http\Routing\Construction\RouteConfigurator; use Tempest\Reflection\ClassReflector; final readonly class RouteDiscovery implements Discovery { public function __construct( + private RouteConfigurator $configurator, private RouteConfig $routeConfig, ) { } @@ -21,22 +25,32 @@ public function discover(ClassReflector $class): void $routeAttributes = $method->getAttributes(Route::class); foreach ($routeAttributes as $routeAttribute) { - $this->routeConfig->addRoute($method, $routeAttribute); + $routeAttribute->setHandler($method); + + $this->configurator->addRoute($routeAttribute); } } } + #[EventHandler(KernelEvent::BOOTED)] + public function finishDiscovery(): void + { + if ($this->configurator->isDirty()) { + $this->routeConfig->apply($this->configurator->toRouteConfig()); + } + } + public function createCachePayload(): string { + $this->finishDiscovery(); + return serialize($this->routeConfig); } public function restoreCachePayload(Container $container, string $payload): void { - $routeConfig = unserialize($payload); + $routeConfig = unserialize($payload, [ 'allowed_classes' => true ]); - $this->routeConfig->staticRoutes = $routeConfig->staticRoutes; - $this->routeConfig->dynamicRoutes = $routeConfig->dynamicRoutes; - $this->routeConfig->matchingRegexes = $routeConfig->matchingRegexes; + $this->routeConfig->apply($routeConfig); } } diff --git a/src/Tempest/Http/src/Routing/Construction/RouteConfigurator.php b/src/Tempest/Http/src/Routing/Construction/RouteConfigurator.php new file mode 100644 index 000000000..65c6a4e7c --- /dev/null +++ b/src/Tempest/Http/src/Routing/Construction/RouteConfigurator.php @@ -0,0 +1,88 @@ +> */ + private array $staticRoutes = []; + + /** @var array> */ + private array $dynamicRoutes = []; + + private bool $isDirty = false; + + private RoutingTree $routingTree; + + public function __construct() + { + + $this->routingTree = new RoutingTree(); + } + + public function addRoute(Route $route): void + { + $this->isDirty = true; + + if ($route->isDynamic) { + $this->addDynamicRoute($route); + } else { + $this->addStaticRoute($route); + } + } + + private function addDynamicRoute(Route $route): void + { + $markedRoute = $this->markRoute($route); + $this->dynamicRoutes[$route->method->value][$markedRoute->mark] = $route; + + $this->routingTree->add($markedRoute); + } + + private function addStaticRoute(Route $route): void + { + $uriWithTrailingSlash = rtrim($route->uri, '/'); + + $this->staticRoutes[$route->method->value][$uriWithTrailingSlash] = $route; + $this->staticRoutes[$route->method->value][$uriWithTrailingSlash . '/'] = $route; + } + + private function markRoute(Route $route): MarkedRoute + { + $this->regexMark = str_increment($this->regexMark); + + return new MarkedRoute( + mark: $this->regexMark, + route: $route, + ); + } + + public function toRouteConfig(): RouteConfig + { + $this->isDirty = false; + + return new RouteConfig( + $this->staticRoutes, + $this->dynamicRoutes, + $this->routingTree->toMatchingRegexes(), + ); + } + + public function isDirty(): bool + { + return $this->isDirty; + } +} diff --git a/src/Tempest/Http/src/Routing/Matching/GenericRouteMatcher.php b/src/Tempest/Http/src/Routing/Matching/GenericRouteMatcher.php index e8c57fe33..a10968dcc 100644 --- a/src/Tempest/Http/src/Routing/Matching/GenericRouteMatcher.php +++ b/src/Tempest/Http/src/Routing/Matching/GenericRouteMatcher.php @@ -46,9 +46,6 @@ private function matchDynamicRoute(PsrRequest $request): ?MatchedRoute return null; } - // Ensures matching regexes are available - $this->routeConfig->prepareMatchingRegexes(); - // Get matching regex for route $matchingRegexForMethod = $this->routeConfig->matchingRegexes[$request->getMethod()]; diff --git a/src/Tempest/Http/tests/RouteConfigTest.php b/src/Tempest/Http/tests/RouteConfigTest.php index de7d42917..b434db337 100644 --- a/src/Tempest/Http/tests/RouteConfigTest.php +++ b/src/Tempest/Http/tests/RouteConfigTest.php @@ -5,126 +5,33 @@ namespace Tempest\Http\Tests; use PHPUnit\Framework\TestCase; -use ReflectionMethod; use Tempest\Http\Method; -use Tempest\Http\Response; -use Tempest\Http\Responses\Ok; use Tempest\Http\Route; use Tempest\Http\RouteConfig; -use Tempest\Reflection\MethodReflector; /** * @internal */ final class RouteConfigTest extends TestCase { - private RouteConfig $subject; - - private MethodReflector $dummyMethod; - - protected function setUp(): void - { - parent::setUp(); - - $this->subject = new RouteConfig(); - $this->dummyMethod = new MethodReflector(new ReflectionMethod($this, 'dummyMethod')); - } - - public function test_empty(): void - { - $this->assertEquals([], $this->subject->dynamicRoutes); - $this->assertEquals([], $this->subject->matchingRegexes); - $this->assertEquals([], $this->subject->staticRoutes); - } - - public function test_matching_regexes_is_updated_using_prepare_method(): void - { - $this->subject->addRoute($this->dummyMethod, new Route('/{id}', Method::GET)); - - $this->assertEquals([], $this->subject->matchingRegexes); - $this->subject->prepareMatchingRegexes(); - $this->assertNotEquals([], $this->subject->matchingRegexes); - } - - public function test_adding_static_routes(): void - { - $routes = [ - new Route('/1', Method::GET), - new Route('/2', Method::POST), - new Route('/3', Method::GET), - ]; - - $this->subject->addRoute($this->dummyMethod, $routes[0]); - $this->subject->addRoute($this->dummyMethod, $routes[1]); - $this->subject->addRoute($this->dummyMethod, $routes[2]); - - $this->assertEquals([ - 'GET' => [ - '/1' => $routes[0], - '/1/' => $routes[0], - '/3' => $routes[2], - '/3/' => $routes[2], - ], - 'POST' => [ - '/2' => $routes[1], - '/2/' => $routes[1], - ], - ], $this->subject->staticRoutes); - } - - public function test_adding_dynamic_routes(): void + public function test_serialization(): void { - $routes = [ - new Route('/{id}/1', Method::GET), - new Route('/{id}/2', Method::POST), - new Route('/{id}/3', Method::GET), - ]; - - $this->subject->addRoute($this->dummyMethod, $routes[0]); - $this->subject->addRoute($this->dummyMethod, $routes[1]); - $this->subject->addRoute($this->dummyMethod, $routes[2]); - - $this->subject->prepareMatchingRegexes(); - - $this->assertEquals([ - 'GET' => [ - 'b' => $routes[0], - 'd' => $routes[2], + $original = new RouteConfig( + [ + 'POST' => ['/a' => new Route('/', Method::POST)], ], - 'POST' => [ - 'c' => $routes[1], + [ + 'POST' => ['b' => new Route('/', Method::POST)], ], - ], $this->subject->dynamicRoutes); - - $this->assertEquals([ - 'GET' => '#^(?|/([^/]++)(?|/1\/?$(*MARK:b)|/3\/?$(*MARK:d)))#', - 'POST' => '#^(?|/([^/]++)(?|/2\/?$(*MARK:c)))#', - ], $this->subject->matchingRegexes); - } - - public function test_serialization(): void - { - $routes = [ - new Route('/{id}/1', Method::GET), - new Route('/{id}/2', Method::POST), - new Route('/3', Method::GET), - ]; + [ + 'POST' => '#^(?|/([^/]++)(?|/1\/?$(*MARK:b)|/3\/?$(*MARK:d)))#', + ] + ); - $this->subject->addRoute($this->dummyMethod, $routes[0]); - $this->subject->addRoute($this->dummyMethod, $routes[1]); - $this->subject->addRoute($this->dummyMethod, $routes[2]); - - $serialized = serialize($this->subject); + $serialized = serialize($original); /** @var RouteConfig $deserialized */ $deserialized = unserialize($serialized); - $this->assertEquals($this->subject->matchingRegexes, $deserialized->matchingRegexes); - $this->assertEquals($this->subject->dynamicRoutes, $deserialized->dynamicRoutes); - $this->assertEquals($this->subject->staticRoutes, $deserialized->staticRoutes); - } - - public function dummyMethod(): Response - { - return new Ok(); + $this->assertEquals($original, $deserialized); } } diff --git a/src/Tempest/Http/tests/Routing/Construction/RouteConfiguratorTest.php b/src/Tempest/Http/tests/Routing/Construction/RouteConfiguratorTest.php new file mode 100644 index 000000000..0dd7369d3 --- /dev/null +++ b/src/Tempest/Http/tests/Routing/Construction/RouteConfiguratorTest.php @@ -0,0 +1,95 @@ +subject = new RouteConfigurator(); + } + + public function test_empty(): void + { + $this->assertEquals(new RouteConfig(), $this->subject->toRouteConfig()); + } + + public function test_adding_static_routes(): void + { + $routes = [ + new Route('/1', Method::GET), + new Route('/2', Method::POST), + new Route('/3', Method::GET), + ]; + + $this->subject->addRoute($routes[0]); + $this->subject->addRoute($routes[1]); + $this->subject->addRoute($routes[2]); + + $config = $this->subject->toRouteConfig(); + + $this->assertEquals([ + 'GET' => [ + '/1' => $routes[0], + '/1/' => $routes[0], + '/3' => $routes[2], + '/3/' => $routes[2], + ], + 'POST' => [ + '/2' => $routes[1], + '/2/' => $routes[1], + ], + ], $config->staticRoutes); + $this->assertEquals([], $config->dynamicRoutes); + $this->assertEquals([], $config->matchingRegexes); + } + + public function test_adding_dynamic_routes(): void + { + $routes = [ + new Route('/dynamic/{id}', Method::GET), + new Route('/dynamic/{id}', Method::PATCH), + new Route('/dynamic/{id}/view', Method::GET), + new Route('/dynamic/{id}/{tag}/{name}/{id}', Method::GET), + ]; + + $this->subject->addRoute($routes[0]); + $this->subject->addRoute($routes[1]); + $this->subject->addRoute($routes[2]); + $this->subject->addRoute($routes[3]); + + $config = $this->subject->toRouteConfig(); + + $this->assertEquals([], $config->staticRoutes); + $this->assertEquals([ + 'GET' => [ + 'b' => $routes[0], + 'd' => $routes[2], + 'e' => $routes[3], + ], + 'PATCH' => [ + 'c' => $routes[1], + ], + ], $config->dynamicRoutes); + + $this->assertEquals([ + 'GET' => '#^(?|/dynamic(?|/([^/]++)(?|/view\/?$(*MARK:d)|/([^/]++)(?|/([^/]++)(?|/([^/]++)\/?$(*MARK:e)))|\/?$(*MARK:b))))#', + 'PATCH' => '#^(?|/dynamic(?|/([^/]++)\/?$(*MARK:c)))#', + ], $config->matchingRegexes); + } +} diff --git a/src/Tempest/Http/tests/Routing/Matching/GenericRouteMatcherTest.php b/src/Tempest/Http/tests/Routing/Matching/GenericRouteMatcherTest.php index 28d60e88d..a9fae55b2 100644 --- a/src/Tempest/Http/tests/Routing/Matching/GenericRouteMatcherTest.php +++ b/src/Tempest/Http/tests/Routing/Matching/GenericRouteMatcherTest.php @@ -6,14 +6,10 @@ use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\TestCase; -use ReflectionMethod; use Tempest\Http\Method; -use Tempest\Http\Response; -use Tempest\Http\Responses\Ok; use Tempest\Http\Route; use Tempest\Http\RouteConfig; use Tempest\Http\Routing\Matching\GenericRouteMatcher; -use Tempest\Reflection\MethodReflector; /** * @internal @@ -28,13 +24,27 @@ protected function setUp(): void { parent::setUp(); - $this->routeConfig = new RouteConfig(); - - $method = new MethodReflector(new ReflectionMethod(self::class, 'dummyMethod')); - $this->routeConfig->addRoute($method, new Route('/static', Method::GET)); - $this->routeConfig->addRoute($method, new Route('/dynamic/{id}', Method::GET)); - $this->routeConfig->addRoute($method, new Route('/dynamic/{id}/view', Method::GET)); - $this->routeConfig->addRoute($method, new Route('/dynamic/{id}/{tag}/{name}/{id}', Method::GET)); + $this->routeConfig = new RouteConfig( + [ + 'GET' => [ + '/static' => new Route('/static', Method::GET), + ], + ], + [ + 'GET' => [ + 'b' => new Route('/dynamic/{id}', Method::GET), + 'c' => new Route('/dynamic/{id}/view', Method::GET), + 'e' => new Route('/dynamic/{id}/{tag}/{name}/{id}', Method::GET), + ], + 'PATCH' => [ + 'c' => new Route('/dynamic/{id}', Method::PATCH), + ], + ], + [ + 'GET' => '#^(?|/dynamic(?|/([^/]++)(?|/view\/?$(*MARK:d)|/([^/]++)(?|/([^/]++)(?|/([^/]++)\/?$(*MARK:e)))|\/?$(*MARK:b))))#', + 'PATCH' => '#^(?|/dynamic(?|/([^/]++)\/?$(*MARK:c)))#', + ] + ); $this->subject = new GenericRouteMatcher($this->routeConfig); } @@ -89,9 +99,4 @@ public function test_match_on_dynamic_route_with_many_parameters(): void $this->assertTrue($matchedRoute->route->isDynamic); $this->assertEquals('/dynamic/{id}/{tag}/{name}/{id}', $matchedRoute->route->uri); } - - public static function dummyMethod(): Response - { - return new Ok(); - } } diff --git a/tests/Benchmark/Http/RouteConfigBench.php b/tests/Benchmark/Http/RouteConfigBench.php index 895cbe649..1e1f012bd 100644 --- a/tests/Benchmark/Http/RouteConfigBench.php +++ b/tests/Benchmark/Http/RouteConfigBench.php @@ -4,16 +4,12 @@ namespace Tests\Tempest\Benchmark\Http; -use PhpBench\Attributes\BeforeMethods; use PhpBench\Attributes\Revs; use PhpBench\Attributes\Warmup; -use ReflectionMethod; use Tempest\Http\Method; -use Tempest\Http\Response; -use Tempest\Http\Responses\Ok; use Tempest\Http\Route; use Tempest\Http\RouteConfig; -use Tempest\Reflection\MethodReflector; +use Tempest\Http\Routing\Construction\RouteConfigurator; final class RouteConfigBench { @@ -21,39 +17,27 @@ final class RouteConfigBench public function __construct() { - $this->config = new RouteConfig(); + $this->config = self::makeRouteConfig(); } #[Warmup(10)] #[Revs(1000)] - public function benchRoutingSetup(): void - { - $this->config = new RouteConfig(); - $this->setupRouter(); - } - - #[Warmup(10)] - #[Revs(1000)] - #[BeforeMethods("setupRouter")] public function benchSerialization(): void { $serialized = serialize($this->config); unserialize($serialized); } - public function setupRouter(): void + private static function makeRouteConfig(): RouteConfig { - $method = new MethodReflector(new ReflectionMethod(self::class, 'dummyMethod')); + $constructor = new RouteConfigurator(); foreach (range(1, 100) as $i) { - $this->config->addRoute($method, new Route("/test/{$i}", Method::GET)); - $this->config->addRoute($method, new Route("/test/{id}/{$i}", Method::GET)); - $this->config->addRoute($method, new Route("/test/{id}/{$i}/delete", Method::GET)); - $this->config->addRoute($method, new Route("/test/{id}/{$i}/edit", Method::GET)); + $constructor->addRoute(new Route("/test/{$i}", Method::GET)); + $constructor->addRoute(new Route("/test/{id}/{$i}", Method::GET)); + $constructor->addRoute(new Route("/test/{id}/{$i}/delete", Method::GET)); + $constructor->addRoute(new Route("/test/{id}/{$i}/edit", Method::GET)); } - } - public static function dummyMethod(): Response - { - return new Ok(); + return $constructor->toRouteConfig(); } } diff --git a/tests/Benchmark/Http/Routing/Construction/RouteConfiguratorBench.php b/tests/Benchmark/Http/Routing/Construction/RouteConfiguratorBench.php new file mode 100644 index 000000000..03a19b698 --- /dev/null +++ b/tests/Benchmark/Http/Routing/Construction/RouteConfiguratorBench.php @@ -0,0 +1,59 @@ +subject = new RouteConfigurator(); + } + + #[Warmup(10)] + #[Revs(1000)] + #[BeforeMethods("setupRouteConfig")] + public function benchRouteConfigConstructionToConfig(): void + { + $this->subject->toRouteConfig(); + } + + #[Warmup(10)] + #[Revs(1000)] + public function benchRouteConfigConstructionRouteAdding(): void + { + $configurator = new RouteConfigurator(); + + foreach (range(1, 100) as $i) { + $configurator->addRoute(new Route("/test/{$i}", Method::GET)); + $configurator->addRoute(new Route("/test/{id}/{$i}", Method::GET)); + $configurator->addRoute(new Route("/test/{id}/{$i}/delete", Method::GET)); + $configurator->addRoute(new Route("/test/{id}/{$i}/edit", Method::GET)); + } + } + + public function setupRouteConfig(): void + { + self::addRoutes($this->subject); + } + + private static function addRoutes(RouteConfigurator $constructor): void + { + foreach (range(1, 100) as $i) { + $constructor->addRoute(new Route("/test/{$i}", Method::GET)); + $constructor->addRoute(new Route("/test/{id}/{$i}", Method::GET)); + $constructor->addRoute(new Route("/test/{id}/{$i}/delete", Method::GET)); + $constructor->addRoute(new Route("/test/{id}/{$i}/edit", Method::GET)); + } + } +} diff --git a/tests/Benchmark/Http/Routing/Matching/GenericRouteMatcherBench.php b/tests/Benchmark/Http/Routing/Matching/GenericRouteMatcherBench.php index 4e656b28d..8ddb78ef3 100644 --- a/tests/Benchmark/Http/Routing/Matching/GenericRouteMatcherBench.php +++ b/tests/Benchmark/Http/Routing/Matching/GenericRouteMatcherBench.php @@ -9,28 +9,21 @@ use PhpBench\Attributes\ParamProviders; use PhpBench\Attributes\Revs; use PhpBench\Attributes\Warmup; -use ReflectionMethod; use Tempest\Http\Method; -use Tempest\Http\Response; -use Tempest\Http\Responses\Ok; use Tempest\Http\Route; use Tempest\Http\RouteConfig; +use Tempest\Http\Routing\Construction\RouteConfigurator; use Tempest\Http\Routing\Matching\GenericRouteMatcher; -use Tempest\Reflection\MethodReflector; final class GenericRouteMatcherBench { - private RouteConfig $config; - private GenericRouteMatcher $matcher; public function __construct() { - $this->config = new RouteConfig(); - - $this->matcher = new GenericRouteMatcher($this->config); + $config = self::makeRouteConfig(); - $this->setupConfigRoutes(); + $this->matcher = new GenericRouteMatcher($config); } #[Warmup(10)] @@ -43,17 +36,6 @@ public function benchMatch(array $params): void ); } - public function setupConfigRoutes(): void - { - $method = new MethodReflector(new ReflectionMethod(self::class, 'dummyMethod')); - foreach (range(1, 100) as $i) { - $this->config->addRoute($method, new Route("/test/{$i}", Method::GET)); - $this->config->addRoute($method, new Route("/test/{id}/{$i}", Method::GET)); - $this->config->addRoute($method, new Route("/test/{id}/{$i}/delete", Method::GET)); - $this->config->addRoute($method, new Route("/test/{id}/{$i}/edit", Method::GET)); - } - } - public function provideDynamicMatchingCases(): Generator { yield 'Dynamic' => [ 'uri' => '/test/key/5/edit' ]; @@ -62,8 +44,16 @@ public function provideDynamicMatchingCases(): Generator yield 'Static route' => [ 'uri' => '/test/5' ]; } - public static function dummyMethod(): Response + private static function makeRouteConfig(): RouteConfig { - return new Ok(); + $constructor = new RouteConfigurator(); + foreach (range(1, 100) as $i) { + $constructor->addRoute(new Route("/test/{$i}", Method::GET)); + $constructor->addRoute(new Route("/test/{id}/{$i}", Method::GET)); + $constructor->addRoute(new Route("/test/{id}/{$i}/delete", Method::GET)); + $constructor->addRoute(new Route("/test/{id}/{$i}/edit", Method::GET)); + } + + return $constructor->toRouteConfig(); } }