diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index 009f9f4..f5d1b1c 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -270,6 +270,28 @@ 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. +Factory functions used in the container configuration map may reference other +classes that will automatically be injected from the container. This can be +particularly useful when combining autowiring with some manual configuration +like this: + +```php title="public/index.php" + function (React\Http\Browser $browser) { + // example UserController class requires two arguments: + // - first argument will be autowired based on class reference + // - second argument expects some manual value + return new Acme\Todo\UserController($browser, 42); + } +]); + +// … +``` + 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 diff --git a/src/Container.php b/src/Container.php index a05dcce..fb93a02 100644 --- a/src/Container.php +++ b/src/Container.php @@ -90,7 +90,12 @@ private function load(string $name, int $depth = 64) { if (isset($this->container[$name])) { if ($this->container[$name] instanceof \Closure) { - $value = ($this->container[$name])(); + // build list of factory parameters based on parameter types + $closure = new \ReflectionFunction($this->container[$name]); + $params = $this->loadFunctionParams($closure, $depth); + + // invoke factory with list of parameters + $value = $params === [] ? ($this->container[$name])() : ($this->container[$name])(...$params); if (\is_string($value)) { if ($depth < 1) { @@ -127,10 +132,17 @@ private function load(string $name, int $depth = 64) } // build list of constructor parameters based on parameter types - $params = []; $ctor = $class->getConstructor(); - assert($ctor === null || $ctor instanceof \ReflectionMethod); - foreach ($ctor !== null ? $ctor->getParameters() : [] as $parameter) { + $params = $ctor === null ? [] : $this->loadFunctionParams($ctor, $depth); + + // instantiate with list of parameters + return $this->container[$name] = $params === [] ? new $name() : $class->newInstance(...$params); + } + + private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $depth): array + { + $params = []; + foreach ($function->getParameters() as $parameter) { assert($parameter instanceof \ReflectionParameter); // stop building parameters when encountering first optional parameter @@ -166,15 +178,19 @@ private function load(string $name, int $depth = 64) throw new \BadMethodCallException(self::parameterError($parameter) . ' is recursive'); } - $params[] = $this->load($type->getName(), --$depth); + $params[] = $this->load($type->getName(), $depth - 1); } - // instantiate with list of parameters - return $this->container[$name] = $params === [] ? new $name() : $class->newInstance(...$params); + return $params; } private static function parameterError(\ReflectionParameter $parameter): string { - return 'Argument ' . ($parameter->getPosition() + 1) . ' ($' . $parameter->getName() . ') of ' . explode("\0", $parameter->getDeclaringClass()->getName())[0] . '::' . $parameter->getDeclaringFunction()->getName() . '()'; + $name = $parameter->getDeclaringFunction()->getShortName(); + if (!$parameter->getDeclaringFunction()->isClosure() && ($class = $parameter->getDeclaringClass()) !== null) { + $name = explode("\0", $class->getName())[0] . '::' . $name; + } + + return 'Argument ' . ($parameter->getPosition() + 1) . ' ($' . $parameter->getName() . ') of ' . $name . '()'; } } diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php index c205795..cf46189 100644 --- a/tests/ContainerTest.php +++ b/tests/ContainerTest.php @@ -202,6 +202,40 @@ public function __invoke(ServerRequestInterface $request) $this->assertEquals('{"name":"Alice"}', (string) $response->getBody()); } + public function testCallableReturnsCallableForClassNameWithSubclassMappedFromFactoryWithClassDependency() + { + $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 $dto) { + return new Response(200, [], json_encode($dto)); + }, + \stdClass::class => 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 testCtorThrowsWhenMapContainsInvalidInteger() { $this->expectException(\BadMethodCallException::class); @@ -242,6 +276,51 @@ public function testCallableReturnsCallableThatThrowsWhenFactoryReturnsInvalidIn $callable($request); } + public function testCallableReturnsCallableThatThrowsWhenFactoryRequiresInvalidClassName() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $container = new Container([ + \stdClass::class => function (self $instance) { return $instance; } + ]); + + $callable = $container->callable(\stdClass::class); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Class self not found'); + $callable($request); + } + + public function testCallableReturnsCallableThatThrowsWhenFactoryRequiresUntypedArgument() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $container = new Container([ + \stdClass::class => function ($data) { return $data; } + ]); + + $callable = $container->callable(\stdClass::class); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Argument 1 ($data) of {closure}() has no type'); + $callable($request); + } + + public function testCallableReturnsCallableThatThrowsWhenFactoryRequiresRecursiveClass() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $container = new Container([ + \stdClass::class => function (\stdClass $data) { return $data; } + ]); + + $callable = $container->callable(\stdClass::class); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Argument 1 ($data) of {closure}() is recursive'); + $callable($request); + } + public function testCallableReturnsCallableThatThrowsWhenFactoryIsRecursive() { $request = new ServerRequest('GET', 'http://example.com/');