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);
+ }
+}