Skip to content

Commit 68f4aba

Browse files
authored
feat(router): improve optional route parameter syntax (#1706)
1 parent 22cfe96 commit 68f4aba

File tree

8 files changed

+417
-9
lines changed

8 files changed

+417
-9
lines changed

packages/router/src/Routing/Construction/DiscoveredRoute.php

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ final class DiscoveredRoute implements Route
1212
{
1313
public const string DEFAULT_MATCHING_GROUP = '[^/]++';
1414

15+
public const string ROUTE_PARAM_OPTIONAL_REGEX = '(\??)';
16+
1517
public const string ROUTE_PARAM_NAME_REGEX = '(\w*)';
1618

1719
public const string ROUTE_PARAM_CUSTOM_REGEX = '(?::([^{}]*(?:\{(?-1)\}[^{}]*)*))?';
@@ -23,10 +25,13 @@ public static function fromRoute(Route $route, array $decorators, MethodReflecto
2325
$route = $decorator->decorate($route);
2426
}
2527

28+
$paramInfo = self::getRouteParams($route->uri);
29+
2630
return new self(
2731
$route->uri,
2832
$route->method,
29-
self::getRouteParams($route->uri),
33+
$paramInfo['names'],
34+
$paramInfo['optional'],
3035
$route->middleware,
3136
$methodReflector,
3237
$route->without ?? [],
@@ -39,6 +44,8 @@ private function __construct(
3944
public string $uri,
4045
public Method $method,
4146
public array $parameters,
47+
/** @var array<string, bool> */
48+
public array $optionalParameters,
4249
/** @var class-string<\Tempest\Router\HttpMiddleware>[] */
4350
public array $middleware,
4451
public MethodReflector $handler,
@@ -47,14 +54,30 @@ private function __construct(
4754
$this->isDynamic = $parameters !== [];
4855
}
4956

50-
/** @return string[] */
57+
/**
58+
* @return array{
59+
* names: string[],
60+
* optional: array<string, bool>
61+
* }
62+
*/
5163
private static function getRouteParams(string $uriPart): array
5264
{
53-
$regex = '#\{' . self::ROUTE_PARAM_NAME_REGEX . self::ROUTE_PARAM_CUSTOM_REGEX . '\}#';
65+
$regex = '#\{' . self::ROUTE_PARAM_OPTIONAL_REGEX . self::ROUTE_PARAM_NAME_REGEX . self::ROUTE_PARAM_CUSTOM_REGEX . '\}#';
5466

5567
preg_match_all($regex, $uriPart, $matches);
5668

57-
return $matches[1] ?? [];
69+
$optionalMarkers = $matches[1] ?? [];
70+
$names = $matches[2] ?? [];
71+
72+
$optional = [];
73+
foreach ($names as $i => $name) {
74+
$optional[$name] = $optionalMarkers[$i] === '?';
75+
}
76+
77+
return [
78+
'names' => $names,
79+
'optional' => $optional,
80+
];
5881
}
5982

6083
/**

packages/router/src/Routing/Construction/RouteTreeNode.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,11 @@ public function setTargetRoute(MarkedRoute $markedRoute): void
6161

6262
private static function convertDynamicSegmentToRegex(string $uriPart): string
6363
{
64-
$regex = '#\{' . DiscoveredRoute::ROUTE_PARAM_NAME_REGEX . DiscoveredRoute::ROUTE_PARAM_CUSTOM_REGEX . '\}#';
64+
$regex = '#\{' . DiscoveredRoute::ROUTE_PARAM_OPTIONAL_REGEX . DiscoveredRoute::ROUTE_PARAM_NAME_REGEX . DiscoveredRoute::ROUTE_PARAM_CUSTOM_REGEX . '\}#';
6565

6666
return preg_replace_callback(
6767
$regex,
68-
static fn ($matches) => trim($matches[2] ?? DiscoveredRoute::DEFAULT_MATCHING_GROUP),
68+
static fn ($matches) => trim($matches[3] ?? DiscoveredRoute::DEFAULT_MATCHING_GROUP),
6969
$uriPart,
7070
);
7171
}

packages/router/src/Routing/Construction/RoutingTree.php

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,33 @@ public function add(MarkedRoute $markedRoute): void
2727
// @mago-expect lint:no-multi-assignments
2828
$node = $this->roots[$method->value] ??= RouteTreeNode::createRootRoute();
2929

30+
$segments = $markedRoute->route->split();
31+
3032
// Traverse the tree and find the node for each route segment
31-
foreach ($markedRoute->route->split() as $routeSegment) {
33+
foreach ($segments as $routeSegment) {
34+
$isOptional = $this->isOptionalSegment($routeSegment);
35+
36+
if ($isOptional) {
37+
$node->setTargetRoute($markedRoute);
38+
$routeSegment = $this->stripOptionalMarker($routeSegment);
39+
}
40+
3241
$node = $node->findOrCreateNodeForSegment($routeSegment);
3342
}
3443

3544
$node->setTargetRoute($markedRoute);
3645
}
3746

47+
private function isOptionalSegment(string $segment): bool
48+
{
49+
return str_starts_with($segment, '{?');
50+
}
51+
52+
private function stripOptionalMarker(string $segment): string
53+
{
54+
return str_replace('?', '', $segment);
55+
}
56+
3857
/** @return array<string, MatchingRegex> */
3958
public function toMatchingRegexes(): array
4059
{

packages/router/src/Routing/Matching/GenericRouteMatcher.php

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,28 @@ private function extractParams(DiscoveredRoute $route, array $routeMatches): arr
8080
$valueMap = [];
8181

8282
foreach ($route->parameters as $i => $param) {
83-
$value = $routeMatches[$i + 1];
83+
$value = $routeMatches[$i + 1] ?? null;
84+
$isOptional = $route->optionalParameters[$param] ?? false;
85+
86+
if ($value === null || $value === '') {
87+
if (! $isOptional) {
88+
continue;
89+
}
90+
91+
$parameterReflector = $route->handler->getParameter($param);
92+
93+
if ($parameterReflector?->hasDefaultValue()) {
94+
$valueMap[$param] = $parameterReflector->getDefaultValue();
95+
96+
continue;
97+
}
98+
99+
continue;
100+
}
84101

85102
$parameterReflector = $route->handler->getParameter($param);
86103

87-
if ($parameterReflector && $parameterReflector->getType()?->isBackedEnum()) {
104+
if ($parameterReflector?->getType()->isBackedEnum()) {
88105
$value = $parameterReflector->getType()->asClass()->callStatic('tryFrom', $value);
89106

90107
if ($value === null) {
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Router\Tests;
6+
7+
use ReflectionMethod;
8+
use Tempest\Http\Method;
9+
use Tempest\Reflection\MethodReflector;
10+
use Tempest\Router\HttpMiddleware;
11+
use Tempest\Router\Route;
12+
use Tempest\Router\Routing\Construction\DiscoveredRoute;
13+
use Tempest\Router\Routing\Construction\MarkedRoute;
14+
15+
final class FakeRouteBuilderWithOptionalParams implements Route
16+
{
17+
private MethodReflector $handler;
18+
19+
public function __construct(
20+
public Method $method = Method::GET,
21+
public string $uri = '/',
22+
/** @var class-string<HttpMiddleware>[] */
23+
public array $middleware = [],
24+
public array $without = [],
25+
private string $handlerMethod = 'handler',
26+
) {
27+
$this->handler = new MethodReflector(new ReflectionMethod($this, $this->handlerMethod));
28+
}
29+
30+
public function withUri(string $uri): self
31+
{
32+
return new self($this->method, $uri, $this->middleware, $this->without, $this->handlerMethod);
33+
}
34+
35+
public function withMethod(Method $method): self
36+
{
37+
return new self($method, $this->uri, $this->middleware, $this->without, $this->handlerMethod);
38+
}
39+
40+
public function withHandler(string $handlerMethod): self
41+
{
42+
return new self($this->method, $this->uri, $this->middleware, $this->without, $handlerMethod);
43+
}
44+
45+
public function asMarkedRoute(string $mark): MarkedRoute
46+
{
47+
return new MarkedRoute($mark, $this->asDiscoveredRoute());
48+
}
49+
50+
public function asDiscoveredRoute(): DiscoveredRoute
51+
{
52+
return DiscoveredRoute::fromRoute($this, [], $this->handler);
53+
}
54+
55+
public function handler(): void
56+
{
57+
}
58+
59+
public function handlerWithOptionalId(string $id = 'default-id'): void
60+
{
61+
}
62+
63+
public function handlerWithTwoOptionalParams(string $id = 'default-id', string $slug = 'default-slug'): void
64+
{
65+
}
66+
67+
public function handlerWithRequiredAndOptional(string $id, string $slug = 'default-slug'): void
68+
{
69+
}
70+
}

0 commit comments

Comments
 (0)