Skip to content

Commit 8d82c8c

Browse files
authored
feat(router): infer constraints from route parameters (#1816)
1 parent 006997c commit 8d82c8c

File tree

3 files changed

+231
-17
lines changed

3 files changed

+231
-17
lines changed

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

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,16 @@ public static function fromRoute(Route $route, array $decorators, MethodReflecto
2525
$route = $decorator->decorate($route);
2626
}
2727

28-
$paramInfo = self::getRouteParams($route->uri);
28+
$uri = self::parseUriAndParameters($route->uri, $methodReflector);
2929

3030
return new self(
31-
$route->uri,
32-
$route->method,
33-
$paramInfo['names'],
34-
$paramInfo['optional'],
35-
$route->middleware,
36-
$methodReflector,
37-
$route->without ?? [],
31+
uri: $uri['uri'],
32+
method: $route->method,
33+
parameters: $uri['names'],
34+
optionalParameters: $uri['optional'],
35+
middleware: $route->middleware,
36+
handler: $methodReflector,
37+
without: $route->without ?? [],
3838
);
3939
}
4040

@@ -55,26 +55,60 @@ private function __construct(
5555
}
5656

5757
/**
58+
* Parses route parameters and infers type constraints from method signature.
59+
*
5860
* @return array{
61+
* uri: string,
5962
* names: string[],
6063
* optional: array<string, bool>
6164
* }
6265
*/
63-
private static function getRouteParams(string $uriPart): array
66+
private static function parseUriAndParameters(string $uri, MethodReflector $methodReflector): array
6467
{
6568
$regex = '#\{' . self::ROUTE_PARAM_OPTIONAL_REGEX . self::ROUTE_PARAM_NAME_REGEX . self::ROUTE_PARAM_CUSTOM_REGEX . '\}#';
6669

67-
preg_match_all($regex, $uriPart, $matches);
68-
69-
$optionalMarkers = $matches[1] ?? [];
70-
$names = $matches[2] ?? [];
71-
70+
$names = [];
7271
$optional = [];
73-
foreach ($names as $i => $name) {
74-
$optional[$name] = $optionalMarkers[$i] === '?';
75-
}
72+
73+
$modifiedUri = preg_replace_callback(
74+
pattern: $regex,
75+
callback: static function (array $matches) use ($methodReflector, &$names, &$optional) {
76+
$isOptional = $matches[1] === '?';
77+
$paramName = $matches[2];
78+
$providedRegExp = $matches[3] ?? '';
79+
80+
$names[] = $paramName;
81+
$optional[$paramName] = $isOptional;
82+
83+
// Skip if there was already a constraint
84+
if ($providedRegExp !== '') {
85+
return $matches[0];
86+
}
87+
88+
$parameter = $methodReflector->getParameter($paramName);
89+
90+
if ($parameter === null) {
91+
return $matches[0];
92+
}
93+
94+
$constraint = match ($parameter->getType()->getName()) {
95+
'int', 'integer' => ':\d+',
96+
'float', 'double' => ':[\d.]+',
97+
'bool', 'boolean' => ':(0|1|true|false)',
98+
default => null,
99+
};
100+
101+
if ($constraint !== null) {
102+
return sprintf('{%s%s%s}', $isOptional ? '?' : '', $paramName, $constraint);
103+
}
104+
105+
return $matches[0];
106+
},
107+
subject: $uri,
108+
);
76109

77110
return [
111+
'uri' => $modifiedUri,
78112
'names' => $names,
79113
'optional' => $optional,
80114
];
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Tempest\Fixtures\Controllers;
6+
7+
use Tempest\Http\Response;
8+
use Tempest\Http\Responses\Ok;
9+
use Tempest\Router\Get;
10+
11+
final readonly class InferredConstraintsController
12+
{
13+
#[Get('/inferred/int/{id}')]
14+
public function withInt(int $id): Response
15+
{
16+
return new Ok("int: {$id}");
17+
}
18+
19+
#[Get('/inferred/string/{name}')]
20+
public function withString(string $name): Response
21+
{
22+
return new Ok("string: {$name}");
23+
}
24+
25+
#[Get('/inferred/float/{price}')]
26+
public function withFloat(float $price): Response
27+
{
28+
return new Ok("float: {$price}");
29+
}
30+
31+
#[Get('/inferred/optional-int/{?id}')]
32+
public function withOptionalInt(?int $id = null): Response
33+
{
34+
return new Ok($id !== null ? "int: {$id}" : 'no id');
35+
}
36+
37+
#[Get('/inferred/explicit/{id:\d{3}}')]
38+
public function withExplicitConstraint(int $id): Response
39+
{
40+
return new Ok("explicit: {$id}");
41+
}
42+
43+
#[Get('/inferred/mixed/{id}/{name}')]
44+
public function withMixedTypes(int $id, string $name): Response
45+
{
46+
return new Ok("id: {$id}, name: {$name}");
47+
}
48+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Tempest\Integration\Route;
6+
7+
use PHPUnit\Framework\Attributes\Test;
8+
use Tempest\Http\Status;
9+
use Tempest\Router\GenericRouter;
10+
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
11+
12+
/**
13+
* @internal
14+
*/
15+
final class InferredConstraintsTest extends FrameworkIntegrationTestCase
16+
{
17+
#[Test]
18+
public function int_parameter_accepts_numeric_values(): void
19+
{
20+
$router = $this->container->get(GenericRouter::class);
21+
22+
$response = $router->dispatch($this->http->makePsrRequest('/inferred/int/123'));
23+
24+
$this->assertEquals(Status::OK, $response->status);
25+
$this->assertEquals('int: 123', $response->body);
26+
}
27+
28+
#[Test]
29+
public function int_parameter_rejects_non_numeric_values(): void
30+
{
31+
$this->http
32+
->get('/inferred/int/abc')
33+
->assertNotFound();
34+
}
35+
36+
#[Test]
37+
public function string_parameter_accepts_any_value(): void
38+
{
39+
$router = $this->container->get(GenericRouter::class);
40+
41+
$response1 = $router->dispatch($this->http->makePsrRequest('/inferred/string/test'));
42+
$this->assertEquals(Status::OK, $response1->status);
43+
$this->assertEquals('string: test', $response1->body);
44+
45+
$response2 = $router->dispatch($this->http->makePsrRequest('/inferred/string/123'));
46+
$this->assertEquals(Status::OK, $response2->status);
47+
$this->assertEquals('string: 123', $response2->body);
48+
}
49+
50+
#[Test]
51+
public function float_parameter_accepts_decimal_values(): void
52+
{
53+
$router = $this->container->get(GenericRouter::class);
54+
$response = $router->dispatch($this->http->makePsrRequest('/inferred/float/12.34'));
55+
56+
$this->assertEquals(Status::OK, $response->status);
57+
$this->assertEquals('float: 12.34', $response->body);
58+
}
59+
60+
#[Test]
61+
public function float_parameter_accepts_integer_values(): void
62+
{
63+
$router = $this->container->get(GenericRouter::class);
64+
$response = $router->dispatch($this->http->makePsrRequest('/inferred/float/42'));
65+
66+
$this->assertEquals(Status::OK, $response->status);
67+
$this->assertEquals('float: 42', $response->body);
68+
}
69+
70+
#[Test]
71+
public function optional_int_parameter_accepts_numeric_values(): void
72+
{
73+
$router = $this->container->get(GenericRouter::class);
74+
$response = $router->dispatch($this->http->makePsrRequest('/inferred/optional-int/456'));
75+
76+
$this->assertEquals(Status::OK, $response->status);
77+
$this->assertEquals('int: 456', $response->body);
78+
}
79+
80+
#[Test]
81+
public function optional_int_parameter_rejects_non_numeric_values(): void
82+
{
83+
$this->http
84+
->get('/inferred/optional-int/xyz')
85+
->assertNotFound();
86+
}
87+
88+
#[Test]
89+
public function optional_int_parameter_accepts_no_value(): void
90+
{
91+
$router = $this->container->get(GenericRouter::class);
92+
$response = $router->dispatch($this->http->makePsrRequest('/inferred/optional-int'));
93+
94+
$this->assertEquals(Status::OK, $response->status);
95+
$this->assertEquals('no id', $response->body);
96+
}
97+
98+
#[Test]
99+
public function explicit_constraint_is_preserved(): void
100+
{
101+
$this->http
102+
->get('/inferred/explicit/123')
103+
->assertOk()
104+
->assertSee('explicit: 123');
105+
106+
$this->http
107+
->get('/inferred/explicit/1234')
108+
->assertNotFound();
109+
110+
$this->http
111+
->get('/inferred/explicit/12')
112+
->assertNotFound();
113+
}
114+
115+
#[Test]
116+
public function mixed_parameter_types(): void
117+
{
118+
$router = $this->container->get(GenericRouter::class);
119+
$response = $router->dispatch($this->http->makePsrRequest('/inferred/mixed/42/john'));
120+
121+
$this->assertEquals(Status::OK, $response->status);
122+
$this->assertEquals('id: 42, name: john', $response->body);
123+
}
124+
125+
#[Test]
126+
public function mixed_parameter_types_reject_invalid_int(): void
127+
{
128+
$this->http
129+
->get('/inferred/mixed/abc/john')
130+
->assertNotFound();
131+
}
132+
}

0 commit comments

Comments
 (0)