Skip to content

Commit

Permalink
feature #54720 [Routing] Add {foo:bar} syntax to define a mapping b…
Browse files Browse the repository at this point in the history
…etween a route parameter and its corresponding request attribute (nicolas-grekas)

This PR was merged into the 7.1 branch.

Discussion
----------

[Routing] Add `{foo:bar}` syntax to define a mapping between a route parameter and its corresponding request attribute

| Q             | A
| ------------- | ---
| Branch?       | 7.1
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Issues        | -
| License       | MIT

While trying to improve the DX of auto-mapping Doctrine entities and from the discussion on the related PR #54455, I realized that it would help a lot if we were able to express a mapping between route parameter and request attributes directly into route definitions.

This PR adds the ability to define a route with such a mapping:
```php
#[Route('/conference/{slug:conference}')]
```

On the router side, the side-effect of this is just that a new `_route_mapping` array is returned by the matcher, and nothing else.

Then, in HttpKernel's RouterListener, we use that parameter to map route parameters to request attributes. On their turn, argument resolvers will just see a request attribute named `conference`. But they can also now read the `_route_mapping` attribute and decide to do more tailored things depending on the mapping.

For example, one could define this route:
```php
#[Route('/conference/{id:conference}/{slug:conference}')]
```

This would be turned into a request attribute named `conference` with `['id' => 'the-id', 'slug' => 'the-slug']` as content.

This mapping concern already leaks into many value resolver attributes (see their "name" property).

For the entity value resolver, this feature will allow deprecating auto-mapping altogether, and will make things more explicit.

Commits
-------

1e091b9 [Routing] Add `{foo:bar}` syntax to define a mapping between a route parameter and its corresponding request attribute
  • Loading branch information
fabpot committed May 2, 2024
2 parents 5bc490c + 1e091b9 commit 15956b2
Show file tree
Hide file tree
Showing 6 changed files with 128 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,33 @@ public function onKernelRequest(RequestEvent $event): void
'method' => $request->getMethod(),
]);

$request->attributes->add($parameters);
$attributes = $parameters;
if ($mapping = $parameters['_route_mapping'] ?? false) {
unset($parameters['_route_mapping']);
$mappedAttributes = [];
$attributes = [];

foreach ($parameters as $parameter => $value) {
$attribute = $mapping[$parameter] ?? $parameter;

if (!isset($mappedAttributes[$attribute])) {
$attributes[$attribute] = $value;
$mappedAttributes[$attribute] = $parameter;
} elseif ('' !== $mappedAttributes[$attribute]) {
$attributes[$attribute] = [
$mappedAttributes[$attribute] => $attributes[$attribute],
$parameter => $value,
];
$mappedAttributes[$attribute] = '';
} else {
$attributes[$attribute][$parameter] = $value;
}
}

$attributes['_route_mapping'] = $mapping;
}

$request->attributes->add($attributes);
unset($parameters['_route'], $parameters['_controller']);
$request->attributes->set('_route_params', $parameters);
} catch (ResourceNotFoundException $e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,4 +264,64 @@ public function testMethodNotAllowedException()
$listener = new RouterListener($urlMatcher, new RequestStack());
$listener->onKernelRequest($event);
}

/**
* @dataProvider provideRouteMapping
*/
public function testRouteMapping(array $expected, array $parameters)
{
$kernel = $this->createMock(HttpKernelInterface::class);
$request = Request::create('http://localhost/');
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);

$requestMatcher = $this->createMock(RequestMatcherInterface::class);
$requestMatcher->expects($this->any())
->method('matchRequest')
->with($this->isInstanceOf(Request::class))
->willReturn($parameters);

$listener = new RouterListener($requestMatcher, new RequestStack(), new RequestContext());
$listener->onKernelRequest($event);

$expected['_route_mapping'] = $parameters['_route_mapping'];
unset($parameters['_route_mapping']);
$expected['_route_params'] = $parameters;

$this->assertEquals($expected, $request->attributes->all());
}

public static function provideRouteMapping(): iterable
{
yield [
[
'conference' => 'vienna-2024',
],
[
'slug' => 'vienna-2024',
'_route_mapping' => [
'slug' => 'conference',
],
],
];

yield [
[
'article' => [
'id' => 'abc123',
'date' => '2024-04-24',
'slug' => 'symfony-rocks',
],
],
[
'id' => 'abc123',
'date' => '2024-04-24',
'slug' => 'symfony-rocks',
'_route_mapping' => [
'id' => 'article',
'date' => 'article',
'slug' => 'article',
],
],
];
}
}
5 changes: 5 additions & 0 deletions src/Symfony/Component/Routing/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
CHANGELOG
=========

7.1
---

* Add `{foo:bar}` syntax to define a mapping between a route parameter and its corresponding request attribute

7.0
---

Expand Down
4 changes: 4 additions & 0 deletions src/Symfony/Component/Routing/Matcher/UrlMatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ protected function getAttributes(Route $route, string $name, array $attributes):
}
$attributes['_route'] = $name;

if ($mapping = $route->getOption('mapping')) {
$attributes['_route_mapping'] = $mapping;
}

return $this->mergeDefaults($attributes, $defaults);
}

Expand Down
19 changes: 15 additions & 4 deletions src/Symfony/Component/Routing/Route.php
Original file line number Diff line number Diff line change
Expand Up @@ -412,20 +412,31 @@ public function compile(): CompiledRoute

private function extractInlineDefaultsAndRequirements(string $pattern): string
{
if (false === strpbrk($pattern, '?<')) {
if (false === strpbrk($pattern, '?<:')) {
return $pattern;
}

return preg_replace_callback('#\{(!?)([\w\x80-\xFF]++)(<.*?>)?(\?[^\}]*+)?\}#', function ($m) {
$mapping = $this->getDefault('_route_mapping') ?? [];

$pattern = preg_replace_callback('#\{(!?)([\w\x80-\xFF]++)(:[\w\x80-\xFF]++)?(<.*?>)?(\?[^\}]*+)?\}#', function ($m) use (&$mapping) {
if (isset($m[5][0])) {
$this->setDefault($m[2], '?' !== $m[5] ? substr($m[5], 1) : null);
}
if (isset($m[4][0])) {
$this->setDefault($m[2], '?' !== $m[4] ? substr($m[4], 1) : null);
$this->setRequirement($m[2], substr($m[4], 1, -1));
}
if (isset($m[3][0])) {
$this->setRequirement($m[2], substr($m[3], 1, -1));
$mapping[$m[2]] = substr($m[3], 1);
}

return '{'.$m[1].$m[2].'}';
}, $pattern);

if ($mapping) {
$this->setDefault('_route_mapping', $mapping);
}

return $pattern;
}

private function sanitizeRequirement(string $key, string $regex): string
Expand Down
17 changes: 17 additions & 0 deletions src/Symfony/Component/Routing/Tests/Matcher/UrlMatcherTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1000,6 +1000,23 @@ public function testUtf8VarName()
$this->assertEquals(['_route' => 'foo', 'bär' => 'baz', 'bäz' => 'foo'], $matcher->match('/foo/baz'));
}

public function testMapping()
{
$collection = new RouteCollection();
$collection->add('a', new Route('/conference/{slug:conference}'));

$matcher = $this->getUrlMatcher($collection);

$expected = [
'_route' => 'a',
'slug' => 'vienna-2024',
'_route_mapping' => [
'slug' => 'conference',
],
];
$this->assertEquals($expected, $matcher->match('/conference/vienna-2024'));
}

protected function getUrlMatcher(RouteCollection $routes, ?RequestContext $context = null)
{
return new UrlMatcher($routes, $context ?? new RequestContext());
Expand Down

0 comments on commit 15956b2

Please sign in to comment.