diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index f0e190b..436522e 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -209,7 +209,7 @@ the dependency injection container like this: require __DIR__ . '/../vendor/autoload.php'; $container = new FrameworkX\Container([ - Acme\Todo\HelloController::class => fn() => new Acme\Todo\HelloController(); + Acme\Todo\HelloController::class => fn() => new Acme\Todo\HelloController() ]); @@ -284,6 +284,36 @@ $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: + +```php title="public/index.php" + function (string $name) { + // example UserController class requires single string argument + return new Acme\Todo\UserController($name); + }, + 'name' => 'Acme' +]); + +// … +``` + +> ℹ️ **Avoiding name conflicts** +> +> Note that class names and string 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 +> namespaced class names like in the previous example. You may also want to make +> sure that container variables use unique names prefixed with your vendor 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 diff --git a/src/Container.php b/src/Container.php index eb0af03..f21b4aa 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) { @@ -63,7 +63,7 @@ public function callable(string $class): callable if ($this->container instanceof ContainerInterface) { $handler = $this->container->get($class); } else { - $handler = $this->load($class); + $handler = $this->loadObject($class); } } catch (\Throwable $e) { throw new \BadMethodCallException( @@ -98,7 +98,7 @@ public function getAccessLogHandler(): AccessLogHandler return new AccessLogHandler(); } } - return $this->load(AccessLogHandler::class); + return $this->loadObject(AccessLogHandler::class); } /** @internal */ @@ -111,15 +111,16 @@ public function getErrorHandler(): ErrorHandler return new ErrorHandler(); } } - return $this->load(ErrorHandler::class); + return $this->loadObject(ErrorHandler::class); } /** - * @param class-string $name - * @return object - * @throws \BadMethodCallException + * @template T + * @param class-string $name + * @return T + * @throws \BadMethodCallException if object of type $name can not be loaded */ - private function load(string $name, int $depth = 64) + private function loadObject(string $name, int $depth = 64) /*: object (PHP 7.2+) */ { if (isset($this->container[$name])) { if (\is_string($this->container[$name])) { @@ -127,7 +128,7 @@ private function load(string $name, int $depth = 64) throw new \BadMethodCallException('Factory for ' . $name . ' is recursive'); } - $value = $this->load($this->container[$name], $depth - 1); + $value = $this->loadObject($this->container[$name], $depth - 1); if (!$value instanceof $name) { throw new \BadMethodCallException('Factory for ' . $name . ' returned unexpected ' . (is_object($value) ? get_class($value) : gettype($value))); } @@ -136,7 +137,7 @@ private function load(string $name, int $depth = 64) } elseif ($this->container[$name] instanceof \Closure) { // build list of factory parameters based on parameter types $closure = new \ReflectionFunction($this->container[$name]); - $params = $this->loadFunctionParams($closure, $depth); + $params = $this->loadFunctionParams($closure, $depth, true); // invoke factory with list of parameters $value = $params === [] ? ($this->container[$name])() : ($this->container[$name])(...$params); @@ -146,7 +147,7 @@ private function load(string $name, int $depth = 64) throw new \BadMethodCallException('Factory for ' . $name . ' is recursive'); } - $value = $this->load($value, $depth - 1); + $value = $this->loadObject($value, $depth - 1); } if (!$value instanceof $name) { throw new \BadMethodCallException('Factory for ' . $name . ' returned unexpected ' . (is_object($value) ? get_class($value) : gettype($value))); @@ -155,6 +156,8 @@ private function load(string $name, int $depth = 64) $this->container[$name] = $value; } + assert($this->container[$name] instanceof $name); + return $this->container[$name]; } @@ -178,13 +181,14 @@ private function load(string $name, int $depth = 64) // build list of constructor parameters based on parameter types $ctor = $class->getConstructor(); - $params = $ctor === null ? [] : $this->loadFunctionParams($ctor, $depth); + $params = $ctor === null ? [] : $this->loadFunctionParams($ctor, $depth, false); // instantiate with list of parameters return $this->container[$name] = $params === [] ? new $name() : $class->newInstance(...$params); } - private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $depth): array + /** @throws \BadMethodCallException if either parameter can not be loaded */ + private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $depth, bool $allowVariables): array { $params = []; foreach ($function->getParameters() as $parameter) { @@ -214,6 +218,14 @@ private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $ } assert($type instanceof \ReflectionNamedType); + + // load string variables from container + if ($allowVariables && $type->getName() === 'string') { + $params[] = $this->loadVariable($parameter->getName()); + continue; + } + + // abort for other primitive types if ($type->isBuiltin()) { throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type->getName()); } @@ -223,12 +235,28 @@ private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $ throw new \BadMethodCallException(self::parameterError($parameter) . ' is recursive'); } - $params[] = $this->load($type->getName(), $depth - 1); + $params[] = $this->loadObject($type->getName(), $depth - 1); } return $params; } + /** @throws \BadMethodCallException if $name is not a valid string variable */ + private function loadVariable(string $name): string + { + if (!isset($this->container[$name])) { + throw new \BadMethodCallException('Container variable $' . $name . ' is not defined'); + } + + $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))); + } + + return $value; + } + + /** @throws void */ private static function parameterError(\ReflectionParameter $parameter): string { $name = $parameter->getDeclaringFunction()->getShortName(); diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php index e7277eb..8bd7837 100644 --- a/tests/ContainerTest.php +++ b/tests/ContainerTest.php @@ -136,7 +136,7 @@ public function __invoke(ServerRequestInterface $request) $this->assertEquals('{"num":1}', (string) $response->getBody()); } - public function testCallableReturnsCallableForClassNameWithExplicitlyMappedSubclassForDependency() + public function testCallableReturnsCallableForClassNameWithDependencyMappedToSubclassExplicitly() { $request = new ServerRequest('GET', 'http://example.com/'); @@ -171,7 +171,7 @@ public function __invoke(ServerRequestInterface $request) $this->assertEquals('{"name":"Alice"}', (string) $response->getBody()); } - public function testCallableReturnsCallableForClassNameWithSubclassMappedFromFactoryForDependency() + public function testCallableReturnsCallableForClassNameWithDependencyMappedToSubclassFromFactory() { $request = new ServerRequest('GET', 'http://example.com/'); @@ -206,7 +206,7 @@ public function __invoke(ServerRequestInterface $request) $this->assertEquals('{"name":"Alice"}', (string) $response->getBody()); } - public function testCallableReturnsCallableForClassNameWithSubclassMappedFromFactoryWithClassDependency() + public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresOtherClassWithFactory() { $request = new ServerRequest('GET', 'http://example.com/'); @@ -240,6 +240,163 @@ public function __invoke() $this->assertEquals('{"name":"Alice"}', (string) $response->getBody()); } + public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresStringVariable() + { + $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' => '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 testCallableReturnsCallableThatThrowsWhenFactoryReferencesUnknownVariable() + { + $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]; + } + ]); + + $callable = $container->callable(get_class($controller)); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Container variable $username is not defined'); + $callable($request); + } + + public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesVariableOfUnexpectedType() + { + $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 $http) { + return (object) ['name' => $http]; + }, + 'http' => function () { + return 'http/3'; + } + ]); + + $callable = $container->callable(get_class($controller)); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Container variable $http expected type string, but got Closure'); + $callable($request); + } + + public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesClassNameButGetsArbitraryString() + { + $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 => 'Yes' + ]); + + $callable = $container->callable(get_class($controller)); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Class Yes not found'); + $callable($request); + } + + public function testCallableReturnsCallableThatThrowsWhenConstructorWithoutFactoryFunctionReferencesStringVariable() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class('Alice') { + private $data; + + public function __construct(string $name) + { + $this->data = $name; + } + + public function __invoke(ServerRequestInterface $request) + { + return new Response(200, [], json_encode($this->data)); + } + }; + + $container = new Container([ + 'name' => 'Alice' + ]); + + $callable = $container->callable(get_class($controller)); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Argument 1 ($name) of class@anonymous::__construct() expects unsupported type string'); + $callable($request); + } + public function testCtorThrowsWhenMapContainsInvalidInteger() { $this->expectException(\BadMethodCallException::class);