From fa030e002a7de4c362529e013e57e5f2586cb69a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 29 Jul 2022 13:45:48 +0200 Subject: [PATCH 1/2] Support explicit container configuration for nullable arguments --- docs/best-practices/controllers.md | 50 +++++--- src/Container.php | 81 ++++++------ tests/ContainerTest.php | 190 ++++++++++++++++++++++++++++- 3 files changed, 269 insertions(+), 52 deletions(-) diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index d930023..e070168 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -189,7 +189,8 @@ covers most common use cases: [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 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 +291,43 @@ 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() + ]); + + // … + ``` + +=== "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..7587f9b 100644 --- a/src/Container.php +++ b/src/Container.php @@ -204,50 +204,60 @@ private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $ break; } - // ensure parameter is typed - $type = $parameter->getType(); - if ($type === null) { - throw new \BadMethodCallException(self::parameterError($parameter) . ' has no type'); - } + $params[] = $this->loadParameter($parameter, $depth, $allowVariables); + } + + return $params; + } + + /** + * @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) { + throw new \BadMethodCallException(self::parameterError($parameter) . ' has no type'); + } - // if allowed, use null value without injecting any instances - assert($type instanceof \ReflectionType); + // abort for union types (PHP 8.0+) and intersection types (PHP 8.1+) + if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) { // @codeCoverageIgnoreStart if ($type->allowsNull()) { - $params[] = null; - continue; + return null; } + throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type); + } // @codeCoverageIgnoreEnd - // 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 - } + assert($type instanceof \ReflectionNamedType); - assert($type instanceof \ReflectionNamedType); + // load container variables if parameter name is known + if ($allowVariables && isset($this->container[$parameter->getName()])) { + return $this->loadVariable($parameter->getName(), $type->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; - } + // use null for nullable arguments if not already loaded above + if ($type->allowsNull() && !isset($this->container[$type->getName()])) { + return null; + } - // abort for other primitive types (array etc.) - if ($type->isBuiltin()) { - throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type->getName()); - } + // 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'); + } - // abort for unreasonably deep nesting or recursive types - if ($depth < 1) { - throw new \BadMethodCallException(self::parameterError($parameter) . ' is recursive'); - } + // abort for other primitive types (array etc.) + if ($type->isBuiltin()) { + throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type->getName()); + } - 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 unreasonably deep nesting or recursive types + if ($depth < 1) { + throw new \BadMethodCallException(self::parameterError($parameter) . ' is recursive'); } - return $params; + return $this->loadObject($type->getName(), $depth - 1); } /** @@ -256,10 +266,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..7055793 100644 --- a/tests/ContainerTest.php +++ b/tests/ContainerTest.php @@ -67,6 +67,95 @@ 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 testCallableReturnsCallableForClassNameViaAutowiringWithFactoryFunctionForDependency() { $request = new ServerRequest('GET', 'http://example.com/'); @@ -310,6 +399,76 @@ 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 testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresScalarVariables() { $request = new ServerRequest('GET', 'http://example.com/'); @@ -446,7 +605,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 +896,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/'); From 9fe083d03033786542f7d9534baaee0e92c119c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 29 Jul 2022 15:59:45 +0200 Subject: [PATCH 2/2] Support explicit container configuration for arguments with defaults --- docs/best-practices/controllers.md | 22 ++++- src/Container.php | 20 ++-- tests/ContainerTest.php | 152 +++++++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 12 deletions(-) diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index e070168..d9cf3d0 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -188,7 +188,8 @@ 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 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. @@ -307,6 +308,25 @@ manual configuration like this: '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 + ]); + + // … ``` diff --git a/src/Container.php b/src/Container.php index 7587f9b..6868c2b 100644 --- a/src/Container.php +++ b/src/Container.php @@ -197,13 +197,6 @@ private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $ { $params = []; foreach ($function->getParameters() as $parameter) { - assert($parameter instanceof \ReflectionParameter); - - // stop building parameters when encountering first optional parameter - if ($parameter->isOptional()) { - break; - } - $params[] = $this->loadParameter($parameter, $depth, $allowVariables); } @@ -219,13 +212,18 @@ private function loadParameter(\ReflectionParameter $parameter, int $depth, bool // 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'); } + $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) { // @codeCoverageIgnoreStart - if ($type->allowsNull()) { - return null; + if ($hasDefault) { + return $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; } throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type); } // @codeCoverageIgnoreEnd @@ -238,8 +236,8 @@ private function loadParameter(\ReflectionParameter $parameter, int $depth, bool } // use null for nullable arguments if not already loaded above - if ($type->allowsNull() && !isset($this->container[$type->getName()])) { - return null; + if ($hasDefault && !isset($this->container[$type->getName()])) { + return $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; } // abort if required container variable is not defined diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php index 7055793..591f967 100644 --- a/tests/ContainerTest.php +++ b/tests/ContainerTest.php @@ -156,6 +156,124 @@ public function __invoke(ServerRequestInterface $request) $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/'); @@ -469,6 +587,40 @@ public function __invoke() $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/');