diff --git a/composer.json b/composer.json index 2bbacf874..9fac5a18d 100644 --- a/composer.json +++ b/composer.json @@ -45,6 +45,7 @@ "jenssegers/blade": "^2.0", "nyholm/psr7": "^1.8", "phpat/phpat": "^0.10.14", + "phpbench/phpbench": "84.x-dev", "phpstan/phpstan": "^1.10.0", "phpunit/phpunit": "^11.3.5", "rector/rector": "^1.2", diff --git a/phpbench.json b/phpbench.json new file mode 100644 index 000000000..5001cbb45 --- /dev/null +++ b/phpbench.json @@ -0,0 +1,4 @@ +{ + "$schema":"./vendor/phpbench/phpbench/phpbench.schema.json", + "runner.bootstrap": "vendor/autoload.php" +} \ No newline at end of file diff --git a/src/Tempest/Http/src/GenericRouter.php b/src/Tempest/Http/src/GenericRouter.php index 0c8eb3a6f..1af4bbdf1 100644 --- a/src/Tempest/Http/src/GenericRouter.php +++ b/src/Tempest/Http/src/GenericRouter.php @@ -16,9 +16,9 @@ use Tempest\Http\Responses\Invalid; use Tempest\Http\Responses\NotFound; use Tempest\Http\Responses\Ok; +use Tempest\Http\Routing\Matching\RouteMatcher; use function Tempest\map; use Tempest\Reflection\ClassReflector; -use function Tempest\Support\str; use Tempest\Validation\Exceptions\ValidationException; use Tempest\View\View; @@ -27,14 +27,12 @@ */ final class GenericRouter implements Router { - public const string REGEX_MARK_TOKEN = 'MARK'; - /** @var class-string[] */ private array $middleware = []; public function __construct( private readonly Container $container, - private readonly RouteConfig $routeConfig, + private readonly RouteMatcher $routeMatcher, private readonly AppConfig $appConfig, ) { } @@ -45,7 +43,7 @@ public function dispatch(Request|PsrRequest $request): Response $request = map($request)->with(RequestToPsrRequestMapper::class); } - $matchedRoute = $this->matchRoute($request); + $matchedRoute = $this->routeMatcher->match($request); if ($matchedRoute === null) { return new NotFound(); @@ -76,17 +74,6 @@ public function dispatch(Request|PsrRequest $request): Response return $response; } - private function matchRoute(PsrRequest $request): ?MatchedRoute - { - // Try to match routes without any parameters - if (($staticRoute = $this->matchStaticRoute($request)) !== null) { - return $staticRoute; - } - - // match dynamic routes - return $this->matchDynamicRoute($request); - } - private function getCallable(MatchedRoute $matchedRoute): Closure { $route = $matchedRoute->route; @@ -167,39 +154,6 @@ public function toUri(array|string $action, ...$params): string return $uri; } - private function resolveParams(Route $route, string $uri): ?array - { - if ($route->uri === $uri) { - return []; - } - - $tokens = str($route->uri)->matchAll('#\{'. Route::ROUTE_PARAM_NAME_REGEX . Route::ROUTE_PARAM_CUSTOM_REGEX .'\}#', ); - - if (empty($tokens)) { - return null; - } - - $tokens = $tokens[1]; - - $matches = str($uri)->matchAll("#^$route->matchingRegex$#"); - - if (empty($matches)) { - return null; - } - - unset($matches[0]); - - $matches = array_values($matches); - - $valueMap = []; - - foreach ($matches as $i => $match) { - $valueMap[trim($tokens[$i], '{}')] = $match[0]; - } - - return $valueMap; - } - private function createResponse(Response|View $input): Response { if ($input instanceof View) { @@ -209,50 +163,6 @@ private function createResponse(Response|View $input): Response return $input; } - private function matchStaticRoute(PsrRequest $request): ?MatchedRoute - { - $staticRoute = $this->routeConfig->staticRoutes[$request->getMethod()][$request->getUri()->getPath()] ?? null; - - if ($staticRoute === null) { - return null; - } - - return new MatchedRoute($staticRoute, []); - } - - private function matchDynamicRoute(PsrRequest $request): ?MatchedRoute - { - // If there are no routes for the given request method, we immediately stop - $routesForMethod = $this->routeConfig->dynamicRoutes[$request->getMethod()] ?? null; - if ($routesForMethod === null) { - return null; - } - - // First we get the Routing-Regex for the request method - $matchingRegexForMethod = $this->routeConfig->matchingRegexes[$request->getMethod()]; - - // Then we'll use this regex to see whether we have a match or not - $matchResult = preg_match($matchingRegexForMethod, $request->getUri()->getPath(), $matches); - - if (! $matchResult || ! array_key_exists(self::REGEX_MARK_TOKEN, $matches)) { - return null; - } - - $route = $routesForMethod[$matches[self::REGEX_MARK_TOKEN]]; - - // TODO: we could probably optimize resolveParams now, - // because we already know for sure there's a match - $routeParams = $this->resolveParams($route, $request->getUri()->getPath()); - - // This check should _in theory_ not be needed, - // since we're certain there's a match - if ($routeParams === null) { - return null; - } - - return new MatchedRoute($route, $routeParams); - } - private function resolveRequest(PsrRequest $psrRequest, MatchedRoute $matchedRoute): Request { // Let's find out if our input request data matches what the route's action needs diff --git a/src/Tempest/Http/src/Route.php b/src/Tempest/Http/src/Route.php index da8eee3b8..d824f70c7 100644 --- a/src/Tempest/Http/src/Route.php +++ b/src/Tempest/Http/src/Route.php @@ -6,20 +6,18 @@ use Attribute; use Tempest\Reflection\MethodReflector; -use function Tempest\Support\arr; -use function Tempest\Support\str; #[Attribute] class Route { public MethodReflector $handler; - /** @var string The Regex used for matching this route against a request URI */ - public readonly string $matchingRegex; - /** @var bool If the route has params */ public readonly bool $isDynamic; + /** @var string[] Route parameters */ + public readonly array $params; + public const string DEFAULT_MATCHING_GROUP = '[^/]++'; public const string ROUTE_PARAM_NAME_REGEX = '(\w*)'; @@ -36,17 +34,8 @@ public function __construct( public array $middleware = [], ) { - // Routes can have parameters in the form of "/{PARAM}/" or /{PARAM:CUSTOM_REGEX}, - // these parameters are replaced with a regex matching group or with the custom regex - $matchingRegex = (string)str($this->uri)->replaceRegex( - '#\{'. self::ROUTE_PARAM_NAME_REGEX . self::ROUTE_PARAM_CUSTOM_REGEX .'\}#', - fn ($matches) => '(' . trim(arr($matches)->get('2', self::DEFAULT_MATCHING_GROUP)). ')' - ); - - $this->isDynamic = $matchingRegex !== $this->uri; - - // Allow for optional trailing slashes - $this->matchingRegex = $matchingRegex . '\/?'; + $this->params = self::getRouteParams($this->uri); + $this->isDynamic = ! empty($this->params); } public function setHandler(MethodReflector $handler): self @@ -55,4 +44,29 @@ public function setHandler(MethodReflector $handler): self return $this; } + + /** @return string[] */ + public static function getRouteParams(string $uriPart): array + { + $regex = '#\{'. self::ROUTE_PARAM_NAME_REGEX . self::ROUTE_PARAM_CUSTOM_REGEX .'\}#'; + + preg_match_all($regex, $uriPart, $matches); + + return $matches[1] ?? []; + } + + /** + * Splits the route URI into separate segments + * + * @example '/test/{id}/edit' becomes ['test', '{id}', 'edit'] + * @return string[] + */ + public function split(): array + { + $parts = explode('/', $this->uri); + + return array_values( + array_filter($parts, static fn (string $part) => $part !== '') + ); + } } diff --git a/src/Tempest/Http/src/RouteConfig.php b/src/Tempest/Http/src/RouteConfig.php index 1df5c8b68..e050ab0a4 100644 --- a/src/Tempest/Http/src/RouteConfig.php +++ b/src/Tempest/Http/src/RouteConfig.php @@ -4,6 +4,8 @@ namespace Tempest\Http; +use Tempest\Http\Routing\Construction\MarkedRoute; +use Tempest\Http\Routing\Construction\RoutingTree; use Tempest\Reflection\MethodReflector; final class RouteConfig @@ -14,12 +16,15 @@ final class RouteConfig /** @var array */ public array $matchingRegexes = []; + public RoutingTree $routingTree; + public function __construct( /** @var array> */ public array $staticRoutes = [], /** @var array> */ public array $dynamicRoutes = [], ) { + $this->routingTree = new RoutingTree(); } public function addRoute(MethodReflector $handler, Route $route): self @@ -29,7 +34,14 @@ public function addRoute(MethodReflector $handler, Route $route): self if ($route->isDynamic) { $this->regexMark = str_increment($this->regexMark); $this->dynamicRoutes[$route->method->value][$this->regexMark] = $route; - $this->addToMatchingRegex($route, $this->regexMark); + + $this->routingTree->add( + new MarkedRoute( + mark: $this->regexMark, + route: $route, + ) + ); + } else { $uriWithTrailingSlash = rtrim($route->uri, '/'); @@ -40,25 +52,33 @@ public function addRoute(MethodReflector $handler, Route $route): self return $this; } + public function prepareMatchingRegexes(): void + { + if (! empty($this->matchingRegexes)) { + return; + } + + $this->matchingRegexes = $this->routingTree->toMatchingRegexes(); + } + /** - * Build one big regex for matching request URIs. - * See https://github.com/tempestphp/tempest-framework/pull/175 for the details + * __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. */ - private function addToMatchingRegex(Route $route, string $routeMark): void + public function __sleep(): array { - // Each route, say "/posts/{postId}", which would have the regex "/posts/[^/]+", is marked. - // e.g "/posts/[^/]+ (*MARK:a)". - // This mark can then be used to find the matched route via a hashmap-lookup. - $routeRegexPart = "{$route->matchingRegex} (*" . GenericRouter::REGEX_MARK_TOKEN . ":{$routeMark})"; - - if (! array_key_exists($route->method->value, $this->matchingRegexes)) { - // initialize matching regex for method - $this->matchingRegexes[$route->method->value] = "#^(?|{$routeRegexPart})$#x"; + $this->prepareMatchingRegexes(); - return; - } + return ['staticRoutes', 'dynamicRoutes', 'matchingRegexes']; + } - // insert regex part of this route into the matching group of the regex for the method - $this->matchingRegexes[$route->method->value] = substr_replace($this->matchingRegexes[$route->method->value], "|{$routeRegexPart}", -4, 0); + /** + * __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 + { + $this->routingTree = new RoutingTree(); } } diff --git a/src/Tempest/Http/src/Routing/Construction/DuplicateRouteException.php b/src/Tempest/Http/src/Routing/Construction/DuplicateRouteException.php new file mode 100644 index 000000000..8ebc5fedb --- /dev/null +++ b/src/Tempest/Http/src/Routing/Construction/DuplicateRouteException.php @@ -0,0 +1,16 @@ +uri}' already exists."); + } +} diff --git a/src/Tempest/Http/src/Routing/Construction/MarkedRoute.php b/src/Tempest/Http/src/Routing/Construction/MarkedRoute.php new file mode 100644 index 000000000..52f592cc6 --- /dev/null +++ b/src/Tempest/Http/src/Routing/Construction/MarkedRoute.php @@ -0,0 +1,18 @@ + */ + private array $staticPaths = []; + + /** @var array */ + private array $dynamicPaths = []; + + private ?MarkedRoute $leaf = null; + + private function __construct( + public readonly RouteTreeNodeType $type, + public readonly ?string $segment = null + ) { + } + + public static function createRootRoute(): self + { + return new self(RouteTreeNodeType::Root); + } + + public static function createDynamicRouteNode(string $regex): self + { + return new self(RouteTreeNodeType::Dynamic, $regex); + } + + public static function createStaticRouteNode(string $name): self + { + return new self(RouteTreeNodeType::Static, $name); + } + + public function addPath(array $pathSegments, MarkedRoute $markedRoute): void + { + // If path segments is empty this node should target to given marked route + if ($pathSegments === []) { + if ($this->leaf !== null) { + throw new DuplicateRouteException($markedRoute->route); + } + + $this->leaf = $markedRoute; + + return; + } + + // Removes the first element of the pathSegments and use it to determin the next routing node + $currentPathSegment = array_shift($pathSegments); + + // Translates a path segment like {id} into it's matching regex. Static segments remain the same + $regexPathSegment = self::convertDynamicSegmentToRegex($currentPathSegment); + + // Find or create the next node to recurse into + if ($currentPathSegment !== $regexPathSegment) { + $node = $this->dynamicPaths[$regexPathSegment] ??= self::createDynamicRouteNode($regexPathSegment); + } else { + $node = $this->staticPaths[$regexPathSegment] ??= self::createStaticRouteNode($currentPathSegment); + } + + // Recurse into the newly created node to add the remainder of the path segments + $node->addPath($pathSegments, $markedRoute); + } + + private static function convertDynamicSegmentToRegex(string $uriPart): string + { + $regex = '#\{'. Route::ROUTE_PARAM_NAME_REGEX . Route::ROUTE_PARAM_CUSTOM_REGEX .'\}#'; + + return preg_replace_callback( + $regex, + static fn ($matches) => trim($matches[2] ?? Route::DEFAULT_MATCHING_GROUP), + $uriPart, + ); + } + + /** + * Return the matching regex of this path and it's children by means of recursion + */ + public function toRegex(): string + { + $regexp = $this->regexSegment(); + + if ($this->staticPaths !== [] || $this->dynamicPaths !== []) { + // The regex uses "Branch reset group" to match different available paths. + // two available routes /a and /b will create the regex (?|a|b) + $regexp .= "(?"; + + // Add static route alteration + foreach ($this->staticPaths as $path) { + $regexp .= '|' . $path->toRegex(); + } + + // Add dynamic route alteration, for example routes {id:\d} and {id:\w} will create the regex (?|(\d)|(\w)). + // Both these parameter matches will end up on the same index in the matches array. + foreach ($this->dynamicPaths as $path) { + $regexp .= '|' . $path->toRegex(); + } + + // Add a leaf alteration with an optional slash and end of line match `$`. + // The `(*MARK:x)` is a marker which when this regex is matched will cause the matches array to contain + // a key `"MARK"` with value `"x"`, it is used to track which route has been matched + if ($this->leaf !== null) { + $regexp .= '|\/?$(*' . MarkedRoute::REGEX_MARK_TOKEN . ':' . $this->leaf->mark . ')'; + } + + $regexp .= ")"; + } elseif ($this->leaf !== null) { + // Add a singular leaf regex without alteration + $regexp .= '\/?$(*' . MarkedRoute::REGEX_MARK_TOKEN . ':' . $this->leaf->mark . ')'; + } + + return $regexp; + } + + /** + * Translates the only current node segment into regex. This does not recurse into it's child nodes. + */ + private function regexSegment(): string + { + return match($this->type) { + RouteTreeNodeType::Root => '^', + RouteTreeNodeType::Static => "/{$this->segment}", + RouteTreeNodeType::Dynamic => '/(' . $this->segment . ')', + }; + } +} diff --git a/src/Tempest/Http/src/Routing/Construction/RouteTreeNodeType.php b/src/Tempest/Http/src/Routing/Construction/RouteTreeNodeType.php new file mode 100644 index 000000000..7c808486f --- /dev/null +++ b/src/Tempest/Http/src/Routing/Construction/RouteTreeNodeType.php @@ -0,0 +1,12 @@ + */ + private array $roots; + + public function __construct() + { + $this->roots = []; + } + + public function add(MarkedRoute $markedRoute): void + { + $method = $markedRoute->route->method; + + // Find the root tree node based on HTTP method + $root = $this->roots[$method->value] ??= RouteTreeNode::createRootRoute(); + + // Add path to tree using recursion + $root->addPath($markedRoute->route->split(), $markedRoute); + } + + /** @return array */ + public function toMatchingRegexes(): array + { + return array_map(static fn (RouteTreeNode $node) => "#{$node->toRegex()}#", $this->roots); + } +} diff --git a/src/Tempest/Http/src/Routing/Matching/GenericRouteMatcher.php b/src/Tempest/Http/src/Routing/Matching/GenericRouteMatcher.php new file mode 100644 index 000000000..e8c57fe33 --- /dev/null +++ b/src/Tempest/Http/src/Routing/Matching/GenericRouteMatcher.php @@ -0,0 +1,86 @@ +matchStaticRoute($request)) !== null) { + return $staticRoute; + } + + // match dynamic routes + return $this->matchDynamicRoute($request); + } + + private function matchStaticRoute(PsrRequest $request): ?MatchedRoute + { + $staticRoute = $this->routeConfig->staticRoutes[$request->getMethod()][$request->getUri()->getPath()] ?? null; + + if ($staticRoute === null) { + return null; + } + + return new MatchedRoute($staticRoute, []); + } + + private function matchDynamicRoute(PsrRequest $request): ?MatchedRoute + { + // If there are no routes for the given request method, we immediately stop + $routesForMethod = $this->routeConfig->dynamicRoutes[$request->getMethod()] ?? null; + if ($routesForMethod === null) { + return null; + } + + // Ensures matching regexes are available + $this->routeConfig->prepareMatchingRegexes(); + + // Get matching regex for route + $matchingRegexForMethod = $this->routeConfig->matchingRegexes[$request->getMethod()]; + + // Then we'll use this regex to see whether we have a match or not + $matchResult = preg_match($matchingRegexForMethod, $request->getUri()->getPath(), $routingMatches); + + if (! $matchResult || ! array_key_exists(MarkedRoute::REGEX_MARK_TOKEN, $routingMatches)) { + return null; + } + + // Get the route based on the matched mark + $route = $routesForMethod[$routingMatches[MarkedRoute::REGEX_MARK_TOKEN]]; + + // Extract the parameters based on the route and matches + $routeParams = $this->extractParams($route, $routingMatches); + + return new MatchedRoute($route, $routeParams); + } + + /** + * Extracts route parameters from the routeMatches + * + * @param array $routeMatches + * @return array + */ + private function extractParams(Route $route, array $routeMatches): array + { + $valueMap = []; + foreach ($route->params as $i => $param) { + $valueMap[$param] = $routeMatches[$i + 1]; + } + + return $valueMap; + } +} diff --git a/src/Tempest/Http/src/Routing/Matching/RouteMatcher.php b/src/Tempest/Http/src/Routing/Matching/RouteMatcher.php new file mode 100644 index 000000000..a7528a52b --- /dev/null +++ b/src/Tempest/Http/src/Routing/Matching/RouteMatcher.php @@ -0,0 +1,13 @@ +get(GenericRouteMatcher::class); + } +} diff --git a/src/Tempest/Http/tests/RouteConfigTest.php b/src/Tempest/Http/tests/RouteConfigTest.php new file mode 100644 index 000000000..de7d42917 --- /dev/null +++ b/src/Tempest/Http/tests/RouteConfigTest.php @@ -0,0 +1,130 @@ +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 + { + $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], + ], + 'POST' => [ + 'c' => $routes[1], + ], + ], $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), + ]; + + $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); + /** @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(); + } +} diff --git a/src/Tempest/Http/tests/RouteTest.php b/src/Tempest/Http/tests/RouteTest.php index 4c81e5e28..ea9222419 100644 --- a/src/Tempest/Http/tests/RouteTest.php +++ b/src/Tempest/Http/tests/RouteTest.php @@ -4,6 +4,7 @@ namespace Tempest\Http\Tests; +use Generator; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Tempest\Http\Method; @@ -12,14 +13,22 @@ /** * @internal */ - final class RouteTest extends TestCase { - #[DataProvider('uri_provider')] - public function test_matching_regex(string $uri, string $expected): void + #[DataProvider('uri_provider_with_param')] + public function test_extract_parameters(string $uri, array $expectedParams): void { $route = new Route($uri, Method::GET); - $this->assertEquals($expected, $route->matchingRegex); + $this->assertEquals($expectedParams, $route->params); + } + + public static function uri_provider_with_param(): Generator + { + yield 'static route' => ['/foo', []]; + yield 'dynamic route' => ['/foo/{bar}', ['bar']]; + yield 'dynamic route custom regex' => ['/foo/{bar:.*}', ['bar']]; + yield 'dynamic route with more parameters' => ['/{foo}/{bar}', ['foo', 'bar']]; + yield 'dynamic route with the same parameters' => ['/{bar}/{bar}', ['bar', 'bar']]; } public function test_correctly_identifies_static_route(): void @@ -34,15 +43,24 @@ public function test_correctly_identifies_dynamic_route(): void $this->assertTrue($route->isDynamic); } - public static function uri_provider(): array + #[DataProvider('uri_with_route_parts')] + public function test_route_parts(string $uri, array $expectedRouteParts): void + { + $route = new Route($uri, Method::GET); + $this->assertEquals($expectedRouteParts, $route->split()); + } + + public static function uri_with_route_parts(): Generator { - return [ - 'static route' => ['/foo', '/foo\/?'], - 'dynamic route' => ['/foo/{bar}', '/foo/([^/]++)\/?'], - 'dynamic route custom regex' => ['/foo/{bar:.*}', '/foo/(.*)\/?'], - 'dynamic route custom regex and nested {}' => ['/foo/{bar:a{3}}', '/foo/(a{3})\/?'], - 'dynamic route with broken custom regex' => ['/foo/{bar: {bar}}', '/foo/({bar})\/?'], - 'dynamic route custom regex and nested group' => ['/foo/{bar:id_([0-9]+)}', '/foo/(id_([0-9]+))\/?'], - ]; + yield 'empty' => ['', []]; + yield 'root route' => ['/', []]; + yield 'static route' => ['/foo', ['foo']]; + yield 'static route with trailing slash' => ['/foo/', ['foo']]; + yield 'route with many slashes' => ['/foo////bar//', ['foo', 'bar']]; + yield 'dynamic route' => ['/foo/{bar}', ['foo', '{bar}']]; + yield 'does not filter out 0 in routes' => ['/foo/0/bar', ['foo', '0', 'bar']]; + yield 'dynamic route custom regex' => ['/foo/{bar:.*}', ['foo', '{bar:.*}']]; + yield 'dynamic route with more parameters' => ['/{foo}/{bar}', ['{foo}', '{bar}']]; + yield 'dynamic route with the same parameters' => ['/{bar}/{bar}', ['{bar}', '{bar}']]; } } diff --git a/src/Tempest/Http/tests/Routing/Construction/RoutingTreeTest.php b/src/Tempest/Http/tests/Routing/Construction/RoutingTreeTest.php new file mode 100644 index 000000000..881500a8f --- /dev/null +++ b/src/Tempest/Http/tests/Routing/Construction/RoutingTreeTest.php @@ -0,0 +1,64 @@ +subject = new RoutingTree(); + } + + public function test_empty_tree(): void + { + $this->assertEquals([], $this->subject->toMatchingRegexes()); + } + + public function test_add_throws_on_duplicated_routes(): void + { + $this->expectException(DuplicateRouteException::class); + + $this->subject->add(new MarkedRoute('a', new Route('/', Method::GET))); + $this->subject->add(new MarkedRoute('b', new Route('/', Method::GET))); + } + + public function test_multiple_routes(): void + { + $this->subject->add(new MarkedRoute('a', new Route('/', Method::GET))); + $this->subject->add(new MarkedRoute('b', new Route('/{id}/hello/{name}', Method::GET))); + $this->subject->add(new MarkedRoute('c', new Route('/{id}/hello/brent', Method::GET))); + $this->subject->add(new MarkedRoute('d', new Route('/{greeting}/{name}', Method::GET))); + $this->subject->add(new MarkedRoute('e', new Route('/{greeting}/brent', Method::GET))); + + $this->assertEquals([ + 'GET' => '#^(?|/([^/]++)(?|/hello(?|/brent\/?$(*MARK:c)|/([^/]++)\/?$(*MARK:b))|/brent\/?$(*MARK:e)|/([^/]++)\/?$(*MARK:d))|\/?$(*MARK:a))#', + ], $this->subject->toMatchingRegexes()); + } + + public function test_multiple_http_methods(): void + { + $this->subject->add(new MarkedRoute('a', new Route('/', Method::GET))); + $this->subject->add(new MarkedRoute('b', new Route('/', Method::POST))); + + $this->assertEquals([ + 'GET' => '#^\/?$(*MARK:a)#', + 'POST' => '#^\/?$(*MARK:b)#', + ], $this->subject->toMatchingRegexes()); + } +} diff --git a/src/Tempest/Http/tests/Routing/Matching/GenericRouteMatcherTest.php b/src/Tempest/Http/tests/Routing/Matching/GenericRouteMatcherTest.php new file mode 100644 index 000000000..28d60e88d --- /dev/null +++ b/src/Tempest/Http/tests/Routing/Matching/GenericRouteMatcherTest.php @@ -0,0 +1,97 @@ +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->subject = new GenericRouteMatcher($this->routeConfig); + } + + public function test_match_on_static_route(): void + { + $request = new ServerRequest(uri: '/static', method: 'GET'); + + $matchedRoute = $this->subject->match($request); + + $this->assertEquals([], $matchedRoute->params); + $this->assertFalse($matchedRoute->route->isDynamic); + $this->assertEquals('/static', $matchedRoute->route->uri); + } + + public function test_match_returns_null_on_unknown_route(): void + { + $request = new ServerRequest(uri: '/non-existing', method: 'GET'); + + $matchedRoute = $this->subject->match($request); + + $this->assertNull($matchedRoute); + } + + public function test_match_returns_null_on_unconfigured_method(): void + { + $request = new ServerRequest(uri: '/static', method: 'POST'); + + $matchedRoute = $this->subject->match($request); + + $this->assertNull($matchedRoute); + } + + public function test_match_on_dynamic_route(): void + { + $request = new ServerRequest(uri: '/dynamic/5', method: 'GET'); + + $matchedRoute = $this->subject->match($request); + + $this->assertEquals([ 'id' => '5' ], $matchedRoute->params); + $this->assertTrue($matchedRoute->route->isDynamic); + $this->assertEquals('/dynamic/{id}', $matchedRoute->route->uri); + } + + public function test_match_on_dynamic_route_with_many_parameters(): void + { + $request = new ServerRequest(uri: '/dynamic/5/brendt/brent/6', method: 'GET'); + + $matchedRoute = $this->subject->match($request); + + $this->assertEquals([ 'id' => '6', 'tag' => 'brendt', 'name' => 'brent' ], $matchedRoute->params); + $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 new file mode 100644 index 000000000..895cbe649 --- /dev/null +++ b/tests/Benchmark/Http/RouteConfigBench.php @@ -0,0 +1,59 @@ +config = new RouteConfig(); + } + + #[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 + { + $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 static function dummyMethod(): Response + { + return new Ok(); + } +} diff --git a/tests/Benchmark/Http/Routing/Matching/GenericRouteMatcherBench.php b/tests/Benchmark/Http/Routing/Matching/GenericRouteMatcherBench.php new file mode 100644 index 000000000..4e656b28d --- /dev/null +++ b/tests/Benchmark/Http/Routing/Matching/GenericRouteMatcherBench.php @@ -0,0 +1,69 @@ +config = new RouteConfig(); + + $this->matcher = new GenericRouteMatcher($this->config); + + $this->setupConfigRoutes(); + } + + #[Warmup(10)] + #[Revs(1000)] + #[ParamProviders('provideDynamicMatchingCases')] + public function benchMatch(array $params): void + { + $this->matcher->match( + new ServerRequest(uri: $params['uri'], method: 'GET') + ); + } + + 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' ]; + yield 'Non existing long' => [ 'uri' => '/test/key/5/nonexisting' ]; + yield 'Non existing short' => [ 'uri' => '/404' ]; + yield 'Static route' => [ 'uri' => '/test/5' ]; + } + + public static function dummyMethod(): Response + { + return new Ok(); + } +} diff --git a/tests/Benchmark/README.md b/tests/Benchmark/README.md new file mode 100644 index 000000000..e9a9f46c5 --- /dev/null +++ b/tests/Benchmark/README.md @@ -0,0 +1,17 @@ +# Tempest benchmarks + +Tempest uses phpbench to benchmark performance critical benchmarks. These are not required to pass any requirements, but +can be used as a tool during optimization efforts. + +## Usage + +Run in the repository root using: + +```shell +./vendor/bin/phpbench run tests/Benchmark --report=aggregate --iterations=5 +``` + +Iterations can be used to alter how many times all benchmarks are ran. + + +