Skip to content

Commit

Permalink
feature #21771 [FrameworkBundle] Add new "controller.service_argument…
Browse files Browse the repository at this point in the history
…s" tag to inject services into actions (nicolas-grekas)

This PR was merged into the 3.3-dev branch.

Discussion
----------

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

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | (no test yet)
| Fixed tickets | -
| License       | MIT
| Doc PR        | -

Talking with @simensen and @weaverryan, we wondered if we could leverage the `ArgumentResolver` mechanism to make it inject services on demand, using e.g. autowiring.

```php
class PostController
{
  public function indexAction(Request $request, PostRepository $postRepository)
  {
    // PostRepository comes from the container
    $postRepository->findAll(); // ...
  }
}
```

This PR achieves that, using a new "controller.service_arguments" tag. Typically:
```yaml
services:
    AppBundle\Controller\PostController:
        autowire: true
        tags:
            - name: controller.service_arguments
```

It also supports with explicit wiring (thus doesn't necessarily require autowiring if you don't want to use it):
```yaml
services:
    AppBundle\Controller\PostController:
        tags:
            - name: controller.service_arguments
              action: fooAction
              argument: logger
              id: my_logger
```

~~The attached diff is bigger than strictly required for now, until #21770 is merged.~~

Todo:
- [x] rebase on top of #21770 when merged
- [x] add tests
- [x] add cleaning pass to remove empty service locators

Commits
-------

9c6e672 [FrameworkBundle] Add new "controller.service_arguments" tag to inject services into actions
  • Loading branch information
fabpot committed Mar 22, 2017
2 parents b2d5ba7 + 9c6e672 commit 0a5998d
Show file tree
Hide file tree
Showing 9 changed files with 591 additions and 0 deletions.
1 change: 1 addition & 0 deletions src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md
Expand Up @@ -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`)
* Deprecated the "framework.trusted_proxies" configuration option and the corresponding "kernel.trusted_proxies" parameter
* Changed default configuration for
Expand Down
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php
Expand Up @@ -33,6 +33,8 @@
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;
Expand Down Expand Up @@ -78,6 +80,8 @@ public function build(ContainerBuilder $container)
{
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
Expand Down
5 changes: 5 additions & 0 deletions src/Symfony/Bundle/FrameworkBundle/Resources/config/web.xml
Expand Up @@ -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>
Expand Down
@@ -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());
}
}
@@ -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'));
}
}
@@ -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);
}
}

0 comments on commit 0a5998d

Please sign in to comment.