Skip to content

Commit

Permalink
feat(routing): add route chunking
Browse files Browse the repository at this point in the history
  • Loading branch information
blackshadev committed Nov 10, 2024
1 parent f719c20 commit 3e57646
Show file tree
Hide file tree
Showing 14 changed files with 328 additions and 92 deletions.
1 change: 1 addition & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,6 @@
<env name="BASE_URI" value="" />
<env name="CACHE" value="null" />
<env name="DISCOVERY_CACHE" value="true" />
<ini name="memory_limit" value="256M" />
</php>
</phpunit>
4 changes: 3 additions & 1 deletion src/Tempest/Http/src/RouteConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@

namespace Tempest\Http;

use Tempest\Http\Routing\Matching\MatchingRegexes;

final class RouteConfig
{
public function __construct(
/** @var array<string, array<string, Route>> */
public array $staticRoutes = [],
/** @var array<string, array<string, Route>> */
public array $dynamicRoutes = [],
/** @var array<string, string> */
/** @var array<string, MatchingRegexes> */
public array $matchingRegexes = [],
) {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ final class RouteConfigurator

public function __construct()
{

$this->routingTree = new RoutingTree();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

declare(strict_types=1);

namespace Tempest\Http\Routing\Construction;

use Tempest\Http\Routing\Matching\MatchingRegexes;

final readonly class RouteMatchingRegexesBuilder
{
// This limit is guesstimated using a small script with an ever in pattern feed into preg_match
private const int PREG_REGEX_SIZE_LIMIT = 32764;

private const int REGEX_SIZE_MARGIN = 264;

private const REGEX_SIZE_LIMIT = self::PREG_REGEX_SIZE_LIMIT - self::REGEX_SIZE_MARGIN;

public function __construct(private RouteTreeNode $rootNode)
{
}

public function toRegex(): MatchingRegexes
{
// Holds all regex "chunks"
$regexes = [];

// Current regex chunk
$regex = '';
// Used to track how to 'end' a regex chunk partially in the building process
$regexBack = '';

/** @var (RouteTreeNode|null)[] $workingSet */
$workingSet = [$this->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 MatchingRegexes([
...$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 . ')',
};
}
}
57 changes: 3 additions & 54 deletions src/Tempest/Http/src/Routing/Construction/RouteTreeNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@
final class RouteTreeNode
{
/** @var array<string, RouteTreeNode> */
private array $staticPaths = [];
public array $staticPaths = [];

/** @var array<string, RouteTreeNode> */
private array $dynamicPaths = [];
public array $dynamicPaths = [];

private ?MarkedRoute $targetRoute = null;
public ?MarkedRoute $targetRoute = null;

private function __construct(
public readonly RouteTreeNodeType $type,
Expand Down Expand Up @@ -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 . ')',
};
}
}
6 changes: 4 additions & 2 deletions src/Tempest/Http/src/Routing/Construction/RoutingTree.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace Tempest\Http\Routing\Construction;

use Tempest\Http\Routing\Matching\MatchingRegexes;

/**
* @internal
*/
Expand Down Expand Up @@ -32,9 +34,9 @@ public function add(MarkedRoute $markedRoute): void
$node->setTargetRoute($markedRoute);
}

/** @return array<string, string> */
/** @return array<string, MatchingRegexes> */
public function toMatchingRegexes(): array
{
return array_map(static fn (RouteTreeNode $node) => "#{$node->toRegex()}#", $this->roots);
return array_map(static fn (RouteTreeNode $node) => (new RouteMatchingRegexesBuilder($node))->toRegex(), $this->roots);
}
}
9 changes: 4 additions & 5 deletions src/Tempest/Http/src/Routing/Matching/GenericRouteMatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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->isFound) {
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);
}
Expand Down
42 changes: 42 additions & 0 deletions src/Tempest/Http/src/Routing/Matching/MatchingRegexes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace Tempest\Http\Routing\Matching;

use RuntimeException;
use Tempest\Http\Routing\Construction\MarkedRoute;

final readonly class MatchingRegexes
{
/**
* @param string[] $patterns
*/
public function __construct(
public array $patterns,
) {
}

public function match(string $uri): RouteMatch
{
foreach ($this->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 RouteMatch::notFound();
}
}
27 changes: 27 additions & 0 deletions src/Tempest/Http/src/Routing/Matching/RouteMatch.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Tempest\Http\Routing\Matching;

use Tempest\Http\Routing\Construction\MarkedRoute;

final readonly class RouteMatch
{
private function __construct(
public bool $isFound,
public ?string $mark,
public array $matches,
) {
}

public static function match(array $params): self
{
return new self(true, $params[MarkedRoute::REGEX_MARK_TOKEN], $params);
}

public static function notFound(): self
{
return new self(false, null, []);
}
}
3 changes: 2 additions & 1 deletion src/Tempest/Http/tests/RouteConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Tempest\Http\Method;
use Tempest\Http\Route;
use Tempest\Http\RouteConfig;
use Tempest\Http\Routing\Matching\MatchingRegexes;

/**
* @internal
Expand All @@ -24,7 +25,7 @@ public function test_serialization(): void
'POST' => ['b' => new Route('/', Method::POST)],
],
[
'POST' => '#^(?|/([^/]++)(?|/1\/?$(*MARK:b)|/3\/?$(*MARK:d)))#',
'POST' => new MatchingRegexes(['#^(?|/([^/]++)(?|/1\/?$(*MARK:b)|/3\/?$(*MARK:d)))#']),
]
);

Expand Down
Loading

0 comments on commit 3e57646

Please # to comment.