From d2ae0977af3a0505e0dd614e1fb184a5b747e608 Mon Sep 17 00:00:00 2001 From: Damien Fernandes Date: Sat, 9 Nov 2024 12:34:13 +0100 Subject: [PATCH] [Routing] Allow aliases in `#[Route]` attribute --- .../Routing/Attribute/DeprecatedAlias.php | 46 ++++++++ .../Component/Routing/Attribute/Route.php | 53 +++++++--- src/Symfony/Component/Routing/CHANGELOG.md | 5 + .../Routing/Loader/AttributeClassLoader.php | 24 +++++ .../Routing/Tests/Attribute/RouteTest.php | 3 +- .../AliasClassController.php | 29 +++++ .../AliasInvokableController.php | 23 ++++ .../AliasRouteController.php | 22 ++++ ...catedAliasCustomMessageRouteController.php | 24 +++++ .../DeprecatedAliasRouteController.php | 23 ++++ .../AttributeFixtures/FooController.php | 5 + ...MultipleDeprecatedAliasRouteController.php | 27 +++++ .../Tests/Loader/AttributeClassLoaderTest.php | 100 ++++++++++++++++++ 13 files changed, 368 insertions(+), 16 deletions(-) create mode 100644 src/Symfony/Component/Routing/Attribute/DeprecatedAlias.php create mode 100644 src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/AliasClassController.php create mode 100644 src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/AliasInvokableController.php create mode 100644 src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/AliasRouteController.php create mode 100644 src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/DeprecatedAliasCustomMessageRouteController.php create mode 100644 src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/DeprecatedAliasRouteController.php create mode 100644 src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/MultipleDeprecatedAliasRouteController.php diff --git a/src/Symfony/Component/Routing/Attribute/DeprecatedAlias.php b/src/Symfony/Component/Routing/Attribute/DeprecatedAlias.php new file mode 100644 index 0000000000000..ae5a6821b6947 --- /dev/null +++ b/src/Symfony/Component/Routing/Attribute/DeprecatedAlias.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Attribute; + +/** + * This class is meant to be used in {@see Route} to define an alias for a route. + */ +class DeprecatedAlias +{ + public function __construct( + private string $aliasName, + private string $package, + private string $version, + private string $message = '', + ) { + } + + public function getMessage(): string + { + return $this->message; + } + + public function getAliasName(): string + { + return $this->aliasName; + } + + public function getPackage(): string + { + return $this->package; + } + + public function getVersion(): string + { + return $this->version; + } +} diff --git a/src/Symfony/Component/Routing/Attribute/Route.php b/src/Symfony/Component/Routing/Attribute/Route.php index 07abc556ce893..003bbe64f5a83 100644 --- a/src/Symfony/Component/Routing/Attribute/Route.php +++ b/src/Symfony/Component/Routing/Attribute/Route.php @@ -22,23 +22,28 @@ class Route private array $localizedPaths = []; private array $methods; private array $schemes; + /** + * @var (string|DeprecatedAlias)[] + */ + private array $aliases = []; /** - * @param string|array|null $path The route path (i.e. "/user/login") - * @param string|null $name The route name (i.e. "app_user_login") - * @param array $requirements Requirements for the route attributes, @see https://symfony.com/doc/current/routing.html#parameters-validation - * @param array $options Options for the route (i.e. ['prefix' => '/api']) - * @param array $defaults Default values for the route attributes and query parameters - * @param string|null $host The host for which this route should be active (i.e. "localhost") - * @param string|string[] $methods The list of HTTP methods allowed by this route - * @param string|string[] $schemes The list of schemes allowed by this route (i.e. "https") - * @param string|null $condition An expression that must evaluate to true for the route to be matched, @see https://symfony.com/doc/current/routing.html#matching-expressions - * @param int|null $priority The priority of the route if multiple ones are defined for the same path - * @param string|null $locale The locale accepted by the route - * @param string|null $format The format returned by the route (i.e. "json", "xml") - * @param bool|null $utf8 Whether the route accepts UTF-8 in its parameters - * @param bool|null $stateless Whether the route is defined as stateless or stateful, @see https://symfony.com/doc/current/routing.html#stateless-routes - * @param string|null $env The env in which the route is defined (i.e. "dev", "test", "prod") + * @param string|array|null $path The route path (i.e. "/user/login") + * @param string|null $name The route name (i.e. "app_user_login") + * @param array $requirements Requirements for the route attributes, @see https://symfony.com/doc/current/routing.html#parameters-validation + * @param array $options Options for the route (i.e. ['prefix' => '/api']) + * @param array $defaults Default values for the route attributes and query parameters + * @param string|null $host The host for which this route should be active (i.e. "localhost") + * @param string|string[] $methods The list of HTTP methods allowed by this route + * @param string|string[] $schemes The list of schemes allowed by this route (i.e. "https") + * @param string|null $condition An expression that must evaluate to true for the route to be matched, @see https://symfony.com/doc/current/routing.html#matching-expressions + * @param int|null $priority The priority of the route if multiple ones are defined for the same path + * @param string|null $locale The locale accepted by the route + * @param string|null $format The format returned by the route (i.e. "json", "xml") + * @param bool|null $utf8 Whether the route accepts UTF-8 in its parameters + * @param bool|null $stateless Whether the route is defined as stateless or stateful, @see https://symfony.com/doc/current/routing.html#stateless-routes + * @param string|null $env The env in which the route is defined (i.e. "dev", "test", "prod") + * @param string|DeprecatedAlias|(string|DeprecatedAlias)[] $alias The list of aliases for this route */ public function __construct( string|array|null $path = null, @@ -56,6 +61,7 @@ public function __construct( ?bool $utf8 = null, ?bool $stateless = null, private ?string $env = null, + string|DeprecatedAlias|array $alias = [], ) { if (\is_array($path)) { $this->localizedPaths = $path; @@ -64,6 +70,7 @@ public function __construct( } $this->setMethods($methods); $this->setSchemes($schemes); + $this->setAliases($alias); if (null !== $locale) { $this->defaults['_locale'] = $locale; @@ -201,6 +208,22 @@ public function getEnv(): ?string { return $this->env; } + + /** + * @return (string|DeprecatedAlias)[] + */ + public function getAliases(): array + { + return $this->aliases; + } + + /** + * @param string|DeprecatedAlias|(string|DeprecatedAlias)[] $aliases + */ + public function setAliases(string|DeprecatedAlias|array $aliases): void + { + $this->aliases = \is_array($aliases) ? $aliases : [$aliases]; + } } if (!class_exists(\Symfony\Component\Routing\Annotation\Route::class, false)) { diff --git a/src/Symfony/Component/Routing/CHANGELOG.md b/src/Symfony/Component/Routing/CHANGELOG.md index 7c4614059fe3d..d66f4526d97c5 100644 --- a/src/Symfony/Component/Routing/CHANGELOG.md +++ b/src/Symfony/Component/Routing/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.3 +--- + + * Allow aliases and deprecations in `#[Route]` attribute + 7.2 --- diff --git a/src/Symfony/Component/Routing/Loader/AttributeClassLoader.php b/src/Symfony/Component/Routing/Loader/AttributeClassLoader.php index 92471af0f8cbf..254582bf35584 100644 --- a/src/Symfony/Component/Routing/Loader/AttributeClassLoader.php +++ b/src/Symfony/Component/Routing/Loader/AttributeClassLoader.php @@ -14,7 +14,9 @@ use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\Config\Loader\LoaderResolverInterface; use Symfony\Component\Config\Resource\FileResource; +use Symfony\Component\Routing\Attribute\DeprecatedAlias; use Symfony\Component\Routing\Attribute\Route as RouteAttribute; +use Symfony\Component\Routing\Exception\InvalidArgumentException; use Symfony\Component\Routing\Exception\LogicException; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; @@ -107,6 +109,15 @@ public function load(mixed $class, ?string $type = null): RouteCollection return $collection; } $fqcnAlias = false; + + if (!$class->hasMethod('__invoke')) { + foreach ($this->getAttributes($class) as $attr) { + if ($attr->getAliases()) { + throw new InvalidArgumentException(\sprintf('Route aliases cannot be used on non-invokable class "%s".', $class->getName())); + } + } + } + foreach ($class->getMethods() as $method) { $this->defaultRouteIndex = 0; $routeNamesBefore = array_keys($collection->all()); @@ -230,6 +241,19 @@ protected function addRoute(RouteCollection $collection, object $attr, array $gl } else { $collection->add($name, $route, $priority); } + foreach ($attr->getAliases() as $aliasAttribute) { + if ($aliasAttribute instanceof DeprecatedAlias) { + $alias = $collection->addAlias($aliasAttribute->getAliasName(), $name); + $alias->setDeprecated( + $aliasAttribute->getPackage(), + $aliasAttribute->getVersion(), + $aliasAttribute->getMessage() + ); + continue; + } + + $collection->addAlias($aliasAttribute, $name); + } } } diff --git a/src/Symfony/Component/Routing/Tests/Attribute/RouteTest.php b/src/Symfony/Component/Routing/Tests/Attribute/RouteTest.php index 2696991c404c5..bbaa7563aa33f 100644 --- a/src/Symfony/Component/Routing/Tests/Attribute/RouteTest.php +++ b/src/Symfony/Component/Routing/Tests/Attribute/RouteTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\Component\Routing\Tests\Annotation; +namespace Symfony\Component\Routing\Tests\Attribute; use PHPUnit\Framework\TestCase; use Symfony\Component\Routing\Attribute\Route; @@ -40,6 +40,7 @@ public static function getValidParameters(): iterable ['methods', 'getMethods', ['GET', 'POST']], ['host', 'getHost', '{locale}.example.com'], ['condition', 'getCondition', 'context.getMethod() == \'GET\''], + ['alias', 'getAliases', ['alias', 'completely_different_name']], ]; } } diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/AliasClassController.php b/src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/AliasClassController.php new file mode 100644 index 0000000000000..c7e87128b73d3 --- /dev/null +++ b/src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/AliasClassController.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures; + +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Routing\Attribute\Route; + +#[Route('/hello', alias: ['alias', 'completely_different_name'])] +class AliasClassController +{ + #[Route('/world')] + public function actionWorld() + { + } + + #[Route('/symfony')] + public function actionSymfony() + { + } +} diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/AliasInvokableController.php b/src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/AliasInvokableController.php new file mode 100644 index 0000000000000..dac27b67141ed --- /dev/null +++ b/src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/AliasInvokableController.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures; + +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Routing\Attribute\Route; + +#[Route('/path', name:'invokable_path', alias: ['alias', 'completely_different_name'])] +class AliasInvokableController +{ + public function __invoke() + { + } +} diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/AliasRouteController.php b/src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/AliasRouteController.php new file mode 100644 index 0000000000000..0b828576f041c --- /dev/null +++ b/src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/AliasRouteController.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures; + +use Symfony\Component\Routing\Attribute\Route; + +class AliasRouteController +{ + #[Route('/path', name: 'action_with_alias', alias: ['alias', 'completely_different_name'])] + public function action() + { + } +} diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/DeprecatedAliasCustomMessageRouteController.php b/src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/DeprecatedAliasCustomMessageRouteController.php new file mode 100644 index 0000000000000..08b1afbdcf108 --- /dev/null +++ b/src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/DeprecatedAliasCustomMessageRouteController.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures; + +use Symfony\Component\Routing\Attribute\DeprecatedAlias; +use Symfony\Component\Routing\Attribute\Route; + +class DeprecatedAliasCustomMessageRouteController +{ + + #[Route('/path', name: 'action_with_deprecated_alias', alias: new DeprecatedAlias('my_other_alias_deprecated', 'MyBundleFixture', '1.0', message: '%alias_id% alias is deprecated.'))] + public function action() + { + } +} diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/DeprecatedAliasRouteController.php b/src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/DeprecatedAliasRouteController.php new file mode 100644 index 0000000000000..06577cd79aa63 --- /dev/null +++ b/src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/DeprecatedAliasRouteController.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures; + +use Symfony\Component\Routing\Attribute\DeprecatedAlias; +use Symfony\Component\Routing\Attribute\Route; + +class DeprecatedAliasRouteController +{ + #[Route('/path', name: 'action_with_deprecated_alias', alias: new DeprecatedAlias('my_other_alias_deprecated', 'MyBundleFixture', '1.0'))] + public function action() + { + } +} diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/FooController.php b/src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/FooController.php index adbd038ad9f73..ba82286508076 100644 --- a/src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/FooController.php +++ b/src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/FooController.php @@ -55,4 +55,9 @@ public function host() public function condition() { } + + #[Route(alias: ['alias', 'completely_different_name'])] + public function alias() + { + } } diff --git a/src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/MultipleDeprecatedAliasRouteController.php b/src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/MultipleDeprecatedAliasRouteController.php new file mode 100644 index 0000000000000..93662d38f1789 --- /dev/null +++ b/src/Symfony/Component/Routing/Tests/Fixtures/AttributeFixtures/MultipleDeprecatedAliasRouteController.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures; + +use Symfony\Component\Routing\Attribute\DeprecatedAlias; +use Symfony\Component\Routing\Attribute\Route; + +class MultipleDeprecatedAliasRouteController +{ + #[Route('/path', name: 'action_with_multiple_deprecated_alias', alias: [ + new DeprecatedAlias('my_first_alias_deprecated', 'MyFirstBundleFixture', '1.0'), + new DeprecatedAlias('my_second_alias_deprecated', 'MySecondBundleFixture', '2.0'), + new DeprecatedAlias('my_third_alias_deprecated', 'SurprisedThirdBundleFixture', '3.0'), + ])] + public function action() + { + } +} diff --git a/src/Symfony/Component/Routing/Tests/Loader/AttributeClassLoaderTest.php b/src/Symfony/Component/Routing/Tests/Loader/AttributeClassLoaderTest.php index afad8731604f2..50a10a16cac2f 100644 --- a/src/Symfony/Component/Routing/Tests/Loader/AttributeClassLoaderTest.php +++ b/src/Symfony/Component/Routing/Tests/Loader/AttributeClassLoaderTest.php @@ -16,8 +16,13 @@ use Symfony\Component\Routing\Exception\LogicException; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\AbstractClassController; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\ActionPathController; +use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\AliasClassController; +use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\AliasInvokableController; +use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\AliasRouteController; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\BazClass; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\DefaultValueController; +use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\DeprecatedAliasCustomMessageRouteController; +use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\DeprecatedAliasRouteController; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\EncodingClass; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\ExplicitLocalizedActionPathController; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\ExtendedRouteOnClassController; @@ -35,6 +40,7 @@ use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\MethodActionControllers; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\MethodsAndSchemes; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\MissingRouteNameController; +use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\MultipleDeprecatedAliasRouteController; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\NothingButNameController; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\PrefixedActionLocalizedRouteController; use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\PrefixedActionPathController; @@ -364,4 +370,98 @@ public function testDefaultRouteName() $this->assertSame('symfony_component_routing_tests_fixtures_attributefixtures_encodingclass_routeàction', $defaultName); } + + public function testAliasesOnMethod() + { + $routes = $this->loader->load(AliasRouteController::class); + $route = $routes->get('action_with_alias'); + $this->assertCount(1, $routes); + $this->assertSame('/path', $route->getPath()); + $this->assertEquals(new Alias('action_with_alias'), $routes->getAlias('alias')); + $this->assertEquals(new Alias('action_with_alias'), $routes->getAlias('completely_different_name')); + } + + public function testThrowsWithAliasesOnClass() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Route aliases cannot be used on non-invokable class "Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\AliasClassController".'); + + $this->loader->load(AliasClassController::class); + } + + public function testAliasesOnInvokableClass() + { + $routes = $this->loader->load(AliasInvokableController::class); + $route = $routes->get('invokable_path'); + $this->assertCount(1, $routes); + $this->assertSame('/path', $route->getPath()); + $this->assertEquals(new Alias('invokable_path'), $routes->getAlias('alias')); + $this->assertEquals(new Alias('invokable_path'), $routes->getAlias('completely_different_name')); + } + + public function testDeprecatedAlias() + { + $routes = $this->loader->load(DeprecatedAliasRouteController::class); + $route = $routes->get('action_with_deprecated_alias'); + $expected = (new Alias('action_with_deprecated_alias')) + ->setDeprecated( + 'MyBundleFixture', + '1.0', + 'The "%alias_id%" route alias is deprecated. You should stop using it, as it will be removed in the future.' + ); + $actual = $routes->getAlias('my_other_alias_deprecated'); + $this->assertCount(1, $routes); + $this->assertSame('/path', $route->getPath()); + $this->assertEquals($expected, $actual); + } + + public function testDeprecatedAliasWithCustomMessage() + { + $routes = $this->loader->load(DeprecatedAliasCustomMessageRouteController::class); + $route = $routes->get('action_with_deprecated_alias'); + $expected = (new Alias('action_with_deprecated_alias')) + ->setDeprecated( + 'MyBundleFixture', + '1.0', + '%alias_id% alias is deprecated.' + ); + $actual = $routes->getAlias('my_other_alias_deprecated'); + $this->assertCount(1, $routes); + $this->assertSame('/path', $route->getPath()); + $this->assertEquals($expected, $actual); + } + + public function testMultipleDeprecatedAlias() + { + $routes = $this->loader->load(MultipleDeprecatedAliasRouteController::class); + $route = $routes->get('action_with_multiple_deprecated_alias'); + $this->assertCount(1, $routes); + $this->assertSame('/path', $route->getPath()); + + $dataset = [ + 'my_first_alias_deprecated' => [ + 'package' => 'MyFirstBundleFixture', + 'version' => '1.0', + ], + 'my_second_alias_deprecated' => [ + 'package' => 'MySecondBundleFixture', + 'version' => '2.0', + ], + 'my_third_alias_deprecated' => [ + 'package' => 'SurprisedThirdBundleFixture', + 'version' => '3.0', + ], + ]; + + foreach ($dataset as $aliasName => $aliasData) { + $expected = (new Alias('action_with_multiple_deprecated_alias')) + ->setDeprecated( + $aliasData['package'], + $aliasData['version'], + 'The "%alias_id%" route alias is deprecated. You should stop using it, as it will be removed in the future.' + ); + $actual = $routes->getAlias($aliasName); + $this->assertEquals($expected, $actual); + } + } }