diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index d930023..d9cf3d0 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -188,8 +188,10 @@ covers most common use cases: * Class names need to be loadable through the autoloader. See [composer autoloading](#composer-autoloading) above. * Each class may or may not have a constructor. -* If the constructor has an optional argument, it will be omitted. -* If the constructor has a nullable argument, it will be given a `null` value. +* If the constructor has an optional argument, it will be omitted unless an + explicit [container configuration](#container-configuration) is used. +* If the constructor has a nullable argument, it will be given a `null` value + unless an explicit [container configuration](#container-configuration) is used. * If the constructor references another class, it will load this class next. This covers most common use cases where the request handler class uses a @@ -290,22 +292,62 @@ 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 uses two container variables - return new Acme\Todo\UserController($debug, $hostname); - }, - 'debug' => false, - 'hostname' => fn(): string => gethostname() -]); + require __DIR__ . '/../vendor/autoload.php'; -// … -``` + $container = new FrameworkX\Container([ + Acme\Todo\UserController::class => function (bool $debug, string $hostname) { + // example UserController class uses two container variables + return new Acme\Todo\UserController($debug, $hostname); + }, + 'debug' => false, + 'hostname' => fn(): string => gethostname() + ]); + + // … + ``` + +=== "Default values" + + ```php title="public/index.php" + function (bool $debug = false) { + // example UserController class uses $debug, apply default if not set + return new Acme\Todo\UserController($debug); + }, + 'debug' => true + ]); + + + // … + ``` + +=== "Nullable values" + + ```php title="public/index.php" + function (?string $name) { + // example UserController class uses $name, defaults to null if not set + return new Acme\Todo\UserController($name ?? 'ACME'); + }, + 'name' => 'Demo' + ]); + + + // … + ``` > ℹ️ **Avoiding name collisions** > diff --git a/src/Container.php b/src/Container.php index 0967d0b..6868c2b 100644 --- a/src/Container.php +++ b/src/Container.php @@ -197,57 +197,65 @@ private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $ { $params = []; foreach ($function->getParameters() as $parameter) { - assert($parameter instanceof \ReflectionParameter); + $params[] = $this->loadParameter($parameter, $depth, $allowVariables); + } - // stop building parameters when encountering first optional parameter - if ($parameter->isOptional()) { - break; - } + return $params; + } - // ensure parameter is typed - $type = $parameter->getType(); - if ($type === null) { - throw new \BadMethodCallException(self::parameterError($parameter) . ' has no type'); + /** + * @return mixed + * @throws \BadMethodCallException if $parameter can not be loaded + */ + private function loadParameter(\ReflectionParameter $parameter, int $depth, bool $allowVariables) /*: mixed (PHP 8.0+) */ + { + // ensure parameter is typed + $type = $parameter->getType(); + if ($type === null) { + if ($parameter->isDefaultValueAvailable()) { + return $parameter->getDefaultValue(); } + throw new \BadMethodCallException(self::parameterError($parameter) . ' has no type'); + } - // if allowed, use null value without injecting any instances - assert($type instanceof \ReflectionType); - if ($type->allowsNull()) { - $params[] = null; - continue; - } + $hasDefault = $parameter->isDefaultValueAvailable() || $parameter->allowsNull(); - // abort for union types (PHP 8.0+) and intersection types (PHP 8.1+) - if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) { - throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type); // @codeCoverageIgnore + // abort for union types (PHP 8.0+) and intersection types (PHP 8.1+) + if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) { // @codeCoverageIgnoreStart + if ($hasDefault) { + return $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; } + throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type); + } // @codeCoverageIgnoreEnd - assert($type instanceof \ReflectionNamedType); + assert($type instanceof \ReflectionNamedType); - // 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; - } + // load container variables if parameter name is known + if ($allowVariables && isset($this->container[$parameter->getName()])) { + return $this->loadVariable($parameter->getName(), $type->getName(), $depth); + } - // abort for other primitive types (array etc.) - if ($type->isBuiltin()) { - throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type->getName()); - } + // use null for nullable arguments if not already loaded above + if ($hasDefault && !isset($this->container[$type->getName()])) { + return $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; + } - // abort for unreasonably deep nesting or recursive types - if ($depth < 1) { - throw new \BadMethodCallException(self::parameterError($parameter) . ' is recursive'); - } + // abort if required container variable is not defined + if ($allowVariables && \in_array($type->getName(), ['string', 'int', 'float', 'bool'])) { + throw new \BadMethodCallException(self::parameterError($parameter) . ' is not defined'); + } - if ($allowVariables && isset($this->container[$parameter->getName()])) { - $params[] = $this->loadVariable($parameter->getName(), $type->getName(), $depth); - } else { - $params[] = $this->loadObject($type->getName(), $depth - 1); - } + // abort for other primitive types (array etc.) + if ($type->isBuiltin()) { + throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type->getName()); } - return $params; + // abort for unreasonably deep nesting or recursive types + if ($depth < 1) { + throw new \BadMethodCallException(self::parameterError($parameter) . ' is recursive'); + } + + return $this->loadObject($type->getName(), $depth - 1); } /** @@ -256,10 +264,7 @@ private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $ */ 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'); - } - + assert(isset($this->container[$name])); if ($this->container[$name] instanceof \Closure) { if ($depth < 1) { throw new \BadMethodCallException('Container variable $' . $name . ' is recursive'); diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php index f77a629..591f967 100644 --- a/tests/ContainerTest.php +++ b/tests/ContainerTest.php @@ -67,6 +67,213 @@ public function __invoke(ServerRequestInterface $request) $this->assertEquals('{"name":"Alice"}', (string) $response->getBody()); } + public function testCallableReturnsCallableForNullableClassViaAutowiringWillDefaultToNullValue() + { + $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([]); + + $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('null', (string) $response->getBody()); + } + + public function testCallableReturnsCallableForNullableClassViaContainerConfiguration() + { + $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 => (object) [] + ]); + + $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('{}', (string) $response->getBody()); + } + + /** + * @requires PHP 8 + */ + public function testCallableReturnsCallableForUnionWithNullViaAutowiringWillDefaultToNullValue() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class(null) { + private $data = false; + + #[PHP8] public function __construct(string|int|null $data) { $this->data = $data; } + + public function __invoke(ServerRequestInterface $request) + { + return new Response(200, [], json_encode($this->data)); + } + }; + + $container = new Container([]); + + $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('null', (string) $response->getBody()); + } + + public function testCallableReturnsCallableForClassWithNullDefaultViaAutowiringWillDefaultToNullValue() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class(null) { + private $data = false; + + public function __construct(\stdClass $data = null) + { + $this->data = $data; + } + + public function __invoke(ServerRequestInterface $request) + { + return new Response(200, [], json_encode($this->data)); + } + }; + + $container = new Container([]); + + $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('null', (string) $response->getBody()); + } + + public function testCallableReturnsCallableForClassWithNullDefaultViaContainerConfiguration() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class(null) { + private $data = false; + + public function __construct(\stdClass $data = null) + { + $this->data = $data; + } + + public function __invoke(ServerRequestInterface $request) + { + return new Response(200, [], json_encode($this->data)); + } + }; + + $container = new Container([ + \stdClass::class => (object) [] + ]); + + $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('{}', (string) $response->getBody()); + } + + /** + * @requires PHP 8 + */ + public function testCallableReturnsCallableForUnionWithIntDefaultValueViaAutowiringWillDefaultToIntValue() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class(null) { + private $data = false; + + #[PHP8] public function __construct(string|int|null $data = 42) { $this->data = $data; } + + public function __invoke(ServerRequestInterface $request) + { + return new Response(200, [], json_encode($this->data)); + } + }; + + $container = new Container([]); + + $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('42', (string) $response->getBody()); + } + + public function testCallableReturnsCallableForUndefaultWithStringDefaultViaAutowiringWillDefaultToStringValue() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class(null) { + private $data = false; + + public function __construct($data = 'empty') + { + $this->data = $data; + } + + public function __invoke(ServerRequestInterface $request) + { + return new Response(200, [], json_encode($this->data)); + } + }; + + $container = new Container([]); + + $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('"empty"', (string) $response->getBody()); + } + public function testCallableReturnsCallableForClassNameViaAutowiringWithFactoryFunctionForDependency() { $request = new ServerRequest('GET', 'http://example.com/'); @@ -310,6 +517,110 @@ public function __invoke() $this->assertEquals('{"name":"Alice"}', (string) $response->getBody()); } + public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresNullableContainerVariables() + { + $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 $user, ?\stdClass $data) { + return new Response(200, [], json_encode(['user' => $user, 'data' => $data])); + }, + 'user' => (object) [] + ]); + + $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('{"user":{},"data":null}', (string) $response->getBody()); + } + + public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresNullableContainerVariablesWithFactory() + { + $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 $user, ?\stdClass $data) { + return new Response(200, [], json_encode(['user' => $user, 'data' => $data])); + }, + 'user' => function (): ?\stdClass { + return (object) []; + } + ]); + + $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('{"user":{},"data":null}', (string) $response->getBody()); + } + + public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresContainerVariablesWithDefaultValues() + { + $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 (string $name = 'Alice', int $age = 0) { + return new Response(200, [], json_encode(['name' => $name, 'age' => $age])); + }, + 'age' => 42 + ]); + + $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","age":42}', (string) $response->getBody()); + } + public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresScalarVariables() { $request = new ServerRequest('GET', 'http://example.com/'); @@ -446,7 +757,7 @@ public function __invoke(ServerRequestInterface $request) $callable = $container->callable(get_class($controller)); $this->expectException(\BadMethodCallException::class); - $this->expectExceptionMessage('Container variable $username is not defined'); + $this->expectExceptionMessage('Argument 1 ($username) of {closure}() is not defined'); $callable($request); } @@ -737,6 +1048,35 @@ public function __invoke(ServerRequestInterface $request) $callable($request); } + public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesNullableClassButGetsStringVariable() + { + $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 testCallableReturnsCallableThatThrowsWhenFactoryReferencesClassNameButGetsIntVariable() { $request = new ServerRequest('GET', 'http://example.com/');