Skip to content

Commit d4240ac

Browse files
[Contracts] Add traits+interfaces from the DI component
1 parent 88efd4a commit d4240ac

File tree

7 files changed

+378
-1
lines changed

7 files changed

+378
-1
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,6 @@ CHANGELOG
77
* added `Service\ResetInterface` to provide a way to reset an object to its initial state
88
* added `Translation\TranslatorInterface` and `Translation\TranslatorTrait`
99
* added `Cache` contract to extend PSR-6 with tag invalidation, callback-based computation and stampede protection
10+
* added `Service\ServiceSubscriberInterface` to declare the dependencies of a class that consumes a service locator
11+
* added `Service\ServiceSubscriberTrait` to implement `Service\ServiceSubscriberInterface` using methods' return types
12+
* added `Service\ServiceLocatorTrait` to help implement PSR-11 service locators

Service/ServiceLocatorTrait.php

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\Contracts\Service;
13+
14+
use Psr\Container\ContainerExceptionInterface;
15+
use Psr\Container\NotFoundExceptionInterface;
16+
17+
/**
18+
* A trait to help implement PSR-11 service locators.
19+
*
20+
* @author Robin Chalas <[email protected]>
21+
* @author Nicolas Grekas <[email protected]>
22+
*/
23+
trait ServiceLocatorTrait
24+
{
25+
private $factories;
26+
private $loading = array();
27+
28+
/**
29+
* @param callable[] $factories
30+
*/
31+
public function __construct(array $factories)
32+
{
33+
$this->factories = $factories;
34+
}
35+
36+
/**
37+
* {@inheritdoc}
38+
*/
39+
public function has($id)
40+
{
41+
return isset($this->factories[$id]);
42+
}
43+
44+
/**
45+
* {@inheritdoc}
46+
*/
47+
public function get($id)
48+
{
49+
if (!isset($this->factories[$id])) {
50+
throw $this->createNotFoundException($id);
51+
}
52+
53+
if (isset($this->loading[$id])) {
54+
$ids = array_values($this->loading);
55+
$ids = \array_slice($this->loading, array_search($id, $ids));
56+
$ids[] = $id;
57+
58+
throw $this->createCircularReferenceException($id, $ids);
59+
}
60+
61+
$this->loading[$id] = $id;
62+
try {
63+
return $this->factories[$id]($this);
64+
} finally {
65+
unset($this->loading[$id]);
66+
}
67+
}
68+
69+
private function createNotFoundException(string $id): NotFoundExceptionInterface
70+
{
71+
if (!$alternatives = array_keys($this->factories)) {
72+
$message = 'is empty...';
73+
} else {
74+
$last = array_pop($alternatives);
75+
if ($alternatives) {
76+
$message = sprintf('only knows about the "%s" and "%s" services.', implode('", "', $alternatives), $last);
77+
} else {
78+
$message = sprintf('only knows about the "%s" service.', $last);
79+
}
80+
}
81+
82+
if ($this->loading) {
83+
$message = sprintf('The service "%s" has a dependency on a non-existent service "%s". This locator %s', end($this->loading), $id, $message);
84+
} else {
85+
$message = sprintf('Service "%s" not found: the current service locator %s', $id, $message);
86+
}
87+
88+
return new class($message) extends \InvalidArgumentException implements NotFoundExceptionInterface {
89+
};
90+
}
91+
92+
private function createCircularReferenceException(string $id, array $path): ContainerExceptionInterface
93+
{
94+
return new class(sprintf('Circular reference detected for service "%s", path: "%s".', $id, implode(' -> ', $path))) extends \RuntimeException implements ContainerExceptionInterface {
95+
};
96+
}
97+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\Contracts\Service;
13+
14+
/**
15+
* A ServiceSubscriber exposes its dependencies via the static {@link getSubscribedServices} method.
16+
*
17+
* The getSubscribedServices method returns an array of service types required by such instances,
18+
* optionally keyed by the service names used internally. Service types that start with an interrogation
19+
* mark "?" are optional, while the other ones are mandatory service dependencies.
20+
*
21+
* The injected service locators SHOULD NOT allow access to any other services not specified by the method.
22+
*
23+
* It is expected that ServiceSubscriber instances consume PSR-11-based service locators internally.
24+
* This interface does not dictate any injection method for these service locators, although constructor
25+
* injection is recommended.
26+
*
27+
* @author Nicolas Grekas <[email protected]>
28+
*/
29+
interface ServiceSubscriberInterface
30+
{
31+
/**
32+
* Returns an array of service types required by such instances, optionally keyed by the service names used internally.
33+
*
34+
* For mandatory dependencies:
35+
*
36+
* * array('logger' => 'Psr\Log\LoggerInterface') means the objects use the "logger" name
37+
* internally to fetch a service which must implement Psr\Log\LoggerInterface.
38+
* * array('loggers' => 'Psr\Log\LoggerInterface[]') means the objects use the "loggers" name
39+
* internally to fetch an iterable of Psr\Log\LoggerInterface instances.
40+
* * array('Psr\Log\LoggerInterface') is a shortcut for
41+
* * array('Psr\Log\LoggerInterface' => 'Psr\Log\LoggerInterface')
42+
*
43+
* otherwise:
44+
*
45+
* * array('logger' => '?Psr\Log\LoggerInterface') denotes an optional dependency
46+
* * array('loggers' => '?Psr\Log\LoggerInterface[]') denotes an optional iterable dependency
47+
* * array('?Psr\Log\LoggerInterface') is a shortcut for
48+
* * array('Psr\Log\LoggerInterface' => '?Psr\Log\LoggerInterface')
49+
*
50+
* @return array The required service types, optionally keyed by service names
51+
*/
52+
public static function getSubscribedServices();
53+
}

Service/ServiceSubscriberTrait.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\Contracts\Service;
13+
14+
use Psr\Container\ContainerInterface;
15+
16+
/**
17+
* Implementation of ServiceSubscriberInterface that determines subscribed services from
18+
* private method return types. Service ids are available as "ClassName::methodName".
19+
*
20+
* @author Kevin Bond <[email protected]>
21+
*/
22+
trait ServiceSubscriberTrait
23+
{
24+
/** @var ContainerInterface */
25+
private $container;
26+
27+
public static function getSubscribedServices(): array
28+
{
29+
static $services;
30+
31+
if (null !== $services) {
32+
return $services;
33+
}
34+
35+
$services = \is_callable(array('parent', __FUNCTION__)) ? parent::getSubscribedServices() : array();
36+
37+
foreach ((new \ReflectionClass(self::class))->getMethods() as $method) {
38+
if ($method->isStatic() || $method->isAbstract() || $method->isGenerator() || $method->isInternal() || $method->getNumberOfRequiredParameters()) {
39+
continue;
40+
}
41+
42+
if (self::class === $method->getDeclaringClass()->name && ($returnType = $method->getReturnType()) && !$returnType->isBuiltin()) {
43+
$services[self::class.'::'.$method->name] = '?'.$returnType->getName();
44+
}
45+
}
46+
47+
return $services;
48+
}
49+
50+
/**
51+
* @required
52+
*/
53+
public function setContainer(ContainerInterface $container)
54+
{
55+
$this->container = $container;
56+
57+
if (\is_callable(array('parent', __FUNCTION__))) {
58+
return parent::setContainer($container);
59+
}
60+
}
61+
}

Tests/Service/ServiceLocatorTest.php

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\Contracts\Tests\Service;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Psr\Container\ContainerInterface;
16+
use Symfony\Contracts\Service\ServiceLocatorTrait;
17+
18+
class ServiceLocatorTest extends TestCase
19+
{
20+
public function getServiceLocator(array $factories)
21+
{
22+
return new class($factories) implements ContainerInterface {
23+
use ServiceLocatorTrait;
24+
};
25+
}
26+
27+
public function testHas()
28+
{
29+
$locator = $this->getServiceLocator(array(
30+
'foo' => function () { return 'bar'; },
31+
'bar' => function () { return 'baz'; },
32+
function () { return 'dummy'; },
33+
));
34+
35+
$this->assertTrue($locator->has('foo'));
36+
$this->assertTrue($locator->has('bar'));
37+
$this->assertFalse($locator->has('dummy'));
38+
}
39+
40+
public function testGet()
41+
{
42+
$locator = $this->getServiceLocator(array(
43+
'foo' => function () { return 'bar'; },
44+
'bar' => function () { return 'baz'; },
45+
));
46+
47+
$this->assertSame('bar', $locator->get('foo'));
48+
$this->assertSame('baz', $locator->get('bar'));
49+
}
50+
51+
public function testGetDoesNotMemoize()
52+
{
53+
$i = 0;
54+
$locator = $this->getServiceLocator(array(
55+
'foo' => function () use (&$i) {
56+
++$i;
57+
58+
return 'bar';
59+
},
60+
));
61+
62+
$this->assertSame('bar', $locator->get('foo'));
63+
$this->assertSame('bar', $locator->get('foo'));
64+
$this->assertSame(2, $i);
65+
}
66+
67+
/**
68+
* @expectedException \Psr\Container\NotFoundExceptionInterface
69+
* @expectedExceptionMessage The service "foo" has a dependency on a non-existent service "bar". This locator only knows about the "foo" service.
70+
*/
71+
public function testThrowsOnUndefinedInternalService()
72+
{
73+
$locator = $this->getServiceLocator(array(
74+
'foo' => function () use (&$locator) { return $locator->get('bar'); },
75+
));
76+
77+
$locator->get('foo');
78+
}
79+
80+
/**
81+
* @expectedException \Psr\Container\ContainerExceptionInterface
82+
* @expectedExceptionMessage Circular reference detected for service "bar", path: "bar -> baz -> bar".
83+
*/
84+
public function testThrowsOnCircularReference()
85+
{
86+
$locator = $this->getServiceLocator(array(
87+
'foo' => function () use (&$locator) { return $locator->get('bar'); },
88+
'bar' => function () use (&$locator) { return $locator->get('baz'); },
89+
'baz' => function () use (&$locator) { return $locator->get('bar'); },
90+
));
91+
92+
$locator->get('foo');
93+
}
94+
}
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 <[email protected]>
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\Contracts\Tests\Service;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Psr\Container\ContainerInterface;
16+
use Symfony\Contracts\Service\ServiceLocatorTrait;
17+
use Symfony\Contracts\Service\ServiceSubscriberInterface;
18+
use Symfony\Contracts\Service\ServiceSubscriberTrait;
19+
20+
class ServiceSubscriberTraitTest extends TestCase
21+
{
22+
public function testMethodsOnParentsAndChildrenAreIgnoredInGetSubscribedServices()
23+
{
24+
$expected = array(TestService::class.'::aService' => '?Symfony\Contracts\Tests\Service\Service2');
25+
26+
$this->assertEquals($expected, ChildTestService::getSubscribedServices());
27+
}
28+
29+
public function testSetContainerIsCalledOnParent()
30+
{
31+
$container = new class(array()) implements ContainerInterface {
32+
use ServiceLocatorTrait;
33+
};
34+
35+
$this->assertSame($container, (new TestService())->setContainer($container));
36+
}
37+
}
38+
39+
class ParentTestService
40+
{
41+
public function aParentService(): Service1
42+
{
43+
}
44+
45+
public function setContainer(ContainerInterface $container)
46+
{
47+
return $container;
48+
}
49+
}
50+
51+
class TestService extends ParentTestService implements ServiceSubscriberInterface
52+
{
53+
use ServiceSubscriberTrait;
54+
55+
public function aService(): Service2
56+
{
57+
}
58+
}
59+
60+
class ChildTestService extends TestService
61+
{
62+
public function aChildService(): Service3
63+
{
64+
}
65+
}

composer.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,12 @@
1818
"require": {
1919
"php": "^7.1.3"
2020
},
21+
"require-dev": {
22+
"psr/container": "^1.0"
23+
},
2124
"suggest": {
22-
"psr/cache": "When using the Cache contract"
25+
"psr/cache": "When using the Cache contracts",
26+
"psr/container": "When using the Service contracts"
2327
},
2428
"autoload": {
2529
"psr-4": { "Symfony\\Contracts\\": "" },

0 commit comments

Comments
 (0)