Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/best-practices/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
]);

Expand All @@ -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),
Expand Down
44 changes: 25 additions & 19 deletions src/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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') ||
Expand Down Expand Up @@ -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;
}

/**
Expand Down
4 changes: 2 additions & 2 deletions tests/AppTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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&amp;ArrayAccess'
'Argument #1 ($value) of %s::__construct() requires container config with type Traversable&amp;ArrayAccess, none given'
];
}

Expand Down
139 changes: 115 additions & 24 deletions tests/ContainerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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; };
Expand All @@ -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; };
Expand Down Expand Up @@ -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;
Expand All @@ -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');
}

Expand Down