Skip to content

Commit 32bf4d0

Browse files
authored
refactor(routing): split route construction (#666)
1 parent 7fdff1d commit 32bf4d0

File tree

10 files changed

+324
-244
lines changed

10 files changed

+324
-244
lines changed

src/Tempest/Http/src/RouteConfig.php

Lines changed: 8 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -4,81 +4,22 @@
44

55
namespace Tempest\Http;
66

7-
use Tempest\Http\Routing\Construction\MarkedRoute;
8-
use Tempest\Http\Routing\Construction\RoutingTree;
9-
use Tempest\Reflection\MethodReflector;
10-
117
final class RouteConfig
128
{
13-
/** @var string The mark to give the next route in the matching Regex */
14-
private string $regexMark = 'a';
15-
16-
/** @var array<string, string> */
17-
public array $matchingRegexes = [];
18-
19-
public RoutingTree $routingTree;
20-
219
public function __construct(
22-
/** @var array<string, array<string, \Tempest\Http\Route>> */
10+
/** @var array<string, array<string, Route>> */
2311
public array $staticRoutes = [],
24-
/** @var array<string, array<string, \Tempest\Http\Route>> */
12+
/** @var array<string, array<string, Route>> */
2513
public array $dynamicRoutes = [],
14+
/** @var array<string, string> */
15+
public array $matchingRegexes = [],
2616
) {
27-
$this->routingTree = new RoutingTree();
28-
}
29-
30-
public function addRoute(MethodReflector $handler, Route $route): self
31-
{
32-
$route->setHandler($handler);
33-
34-
if ($route->isDynamic) {
35-
$this->regexMark = str_increment($this->regexMark);
36-
$this->dynamicRoutes[$route->method->value][$this->regexMark] = $route;
37-
38-
$this->routingTree->add(
39-
new MarkedRoute(
40-
mark: $this->regexMark,
41-
route: $route,
42-
)
43-
);
44-
45-
} else {
46-
$uriWithTrailingSlash = rtrim($route->uri, '/');
47-
48-
$this->staticRoutes[$route->method->value][$uriWithTrailingSlash] = $route;
49-
$this->staticRoutes[$route->method->value][$uriWithTrailingSlash . '/'] = $route;
50-
}
51-
52-
return $this;
53-
}
54-
55-
public function prepareMatchingRegexes(): void
56-
{
57-
if (! empty($this->matchingRegexes)) {
58-
return;
59-
}
60-
61-
$this->matchingRegexes = $this->routingTree->toMatchingRegexes();
62-
}
63-
64-
/**
65-
* __sleep is called before serialize and returns the public properties to serialize. We do not want the routingTree
66-
* to be serialized, but we do want the result to be serialized. Thus prepareMatchingRegexes is called and the
67-
* resulting matchingRegexes are stored.
68-
*/
69-
public function __sleep(): array
70-
{
71-
$this->prepareMatchingRegexes();
72-
73-
return ['staticRoutes', 'dynamicRoutes', 'matchingRegexes'];
7417
}
7518

76-
/**
77-
* __wakeup is called after unserialize. We do not serialize the routingTree thus we need to provide some default
78-
* for it. Otherwise, it will be uninitialized and cause issues when tempest expects it to be defined.
79-
*/
80-
public function __wakeup(): void
19+
public function apply(RouteConfig $newConfig): void
8120
{
82-
$this->routingTree = new RoutingTree();
21+
$this->staticRoutes = $newConfig->staticRoutes;
22+
$this->dynamicRoutes = $newConfig->dynamicRoutes;
23+
$this->matchingRegexes = $newConfig->matchingRegexes;
8324
}
8425
}

src/Tempest/Http/src/RouteDiscovery.php

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@
66

77
use Tempest\Container\Container;
88
use Tempest\Core\Discovery;
9+
use Tempest\Core\KernelEvent;
10+
use Tempest\EventBus\EventHandler;
11+
use Tempest\Http\Routing\Construction\RouteConfigurator;
912
use Tempest\Reflection\ClassReflector;
1013

1114
final readonly class RouteDiscovery implements Discovery
1215
{
1316
public function __construct(
17+
private RouteConfigurator $configurator,
1418
private RouteConfig $routeConfig,
1519
) {
1620
}
@@ -21,22 +25,32 @@ public function discover(ClassReflector $class): void
2125
$routeAttributes = $method->getAttributes(Route::class);
2226

2327
foreach ($routeAttributes as $routeAttribute) {
24-
$this->routeConfig->addRoute($method, $routeAttribute);
28+
$routeAttribute->setHandler($method);
29+
30+
$this->configurator->addRoute($routeAttribute);
2531
}
2632
}
2733
}
2834

35+
#[EventHandler(KernelEvent::BOOTED)]
36+
public function finishDiscovery(): void
37+
{
38+
if ($this->configurator->isDirty()) {
39+
$this->routeConfig->apply($this->configurator->toRouteConfig());
40+
}
41+
}
42+
2943
public function createCachePayload(): string
3044
{
45+
$this->finishDiscovery();
46+
3147
return serialize($this->routeConfig);
3248
}
3349

3450
public function restoreCachePayload(Container $container, string $payload): void
3551
{
36-
$routeConfig = unserialize($payload);
52+
$routeConfig = unserialize($payload, [ 'allowed_classes' => true ]);
3753

38-
$this->routeConfig->staticRoutes = $routeConfig->staticRoutes;
39-
$this->routeConfig->dynamicRoutes = $routeConfig->dynamicRoutes;
40-
$this->routeConfig->matchingRegexes = $routeConfig->matchingRegexes;
54+
$this->routeConfig->apply($routeConfig);
4155
}
4256
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Http\Routing\Construction;
6+
7+
use Tempest\Container\Singleton;
8+
use Tempest\Http\Route;
9+
use Tempest\Http\RouteConfig;
10+
11+
/**
12+
* @internal
13+
*/
14+
#[Singleton]
15+
final class RouteConfigurator
16+
{
17+
/** @var string The mark to give the next route in the matching Regex */
18+
private string $regexMark = 'a';
19+
20+
/** @var array<string, array<string, Route>> */
21+
private array $staticRoutes = [];
22+
23+
/** @var array<string, array<string, Route>> */
24+
private array $dynamicRoutes = [];
25+
26+
private bool $isDirty = false;
27+
28+
private RoutingTree $routingTree;
29+
30+
public function __construct()
31+
{
32+
33+
$this->routingTree = new RoutingTree();
34+
}
35+
36+
public function addRoute(Route $route): void
37+
{
38+
$this->isDirty = true;
39+
40+
if ($route->isDynamic) {
41+
$this->addDynamicRoute($route);
42+
} else {
43+
$this->addStaticRoute($route);
44+
}
45+
}
46+
47+
private function addDynamicRoute(Route $route): void
48+
{
49+
$markedRoute = $this->markRoute($route);
50+
$this->dynamicRoutes[$route->method->value][$markedRoute->mark] = $route;
51+
52+
$this->routingTree->add($markedRoute);
53+
}
54+
55+
private function addStaticRoute(Route $route): void
56+
{
57+
$uriWithTrailingSlash = rtrim($route->uri, '/');
58+
59+
$this->staticRoutes[$route->method->value][$uriWithTrailingSlash] = $route;
60+
$this->staticRoutes[$route->method->value][$uriWithTrailingSlash . '/'] = $route;
61+
}
62+
63+
private function markRoute(Route $route): MarkedRoute
64+
{
65+
$this->regexMark = str_increment($this->regexMark);
66+
67+
return new MarkedRoute(
68+
mark: $this->regexMark,
69+
route: $route,
70+
);
71+
}
72+
73+
public function toRouteConfig(): RouteConfig
74+
{
75+
$this->isDirty = false;
76+
77+
return new RouteConfig(
78+
$this->staticRoutes,
79+
$this->dynamicRoutes,
80+
$this->routingTree->toMatchingRegexes(),
81+
);
82+
}
83+
84+
public function isDirty(): bool
85+
{
86+
return $this->isDirty;
87+
}
88+
}

src/Tempest/Http/src/Routing/Matching/GenericRouteMatcher.php

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,6 @@ private function matchDynamicRoute(PsrRequest $request): ?MatchedRoute
4646
return null;
4747
}
4848

49-
// Ensures matching regexes are available
50-
$this->routeConfig->prepareMatchingRegexes();
51-
5249
// Get matching regex for route
5350
$matchingRegexForMethod = $this->routeConfig->matchingRegexes[$request->getMethod()];
5451

src/Tempest/Http/tests/RouteConfigTest.php

Lines changed: 12 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -5,126 +5,33 @@
55
namespace Tempest\Http\Tests;
66

77
use PHPUnit\Framework\TestCase;
8-
use ReflectionMethod;
98
use Tempest\Http\Method;
10-
use Tempest\Http\Response;
11-
use Tempest\Http\Responses\Ok;
129
use Tempest\Http\Route;
1310
use Tempest\Http\RouteConfig;
14-
use Tempest\Reflection\MethodReflector;
1511

1612
/**
1713
* @internal
1814
*/
1915
final class RouteConfigTest extends TestCase
2016
{
21-
private RouteConfig $subject;
22-
23-
private MethodReflector $dummyMethod;
24-
25-
protected function setUp(): void
26-
{
27-
parent::setUp();
28-
29-
$this->subject = new RouteConfig();
30-
$this->dummyMethod = new MethodReflector(new ReflectionMethod($this, 'dummyMethod'));
31-
}
32-
33-
public function test_empty(): void
34-
{
35-
$this->assertEquals([], $this->subject->dynamicRoutes);
36-
$this->assertEquals([], $this->subject->matchingRegexes);
37-
$this->assertEquals([], $this->subject->staticRoutes);
38-
}
39-
40-
public function test_matching_regexes_is_updated_using_prepare_method(): void
41-
{
42-
$this->subject->addRoute($this->dummyMethod, new Route('/{id}', Method::GET));
43-
44-
$this->assertEquals([], $this->subject->matchingRegexes);
45-
$this->subject->prepareMatchingRegexes();
46-
$this->assertNotEquals([], $this->subject->matchingRegexes);
47-
}
48-
49-
public function test_adding_static_routes(): void
50-
{
51-
$routes = [
52-
new Route('/1', Method::GET),
53-
new Route('/2', Method::POST),
54-
new Route('/3', Method::GET),
55-
];
56-
57-
$this->subject->addRoute($this->dummyMethod, $routes[0]);
58-
$this->subject->addRoute($this->dummyMethod, $routes[1]);
59-
$this->subject->addRoute($this->dummyMethod, $routes[2]);
60-
61-
$this->assertEquals([
62-
'GET' => [
63-
'/1' => $routes[0],
64-
'/1/' => $routes[0],
65-
'/3' => $routes[2],
66-
'/3/' => $routes[2],
67-
],
68-
'POST' => [
69-
'/2' => $routes[1],
70-
'/2/' => $routes[1],
71-
],
72-
], $this->subject->staticRoutes);
73-
}
74-
75-
public function test_adding_dynamic_routes(): void
17+
public function test_serialization(): void
7618
{
77-
$routes = [
78-
new Route('/{id}/1', Method::GET),
79-
new Route('/{id}/2', Method::POST),
80-
new Route('/{id}/3', Method::GET),
81-
];
82-
83-
$this->subject->addRoute($this->dummyMethod, $routes[0]);
84-
$this->subject->addRoute($this->dummyMethod, $routes[1]);
85-
$this->subject->addRoute($this->dummyMethod, $routes[2]);
86-
87-
$this->subject->prepareMatchingRegexes();
88-
89-
$this->assertEquals([
90-
'GET' => [
91-
'b' => $routes[0],
92-
'd' => $routes[2],
19+
$original = new RouteConfig(
20+
[
21+
'POST' => ['/a' => new Route('/', Method::POST)],
9322
],
94-
'POST' => [
95-
'c' => $routes[1],
23+
[
24+
'POST' => ['b' => new Route('/', Method::POST)],
9625
],
97-
], $this->subject->dynamicRoutes);
98-
99-
$this->assertEquals([
100-
'GET' => '#^(?|/([^/]++)(?|/1\/?$(*MARK:b)|/3\/?$(*MARK:d)))#',
101-
'POST' => '#^(?|/([^/]++)(?|/2\/?$(*MARK:c)))#',
102-
], $this->subject->matchingRegexes);
103-
}
104-
105-
public function test_serialization(): void
106-
{
107-
$routes = [
108-
new Route('/{id}/1', Method::GET),
109-
new Route('/{id}/2', Method::POST),
110-
new Route('/3', Method::GET),
111-
];
26+
[
27+
'POST' => '#^(?|/([^/]++)(?|/1\/?$(*MARK:b)|/3\/?$(*MARK:d)))#',
28+
]
29+
);
11230

113-
$this->subject->addRoute($this->dummyMethod, $routes[0]);
114-
$this->subject->addRoute($this->dummyMethod, $routes[1]);
115-
$this->subject->addRoute($this->dummyMethod, $routes[2]);
116-
117-
$serialized = serialize($this->subject);
31+
$serialized = serialize($original);
11832
/** @var RouteConfig $deserialized */
11933
$deserialized = unserialize($serialized);
12034

121-
$this->assertEquals($this->subject->matchingRegexes, $deserialized->matchingRegexes);
122-
$this->assertEquals($this->subject->dynamicRoutes, $deserialized->dynamicRoutes);
123-
$this->assertEquals($this->subject->staticRoutes, $deserialized->staticRoutes);
124-
}
125-
126-
public function dummyMethod(): Response
127-
{
128-
return new Ok();
35+
$this->assertEquals($original, $deserialized);
12936
}
13037
}

0 commit comments

Comments
 (0)