diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 51805940d..7351f005a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -33,5 +33,6 @@ + diff --git a/src/Tempest/Http/src/RouteConfig.php b/src/Tempest/Http/src/RouteConfig.php index 84a44aca3..216d857e5 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\Matching\MatchingRegex; + final class RouteConfig { public function __construct( @@ -11,7 +13,7 @@ public function __construct( public array $staticRoutes = [], /** @var array> */ public array $dynamicRoutes = [], - /** @var array */ + /** @var array */ public array $matchingRegexes = [], ) { } diff --git a/src/Tempest/Http/src/Routing/Construction/RouteConfigurator.php b/src/Tempest/Http/src/Routing/Construction/RouteConfigurator.php index 65c6a4e7c..bb45b3a05 100644 --- a/src/Tempest/Http/src/Routing/Construction/RouteConfigurator.php +++ b/src/Tempest/Http/src/Routing/Construction/RouteConfigurator.php @@ -29,7 +29,6 @@ final class RouteConfigurator public function __construct() { - $this->routingTree = new RoutingTree(); } diff --git a/src/Tempest/Http/src/Routing/Construction/RouteMatchingRegexBuilder.php b/src/Tempest/Http/src/Routing/Construction/RouteMatchingRegexBuilder.php new file mode 100644 index 000000000..a1e0e425b --- /dev/null +++ b/src/Tempest/Http/src/Routing/Construction/RouteMatchingRegexBuilder.php @@ -0,0 +1,136 @@ +rootNode]; + + // Track how 'deep' we are in the tree to be able to rebuild the regex prefix when chunking + /** @var RouteTreeNode[] $stack */ + $stack = []; + + // Processes the working set until it is empty + while ($workingSet !== []) { + // Use array_pop for performance reasons, this does mean that the working set works in a fifo order + /** @var RouteTreeNode|null $node */ + $node = array_pop($workingSet); + + // null values are used as an end-marker, if one is found pop the stack and 'close' the regex + if ($node === null) { + array_pop($stack); + $regex .= $regexBack[0]; + + $regexBack = substr($regexBack, 1); + + continue; + } + + // Checks if the regex is getting to big, and thus if we need to chunk it. + if (strlen($regex) > self::REGEX_SIZE_LIMIT) { + $regexes[] = '#' . substr($regex, 1) . $regexBack . '#'; + $regex = ''; + + // Rebuild the regex match prefix based on the current visited parent nodes, known as 'the stack' + foreach ($stack as $previousNode) { + $regex .= '|' . self::routeNodeSegmentRegex($previousNode); + $regex .= '(?'; + } + } + + // Add the node route segment to the current regex + $regex .= '|' . self::routeNodeSegmentRegex($node); + $targetRouteRegex = self::routeNodeTargetRegex($node); + + // Check if node has children to ensure we only use branches if the node has children + if ($node->dynamicPaths !== [] || $node->staticPaths !== []) { + // The regex uses "Branch reset group" to match different available paths. + // two available routes /a and /b will create the regex (?|a|b) + $regex .= '(?'; + $regexBack .= ')'; + $stack[] = $node; + + // Add target route regex as an alteration group + if ($targetRouteRegex) { + $regex .= '|' . $targetRouteRegex; + } + + // Add an end marker to the working set, this will be processed after the children has been processed + $workingSet[] = null; + + // Add dynamic routes to the working set, these will be processed before the end marker + foreach ($node->dynamicPaths as $child) { + $workingSet[] = $child; + } + + // Add static routes to the working set, these will be processed first due to the array_pop + foreach ($node->staticPaths as $child) { + $workingSet[] = $child; + } + + } else { + // Add target route to main regex without any children + $regex .= $targetRouteRegex; + } + } + + // Return all regex chunks including the current one + return new MatchingRegex([ + ...$regexes, + '#' . substr($regex, 1) . '#', + ]); + } + + /** + * Create regex for the targetRoute in node with 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. + * Returns an empty string for nodes without a target. + */ + private static function routeNodeTargetRegex(RouteTreeNode $node): string + { + if ($node->targetRoute === null) { + return ''; + } + + return '\/?$(*' . MarkedRoute::REGEX_MARK_TOKEN . ':' . $node->targetRoute->mark . ')'; + } + + /** + * Creates the regex for a route node's segment + */ + private static function routeNodeSegmentRegex(RouteTreeNode $node): string + { + return match($node->type) { + RouteTreeNodeType::Root => '^', + RouteTreeNodeType::Static => "/{$node->segment}", + RouteTreeNodeType::Dynamic => '/(' . $node->segment . ')', + }; + } +} diff --git a/src/Tempest/Http/src/Routing/Construction/RouteTreeNode.php b/src/Tempest/Http/src/Routing/Construction/RouteTreeNode.php index c2a0cf964..33c762a74 100644 --- a/src/Tempest/Http/src/Routing/Construction/RouteTreeNode.php +++ b/src/Tempest/Http/src/Routing/Construction/RouteTreeNode.php @@ -12,12 +12,12 @@ final class RouteTreeNode { /** @var array */ - private array $staticPaths = []; + public array $staticPaths = []; /** @var array */ - private array $dynamicPaths = []; + public array $dynamicPaths = []; - private ?MarkedRoute $targetRoute = null; + public ?MarkedRoute $targetRoute = null; private function __construct( public readonly RouteTreeNodeType $type, @@ -72,55 +72,4 @@ private static function convertDynamicSegmentToRegex(string $uriPart): string $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->targetRoute !== null) { - $regexp .= '|\/?$(*' . MarkedRoute::REGEX_MARK_TOKEN . ':' . $this->targetRoute->mark . ')'; - } - - $regexp .= ")"; - } elseif ($this->targetRoute !== null) { - // Add a singular leaf regex without alteration - $regexp .= '\/?$(*' . MarkedRoute::REGEX_MARK_TOKEN . ':' . $this->targetRoute->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/RoutingTree.php b/src/Tempest/Http/src/Routing/Construction/RoutingTree.php index 8c765ba95..7197680ad 100644 --- a/src/Tempest/Http/src/Routing/Construction/RoutingTree.php +++ b/src/Tempest/Http/src/Routing/Construction/RoutingTree.php @@ -4,6 +4,8 @@ namespace Tempest\Http\Routing\Construction; +use Tempest\Http\Routing\Matching\MatchingRegex; + /** * @internal */ @@ -32,9 +34,9 @@ public function add(MarkedRoute $markedRoute): void $node->setTargetRoute($markedRoute); } - /** @return array */ + /** @return array */ public function toMatchingRegexes(): array { - return array_map(static fn (RouteTreeNode $node) => "#{$node->toRegex()}#", $this->roots); + return array_map(static fn (RouteTreeNode $node) => (new RouteMatchingRegexBuilder($node))->toRegex(), $this->roots); } } diff --git a/src/Tempest/Http/src/Routing/Matching/GenericRouteMatcher.php b/src/Tempest/Http/src/Routing/Matching/GenericRouteMatcher.php index a10968dcc..50a0f1119 100644 --- a/src/Tempest/Http/src/Routing/Matching/GenericRouteMatcher.php +++ b/src/Tempest/Http/src/Routing/Matching/GenericRouteMatcher.php @@ -8,7 +8,6 @@ use Tempest\Http\MatchedRoute; use Tempest\Http\Route; use Tempest\Http\RouteConfig; -use Tempest\Http\Routing\Construction\MarkedRoute; final readonly class GenericRouteMatcher implements RouteMatcher { @@ -50,17 +49,17 @@ private function matchDynamicRoute(PsrRequest $request): ?MatchedRoute $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); + $matchResult = $matchingRegexForMethod->match($request->getUri()->getPath()); - if (! $matchResult || ! array_key_exists(MarkedRoute::REGEX_MARK_TOKEN, $routingMatches)) { + if ($matchResult === null) { return null; } // Get the route based on the matched mark - $route = $routesForMethod[$routingMatches[MarkedRoute::REGEX_MARK_TOKEN]]; + $route = $routesForMethod[$matchResult->mark]; // Extract the parameters based on the route and matches - $routeParams = $this->extractParams($route, $routingMatches); + $routeParams = $this->extractParams($route, $matchResult->matches); return new MatchedRoute($route, $routeParams); } diff --git a/src/Tempest/Http/src/Routing/Matching/MatchingRegex.php b/src/Tempest/Http/src/Routing/Matching/MatchingRegex.php new file mode 100644 index 000000000..d23ae50ba --- /dev/null +++ b/src/Tempest/Http/src/Routing/Matching/MatchingRegex.php @@ -0,0 +1,42 @@ +patterns as $pattern) { + $matchResult = preg_match($pattern, $uri, $matches); + + if ($matchResult === false) { + throw new RuntimeException("Failed to use matching regex. Got error " . preg_last_error()); + } + + if (! $matchResult) { + continue; + } + + if (! array_key_exists(MarkedRoute::REGEX_MARK_TOKEN, $matches)) { + continue; + } + + return RouteMatch::match($matches); + } + + return null; + } +} diff --git a/src/Tempest/Http/src/Routing/Matching/RouteMatch.php b/src/Tempest/Http/src/Routing/Matching/RouteMatch.php new file mode 100644 index 000000000..87f6329c7 --- /dev/null +++ b/src/Tempest/Http/src/Routing/Matching/RouteMatch.php @@ -0,0 +1,21 @@ + ['b' => new Route('/', Method::POST)], ], [ - 'POST' => '#^(?|/([^/]++)(?|/1\/?$(*MARK:b)|/3\/?$(*MARK:d)))#', + 'POST' => new MatchingRegex(['#^(?|/([^/]++)(?|/1\/?$(*MARK:b)|/3\/?$(*MARK:d)))#']), ] ); diff --git a/src/Tempest/Http/tests/Routing/Construction/RouteConfiguratorTest.php b/src/Tempest/Http/tests/Routing/Construction/RouteConfiguratorTest.php index 665fd0230..3e163492f 100644 --- a/src/Tempest/Http/tests/Routing/Construction/RouteConfiguratorTest.php +++ b/src/Tempest/Http/tests/Routing/Construction/RouteConfiguratorTest.php @@ -10,6 +10,7 @@ use Tempest\Http\Route; use Tempest\Http\RouteConfig; use Tempest\Http\Routing\Construction\RouteConfigurator; +use Tempest\Http\Routing\Matching\MatchingRegex; /** * @internal @@ -100,9 +101,15 @@ public function test_adding_dynamic_routes(): void ], $config->dynamicRoutes); $this->assertEquals([ - 'GET' => '#^(?|/dynamic(?|/([^/]++)(?|/view\/?$(*MARK:d)|/([^/]++)(?|/([^/]++)(?|/([^/]++)\/?$(*MARK:e)))|\/?$(*MARK:b))))#', - 'PATCH' => '#^(?|/dynamic(?|/([^/]++)\/?$(*MARK:c)))#', - 'DELETE' => '#^(?|/dynamic(?|/([^/]++)\/?$(*MARK:f)))#', + 'GET' => new MatchingRegex([ + '#^(?|/dynamic(?|/([^/]++)(?|\/?$(*MARK:b)|/view\/?$(*MARK:d)|/([^/]++)(?|/([^/]++)(?|/([^/]++)\/?$(*MARK:e))))))#', + ]), + 'PATCH' => new MatchingRegex([ + '#^(?|/dynamic(?|/([^/]++)\/?$(*MARK:c)))#', + ]), + 'DELETE' => new MatchingRegex([ + '#^(?|/dynamic(?|/([^/]++)\/?$(*MARK:f)))#', + ]), ], $config->matchingRegexes); } } diff --git a/src/Tempest/Http/tests/Routing/Construction/RoutingTreeTest.php b/src/Tempest/Http/tests/Routing/Construction/RoutingTreeTest.php index 881500a8f..a0b87ee18 100644 --- a/src/Tempest/Http/tests/Routing/Construction/RoutingTreeTest.php +++ b/src/Tempest/Http/tests/Routing/Construction/RoutingTreeTest.php @@ -10,55 +10,70 @@ use Tempest\Http\Routing\Construction\DuplicateRouteException; use Tempest\Http\Routing\Construction\MarkedRoute; use Tempest\Http\Routing\Construction\RoutingTree; +use Tempest\Http\Routing\Matching\MatchingRegex; /** * @internal */ final class RoutingTreeTest extends TestCase { - private RoutingTree $subject; - - protected function setUp(): void - { - parent::setUp(); - - $this->subject = new RoutingTree(); - } - public function test_empty_tree(): void { - $this->assertEquals([], $this->subject->toMatchingRegexes()); + $subject = new RoutingTree(); + $this->assertEquals([], $subject->toMatchingRegexes()); } public function test_add_throws_on_duplicated_routes(): void { + $subject = new RoutingTree(); $this->expectException(DuplicateRouteException::class); - $this->subject->add(new MarkedRoute('a', new Route('/', Method::GET))); - $this->subject->add(new MarkedRoute('b', new Route('/', Method::GET))); + $subject->add(new MarkedRoute('a', new Route('/', Method::GET))); + $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))); + $subject = new RoutingTree(); + $subject->add(new MarkedRoute('a', new Route('/', Method::GET))); + $subject->add(new MarkedRoute('b', new Route('/{id}/hello/{name}', Method::GET))); + $subject->add(new MarkedRoute('c', new Route('/{id}/hello/brent', Method::GET))); + $subject->add(new MarkedRoute('d', new Route('/{greeting}/{name}', Method::GET))); + $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()); + 'GET' => new MatchingRegex([ + '#^(?|\/?$(*MARK:a)|/([^/]++)(?|/brent\/?$(*MARK:e)|/hello(?|/brent\/?$(*MARK:c)|/([^/]++)\/?$(*MARK:b))|/([^/]++)\/?$(*MARK:d)))#', + ]), + ], $subject->toMatchingRegexes()); + } + + public function test_chunked_routes(): void + { + $subject = new RoutingTree(); + $mark = 'a'; + + for ($i = 0; $i <= 1000; $i++) { + $mark = str_increment($mark); + $subject->add(new MarkedRoute($mark, new Route("/test/{$i}/route_{$i}", Method::GET))); + } + + $matchingRegexes = $subject->toMatchingRegexes()['GET']; + $this->assertGreaterThan(1, count($matchingRegexes->patterns)); + + $this->assertNotNull($matchingRegexes->match('/test/0/route_0')); + $this->assertNotNull($matchingRegexes->match('/test/1000/route_1000')); } 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))); + $subject = new RoutingTree(); + $subject->add(new MarkedRoute('a', new Route('/', Method::GET))); + $subject->add(new MarkedRoute('b', new Route('/', Method::POST))); $this->assertEquals([ - 'GET' => '#^\/?$(*MARK:a)#', - 'POST' => '#^\/?$(*MARK:b)#', - ], $this->subject->toMatchingRegexes()); + 'GET' => new MatchingRegex(['#^\/?$(*MARK:a)#']), + 'POST' => new MatchingRegex(['#^\/?$(*MARK:b)#']), + ], $subject->toMatchingRegexes()); } } diff --git a/src/Tempest/Http/tests/Routing/Matching/GenericRouteMatcherTest.php b/src/Tempest/Http/tests/Routing/Matching/GenericRouteMatcherTest.php index a9fae55b2..e3ddebbeb 100644 --- a/src/Tempest/Http/tests/Routing/Matching/GenericRouteMatcherTest.php +++ b/src/Tempest/Http/tests/Routing/Matching/GenericRouteMatcherTest.php @@ -10,6 +10,7 @@ use Tempest\Http\Route; use Tempest\Http\RouteConfig; use Tempest\Http\Routing\Matching\GenericRouteMatcher; +use Tempest\Http\Routing\Matching\MatchingRegex; /** * @internal @@ -41,8 +42,8 @@ protected function setUp(): void ], ], [ - 'GET' => '#^(?|/dynamic(?|/([^/]++)(?|/view\/?$(*MARK:d)|/([^/]++)(?|/([^/]++)(?|/([^/]++)\/?$(*MARK:e)))|\/?$(*MARK:b))))#', - 'PATCH' => '#^(?|/dynamic(?|/([^/]++)\/?$(*MARK:c)))#', + 'GET' => new MatchingRegex(['#^(?|/dynamic(?|/([^/]++)(?|/view\/?$(*MARK:d)|/([^/]++)(?|/([^/]++)(?|/([^/]++)\/?$(*MARK:e)))|\/?$(*MARK:b))))#']), + 'PATCH' => new MatchingRegex(['#^(?|/dynamic(?|/([^/]++)\/?$(*MARK:c)))#']), ] ); diff --git a/src/Tempest/Http/tests/Routing/Matching/MatchingRegexTest.php b/src/Tempest/Http/tests/Routing/Matching/MatchingRegexTest.php new file mode 100644 index 000000000..8f8f8f4c3 --- /dev/null +++ b/src/Tempest/Http/tests/Routing/Matching/MatchingRegexTest.php @@ -0,0 +1,56 @@ +subject = new MatchingRegex([ + '#^(a)(*MARK:a)$#', + '#^(b)(*MARK:b)$#', + '#^(c)(*MARK:c)$#', + ]); + } + + public function test_empty(): void + { + $subject = new MatchingRegex([]); + + $this->assertNull($subject->match('')); + } + + #[TestWith(['a'])] + #[TestWith(['b'])] + #[TestWith(['c'])] + public function test_match(string $expectedMatch): void + { + $match = $this->subject->match($expectedMatch); + + $this->assertNotNull($match); + $this->assertEquals($expectedMatch, $match->mark); + $this->assertEquals($expectedMatch, $match->matches[1]); + } + + #[TestWith([''])] + #[TestWith(['d'])] + public function test_non_match(string $expectedNonMatch): void + { + $match = $this->subject->match($expectedNonMatch); + + $this->assertNull($match); + } +}