diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index 8689ada..d930023 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -285,9 +285,10 @@ $container = new FrameworkX\Container([ ``` Factory functions used in the container configuration map may also reference -scalar variables defined in the container configuration. You may also use -factory functions that return scalar variables. This can be particularly useful -when combining autowiring with some manual configuration like this: +variables defined in the container configuration. You may use any object or +scalar value for container variables or factory functions that return any such +value. This can be particularly useful when combining autowiring with some +manual configuration like this: ```php title="public/index.php" function (bool $debug, string $hostname) { - // example UserController class requires two scalar arguments + // example UserController class uses two container variables return new Acme\Todo\UserController($debug, $hostname); }, 'debug' => false, @@ -306,9 +307,9 @@ $container = new FrameworkX\Container([ // … ``` -> ℹ️ **Avoiding name conflicts** +> ℹ️ **Avoiding name collisions** > -> Note that class names and scalar variables share the same container +> Note that class names and container variables share the same container > configuration map and as such might be subject to name collisions as a single > entry may only have a single value. For this reason, container variables will > only be used for container functions by default. We highly recommend using diff --git a/src/Container.php b/src/Container.php index b054ece..0967d0b 100644 --- a/src/Container.php +++ b/src/Container.php @@ -23,7 +23,10 @@ public function __construct($loader = []) } foreach (($loader instanceof ContainerInterface ? [] : $loader) as $name => $value) { - if (!\is_scalar($value) && !$value instanceof \Closure && !$value instanceof $name) { + if ( + (!\is_object($value) && !\is_scalar($value)) || + (!$value instanceof $name && !$value instanceof \Closure && !\is_string($value) && \strpos($name, '\\') !== false) + ) { throw new \BadMethodCallException('Map for ' . $name . ' contains unexpected ' . (is_object($value) ? get_class($value) : gettype($value))); } } @@ -154,8 +157,8 @@ private function loadObject(string $name, int $depth = 64) /*: object (PHP 7.2+) } $this->container[$name] = $value; - } elseif (\is_scalar($this->container[$name])) { - throw new \BadMethodCallException('Map for ' . $name . ' contains unexpected ' . \gettype($this->container[$name])); + } elseif (!$this->container[$name] instanceof $name) { + throw new \BadMethodCallException('Map for ' . $name . ' contains unexpected ' . (\is_object($this->container[$name]) ? \get_class($this->container[$name]) : \gettype($this->container[$name]))); } assert($this->container[$name] instanceof $name); @@ -237,17 +240,21 @@ private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $ throw new \BadMethodCallException(self::parameterError($parameter) . ' is recursive'); } - $params[] = $this->loadObject($type->getName(), $depth - 1); + if ($allowVariables && isset($this->container[$parameter->getName()])) { + $params[] = $this->loadVariable($parameter->getName(), $type->getName(), $depth); + } else { + $params[] = $this->loadObject($type->getName(), $depth - 1); + } } return $params; } /** - * @return string|int|float|bool - * @throws \BadMethodCallException if $name is not a valid scalar variable + * @return object|string|int|float|bool + * @throws \BadMethodCallException if $name is not a valid container variable */ - private function loadVariable(string $name, string $type, int $depth) /*: string|int|float|bool (PHP 8.0+) */ + private function loadVariable(string $name, string $type, int $depth) /*: object|string|int|float|bool (PHP 8.0+) */ { if (!isset($this->container[$name])) { throw new \BadMethodCallException('Container variable $' . $name . ' is not defined'); @@ -265,20 +272,22 @@ private function loadVariable(string $name, string $type, int $depth) /*: string // invoke factory with list of parameters $value = $params === [] ? ($this->container[$name])() : ($this->container[$name])(...$params); - if (!\is_scalar($value)) { - throw new \BadMethodCallException('Container variable $' . $name . ' expected scalar type from factory, but got ' . (\is_object($value) ? \get_class($value) : \gettype($value))); + if (!\is_object($value) && !\is_scalar($value)) { + throw new \BadMethodCallException('Container variable $' . $name . ' expected type object|scalar from factory, but got ' . \gettype($value)); } $this->container[$name] = $value; } $value = $this->container[$name]; - if (!\is_scalar($value)) { - throw new \BadMethodCallException('Container variable $' . $name . ' expected scalar type, but got ' . (\is_object($value) ? \get_class($value) : \gettype($value))); - } - - if (($type === 'string' && !\is_string($value)) || ($type === 'int' && !\is_int($value)) || ($type === 'float' && !\is_float($value)) || ($type === 'bool' && !\is_bool($value))) { - throw new \BadMethodCallException('Container variable $' . $name . ' expected type ' . $type . ', but got ' . \gettype($value)); + assert(\is_object($value) || \is_scalar($value)); + + if ( + (\is_object($value) && !$value instanceof $type) || + (!\is_object($value) && !\in_array($type, ['string', 'int', 'float', 'bool'])) || + ($type === 'string' && !\is_string($value)) || ($type === 'int' && !\is_int($value)) || ($type === 'float' && !\is_float($value)) || ($type === 'bool' && !\is_bool($value)) + ) { + throw new \BadMethodCallException('Container variable $' . $name . ' expected type ' . $type . ', but got ' . (\is_object($value) ? \get_class($value) : \gettype($value))); } return $value; diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php index 8fe0ef5..f77a629 100644 --- a/tests/ContainerTest.php +++ b/tests/ContainerTest.php @@ -240,6 +240,76 @@ public function __invoke() $this->assertEquals('{"name":"Alice"}', (string) $response->getBody()); } + public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresContainerVariable() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class(new Response()) { + private $response; + + public function __construct(ResponseInterface $response) + { + $this->response = $response; + } + + public function __invoke() + { + return $this->response; + } + }; + + $container = new Container([ + ResponseInterface::class => function (\stdClass $data) { + return new Response(200, [], json_encode($data)); + }, + 'data' => (object) ['name' => 'Alice'] + ]); + + $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 testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresContainerVariableWithFactory() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class(new Response()) { + private $response; + + public function __construct(ResponseInterface $response) + { + $this->response = $response; + } + + public function __invoke() + { + return $this->response; + } + }; + + $container = new Container([ + ResponseInterface::class => function (\stdClass $data) { + return new Response(200, [], json_encode($data)); + }, + 'data' => function () { + return (object) ['name' => 'Alice']; + } + ]); + + $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 testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresScalarVariables() { $request = new ServerRequest('GET', 'http://example.com/'); @@ -411,7 +481,7 @@ public function __invoke(ServerRequestInterface $request) $callable($request); } - public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesVariableMappedWithUnexpectedType() + public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesStringVariableMappedWithUnexpectedObjectType() { $request = new ServerRequest('GET', 'http://example.com/'); @@ -440,7 +510,7 @@ public function __invoke(ServerRequestInterface $request) $callable = $container->callable(get_class($controller)); $this->expectException(\BadMethodCallException::class); - $this->expectExceptionMessage('Container variable $stdClass expected scalar type, but got stdClass'); + $this->expectExceptionMessage('Container variable $stdClass expected type string, but got stdClass'); $callable($request); } @@ -474,7 +544,39 @@ public function __invoke(ServerRequestInterface $request) $callable = $container->callable(get_class($controller)); $this->expectException(\BadMethodCallException::class); - $this->expectExceptionMessage('Container variable $http expected scalar type from factory, but got resource'); + $this->expectExceptionMessage('Container variable $http expected type object|scalar from factory, but got resource'); + $callable($request); + } + + public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesObjectVariableMappedFromFactoryWithReturnsUnexpectedInteger() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $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 (\stdClass $http) { + return (object) ['name' => $http]; + }, + 'http' => 1 + ]); + + $callable = $container->callable(get_class($controller)); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Container variable $http expected type stdClass, but got integer'); $callable($request); } @@ -664,6 +766,35 @@ public function __invoke(ServerRequestInterface $request) $callable($request); } + public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesClassMappedToUnexpectedObject() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $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 => new Response() + ]); + + $callable = $container->callable(get_class($controller)); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Map for stdClass contains unexpected React\Http\Message\Response'); + $callable($request); + } + public function testCallableReturnsCallableThatThrowsWhenConstructorWithoutFactoryFunctionReferencesStringVariable() { $request = new ServerRequest('GET', 'http://example.com/'); @@ -693,13 +824,43 @@ public function __invoke(ServerRequestInterface $request) $callable($request); } + public function testCtorThrowsWhenMapContainsInvalidArray() + { + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Map for all contains unexpected array'); + + new Container([ + 'all' => [] + ]); + } + + public function testCtorThrowsWhenMapContainsInvalidNull() + { + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Map for user contains unexpected NULL'); + + new Container([ + 'user' => null + ]); + } + public function testCtorThrowsWhenMapContainsInvalidResource() { $this->expectException(\BadMethodCallException::class); - $this->expectExceptionMessage('Map for stdClass contains unexpected resource'); + $this->expectExceptionMessage('Map for file contains unexpected resource'); + + new Container([ + 'file' => tmpfile() + ]); + } + + public function testCtorThrowsWhenMapForClassContainsInvalidObject() + { + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Map for Psr\Http\Message\ResponseInterface contains unexpected stdClass'); new Container([ - \stdClass::class => tmpfile() + ResponseInterface::class => new \stdClass() ]); }