Skip to content

Commit d0bbf04

Browse files
[Routing][FrameworkBundle] Auto-register routes from attributes found on controller services
1 parent ed38673 commit d0bbf04

File tree

10 files changed

+264
-1
lines changed

10 files changed

+264
-1
lines changed

src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
7.4
55
---
66

7+
* Auto-register routes from attributes found on controller services
78
* Add `ControllerHelper`; the helpers from AbstractController as a standalone service
89
* Allow using their name without added suffix when using `#[Target]` for custom services
910
* Deprecate `Symfony\Bundle\FrameworkBundle\Console\Application::add()` in favor of `Symfony\Bundle\FrameworkBundle\Console\Application::addCommand()`

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@
172172
use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer;
173173
use Symfony\Component\RemoteEvent\RemoteEvent;
174174
use Symfony\Component\Routing\Attribute\Route;
175+
use Symfony\Component\Routing\Loader\AttributeServicesLoader;
175176
use Symfony\Component\Scheduler\Attribute\AsCronTask;
176177
use Symfony\Component\Scheduler\Attribute\AsPeriodicTask;
177178
use Symfony\Component\Scheduler\Attribute\AsSchedule;
@@ -768,7 +769,7 @@ public function load(array $configs, ContainerBuilder $container): void
768769
$definition->addTag('controller.service_arguments');
769770
});
770771
$container->registerAttributeForAutoconfiguration(Route::class, static function (ChildDefinition $definition, Route $attribute, \ReflectionClass|\ReflectionMethod $reflection): void {
771-
$definition->addTag('controller.service_arguments');
772+
$definition->addTag('controller.service_arguments')->addTag('routing.controller');
772773
});
773774
$container->registerAttributeForAutoconfiguration(AsRemoteEventConsumer::class, static function (ChildDefinition $definition, AsRemoteEventConsumer $attribute): void {
774775
$definition->addTag('remote_event.consumer', ['consumer' => $attribute->name]);
@@ -1307,6 +1308,10 @@ private function registerRouterConfiguration(array $config, ContainerBuilder $co
13071308

13081309
$loader->load('routing.php');
13091310

1311+
if (!class_exists(AttributeServicesLoader::class)) {
1312+
$container->removeDefinition('routing.loader.attribute.services');
1313+
}
1314+
13101315
if ($config['utf8']) {
13111316
$container->getDefinition('routing.loader')->replaceArgument(1, ['utf8' => true]);
13121317
}

src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
use Symfony\Component\PropertyInfo\DependencyInjection\PropertyInfoConstructorPass;
6262
use Symfony\Component\PropertyInfo\DependencyInjection\PropertyInfoPass;
6363
use Symfony\Component\Routing\DependencyInjection\AddExpressionLanguageProvidersPass;
64+
use Symfony\Component\Routing\DependencyInjection\RoutingControllerPass;
6465
use Symfony\Component\Routing\DependencyInjection\RoutingResolverPass;
6566
use Symfony\Component\Runtime\SymfonyRuntime;
6667
use Symfony\Component\Scheduler\DependencyInjection\AddScheduleMessengerPass;
@@ -146,6 +147,7 @@ public function build(ContainerBuilder $container): void
146147
$container->addCompilerPass(new RegisterControllerArgumentLocatorsPass());
147148
$container->addCompilerPass(new RemoveEmptyControllerArgumentLocatorsPass(), PassConfig::TYPE_BEFORE_REMOVING);
148149
$container->addCompilerPass(new RoutingResolverPass());
150+
$this->addCompilerPassIfExists($container, RoutingControllerPass::class);
149151
$this->addCompilerPassIfExists($container, DataCollectorTranslatorPass::class);
150152
$container->addCompilerPass(new ProfilerPass());
151153
// must be registered before removing private services as some might be listeners/subscribers

src/Symfony/Bundle/FrameworkBundle/Resources/config/routing.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
2727
use Symfony\Component\Routing\Loader\AttributeDirectoryLoader;
2828
use Symfony\Component\Routing\Loader\AttributeFileLoader;
29+
use Symfony\Component\Routing\Loader\AttributeServicesLoader;
2930
use Symfony\Component\Routing\Loader\ContainerLoader;
3031
use Symfony\Component\Routing\Loader\DirectoryLoader;
3132
use Symfony\Component\Routing\Loader\GlobFileLoader;
@@ -98,6 +99,12 @@
9899
])
99100
->tag('routing.loader', ['priority' => -10])
100101

102+
->set('routing.loader.attribute.services', AttributeServicesLoader::class)
103+
->args([
104+
abstract_arg('classes tagged with "routing.controller"'),
105+
])
106+
->tag('routing.loader', ['priority' => -10])
107+
101108
->set('routing.loader.attribute.directory', AttributeDirectoryLoader::class)
102109
->args([
103110
service('file_locator'),
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bundle\FrameworkBundle\Tests\Routing;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Bundle\FrameworkBundle\Routing\AttributeRouteControllerLoader;
16+
use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\InvokableController;
17+
use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\MethodActionControllers;
18+
19+
class AttributeRouteControllerLoaderTest extends TestCase
20+
{
21+
public function testConfigureRouteSetsControllerForInvokable()
22+
{
23+
$loader = new AttributeRouteControllerLoader();
24+
$collection = $loader->load(InvokableController::class);
25+
26+
$route = $collection->get('lol');
27+
$this->assertSame(InvokableController::class, $route->getDefault('_controller'));
28+
}
29+
30+
public function testConfigureRouteSetsControllerForMethod()
31+
{
32+
$loader = new AttributeRouteControllerLoader();
33+
$collection = $loader->load(MethodActionControllers::class);
34+
35+
$put = $collection->get('put');
36+
$post = $collection->get('post');
37+
38+
$this->assertSame(MethodActionControllers::class.'::put', $put->getDefault('_controller'));
39+
$this->assertSame(MethodActionControllers::class.'::post', $post->getDefault('_controller'));
40+
}
41+
}

src/Symfony/Component/Routing/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
7.4
55
---
66

7+
* Add `AttributeServicesLoader` and `RoutingControllerPass` to auto-register routes from attributes on services
78
* Allow query-specific parameters in `UrlGenerator` using `_query`
89
* Add support of multiple env names in the `Symfony\Component\Routing\Attribute\Route` attribute
910
* Add argument `$parameters` to `RequestContext`'s constructor
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Routing\DependencyInjection;
13+
14+
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
15+
use Symfony\Component\DependencyInjection\Compiler\PriorityTaggedServiceTrait;
16+
use Symfony\Component\DependencyInjection\ContainerBuilder;
17+
18+
/**
19+
* @author Nicolas Grekas <p@tchwork.com>
20+
*/
21+
class RoutingControllerPass implements CompilerPassInterface
22+
{
23+
use PriorityTaggedServiceTrait;
24+
25+
public function process(ContainerBuilder $container): void
26+
{
27+
if (!$container->hasDefinition('routing.loader.attribute.services')) {
28+
return;
29+
}
30+
31+
$resolve = $container->getParameterBag()->resolveValue(...);
32+
$taggedClasses = [];
33+
foreach ($this->findAndSortTaggedServices('routing.controller', $container) as $id) {
34+
$taggedClasses[$resolve($container->getDefinition($id)->getClass())] = true;
35+
}
36+
37+
$container->getDefinition('routing.loader.attribute.services')
38+
->replaceArgument(0, array_keys($taggedClasses));
39+
}
40+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Routing\Loader;
13+
14+
use Symfony\Component\Config\Loader\Loader;
15+
use Symfony\Component\Routing\RouteCollection;
16+
17+
/**
18+
* Loads routes from a list of tagged classes by delegating to the attribute class loader.
19+
*
20+
* @author Nicolas Grekas <p@tchwork.com>
21+
*/
22+
class AttributeServicesLoader extends Loader
23+
{
24+
/**
25+
* @param class-string[] $taggedClasses
26+
*/
27+
public function __construct(
28+
private array $taggedClasses = [],
29+
) {
30+
}
31+
32+
public function load(mixed $resource, ?string $type = null): RouteCollection
33+
{
34+
$collection = new RouteCollection();
35+
36+
foreach ($this->taggedClasses as $class) {
37+
$collection->addCollection($this->import($class, 'attribute'));
38+
}
39+
40+
return $collection;
41+
}
42+
43+
public function supports(mixed $resource, ?string $type = null): bool
44+
{
45+
return 'tagged_services' === $type && 'attributes' === $resource;
46+
}
47+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Routing\Tests\DependencyInjection;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\DependencyInjection\ContainerBuilder;
16+
use Symfony\Component\DependencyInjection\Definition;
17+
use Symfony\Component\Routing\DependencyInjection\RoutingControllerPass;
18+
19+
class RoutingControllerPassTest extends TestCase
20+
{
21+
public function testProcessInjectsTaggedControllerClassesOrderedAndUnique()
22+
{
23+
$container = new ContainerBuilder();
24+
$container->setParameter('ctrl_a.class', CtrlA::class);
25+
26+
$container->register('routing.loader.attribute.services', \stdClass::class)
27+
->setArguments([null]);
28+
29+
$container->register('ctrl_a', '%ctrl_a.class%')->addTag('routing.controller', ['priority' => 10]);
30+
$container->register('ctrl_b', CtrlB::class)->addTag('routing.controller', ['priority' => 20]);
31+
$container->register('ctrl_c', CtrlC::class)->addTag('routing.controller', ['priority' => -5]);
32+
33+
(new RoutingControllerPass())->process($container);
34+
35+
$this->assertSame([
36+
CtrlB::class,
37+
CtrlA::class,
38+
CtrlC::class,
39+
], $container->getDefinition('routing.loader.attribute.services')->getArgument(0));
40+
}
41+
42+
public function testProcessWithNoTaggedControllersSetsEmptyList()
43+
{
44+
$container = new ContainerBuilder();
45+
46+
$loaderDef = new Definition(\stdClass::class);
47+
$loaderDef->setArguments([['preexisting']]);
48+
$container->setDefinition('routing.loader.attribute.services', $loaderDef);
49+
50+
(new RoutingControllerPass())->process($container);
51+
52+
$this->assertSame([], $container->getDefinition('routing.loader.attribute.services')->getArgument(0));
53+
}
54+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Routing\Tests\Loader;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Config\Loader\LoaderResolver;
16+
use Symfony\Component\Routing\Loader\AttributeServicesLoader;
17+
use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\ActionPathController;
18+
use Symfony\Component\Routing\Tests\Fixtures\AttributeFixtures\MethodActionControllers;
19+
use Symfony\Component\Routing\Tests\Fixtures\TraceableAttributeClassLoader;
20+
21+
class AttributeServicesLoaderTest extends TestCase
22+
{
23+
public function testSupports()
24+
{
25+
$loader = new AttributeServicesLoader();
26+
27+
$this->assertFalse($loader->supports('attributes', null));
28+
$this->assertFalse($loader->supports('attributes', 'attribute'));
29+
$this->assertFalse($loader->supports('other', 'tagged_services'));
30+
$this->assertTrue($loader->supports('attributes', 'tagged_services'));
31+
}
32+
33+
public function testDelegatesToAttributeLoaderAndMergesCollections()
34+
{
35+
$attributeLoader = new TraceableAttributeClassLoader();
36+
37+
$servicesLoader = new AttributeServicesLoader([
38+
ActionPathController::class,
39+
MethodActionControllers::class,
40+
]);
41+
42+
$resolver = new LoaderResolver([
43+
$attributeLoader,
44+
$servicesLoader,
45+
]);
46+
47+
$attributeLoader->setResolver($resolver);
48+
$servicesLoader->setResolver($resolver);
49+
50+
$collection = $servicesLoader->load('attributes', 'tagged_services');
51+
52+
$this->assertArrayHasKey('action', $collection->all());
53+
$this->assertArrayHasKey('put', $collection->all());
54+
$this->assertArrayHasKey('post', $collection->all());
55+
56+
$this->assertSame(['/path'], [$collection->get('action')->getPath()]);
57+
$this->assertSame('/the/path', $collection->get('put')->getPath());
58+
$this->assertSame('/the/path', $collection->get('post')->getPath());
59+
60+
$this->assertSame([
61+
ActionPathController::class,
62+
MethodActionControllers::class,
63+
], $attributeLoader->foundClasses);
64+
}
65+
}

0 commit comments

Comments
 (0)