diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index 6bcc790..8689ada 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -285,8 +285,8 @@ $container = new FrameworkX\Container([ ``` Factory functions used in the container configuration map may also reference -string variables defined in the container configuration. You may also use -factory functions that return string variables. This can be particularly useful +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: ```php title="public/index.php" @@ -295,11 +295,11 @@ when combining autowiring with some manual configuration like this: require __DIR__ . '/../vendor/autoload.php'; $container = new FrameworkX\Container([ - Acme\Todo\UserController::class => function (string $name, string $hostname) { - // example UserController class requires two string arguments - return new Acme\Todo\UserController($name, $hostname); + Acme\Todo\UserController::class => function (bool $debug, string $hostname) { + // example UserController class requires two scalar arguments + return new Acme\Todo\UserController($debug, $hostname); }, - 'name' => 'Acme', + 'debug' => false, 'hostname' => fn(): string => gethostname() ]); @@ -308,7 +308,7 @@ $container = new FrameworkX\Container([ > ℹ️ **Avoiding name conflicts** > -> Note that class names and string variables share the same container +> Note that class names and scalar 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 cd83c62..3894b36 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) { @@ -23,7 +23,7 @@ public function __construct($loader = []) } foreach (($loader instanceof ContainerInterface ? [] : $loader) as $name => $value) { - if (!\is_string($value) && !$value instanceof \Closure && !$value instanceof $name) { + if (!\is_scalar($value) && !$value instanceof \Closure && !$value instanceof $name) { throw new \BadMethodCallException('Map for ' . $name . ' contains unexpected ' . (is_object($value) ? get_class($value) : gettype($value))); } } @@ -154,6 +154,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])); } assert($this->container[$name] instanceof $name); @@ -219,13 +221,13 @@ 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(), $depth); + // load variables from container for primitive/scalar types + if ($allowVariables && \in_array($type->getName(), ['string', 'int', 'float', 'bool'])) { + $params[] = $this->loadVariable($parameter->getName(), $type->getName(), $depth); continue; } - // abort for other primitive types + // abort for other primitive types (array etc.) if ($type->isBuiltin()) { throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type->getName()); } @@ -241,8 +243,11 @@ private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $ return $params; } - /** @throws \BadMethodCallException if $name is not a valid string variable */ - private function loadVariable(string $name, int $depth): string + /** + * @return string|int|float|bool + * @throws \BadMethodCallException if $name is not a valid string variable + */ + private function loadVariable(string $name, string $type, int $depth) /*: string|int|float|bool (PHP 8.0+) */ { if (!isset($this->container[$name])) { throw new \BadMethodCallException('Container variable $' . $name . ' is not defined'); @@ -260,16 +265,20 @@ private function loadVariable(string $name, int $depth): string // 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))); + 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))); } $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))); + 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)); } return $value; diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php index ac92956..8fe0ef5 100644 --- a/tests/ContainerTest.php +++ b/tests/ContainerTest.php @@ -240,7 +240,7 @@ public function __invoke() $this->assertEquals('{"name":"Alice"}', (string) $response->getBody()); } - public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresStringVariable() + public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresScalarVariables() { $request = new ServerRequest('GET', 'http://example.com/'); @@ -259,10 +259,13 @@ public function __invoke(ServerRequestInterface $request) }; $container = new Container([ - \stdClass::class => function (string $username) { - return (object) ['name' => $username]; + \stdClass::class => function (string $username, int $age, bool $admin, float $percent) { + return (object) ['name' => $username, 'age' => $age, 'admin' => $admin, 'percent' => $percent]; }, - 'username' => 'Alice' + 'username' => 'Alice', + 'age' => 42, + 'admin' => true, + 'percent' => 0.5 ]); $callable = $container->callable(get_class($controller)); @@ -271,10 +274,10 @@ public function __invoke(ServerRequestInterface $request) $response = $callable($request); $this->assertInstanceOf(ResponseInterface::class, $response); $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals('{"name":"Alice"}', (string) $response->getBody()); + $this->assertEquals('{"name":"Alice","age":42,"admin":true,"percent":0.5}', (string) $response->getBody()); } - public function testCallableReturnsCallableForClassNameMappedFromFactoryWithStringVariableMappedFromFactory() + public function testCallableReturnsCallableForClassNameMappedFromFactoryWithScalarVariablesMappedFromFactory() { $request = new ServerRequest('GET', 'http://example.com/'); @@ -293,12 +296,13 @@ public function __invoke(ServerRequestInterface $request) }; $container = new Container([ - \stdClass::class => function (string $username) { - return (object) ['name' => $username]; + \stdClass::class => function (string $username, int $age, bool $admin, float $percent) { + return (object) ['name' => $username, 'age' => $age, 'admin' => $admin, 'percent' => $percent]; }, - 'username' => function () { - return 'Alice'; - } + 'username' => function () { return 'Alice'; }, + 'age' => function () { return 42; }, + 'admin' => function () { return true; }, + 'percent' => function () { return 0.5; } ]); $callable = $container->callable(get_class($controller)); @@ -306,7 +310,7 @@ public function __invoke(ServerRequestInterface $request) $response = $callable($request); $this->assertInstanceOf(ResponseInterface::class, $response); $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals('{"name":"Alice"}', (string) $response->getBody()); + $this->assertEquals('{"name":"Alice","age":42,"admin":true,"percent":0.5}', (string) $response->getBody()); } public function testCallableReturnsCallableForClassNameReferencingVariableMappedFromFactoryReferencingVariable() @@ -436,7 +440,7 @@ public function __invoke(ServerRequestInterface $request) $callable = $container->callable(get_class($controller)); $this->expectException(\BadMethodCallException::class); - $this->expectExceptionMessage('Container variable $stdClass expected type string, but got stdClass'); + $this->expectExceptionMessage('Container variable $stdClass expected scalar type, but got stdClass'); $callable($request); } @@ -463,18 +467,146 @@ public function __invoke(ServerRequestInterface $request) return (object) ['name' => $http]; }, 'http' => function () { - return 3; + return tmpfile(); + } + ]); + + $callable = $container->callable(get_class($controller)); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Container variable $http expected scalar type from factory, but got resource'); + $callable($request); + } + + public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesStringVariableMappedFromFactoryWithReturnsUnexpectedInteger() + { + $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' => 1 + ]); + + $callable = $container->callable(get_class($controller)); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Container variable $http expected type string, but got integer'); + $callable($request); + } + + public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesIntVariableMappedFromFactoryWithReturnsUnexpectedString() + { + $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 (int $http) { + return (object) ['name' => $http]; + }, + 'http' => '1.1' + ]); + + $callable = $container->callable(get_class($controller)); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Container variable $http expected type int, but got string'); + $callable($request); + } + + public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesFloatVariableMappedFromFactoryWithReturnsUnexpectedString() + { + $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 (float $percent) { + return (object) ['percent' => $percent]; + }, + 'percent' => '100%' + ]); + + $callable = $container->callable(get_class($controller)); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Container variable $percent expected type float, but got string'); + $callable($request); + } + + public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesBoolVariableMappedFromFactoryWithReturnsUnexpectedString() + { + $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 (bool $admin) { + return (object) ['admin' => $admin]; + }, + 'admin' => 'Yes' ]); $callable = $container->callable(get_class($controller)); $this->expectException(\BadMethodCallException::class); - $this->expectExceptionMessage('Container variable $http expected type string from factory, but got integer'); + $this->expectExceptionMessage('Container variable $admin expected type bool, but got string'); $callable($request); } - public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesClassNameButGetsArbitraryString() + public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesClassNameButGetsStringVariable() { $request = new ServerRequest('GET', 'http://example.com/'); @@ -503,6 +635,35 @@ public function __invoke(ServerRequestInterface $request) $callable($request); } + public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesClassNameButGetsIntVariable() + { + $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 => 42 + ]); + + $callable = $container->callable(get_class($controller)); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Map for stdClass contains unexpected integer'); + $callable($request); + } + public function testCallableReturnsCallableThatThrowsWhenConstructorWithoutFactoryFunctionReferencesStringVariable() { $request = new ServerRequest('GET', 'http://example.com/'); @@ -532,13 +693,13 @@ public function __invoke(ServerRequestInterface $request) $callable($request); } - public function testCtorThrowsWhenMapContainsInvalidInteger() + public function testCtorThrowsWhenMapContainsInvalidResource() { $this->expectException(\BadMethodCallException::class); - $this->expectExceptionMessage('Map for stdClass contains unexpected integer'); + $this->expectExceptionMessage('Map for stdClass contains unexpected resource'); new Container([ - \stdClass::class => 42 + \stdClass::class => tmpfile() ]); }