From 1e091b9da3ba67f3baf6e8831b2c7ff712f442c6 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Wed, 24 Apr 2024 11:31:47 +0200 Subject: [PATCH] [Routing] Add `{foo:bar}` syntax to define a mapping between a route parameter and its corresponding request attribute --- .../EventListener/RouterListener.php | 28 ++++++++- .../EventListener/RouterListenerTest.php | 60 +++++++++++++++++++ src/Symfony/Component/Routing/CHANGELOG.md | 5 ++ .../Component/Routing/Matcher/UrlMatcher.php | 4 ++ src/Symfony/Component/Routing/Route.php | 19 ++++-- .../Routing/Tests/Matcher/UrlMatcherTest.php | 17 ++++++ 6 files changed, 128 insertions(+), 5 deletions(-) diff --git a/src/Symfony/Component/HttpKernel/EventListener/RouterListener.php b/src/Symfony/Component/HttpKernel/EventListener/RouterListener.php index a957af8c0c0a..689d08122afb 100644 --- a/src/Symfony/Component/HttpKernel/EventListener/RouterListener.php +++ b/src/Symfony/Component/HttpKernel/EventListener/RouterListener.php @@ -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) { diff --git a/src/Symfony/Component/HttpKernel/Tests/EventListener/RouterListenerTest.php b/src/Symfony/Component/HttpKernel/Tests/EventListener/RouterListenerTest.php index e68461a18cfa..d13093db0c55 100644 --- a/src/Symfony/Component/HttpKernel/Tests/EventListener/RouterListenerTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/EventListener/RouterListenerTest.php @@ -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', + ], + ], + ]; + } } diff --git a/src/Symfony/Component/Routing/CHANGELOG.md b/src/Symfony/Component/Routing/CHANGELOG.md index 0a3f28a7672c..bb4f4baf2221 100644 --- a/src/Symfony/Component/Routing/CHANGELOG.md +++ b/src/Symfony/Component/Routing/CHANGELOG.md @@ -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 --- diff --git a/src/Symfony/Component/Routing/Matcher/UrlMatcher.php b/src/Symfony/Component/Routing/Matcher/UrlMatcher.php index 723803323dbe..09c1d29967cc 100644 --- a/src/Symfony/Component/Routing/Matcher/UrlMatcher.php +++ b/src/Symfony/Component/Routing/Matcher/UrlMatcher.php @@ -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); } diff --git a/src/Symfony/Component/Routing/Route.php b/src/Symfony/Component/Routing/Route.php index ac8d8bc6e908..abbc39907ccf 100644 --- a/src/Symfony/Component/Routing/Route.php +++ b/src/Symfony/Component/Routing/Route.php @@ -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 diff --git a/src/Symfony/Component/Routing/Tests/Matcher/UrlMatcherTest.php b/src/Symfony/Component/Routing/Tests/Matcher/UrlMatcherTest.php index 78bf2b3d75a6..d9cfa7b1bd57 100644 --- a/src/Symfony/Component/Routing/Tests/Matcher/UrlMatcherTest.php +++ b/src/Symfony/Component/Routing/Tests/Matcher/UrlMatcherTest.php @@ -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());