diff --git a/src/Container.php b/src/Container.php index 6868c2b..ac6a77e 100644 --- a/src/Container.php +++ b/src/Container.php @@ -209,16 +209,8 @@ private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $ */ 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'); - } - - $hasDefault = $parameter->isDefaultValueAvailable() || $parameter->allowsNull(); + $hasDefault = $parameter->isDefaultValueAvailable() || ((!$type instanceof \ReflectionNamedType || $type->getName() !== 'mixed') && $parameter->allowsNull()); // abort for union types (PHP 8.0+) and intersection types (PHP 8.1+) if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) { // @codeCoverageIgnoreStart @@ -228,26 +220,34 @@ private function loadParameter(\ReflectionParameter $parameter, int $depth, bool throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type); } // @codeCoverageIgnoreEnd - assert($type instanceof \ReflectionNamedType); - // load container variables if parameter name is known + assert($type === null || $type instanceof \ReflectionNamedType); if ($allowVariables && isset($this->container[$parameter->getName()])) { - return $this->loadVariable($parameter->getName(), $type->getName(), $depth); + return $this->loadVariable($parameter->getName(), $type === null ? 'mixed' : $type->getName(), $depth); } - // use null for nullable arguments if not already loaded above - if ($hasDefault && !isset($this->container[$type->getName()])) { - return $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; + // abort if parameter is untyped and not explicitly defined by container variable + if ($type === null) { + assert($parameter->allowsNull()); + if ($parameter->isDefaultValueAvailable()) { + return $parameter->getDefaultValue(); + } + throw new \BadMethodCallException(self::parameterError($parameter) . ' has no type'); } - // 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'); + // use default/nullable argument if not loadable as container variable or by type + assert($type instanceof \ReflectionNamedType); + if ($hasDefault && ($type->isBuiltin() || !isset($this->container[$type->getName()]))) { + return $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; } - // abort for other primitive types (array etc.) + // abort if required container variable is not defined or for any other primitive types (array etc.) if ($type->isBuiltin()) { - throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type->getName()); + if ($allowVariables) { + throw new \BadMethodCallException(self::parameterError($parameter) . ' is not defined'); + } else { + throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type->getName()); + } } // abort for unreasonably deep nesting or recursive types @@ -287,6 +287,11 @@ private function loadVariable(string $name, string $type, int $depth) /*: object $value = $this->container[$name]; assert(\is_object($value) || \is_scalar($value)); + // skip type checks and allow all values if expected type is undefined or mixed (PHP 8+) + if ($type === 'mixed') { + return $value; + } + if ( (\is_object($value) && !$value instanceof $type) || (!\is_object($value) && !\in_array($type, ['string', 'int', 'float', 'bool'])) || diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php index 591f967..858158c 100644 --- a/tests/ContainerTest.php +++ b/tests/ContainerTest.php @@ -245,7 +245,7 @@ public function __invoke(ServerRequestInterface $request) $this->assertEquals('42', (string) $response->getBody()); } - public function testCallableReturnsCallableForUndefaultWithStringDefaultViaAutowiringWillDefaultToStringValue() + public function testCallableReturnsCallableForUntypedWithStringDefaultViaAutowiringWillDefaultToStringValue() { $request = new ServerRequest('GET', 'http://example.com/'); @@ -274,6 +274,35 @@ public function __invoke(ServerRequestInterface $request) $this->assertEquals('"empty"', (string) $response->getBody()); } + /** + * @requires PHP 8 + */ + public function testCallableReturnsCallableForMixedWithStringDefaultViaAutowiringWillDefaultToStringValue() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class(null) { + private $data = false; + + #[PHP8] public function __construct(mixed $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/'); @@ -517,6 +546,152 @@ public function __invoke() $this->assertEquals('{"name":"Alice"}', (string) $response->getBody()); } + public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresUntypedContainerVariable() + { + $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 ($data) { + return new Response(200, [], json_encode($data)); + }, + 'data' => (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 testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresUntypedContainerVariableWithFactory() + { + $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 ($data) { + return new Response(200, [], json_encode($data)); + }, + 'data' => 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()); + } + + /** + * @requires PHP 8 + */ + public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresMixedContainerVariable() + { + $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 (mixed $data) { + return new Response(200, [], json_encode($data)); + }, + 'data' => (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()); + } + + /** + * @requires PHP 8 + */ + public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresMixedContainerVariableWithFactory() + { + $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 (mixed $data) { + return new Response(200, [], json_encode($data)); + }, + 'data' => 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 testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresNullableContainerVariables() { $request = new ServerRequest('GET', 'http://example.com/'); @@ -1284,13 +1459,31 @@ public function testCallableReturnsCallableThatThrowsWhenFactoryRequiresUntypedA $request = new ServerRequest('GET', 'http://example.com/'); $container = new Container([ - \stdClass::class => function ($data) { return $data; } + \stdClass::class => function ($undefined) { return $undefined; } + ]); + + $callable = $container->callable(\stdClass::class); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Argument 1 ($undefined) of {closure}() has no type'); + $callable($request); + } + + /** + * @requires PHP 8 + */ + public function testCallableReturnsCallableThatThrowsWhenFactoryRequiresUndefinedMixedArgument() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $container = new Container([ + \stdClass::class => function (mixed $undefined) { return $undefined; } ]); $callable = $container->callable(\stdClass::class); $this->expectException(\BadMethodCallException::class); - $this->expectExceptionMessage('Argument 1 ($data) of {closure}() has no type'); + $this->expectExceptionMessage('Argument 1 ($undefined) of {closure}() is not defined'); $callable($request); }