Skip to content

[DependencyInjection] Allow service subscribers to return SubscribedService[] #47101

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use Symfony\Component\DependencyInjection\LazyProxy\ProxyHelper;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\TypedReference;
use Symfony\Contracts\Service\Attribute\SubscribedService;

/**
* Inspects existing service definitions and wires the autowired ones using the type hints of their classes.
Expand Down Expand Up @@ -104,6 +105,19 @@ protected function processValue(mixed $value, bool $isRoot = false): mixed
private function doProcessValue(mixed $value, bool $isRoot = false): mixed
{
if ($value instanceof TypedReference) {
if ($attributes = $value->getAttributes()) {
$attribute = array_pop($attributes);

if ($attributes) {
throw new AutowiringFailedException($this->currentId, sprintf('Using multiple attributes with "%s" is not supported.', SubscribedService::class));
}

if (!$attribute instanceof Target) {
return $this->processAttribute($attribute, ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE !== $value->getInvalidBehavior());
}

$value = new TypedReference($value->getType(), $value->getType(), $value->getInvalidBehavior(), $attribute->name);
}
if ($ref = $this->getAutowiredReference($value, true)) {
return $ref;
}
Expand Down Expand Up @@ -158,6 +172,29 @@ private function doProcessValue(mixed $value, bool $isRoot = false): mixed
return $value;
}

private function processAttribute(object $attribute, bool $isOptional = false): mixed
{
switch (true) {
case $attribute instanceof Autowire:
$value = $this->container->getParameterBag()->resolveValue($attribute->value);

return $value instanceof Reference && $isOptional ? new Reference($value, ContainerInterface::NULL_ON_INVALID_REFERENCE) : $value;

case $attribute instanceof TaggedIterator:
return new TaggedIteratorArgument($attribute->tag, $attribute->indexAttribute, $attribute->defaultIndexMethod, false, $attribute->defaultPriorityMethod, (array) $attribute->exclude);

case $attribute instanceof TaggedLocator:
return new ServiceLocatorArgument(new TaggedIteratorArgument($attribute->tag, $attribute->indexAttribute, $attribute->defaultIndexMethod, true, $attribute->defaultPriorityMethod, (array) $attribute->exclude));

case $attribute instanceof MapDecorated:
$definition = $this->container->getDefinition($this->currentId);

return new Reference($definition->innerServiceId ?? $this->currentId.'.inner', $definition->decorationOnInvalid ?? ContainerInterface::NULL_ON_INVALID_REFERENCE);
}

throw new AutowiringFailedException($this->currentId, sprintf('"%s" is an unsupported attribute.', $attribute::class));
}

private function autowireCalls(\ReflectionClass $reflectionClass, bool $isRoot, bool $checkAttributes): array
{
$this->decoratedId = null;
Expand Down Expand Up @@ -249,34 +286,8 @@ private function autowireMethod(\ReflectionFunctionAbstract $reflectionMethod, a

if ($checkAttributes) {
foreach ($parameter->getAttributes() as $attribute) {
if (TaggedIterator::class === $attribute->getName()) {
$attribute = $attribute->newInstance();
$arguments[$index] = new TaggedIteratorArgument($attribute->tag, $attribute->indexAttribute, $attribute->defaultIndexMethod, false, $attribute->defaultPriorityMethod, (array) $attribute->exclude);
break;
}

if (TaggedLocator::class === $attribute->getName()) {
$attribute = $attribute->newInstance();
$arguments[$index] = new ServiceLocatorArgument(new TaggedIteratorArgument($attribute->tag, $attribute->indexAttribute, $attribute->defaultIndexMethod, true, $attribute->defaultPriorityMethod, (array) $attribute->exclude));
break;
}

if (Autowire::class === $attribute->getName()) {
$value = $attribute->newInstance()->value;
$value = $this->container->getParameterBag()->resolveValue($value);

if ($value instanceof Reference && $parameter->allowsNull()) {
$value = new Reference($value, ContainerInterface::NULL_ON_INVALID_REFERENCE);
}

$arguments[$index] = $value;

break;
}

if (MapDecorated::class === $attribute->getName()) {
$definition = $this->container->getDefinition($this->currentId);
$arguments[$index] = new Reference($definition->innerServiceId ?? $this->currentId.'.inner', $definition->decorationOnInvalid ?? ContainerInterface::NULL_ON_INVALID_REFERENCE);
if (\in_array($attribute->getName(), [TaggedIterator::class, TaggedLocator::class, Autowire::class, MapDecorated::class], true)) {
$arguments[$index] = $this->processAttribute($attribute->newInstance(), $parameter->allowsNull());

break;
}
Expand Down Expand Up @@ -315,7 +326,7 @@ private function autowireMethod(\ReflectionFunctionAbstract $reflectionMethod, a
}

$getValue = function () use ($type, $parameter, $class, $method) {
if (!$value = $this->getAutowiredReference($ref = new TypedReference($type, $type, ContainerBuilder::EXCEPTION_ON_INVALID_REFERENCE, Target::parseName($parameter)), true)) {
if (!$value = $this->getAutowiredReference($ref = new TypedReference($type, $type, ContainerBuilder::EXCEPTION_ON_INVALID_REFERENCE, Target::parseName($parameter)), false)) {
$failureMessage = $this->createTypeNotFoundMessageCallback($ref, sprintf('argument "$%s" of method "%s()"', $parameter->name, $class !== $this->currentId ? $class.'::'.$method : $method));

if ($parameter->isDefaultValueAvailable()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\TypedReference;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Contracts\Service\Attribute\SubscribedService;
use Symfony\Contracts\Service\ServiceProviderInterface;
use Symfony\Contracts\Service\ServiceSubscriberInterface;

Expand Down Expand Up @@ -73,6 +74,14 @@ protected function processValue(mixed $value, bool $isRoot = false): mixed
$subscriberMap = [];

foreach ($class::getSubscribedServices() as $key => $type) {
$attributes = [];

if ($type instanceof SubscribedService) {
$key = $type->key;
$attributes = $type->attributes;
$type = ($type->nullable ? '?' : '').($type->type ?? throw new InvalidArgumentException(sprintf('When "%s::getSubscribedServices()" returns "%s", a type must be set.', $class, SubscribedService::class)));
}

if (!\is_string($type) || !preg_match('/(?(DEFINE)(?<cn>[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+))(?(DEFINE)(?<fqcn>(?&cn)(?:\\\\(?&cn))*+))^\??(?&fqcn)(?:(?:\|(?&fqcn))*+|(?:&(?&fqcn))*+)$/', $type)) {
throw new InvalidArgumentException(sprintf('"%s::getSubscribedServices()" must return valid PHP types for service "%s" key "%s", "%s" returned.', $class, $this->currentId, $key, \is_string($type) ? $type : get_debug_type($type)));
}
Expand Down Expand Up @@ -109,7 +118,7 @@ protected function processValue(mixed $value, bool $isRoot = false): mixed
$name = $this->container->has($type.' $'.$camelCaseName) ? $camelCaseName : $name;
}

$subscriberMap[$key] = new TypedReference((string) $serviceMap[$key], $type, $optionalBehavior ?: ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $name);
$subscriberMap[$key] = new TypedReference((string) $serviceMap[$key], $type, $optionalBehavior ?: ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $name, $attributes);
unset($serviceMap[$key]);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface as PsrContainerInterface;
use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument;
use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\Attribute\MapDecorated;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
use Symfony\Component\DependencyInjection\Attribute\TaggedLocator;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\DependencyInjection\Compiler\AutowirePass;
use Symfony\Component\DependencyInjection\Compiler\RegisterServiceSubscribersPass;
use Symfony\Component\DependencyInjection\Compiler\ResolveBindingsPass;
Expand Down Expand Up @@ -402,6 +409,65 @@ public static function getSubscribedServices(): array
$this->assertEquals($expected, $container->getDefinition((string) $locator->getFactory()[0])->getArgument(0));
}

public function testSubscribedServiceWithAttributes()
{
if (!property_exists(SubscribedService::class, 'attributes')) {
$this->markTestSkipped('Requires symfony/service-contracts 3.2+');
}

$container = new ContainerBuilder();

$subscriber = new class() implements ServiceSubscriberInterface {
public static function getSubscribedServices(): array
{
return [
new SubscribedService('tagged.iterator', 'iterable', attributes: new TaggedIterator('tag')),
new SubscribedService('tagged.locator', PsrContainerInterface::class, attributes: new TaggedLocator('tag')),
new SubscribedService('autowired', 'stdClass', attributes: new Autowire(service: 'service.id')),
new SubscribedService('autowired.nullable', 'stdClass', nullable: true, attributes: new Autowire(service: 'service.id')),
new SubscribedService('autowired.parameter', 'string', attributes: new Autowire('%parameter.1%')),
new SubscribedService('map.decorated', \stdClass::class, attributes: new MapDecorated()),
new SubscribedService('target', \stdClass::class, attributes: new Target('someTarget')),
];
}
};

$container->setParameter('parameter.1', 'foobar');
$container->register('foo', \get_class($subscriber))
->addMethodCall('setContainer', [new Reference(PsrContainerInterface::class)])
->addTag('container.service_subscriber');

(new RegisterServiceSubscribersPass())->process($container);
(new ResolveServiceSubscribersPass())->process($container);

$foo = $container->getDefinition('foo');
$locator = $container->getDefinition((string) $foo->getMethodCalls()[0][1][0]);

$expected = [
'tagged.iterator' => new ServiceClosureArgument(new TypedReference('iterable', 'iterable', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'tagged.iterator', [new TaggedIterator('tag')])),
'tagged.locator' => new ServiceClosureArgument(new TypedReference(PsrContainerInterface::class, PsrContainerInterface::class, ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'tagged.locator', [new TaggedLocator('tag')])),
'autowired' => new ServiceClosureArgument(new TypedReference('stdClass', 'stdClass', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'autowired', [new Autowire(service: 'service.id')])),
'autowired.nullable' => new ServiceClosureArgument(new TypedReference('stdClass', 'stdClass', ContainerInterface::IGNORE_ON_INVALID_REFERENCE, 'autowired.nullable', [new Autowire(service: 'service.id')])),
'autowired.parameter' => new ServiceClosureArgument(new TypedReference('string', 'string', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'autowired.parameter', [new Autowire(service: '%parameter.1%')])),
'map.decorated' => new ServiceClosureArgument(new TypedReference('stdClass', 'stdClass', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'map.decorated', [new MapDecorated()])),
'target' => new ServiceClosureArgument(new TypedReference('stdClass', 'stdClass', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'target', [new Target('someTarget')])),
];
$this->assertEquals($expected, $container->getDefinition((string) $locator->getFactory()[0])->getArgument(0));

(new AutowirePass())->process($container);

$expected = [
'tagged.iterator' => new ServiceClosureArgument(new TaggedIteratorArgument('tag')),
'tagged.locator' => new ServiceClosureArgument(new ServiceLocatorArgument(new TaggedIteratorArgument('tag', 'tag', needsIndexes: true))),
'autowired' => new ServiceClosureArgument(new Reference('service.id')),
'autowired.nullable' => new ServiceClosureArgument(new Reference('service.id', ContainerInterface::NULL_ON_INVALID_REFERENCE)),
'autowired.parameter' => new ServiceClosureArgument('foobar'),
'map.decorated' => new ServiceClosureArgument(new Reference('.service_locator.oZHAdom.inner', ContainerInterface::NULL_ON_INVALID_REFERENCE)),
'target' => new ServiceClosureArgument(new TypedReference('stdClass', 'stdClass', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'someTarget')),
];
$this->assertEquals($expected, $container->getDefinition((string) $locator->getFactory()[0])->getArgument(0));
}

public function testBinding()
{
$container = new ContainerBuilder();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ public function isCompiled(): bool
public function getRemovedIds(): array
{
return [
'.service_locator.JmEob1b' => true,
'.service_locator.KIgkoLM' => true,
'.service_locator.qUb.lJI' => true,
'.service_locator.qUb.lJI.foo_service' => true,
'.service_locator.0H1ht0q' => true,
'.service_locator.0H1ht0q.foo_service' => true,
'.service_locator.2hyyc9y' => true,
'.service_locator.KGUGnmw' => true,
'Symfony\\Component\\DependencyInjection\\Tests\\Fixtures\\CustomDefinition' => true,
];
}
Expand Down
10 changes: 9 additions & 1 deletion src/Symfony/Component/DependencyInjection/TypedReference.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,21 @@ class TypedReference extends Reference
{
private string $type;
private ?string $name;
private array $attributes;

/**
* @param string $id The service identifier
* @param string $type The PHP type of the identified service
* @param int $invalidBehavior The behavior when the service does not exist
* @param string|null $name The name of the argument targeting the service
* @param array $attributes The attributes to be used
*/
public function __construct(string $id, string $type, int $invalidBehavior = ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, string $name = null)
public function __construct(string $id, string $type, int $invalidBehavior = ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, string $name = null, array $attributes = [])
{
$this->name = $type === $id ? $name : null;
parent::__construct($id, $invalidBehavior);
$this->type = $type;
$this->attributes = $attributes;
}

public function getType()
Expand All @@ -43,4 +46,9 @@ public function getName(): ?string
{
return $this->name;
}

public function getAttributes(): array
{
return $this->attributes;
}
}
5 changes: 5 additions & 0 deletions src/Symfony/Contracts/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
CHANGELOG
=========

3.2
---

* Allow `ServiceSubscriberInterface::getSubscribedServices()` to return `SubscribedService[]`

3.0
---

Expand Down
20 changes: 17 additions & 3 deletions src/Symfony/Contracts/Service/Attribute/SubscribedService.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,14 @@

namespace Symfony\Contracts\Service\Attribute;

use Symfony\Contracts\Service\ServiceSubscriberInterface;
use Symfony\Contracts\Service\ServiceSubscriberTrait;

/**
* For use as the return value for {@see ServiceSubscriberInterface}.
*
* @example new SubscribedService('http_client', HttpClientInterface::class, false, new Target('githubApi'))
*
* Use with {@see ServiceSubscriberTrait} to mark a method's return type
* as a subscribed service.
*
Expand All @@ -22,12 +27,21 @@
#[\Attribute(\Attribute::TARGET_METHOD)]
final class SubscribedService
{
/** @var object[] */
public array $attributes;

/**
* @param string|null $key The key to use for the service
* If null, use "ClassName::methodName"
* @param string|null $key The key to use for the service
* @param class-string|null $type The service class
* @param bool $nullable Whether the service is optional
* @param object|object[] $attributes One or more dependency injection attributes to use
*/
public function __construct(
public ?string $key = null
public ?string $key = null,
public ?string $type = null,
public bool $nullable = false,
array|object $attributes = [],
) {
$this->attributes = \is_array($attributes) ? $attributes : [$attributes];
}
}
13 changes: 11 additions & 2 deletions src/Symfony/Contracts/Service/ServiceSubscriberInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

namespace Symfony\Contracts\Service;

use Symfony\Contracts\Service\Attribute\SubscribedService;

/**
* A ServiceSubscriber exposes its dependencies via the static {@link getSubscribedServices} method.
*
Expand All @@ -29,7 +31,8 @@
interface ServiceSubscriberInterface
{
/**
* Returns an array of service types required by such instances, optionally keyed by the service names used internally.
* Returns an array of service types (or {@see SubscribedService} objects) required
* by such instances, optionally keyed by the service names used internally.
*
* For mandatory dependencies:
*
Expand All @@ -47,7 +50,13 @@ interface ServiceSubscriberInterface
* * ['?Psr\Log\LoggerInterface'] is a shortcut for
* * ['Psr\Log\LoggerInterface' => '?Psr\Log\LoggerInterface']
*
* @return string[] The required service types, optionally keyed by service names
* additionally, an array of {@see SubscribedService}'s can be returned:
*
* * [new SubscribedService('logger', Psr\Log\LoggerInterface::class)]
* * [new SubscribedService(type: Psr\Log\LoggerInterface::class, nullable: true)]
* * [new SubscribedService('http_client', HttpClientInterface::class, attributes: new Target('githubApi'))]
*
* @return string[]|SubscribedService[] The required service types, optionally keyed by service names
*/
public static function getSubscribedServices(): array;
}
Loading