diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index 934a738..009f9f4 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -262,9 +262,35 @@ This can be useful in these cases: The configured container instance can be passed into the application like any other middleware request handler. In most cases this means you create a single -`Container` instance with a number of factory methods and pass this instance as +`Container` instance with a number of factory functions and pass this instance as the first argument to the `App`. +In its most common form, each entry in the container configuration maps a class +name to a factory function that will be invoked when this class is first +requested. The factory function is responsible for returning an instance that +implements the given class name. + +The container configuration may also be used to map a class name to a different +class name that implements the same interface, either by mapping between two +class names or using a factory function that returns a class name. This is +particularly useful when implementing an interface. + +```php title="public/index.php" + React\Cache\ArrayCache::class, + Psr\Http\Message\ResponseInterface::class => function () { + // returns class implementing interface from factory function + return React\Http\Message\Response::class; + } +]); + +// … +``` + ### PSR-11 compatibility > ⚠️ **Feature preview** diff --git a/src/Container.php b/src/Container.php index fe9c394..a05dcce 100644 --- a/src/Container.php +++ b/src/Container.php @@ -9,12 +9,21 @@ */ class Container { - /** @var array */ + /** @var array */ private $container; - /** @var array */ + /** @var array */ public function __construct(array $map = []) { + foreach ($map as $name => $value) { + if (\is_string($value)) { + $map[$name] = static function () use ($value) { + return $value; + }; + } elseif (!$value instanceof \Closure && !$value instanceof $name) { + throw new \BadMethodCallException('Map for ' . $name . ' contains unexpected ' . (is_object($value) ? get_class($value) : gettype($value))); + } + } $this->container = $map; } @@ -81,7 +90,19 @@ private function load(string $name, int $depth = 64) { if (isset($this->container[$name])) { if ($this->container[$name] instanceof \Closure) { - $this->container[$name] = ($this->container[$name])(); + $value = ($this->container[$name])(); + + if (\is_string($value)) { + if ($depth < 1) { + throw new \BadMethodCallException('Factory for ' . $name . ' is recursive'); + } + + $value = $this->load($value, $depth - 1); + } elseif (!$value instanceof $name) { + throw new \BadMethodCallException('Factory for ' . $name . ' returned unexpected ' . (is_object($value) ? get_class($value) : gettype($value))); + } + + $this->container[$name] = $value; } return $this->container[$name]; diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php index 94f3d35..c205795 100644 --- a/tests/ContainerTest.php +++ b/tests/ContainerTest.php @@ -132,6 +132,131 @@ public function __invoke(ServerRequestInterface $request) $this->assertEquals('{"num":1}', (string) $response->getBody()); } + public function testCallableReturnsCallableForClassNameWithExplicitlyMappedSubclassForDependency() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $dto = new class extends \stdClass { }; + $dto->name = 'Alice'; + + $controller = new class(new \stdClass()) { + private $data; + + public function __construct(\stdClass $data) + { + $this->data = $data; + } + + public function __invoke(ServerRequestInterface $request) + { + return new Response(200, [], json_encode($this->data)); + } + }; + + $container = new Container([ + \stdClass::class => get_class($dto), + get_class($dto) => $dto + ]); + + $callable = $container->callable(get_class($controller)); + $this->assertInstanceOf(\Closure::class, $callable); + + $response = $callable($request); + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('{"name":"Alice"}', (string) $response->getBody()); + } + + public function testCallableReturnsCallableForClassNameWithSubclassMappedFromFactoryForDependency() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $dto = new class extends \stdClass { }; + $dto->name = 'Alice'; + + $controller = new class(new \stdClass()) { + private $data; + + public function __construct(\stdClass $data) + { + $this->data = $data; + } + + public function __invoke(ServerRequestInterface $request) + { + return new Response(200, [], json_encode($this->data)); + } + }; + + $container = new Container([ + \stdClass::class => function () use ($dto) { return get_class($dto); }, + get_class($dto) => function () use ($dto) { return $dto; } + ]); + + $callable = $container->callable(get_class($controller)); + $this->assertInstanceOf(\Closure::class, $callable); + + $response = $callable($request); + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('{"name":"Alice"}', (string) $response->getBody()); + } + + public function testCtorThrowsWhenMapContainsInvalidInteger() + { + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Map for stdClass contains unexpected integer'); + + new Container([ + \stdClass::class => 42 + ]); + } + + public function testCallableReturnsCallableThatThrowsWhenFactoryReturnsInvalidClassName() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $container = new Container([ + \stdClass::class => function () { return 'invalid'; } + ]); + + $callable = $container->callable(\stdClass::class); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Class invalid not found'); + $callable($request); + } + + public function testCallableReturnsCallableThatThrowsWhenFactoryReturnsInvalidInteger() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $container = new Container([ + \stdClass::class => function () { return 42; } + ]); + + $callable = $container->callable(\stdClass::class); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Factory for stdClass returned unexpected integer'); + $callable($request); + } + + public function testCallableReturnsCallableThatThrowsWhenFactoryIsRecursive() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $container = new Container([ + \stdClass::class => \stdClass::class + ]); + + $callable = $container->callable(\stdClass::class); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Factory for stdClass is recursive'); + $callable($request); + } + public function testInvokeContainerAsMiddlewareReturnsFromNextRequestHandler() { $request = new ServerRequest('GET', 'http://example.com/');