diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index 436522e..6bcc790 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -285,9 +285,9 @@ $container = new FrameworkX\Container([ ``` Factory functions used in the container configuration map may also reference -string variables defined in the container configuration. This can be -particularly useful when combining autowiring with some manual configuration -like this: +string variables defined in the container configuration. You may also use +factory functions that return string variables. This can be particularly useful +when combining autowiring with some manual configuration like this: ```php title="public/index.php" function (string $name) { - // example UserController class requires single string argument - return new Acme\Todo\UserController($name); + Acme\Todo\UserController::class => function (string $name, string $hostname) { + // example UserController class requires two string arguments + return new Acme\Todo\UserController($name, $hostname); }, - 'name' => 'Acme' + 'name' => 'Acme', + 'hostname' => fn(): string => gethostname() ]); // … diff --git a/src/Container.php b/src/Container.php index f21b4aa..cd83c62 100644 --- a/src/Container.php +++ b/src/Container.php @@ -10,10 +10,10 @@ */ class Container { - /** @var array|ContainerInterface */ + /** @var array|ContainerInterface */ private $container; - /** @var array|ContainerInterface $loader */ + /** @var array|ContainerInterface $loader */ public function __construct($loader = []) { if (!\is_array($loader) && !$loader instanceof ContainerInterface) { @@ -221,7 +221,7 @@ private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $ // load string variables from container if ($allowVariables && $type->getName() === 'string') { - $params[] = $this->loadVariable($parameter->getName()); + $params[] = $this->loadVariable($parameter->getName(), $depth); continue; } @@ -242,12 +242,31 @@ private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $ } /** @throws \BadMethodCallException if $name is not a valid string variable */ - private function loadVariable(string $name): string + private function loadVariable(string $name, int $depth): string { if (!isset($this->container[$name])) { throw new \BadMethodCallException('Container variable $' . $name . ' is not defined'); } + if ($this->container[$name] instanceof \Closure) { + if ($depth < 1) { + throw new \BadMethodCallException('Container variable $' . $name . ' is recursive'); + } + + // build list of factory parameters based on parameter types + $closure = new \ReflectionFunction($this->container[$name]); + $params = $this->loadFunctionParams($closure, $depth - 1, true); + + // invoke factory with list of parameters + $value = $params === [] ? ($this->container[$name])() : ($this->container[$name])(...$params); + + if (!\is_string($value)) { + throw new \BadMethodCallException('Container variable $' . $name . ' expected type string from factory, but got ' . (\is_object($value) ? \get_class($value) : \gettype($value))); + } + + $this->container[$name] = $value; + } + $value = $this->container[$name]; if (!\is_string($value)) { throw new \BadMethodCallException('Container variable $' . $name . ' expected type string, but got ' . (\is_object($value) ? \get_class($value) : \gettype($value))); diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php index 8bd7837..ac92956 100644 --- a/tests/ContainerTest.php +++ b/tests/ContainerTest.php @@ -274,6 +274,77 @@ public function __invoke(ServerRequestInterface $request) $this->assertEquals('{"name":"Alice"}', (string) $response->getBody()); } + public function testCallableReturnsCallableForClassNameMappedFromFactoryWithStringVariableMappedFromFactory() + { + $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 (string $username) { + return (object) ['name' => $username]; + }, + 'username' => function () { + return 'Alice'; + } + ]); + + $callable = $container->callable(get_class($controller)); + + $response = $callable($request); + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('{"name":"Alice"}', (string) $response->getBody()); + } + + public function testCallableReturnsCallableForClassNameReferencingVariableMappedFromFactoryReferencingVariable() + { + $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 (string $username) { + return (object) ['name' => $username]; + }, + 'username' => function (string $role) { + return strtoupper($role); + }, + 'role' => 'admin' + ]); + + $callable = $container->callable(get_class($controller)); + + $response = $callable($request); + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('{"name":"ADMIN"}', (string) $response->getBody()); + } + public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesUnknownVariable() { $request = new ServerRequest('GET', 'http://example.com/'); @@ -305,7 +376,71 @@ public function __invoke(ServerRequestInterface $request) $callable($request); } - public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesVariableOfUnexpectedType() + public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesRecursiveVariable() + { + $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 (string $stdClass) { + return (object) ['name' => $stdClass]; + } + ]); + + $callable = $container->callable(get_class($controller)); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Container variable $stdClass is recursive'); + $callable($request); + } + + public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesVariableMappedWithUnexpectedType() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class('') { + private $data; + + public function __construct(string $stdClass) + { + $this->data = $stdClass; + } + + public function __invoke(ServerRequestInterface $request) + { + return new Response(200, [], json_encode($this->data)); + } + }; + + $container = new Container([ + get_class($controller) => function (string $stdClass) use ($controller) { + $class = get_class($controller); + return new $class($stdClass); + }, + \stdClass::class => (object) [] + ]); + + $callable = $container->callable(get_class($controller)); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Container variable $stdClass expected type string, but got stdClass'); + $callable($request); + } + + public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesVariableMappedFromFactoryWithUnexpectedReturnType() { $request = new ServerRequest('GET', 'http://example.com/'); @@ -328,14 +463,14 @@ public function __invoke(ServerRequestInterface $request) return (object) ['name' => $http]; }, 'http' => function () { - return 'http/3'; + return 3; } ]); $callable = $container->callable(get_class($controller)); $this->expectException(\BadMethodCallException::class); - $this->expectExceptionMessage('Container variable $http expected type string, but got Closure'); + $this->expectExceptionMessage('Container variable $http expected type string from factory, but got integer'); $callable($request); }