diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index a4b95e5..b406d6d 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -392,7 +392,7 @@ all uppercase in any factory function like this: // Framework X also uses environment variables internally. // You may explicitly configure this built-in functionality like this: // 'X_LISTEN' => '0.0.0.0:8081' - // 'X_LISTEN' => fn(string $PORT = '8080') => '0.0.0.0:' . $PORT + // 'X_LISTEN' => fn(int|string $PORT = 8080) => '0.0.0.0:' . $PORT 'X_LISTEN' => '127.0.0.1:8080' ]); @@ -404,7 +404,10 @@ all uppercase in any factory function like this: > ℹ️ **Passing environment variables** > > All environment variables defined on the process level will be made available -> automatically. For temporary testing purposes, you may explicitly `export` or +> automatically. Note that all environment variables are of type string by +> definition, so may have to cast values or accept unions as required. +> +> For temporary testing purposes, you may explicitly `export` or > prefix environment variables to the command line. As a more permanent > solution, you may want to save your environment variables in your > [systemd configuration](deployment.md#systemd), [Docker settings](deployment.md#docker-containers), diff --git a/src/Container.php b/src/Container.php index 01c7e14..35a35ec 100644 --- a/src/Container.php +++ b/src/Container.php @@ -290,26 +290,13 @@ private function loadParameter(\ReflectionParameter $parameter, int $depth, bool \assert(\is_array($this->container)); $type = $parameter->getType(); - // abort for union types (PHP 8.0+) and intersection types (PHP 8.1+) - // @phpstan-ignore-next-line for PHP < 8 - if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) { // @codeCoverageIgnoreStart - if ($parameter->isDefaultValueAvailable()) { - return $parameter->getDefaultValue(); - } - - throw new \Error( - self::parameterError($parameter, $for) . ' expects unsupported type ' . $type - ); - } // @codeCoverageIgnoreEnd - // load container variables if parameter name is known - \assert($type === null || $type instanceof \ReflectionNamedType); if ($allowVariables && $this->hasVariable($parameter->getName())) { $value = $this->loadVariable($parameter->getName(), $depth); // skip type checks and allow all values if expected type is undefined or mixed (PHP 8+) // allow null values if parameter is marked nullable or untyped or mixed - if ($type === null || ($value === null && $parameter->allowsNull()) || $type->getName() === 'mixed' || $this->validateType($value, $type)) { + if ($type === null || ($value === null && $parameter->allowsNull()) || ($type instanceof \ReflectionNamedType && $type->getName() === 'mixed') || $this->validateType($value, $type)) { return $value; } @@ -389,12 +376,31 @@ private function loadVariable(string $name, int $depth = 64) /*: object|string|i /** * @param object|string|int|float|bool|null $value - * @param \ReflectionNamedType $type + * @param \ReflectionType $type * @throws void */ - private function validateType($value, \ReflectionNamedType $type): bool + private function validateType($value, \ReflectionType $type): bool { + // check union types (PHP 8.0+) and intersection types (PHP 8.1+) and DNF types (PHP 8.2+) + if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) { // @codeCoverageIgnoreStart + $early = $type instanceof \ReflectionUnionType; + foreach ($type->getTypes() as $type) { + // return early success if any union type matches + // return early failure if any intersection type doesn't match + if ($this->validateType($value, $type) === $early) { + return $early; + } + } + return !$early; + } // @codeCoverageIgnoreEnd + + // if we reach here, we handle only a single named type + \assert($type instanceof \ReflectionNamedType); $type = $type->getName(); + + // nullable types and mixed already handled before entering this check + \assert($type !== 'null' && $type !== 'mixed'); + return ( (\is_object($value) && $value instanceof $type) || (\is_string($value) && $type === 'string') || @@ -424,14 +430,14 @@ private static function parameterError(\ReflectionParameter $parameter, string $ } /** - * @param \ReflectionNamedType $type + * @param \ReflectionType $type * @return string * @throws void * @see https://www.php.net/manual/en/reflectiontype.tostring.php (PHP 8+) */ - private static function typeName(\ReflectionNamedType $type): string + private static function typeName(\ReflectionType $type): string { - return ($type->allowsNull() && $type->getName() !== 'mixed' ? '?' : '') . $type->getName(); + return $type instanceof \ReflectionNamedType ? ($type->allowsNull() && $type->getName() !== 'mixed' ? '?' : '') . $type->getName() : (string) $type; } /** diff --git a/tests/AppTest.php b/tests/AppTest.php index f16189b..0fe731d 100644 --- a/tests/AppTest.php +++ b/tests/AppTest.php @@ -1648,14 +1648,14 @@ public function provideInvalidClasses(): \Generator if (PHP_VERSION_ID >= 80000) { yield [ InvalidConstructorUnion::class, - 'Argument #1 ($value) of %s::__construct() expects unsupported type int|float' + 'Argument #1 ($value) of %s::__construct() requires container config with type int|float, none given' ]; } if (PHP_VERSION_ID >= 80100) { yield [ InvalidConstructorIntersection::class, - 'Argument #1 ($value) of %s::__construct() expects unsupported type Traversable&ArrayAccess' + 'Argument #1 ($value) of %s::__construct() requires container config with type Traversable&ArrayAccess, none given' ]; } diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php index c0db268..84023b6 100644 --- a/tests/ContainerTest.php +++ b/tests/ContainerTest.php @@ -743,7 +743,7 @@ public function __invoke(): ResponseInterface } }; - $fn = $data = null; + $fn = null; $fn = #[PHP8] fn(mixed $data = 42) => new Response(200, [], (string) json_encode($data)); // @phpstan-ignore-line $container = new Container([ ResponseInterface::class => $fn, @@ -1906,31 +1906,52 @@ public function testGetEnvReturnsStringFromMapFactory(): void /** * @requires PHP 8 */ - public function testGetEnvReturnsNullFromFactoryForUnsupportedUnionVariableWithNullDefaultEvenForKnownVariable(): void + public function testGetEnvReturnsStringFromFactoryFunctionWithUnionType(): void { $fn = null; - $fn = #[PHP8] function (string|int|null $bar = null) { return $bar; }; + $fn = #[PHP8] function (string|int $X_UNION) { return (string) $X_UNION; }; $container = new Container([ 'X_FOO' => $fn, - 'bar' => 'ignored' + 'X_UNION' => 42 ]); - $this->assertNull($container->getEnv('X_FOO')); + $this->assertEquals('42', $container->getEnv('X_FOO')); } /** - * @requires PHP 8 + * @requires PHP 8.1 */ - public function testGetEnvReturnsStringFromFactoryForUnsupportedUnionVariableWithStringDefaultEvenForKnownVariable(): void + public function testGetEnvReturnsStringFromFactoryFunctionWithIntersectionType(): void { - $fn = null; - $fn = #[PHP8] function (string|int|null $bar = 'default') { return $bar; }; + // eval to avoid syntax error on PHP < 8.1 + $fn = eval('return function (\Traversable&\Stringable $X_UNION) { return (string) $X_UNION; };'); $container = new Container([ 'X_FOO' => $fn, - 'bar' => 'ignored' + 'X_UNION' => new class implements \IteratorAggregate, \Stringable { + public function __toString(): string { return '42'; } + public function getIterator(): \Traversable { yield from []; } + } ]); - $this->assertEquals('default', $container->getEnv('X_FOO')); + $this->assertEquals('42', $container->getEnv('X_FOO')); + } + + /** + * @requires PHP 8.2 + */ + public function testGetEnvReturnsStringFromFactoryFunctionWithDnfType(): void + { + // eval to avoid syntax error on PHP < 8.2 + $fn = eval('return function (float|(\Traversable&\Stringable)|string $X_UNION) { return (string) $X_UNION; };'); + $container = new Container([ + 'X_FOO' => $fn, + 'X_UNION' => new class implements \IteratorAggregate, \Stringable { + public function __toString(): string { return '42'; } + public function getIterator(): \Traversable { yield from []; } + } + ]); + + $this->assertEquals('42', $container->getEnv('X_FOO')); } public function testGetEnvReturnsNullFromFactoryForUnknownNullableVariableWithNullDefault(): void @@ -1954,7 +1975,7 @@ public function testGetEnvReturnsStringFromFactoryForUnknownVariableWithStringDe /** * @requires PHP 8 */ - public function testGetEnvReturnsNullFromFactoryForUnknownAndUnsupportedUnionVariableWithNullDefault(): void + public function testGetEnvReturnsNullFromFactoryForUnknownUnionVariableWithNullDefault(): void { $fn = null; $fn = #[PHP8] function (string|int|null $X_UNDEFINED = null) { return $X_UNDEFINED; }; @@ -1968,7 +1989,7 @@ public function testGetEnvReturnsNullFromFactoryForUnknownAndUnsupportedUnionVar /** * @requires PHP 8 */ - public function testGetEnvReturnsStringFromFactoryForUnknownAndUnsupportedUnionVariableWithStringDefault(): void + public function testGetEnvReturnsStringFromFactoryForUnknownUnionVariableWithStringDefault(): void { $fn = null; $fn = #[PHP8] function (string|int|null $X_UNDEFINED = 'default') { return $X_UNDEFINED; }; @@ -2266,6 +2287,57 @@ public function testGetEnvThrowsWhenFactoryFunctionExpectsRequiredMixedEnvVariab $container->getEnv('X_FOO'); } + /** + * @requires PHP 8 + */ + public function testGetEnvThrowsWhenFactoryFunctionExpectsUnionTypeButNoneGiven(): void + { + $line = __LINE__ + 2; + $fn = null; + $fn = #[PHP8] function (string|int|null $X_UNDEFINED) { return $X_UNDEFINED; }; + $container = new Container([ + 'X_FOO' => $fn + ]); + + $this->expectException(\Error::class); + $this->expectExceptionMessage('Argument #1 ($X_UNDEFINED) of {closure:' . __FILE__ . ':' . $line . '}() for $X_FOO requires container config with type string|int|null, none given'); + $container->getEnv('X_FOO'); + } + + /** + * @requires PHP 8.1 + */ + public function testGetEnvThrowsWhenFactoryFunctionExpectsIntersectionTypeButNoneGiven(): void + { + $line = __LINE__ + 2; + // eval to avoid syntax error on PHP < 8.1 + $fn = eval('return function (\Traversable&\Stringable $X_UNDEFINED) { return (string) $X_UNDEFINED; };'); + $container = new Container([ + 'X_FOO' => $fn + ]); + + $this->expectException(\Error::class); + $this->expectExceptionMessage('Argument #1 ($X_UNDEFINED) of {closure:' . __FILE__ . '(' . $line . ') : eval()\'d code:1}() for $X_FOO requires container config with type Traversable&Stringable, none given'); + $container->getEnv('X_FOO'); + } + + /** + * @requires PHP 8.2 + */ + public function testGetEnvThrowsWhenFactoryFunctionExpectsDnfTypeButNoneGiven(): void + { + $line = __LINE__ + 2; + // eval to avoid syntax error on PHP < 8.2 + $fn = eval('return function (float|(\Traversable&\Stringable)|string $X_UNDEFINED) { return (string) $X_UNDEFINED; };'); + $container = new Container([ + 'X_FOO' => $fn + ]); + + $this->expectException(\Error::class); + $this->expectExceptionMessage('Argument #1 ($X_UNDEFINED) of {closure:' . __FILE__ . '(' . $line . ') : eval()\'d code:1}() for $X_FOO requires container config with type (Traversable&Stringable)|string|float, none given'); + $container->getEnv('X_FOO'); + } + public function testGetEnvThrowsWhenFactoryFunctionExpectsNullableIntArgumentButGivenString(): void { $line = __LINE__ + 2; @@ -2282,35 +2354,54 @@ public function testGetEnvThrowsWhenFactoryFunctionExpectsNullableIntArgumentBut /** * @requires PHP 8 */ - public function testGetEnvThrowsWhenFactoryFunctionExpectsUnsupportedUnionType(): void + public function testGetEnvThrowsWhenFactoryFunctionExpectsUnionTypeButWrongTypeGiven(): void { $line = __LINE__ + 2; $fn = null; $fn = #[PHP8] function (string|int $X_UNION) { return (string) $X_UNION; }; $container = new Container([ 'X_FOO' => $fn, - 'X_UNION' => 42 + 'X_UNION' => false ]); - $this->expectException(\Error::class); - $this->expectExceptionMessage('Argument #1 ($X_UNION) of {closure:' . __FILE__ . ':' . $line . '}() for $X_FOO expects unsupported type string|int'); + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Argument #1 ($X_UNION) of {closure:' . __FILE__ . ':' . $line . '}() for $X_FOO must be of type string|int, false given'); $container->getEnv('X_FOO'); } /** - * @requires PHP 8 + * @requires PHP 8.1 */ - public function testGetEnvThrowsWhenFactoryFunctionExpectsNullableUnionType(): void + public function testGetEnvThrowsWhenFactoryFunctionExpectsIntersectionTypeButWrongTypeGiven(): void { $line = __LINE__ + 2; - $fn = null; - $fn = #[PHP8] function (string|int|null $X_UNDEFINED) { return $X_UNDEFINED; }; + // eval to avoid syntax error on PHP < 8.1 + $fn = eval('return function (\Traversable&\ArrayAccess $X_INTERSECTION) { return var_export($X_INTERSECTION); };'); $container = new Container([ - 'X_FOO' => $fn + 'X_FOO' => $fn, + 'X_INTERSECTION' => false ]); - $this->expectException(\Error::class); - $this->expectExceptionMessage('Argument #1 ($X_UNDEFINED) of {closure:' . __FILE__ . ':' . $line . '}() for $X_FOO expects unsupported type string|int|null'); + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Argument #1 ($X_INTERSECTION) of {closure:' . __FILE__ . '(' . $line . ') : eval()\'d code:1}() for $X_FOO must be of type Traversable&ArrayAccess, false given'); + $container->getEnv('X_FOO'); + } + + /** + * @requires PHP 8.2 + */ + public function testGetEnvThrowsWhenFactoryFunctionExpectsDnfTypeButWrongTypeGiven(): void + { + $line = __LINE__ + 2; + // eval to avoid syntax error on PHP < 8.2 + $fn = eval('return function (float|(\Traversable&\Stringable)|string $X_UNION) { return (string) $X_UNION; };'); + $container = new Container([ + 'X_FOO' => $fn, + 'X_UNION' => null + ]); + + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Argument #1 ($X_UNION) of {closure:' . __FILE__ . '(' . $line . ') : eval()\'d code:1}() for $X_FOO must be of type (Traversable&Stringable)|string|float, null given'); $container->getEnv('X_FOO'); }