From 1009341bc7b5fb6e2575d82553713217f5e8f885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 11 Dec 2021 00:26:08 +0100 Subject: [PATCH 1/2] Validate `Container` configuration references valid class instances --- docs/best-practices/controllers.md | 7 ++++++- src/Container.php | 13 ++++++++++++- tests/ContainerTest.php | 25 +++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index 934a738..f198719 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -262,9 +262,14 @@ 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. + ### PSR-11 compatibility > ⚠️ **Feature preview** diff --git a/src/Container.php b/src/Container.php index fe9c394..1373a81 100644 --- a/src/Container.php +++ b/src/Container.php @@ -15,6 +15,11 @@ class Container /** @var array */ public function __construct(array $map = []) { + foreach ($map as $name => $value) { + if (!$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 +86,13 @@ 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 (!$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..13d4cb5 100644 --- a/tests/ContainerTest.php +++ b/tests/ContainerTest.php @@ -132,6 +132,31 @@ public function __invoke(ServerRequestInterface $request) $this->assertEquals('{"num":1}', (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 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 testInvokeContainerAsMiddlewareReturnsFromNextRequestHandler() { $request = new ServerRequest('GET', 'http://example.com/'); From 010c23e4aaf451de8f334ff214b6d243956b74dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 11 Dec 2021 01:54:52 +0100 Subject: [PATCH 2/2] Support class name references (aliases) in `Container` configuration --- docs/best-practices/controllers.md | 21 ++++++ src/Container.php | 18 ++++-- tests/ContainerTest.php | 100 +++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 4 deletions(-) diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index f198719..009f9f4 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -270,6 +270,27 @@ 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 1373a81..a05dcce 100644 --- a/src/Container.php +++ b/src/Container.php @@ -9,14 +9,18 @@ */ class Container { - /** @var array */ + /** @var array */ private $container; - /** @var array */ + /** @var array */ public function __construct(array $map = []) { foreach ($map as $name => $value) { - if (!$value instanceof \Closure && !$value instanceof $name) { + 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))); } } @@ -88,7 +92,13 @@ private function load(string $name, int $depth = 64) if ($this->container[$name] instanceof \Closure) { $value = ($this->container[$name])(); - if (!$value instanceof $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))); } diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php index 13d4cb5..c205795 100644 --- a/tests/ContainerTest.php +++ b/tests/ContainerTest.php @@ -132,6 +132,76 @@ 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); @@ -142,6 +212,21 @@ public function testCtorThrowsWhenMapContainsInvalidInteger() ]); } + 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/'); @@ -157,6 +242,21 @@ public function testCallableReturnsCallableThatThrowsWhenFactoryReturnsInvalidIn $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/');