[FrameworkBundle] Add new "controller.service_arguments" tag to inject services into actions

This commit is contained in:
Nicolas Grekas 2017-02-26 17:28:32 +01:00
parent 3023e4b707
commit 9c6e672780
9 changed files with 591 additions and 0 deletions

View File

@ -4,6 +4,7 @@ CHANGELOG
3.3.0
-----
* Added support for the `controller.service_arguments` tag, for injecting services into controllers' actions
* Deprecated `cache:clear` with warmup (always call it with `--no-warmup`)
* Changed default configuration for
assets/forms/validation/translation/serialization/csrf from `canBeEnabled()` to

View File

@ -25,6 +25,7 @@ class UnusedTagsPass implements CompilerPassInterface
'console.command',
'container.service_locator',
'container.service_subscriber',
'controller.service_arguments',
'config_cache.resource_checker',
'data_collector',
'form.type',

View File

@ -35,6 +35,8 @@ use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\ValidateWorkflow
use Symfony\Component\Config\DependencyInjection\ConfigCachePass;
use Symfony\Component\Console\DependencyInjection\AddConsoleCommandPass;
use Symfony\Component\HttpKernel\DependencyInjection\ControllerArgumentValueResolverPass;
use Symfony\Component\HttpKernel\DependencyInjection\RegisterControllerArgumentLocatorsPass;
use Symfony\Component\HttpKernel\DependencyInjection\RemoveEmptyControllerArgumentLocatorsPass;
use Symfony\Component\PropertyInfo\DependencyInjection\PropertyInfoPass;
use Symfony\Component\Routing\DependencyInjection\RoutingResolverPass;
use Symfony\Component\Serializer\DependencyInjection\SerializerPass;
@ -76,6 +78,8 @@ class FrameworkBundle extends Bundle
{
parent::build($container);
$container->addCompilerPass(new RegisterControllerArgumentLocatorsPass());
$container->addCompilerPass(new RemoveEmptyControllerArgumentLocatorsPass(), PassConfig::TYPE_BEFORE_REMOVING);
$container->addCompilerPass(new RoutingResolverPass());
$container->addCompilerPass(new ProfilerPass());
// must be registered before removing private services as some might be listeners/subscribers

View File

@ -36,6 +36,11 @@
<tag name="controller.argument_value_resolver" priority="50" />
</service>
<service id="argument_resolver.service" class="Symfony\Component\HttpKernel\Controller\ArgumentResolver\ServiceValueResolver" public="false">
<tag name="controller.argument_value_resolver" priority="-50" />
<argument />
</service>
<service id="argument_resolver.default" class="Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver" public="false">
<tag name="controller.argument_value_resolver" priority="-100" />
</service>

View File

@ -0,0 +1,48 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpKernel\Controller\ArgumentResolver;
use Psr\Container\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
/**
* Yields a service keyed by _controller and argument name.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class ServiceValueResolver implements ArgumentValueResolverInterface
{
private $container;
public function __construct(ContainerInterface $container)
{
$this->container = $container;
}
/**
* {@inheritdoc}
*/
public function supports(Request $request, ArgumentMetadata $argument)
{
return is_string($controller = $request->attributes->get('_controller')) && $this->container->has($controller) && $this->container->get($controller)->has($argument->getName());
}
/**
* {@inheritdoc}
*/
public function resolve(Request $request, ArgumentMetadata $argument)
{
yield $this->container->get($request->attributes->get('_controller'))->get($argument->getName());
}
}

View File

@ -0,0 +1,151 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpKernel\DependencyInjection;
use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\LazyProxy\InheritanceProxyHelper;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Component\DependencyInjection\TypedReference;
/**
* Creates the service-locators required by ServiceArgumentValueResolver.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class RegisterControllerArgumentLocatorsPass implements CompilerPassInterface
{
private $resolverServiceId;
private $controllerTag;
public function __construct($resolverServiceId = 'argument_resolver.service', $controllerTag = 'controller.service_arguments')
{
$this->resolverServiceId = $resolverServiceId;
$this->controllerTag = $controllerTag;
}
public function process(ContainerBuilder $container)
{
if (false === $container->hasDefinition($this->resolverServiceId)) {
return;
}
$parameterBag = $container->getParameterBag();
$controllers = array();
foreach ($container->findTaggedServiceIds($this->controllerTag) as $id => $tags) {
$def = $container->getDefinition($id);
if ($def->isAbstract()) {
continue;
}
$class = $def->getClass();
$isAutowired = $def->isAutowired();
// resolve service class, taking parent definitions into account
while (!$class && $def instanceof ChildDefinition) {
$def = $container->findDefinition($def->getParent());
$class = $def->getClass();
}
$class = $parameterBag->resolveValue($class);
if (!$r = $container->getReflectionClass($class)) {
throw new InvalidArgumentException(sprintf('Class "%s" used for service "%s" cannot be found.', $class, $id));
}
// get regular public methods
$methods = array();
$arguments = array();
foreach ($r->getMethods(\ReflectionMethod::IS_PUBLIC) as $r) {
if (!$r->isConstructor() && !$r->isDestructor() && !$r->isAbstract()) {
$methods[strtolower($r->name)] = array($r, $r->getParameters());
}
}
// validate and collect explicit per-actions and per-arguments service references
foreach ($tags as $attributes) {
if (!isset($attributes['action']) && !isset($attributes['argument']) && !isset($attributes['id'])) {
continue;
}
foreach (array('action', 'argument', 'id') as $k) {
if (!isset($attributes[$k][0])) {
throw new InvalidArgumentException(sprintf('Missing "%s" attribute on tag "%s" %s for service "%s".', $k, $this->controllerTag, json_encode($attributes, JSON_UNESCAPED_UNICODE), $id));
}
}
if (!isset($methods[$action = strtolower($attributes['action'])])) {
throw new InvalidArgumentException(sprintf('Invalid "action" attribute on tag "%s" for service "%s": no public "%s()" method found on class "%s".', $this->controllerTag, $id, $attributes['action'], $class));
}
list($r, $parameters) = $methods[$action];
$found = false;
foreach ($parameters as $p) {
if ($attributes['argument'] === $p->name) {
if (!isset($arguments[$r->name][$p->name])) {
$arguments[$r->name][$p->name] = $attributes['id'];
}
$found = true;
break;
}
}
if (!$found) {
throw new InvalidArgumentException(sprintf('Invalid "%s" tag for service "%s": method "%s()" has no "%s" argument on class "%s".', $this->controllerTag, $id, $r->name, $attributes['argument'], $class));
}
}
foreach ($methods as list($r, $parameters)) {
// create a per-method map of argument-names to service/type-references
$args = array();
foreach ($parameters as $p) {
$type = $target = InheritanceProxyHelper::getTypeHint($r, $p, true);
$invalidBehavior = ContainerInterface::IGNORE_ON_INVALID_REFERENCE;
if (isset($arguments[$r->name][$p->name])) {
$target = $arguments[$r->name][$p->name];
if ('?' !== $target[0]) {
$invalidBehavior = ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE;
} elseif ('' === $target = (string) substr($target, 1)) {
throw new InvalidArgumentException(sprintf('A "%s" tag must have non-empty "id" attributes for service "%s".', $this->controllerTag, $id));
} elseif ($p->allowsNull() && !$p->isOptional()) {
$invalidBehavior = ContainerInterface::NULL_ON_INVALID_REFERENCE;
}
} elseif (!$type) {
continue;
}
$args[$p->name] = new ServiceClosureArgument($type ? new TypedReference($target, $type, $invalidBehavior, false) : new Reference($target, $invalidBehavior));
}
// register the maps as a per-method service-locators
if ($args) {
$argsId = sprintf('arguments.%s:%s', $id, $r->name);
$container->register($argsId, ServiceLocator::class)
->addArgument($args)
->setPublic(false)
->setAutowired($isAutowired)
->addTag('controller.arguments_locator', array($class, $id, $r->name));
$controllers[$id.':'.$r->name] = new ServiceClosureArgument(new Reference($argsId));
if ($id === $class) {
$controllers[$id.'::'.$r->name] = new ServiceClosureArgument(new Reference($argsId));
}
}
}
}
$container->getDefinition($this->resolverServiceId)
->replaceArgument(0, (new Definition(ServiceLocator::class, array($controllers)))->addTag('container.service_locator'));
}
}

View File

@ -0,0 +1,71 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpKernel\DependencyInjection;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
/**
* Removes empty service-locators registered for ServiceArgumentValueResolver.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class RemoveEmptyControllerArgumentLocatorsPass implements CompilerPassInterface
{
private $resolverServiceId;
public function __construct($resolverServiceId = 'argument_resolver.service')
{
$this->resolverServiceId = $resolverServiceId;
}
public function process(ContainerBuilder $container)
{
if (false === $container->hasDefinition($this->resolverServiceId)) {
return;
}
$serviceResolver = $container->getDefinition($this->resolverServiceId);
$controllers = $serviceResolver->getArgument(0)->getArgument(0);
foreach ($container->findTaggedServiceIds('controller.arguments_locator') as $id => $tags) {
$argumentLocator = $container->getDefinition($id)->clearTag('controller.arguments_locator');
list($class, $service, $action) = $tags[0];
if (!$argumentLocator->getArgument(0)) {
// remove empty argument locators
$reason = sprintf('Removing service-argument-resolver for controller "%s:%s": no corresponding definitions were found for the referenced services/types.%s', $service, $action, !$argumentLocator->isAutowired() ? ' Did you forget to enable autowiring?' : '');
} else {
// any methods listed for call-at-instantiation cannot be actions
$reason = false;
foreach ($container->getDefinition($service)->getMethodCalls() as list($method, $args)) {
if (0 === strcasecmp($action, $method)) {
$reason = sprintf('Removing method "%s" of service "%s" from controller candidates: the method is called at instantiation, thus cannot be an action.', $action, $service);
break;
}
}
if (!$reason) {
continue;
}
}
$container->removeDefinition($id);
unset($controllers[$service.':'.$action]);
if ($service === $class) {
unset($controllers[$service.'::'.$action]);
}
$container->log($this, $reason);
}
$serviceResolver->getArgument(0)->replaceArgument(0, $controllers);
}
}

View File

@ -0,0 +1,226 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpKernel\Tests\DependencyInjection;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Component\DependencyInjection\TypedReference;
use Symfony\Component\HttpKernel\DependencyInjection\RegisterControllerArgumentLocatorsPass;
class RegisterControllerArgumentLocatorsPassTest extends TestCase
{
/**
* @expectedException \Symfony\Component\DependencyInjection\Exception\InvalidArgumentException
* @expectedExceptionMessage Class "Symfony\Component\HttpKernel\Tests\DependencyInjection\NotFound" used for service "foo" cannot be found.
*/
public function testInvalidClass()
{
$container = new ContainerBuilder();
$container->register('argument_resolver.service')->addArgument(array());
$container->register('foo', NotFound::class)
->addTag('controller.service_arguments')
;
$pass = new RegisterControllerArgumentLocatorsPass();
$pass->process($container);
}
/**
* @expectedException \Symfony\Component\DependencyInjection\Exception\InvalidArgumentException
* @expectedExceptionMessage Missing "action" attribute on tag "controller.service_arguments" {"argument":"bar"} for service "foo".
*/
public function testNoAction()
{
$container = new ContainerBuilder();
$container->register('argument_resolver.service')->addArgument(array());
$container->register('foo', RegisterTestController::class)
->addTag('controller.service_arguments', array('argument' => 'bar'))
;
$pass = new RegisterControllerArgumentLocatorsPass();
$pass->process($container);
}
/**
* @expectedException \Symfony\Component\DependencyInjection\Exception\InvalidArgumentException
* @expectedExceptionMessage Missing "argument" attribute on tag "controller.service_arguments" {"action":"fooAction"} for service "foo".
*/
public function testNoArgument()
{
$container = new ContainerBuilder();
$container->register('argument_resolver.service')->addArgument(array());
$container->register('foo', RegisterTestController::class)
->addTag('controller.service_arguments', array('action' => 'fooAction'))
;
$pass = new RegisterControllerArgumentLocatorsPass();
$pass->process($container);
}
/**
* @expectedException \Symfony\Component\DependencyInjection\Exception\InvalidArgumentException
* @expectedExceptionMessage Missing "id" attribute on tag "controller.service_arguments" {"action":"fooAction","argument":"bar"} for service "foo".
*/
public function testNoService()
{
$container = new ContainerBuilder();
$container->register('argument_resolver.service')->addArgument(array());
$container->register('foo', RegisterTestController::class)
->addTag('controller.service_arguments', array('action' => 'fooAction', 'argument' => 'bar'))
;
$pass = new RegisterControllerArgumentLocatorsPass();
$pass->process($container);
}
/**
* @expectedException \Symfony\Component\DependencyInjection\Exception\InvalidArgumentException
* @expectedExceptionMessage Invalid "action" attribute on tag "controller.service_arguments" for service "foo": no public "barAction()" method found on class "Symfony\Component\HttpKernel\Tests\DependencyInjection\RegisterTestController".
*/
public function testInvalidMethod()
{
$container = new ContainerBuilder();
$container->register('argument_resolver.service')->addArgument(array());
$container->register('foo', RegisterTestController::class)
->addTag('controller.service_arguments', array('action' => 'barAction', 'argument' => 'bar', 'id' => 'bar_service'))
;
$pass = new RegisterControllerArgumentLocatorsPass();
$pass->process($container);
}
/**
* @expectedException \Symfony\Component\DependencyInjection\Exception\InvalidArgumentException
* @expectedExceptionMessage Invalid "controller.service_arguments" tag for service "foo": method "fooAction()" has no "baz" argument on class "Symfony\Component\HttpKernel\Tests\DependencyInjection\RegisterTestController".
*/
public function testInvalidArgument()
{
$container = new ContainerBuilder();
$container->register('argument_resolver.service')->addArgument(array());
$container->register('foo', RegisterTestController::class)
->addTag('controller.service_arguments', array('action' => 'fooAction', 'argument' => 'baz', 'id' => 'bar'))
;
$pass = new RegisterControllerArgumentLocatorsPass();
$pass->process($container);
}
public function testAllActions()
{
$container = new ContainerBuilder();
$container->register('argument_resolver.service')->addArgument(array());
$container->register('foo', RegisterTestController::class)
->setAutowired(true)
->addTag('controller.service_arguments')
;
$pass = new RegisterControllerArgumentLocatorsPass();
$pass->process($container);
$expected = new Definition(ServiceLocator::class);
$expected->addArgument(array('foo:fooAction' => new ServiceClosureArgument(new Reference('arguments.foo:fooAction'))));
$expected->addTag('container.service_locator');
$this->assertEquals($expected, $container->getDefinition('argument_resolver.service')->getArgument(0));
$locator = $container->getDefinition('arguments.foo:fooAction');
$this->assertSame(ServiceLocator::class, $locator->getClass());
$this->assertFalse($locator->isPublic());
$this->assertTrue($locator->isAutowired());
$expected = array('bar' => new ServiceClosureArgument(new TypedReference('stdClass', 'stdClass', ContainerInterface::IGNORE_ON_INVALID_REFERENCE, false)));
$this->assertEquals($expected, $locator->getArgument(0));
}
public function testExplicitArgument()
{
$container = new ContainerBuilder();
$container->register('argument_resolver.service')->addArgument(array());
$container->register('foo', RegisterTestController::class)
->addTag('controller.service_arguments', array('action' => 'fooAction', 'argument' => 'bar', 'id' => 'bar'))
->addTag('controller.service_arguments', array('action' => 'fooAction', 'argument' => 'bar', 'id' => 'baz')) // should be ignored, the first wins
;
$pass = new RegisterControllerArgumentLocatorsPass();
$pass->process($container);
$locator = $container->getDefinition('arguments.foo:fooAction');
$this->assertFalse($locator->isAutowired());
$expected = array('bar' => new ServiceClosureArgument(new TypedReference('bar', 'stdClass', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, false)));
$this->assertEquals($expected, $locator->getArgument(0));
}
public function testOptionalArgument()
{
$container = new ContainerBuilder();
$container->register('argument_resolver.service')->addArgument(array());
$container->register('foo', RegisterTestController::class)
->addTag('controller.service_arguments', array('action' => 'fooAction', 'argument' => 'bar', 'id' => '?bar'))
;
$pass = new RegisterControllerArgumentLocatorsPass();
$pass->process($container);
$locator = $container->getDefinition('arguments.foo:fooAction');
$expected = array('bar' => new ServiceClosureArgument(new TypedReference('bar', 'stdClass', ContainerInterface::IGNORE_ON_INVALID_REFERENCE, false)));
$this->assertEquals($expected, $locator->getArgument(0));
}
public function testSameIdClass()
{
$container = new ContainerBuilder();
$resolver = $container->register('argument_resolver.service')->addArgument(array());
$container->register(RegisterTestController::class, RegisterTestController::class)
->addTag('controller.service_arguments')
;
$pass = new RegisterControllerArgumentLocatorsPass();
$pass->process($container);
$expected = array(
RegisterTestController::class.':fooAction' => new ServiceClosureArgument(new Reference('arguments.'.RegisterTestController::class.':fooAction')),
RegisterTestController::class.'::fooAction' => new ServiceClosureArgument(new Reference('arguments.'.RegisterTestController::class.':fooAction')),
);
$this->assertEquals($expected, $resolver->getArgument(0)->getArgument(0));
}
}
class RegisterTestController
{
public function __construct(\stdClass $bar)
{
}
public function fooAction(\stdClass $bar)
{
}
protected function barAction(\stdClass $bar)
{
}
}

View File

@ -0,0 +1,84 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpKernel\Tests\DependencyInjection;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
use Symfony\Component\DependencyInjection\Compiler\ResolveInvalidReferencesPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\HttpKernel\DependencyInjection\RegisterControllerArgumentLocatorsPass;
use Symfony\Component\HttpKernel\DependencyInjection\RemoveEmptyControllerArgumentLocatorsPass;
class RemoveEmptyControllerArgumentLocatorsPassTest extends TestCase
{
public function testProcess()
{
$container = new ContainerBuilder();
$container->register('argument_resolver.service')->addArgument(array());
$container->register('stdClass', 'stdClass');
$container->register(parent::class, 'stdClass');
$container->register('c1', RemoveTestController1::class)->addTag('controller.service_arguments');
$container->register('c2', RemoveTestController2::class)->addTag('controller.service_arguments')
->addMethodCall('setTestCase', array(new Reference('c1')));
$pass = new RegisterControllerArgumentLocatorsPass();
$pass->process($container);
$this->assertCount(2, $container->getDefinition('arguments.c1:fooAction')->getArgument(0));
$this->assertCount(1, $container->getDefinition('arguments.c2:setTestCase')->getArgument(0));
$this->assertCount(1, $container->getDefinition('arguments.c2:fooAction')->getArgument(0));
$pass = new ResolveInvalidReferencesPass();
$pass->process($container);
$this->assertCount(1, $container->getDefinition('arguments.c2:setTestCase')->getArgument(0));
$this->assertSame(array(), $container->getDefinition('arguments.c2:fooAction')->getArgument(0));
$pass = new RemoveEmptyControllerArgumentLocatorsPass();
$pass->process($container);
$this->assertFalse($container->hasDefinition('arguments.c2:setTestCase'));
$this->assertFalse($container->hasDefinition('arguments.c2:fooAction'));
$this->assertCount(1, $container->getDefinition('arguments.c1:fooAction')->getArgument(0));
$this->assertArrayHasKey('bar', $container->getDefinition('arguments.c1:fooAction')->getArgument(0));
$expectedLog = array(
'Symfony\Component\HttpKernel\DependencyInjection\RemoveEmptyControllerArgumentLocatorsPass: Removing method "setTestCase" of service "c2" from controller candidates: the method is called at instantiation, thus cannot be an action.',
'Symfony\Component\HttpKernel\DependencyInjection\RemoveEmptyControllerArgumentLocatorsPass: Removing service-argument-resolver for controller "c2:fooAction": no corresponding definitions were found for the referenced services/types. Did you forget to enable autowiring?',
);
$this->assertSame($expectedLog, $container->getCompiler()->getLog());
$this->assertEquals(array('c1:fooAction' => new ServiceClosureArgument(new Reference('arguments.c1:fooAction'))), $container->getDefinition('argument_resolver.service')->getArgument(0)->getArgument(0));
}
}
class RemoveTestController1
{
public function fooAction(\stdClass $bar, NotFound $baz)
{
}
}
class RemoveTestController2
{
public function setTestCase(TestCase $test)
{
}
public function fooAction(NotFound $bar)
{
}
}