From e78bf884358511b0759b54dbf5fac6080fb885c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sun, 5 Jan 2025 11:22:46 +0100 Subject: [PATCH] Add support for loading recursive environment variables from `Container` --- docs/best-practices/controllers.md | 2 +- src/Container.php | 14 +++-- tests/ContainerTest.php | 93 +++++++++++++++++++++++++++++- 3 files changed, 103 insertions(+), 6 deletions(-) diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index b406d6d..7f725f4 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -393,7 +393,7 @@ all uppercase in any factory function like this: // You may explicitly configure this built-in functionality like this: // 'X_LISTEN' => '0.0.0.0:8081' // 'X_LISTEN' => fn(int|string $PORT = 8080) => '0.0.0.0:' . $PORT - 'X_LISTEN' => '127.0.0.1:8080' + 'X_LISTEN' => fn(string $X_LISTEN = '127.0.0.1:8080') => $X_LISTEN ]); $app = new FrameworkX\App($container); diff --git a/src/Container.php b/src/Container.php index 35a35ec..eeeaaa8 100644 --- a/src/Container.php +++ b/src/Container.php @@ -341,15 +341,21 @@ private function loadVariable(string $name, int $depth = 64) /*: object|string|i \assert(\is_array($this->container) || !$this->container->has($name)); if (\is_array($this->container) && ($this->container[$name] ?? null) instanceof \Closure) { - // build list of factory parameters based on parameter types $factory = $this->container[$name]; \assert($factory instanceof \Closure); $closure = new \ReflectionFunction($factory); - $params = $this->loadFunctionParams($closure, $depth - 1, true, '$' . $name); - // invoke factory with list of parameters - $value = $params === [] ? $factory() : $factory(...$params); + // build list of factory parameters based on parameter types + // temporarily unset factory reference to allow loading recursive variables from environment + try { + unset($this->container[$name]); + $params = $this->loadFunctionParams($closure, $depth - 1, true, '$' . $name); + } finally { + $this->container[$name] = $factory; + } + // invoke factory with list of parameters + $value = $factory(...$params); if (!\is_object($value) && !\is_scalar($value) && $value !== null) { throw new \TypeError( 'Return value of ' . self::functionName($closure) . ' for $' . $name . ' must be of type object|string|int|float|bool|null, ' . $this->gettype($value) . ' returned' diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php index 84023b6..d6166ab 100644 --- a/tests/ContainerTest.php +++ b/tests/ContainerTest.php @@ -1268,7 +1268,7 @@ public function __construct(\stdClass $data) $callable = $container->callable(get_class($controller)); $this->expectException(\Error::class); - $this->expectExceptionMessage('Argument #1 ($stdClass) of {closure:' . __FILE__ . ':' . $line .'}() for $stdClass is recursive'); + $this->expectExceptionMessage('Argument #1 ($stdClass) of {closure:' . __FILE__ . ':' . $line .'}() for $stdClass requires container config with type string, none given'); $callable($request); } @@ -2058,6 +2058,37 @@ public function testGetEnvReturnsStringFromGlobalEnvBeforeProcessEnvIfNotSetInMa $this->assertEquals('foo', $ret); } + public function testGetEnvReturnsStringFromRecursiveFactoryReferencingStringFromGlobalEnv(): void + { + $container = new Container([ + 'X_FOO' => function (string $X_FOO) { return strtoupper($X_FOO); } + ]); + + $_ENV['X_FOO'] = 'foo'; + $ret = $container->getEnv('X_FOO'); + unset($_ENV['X_FOO']); + + $this->assertEquals('FOO', $ret); + } + + public function testGetEnvReturnsStringFromRecursiveFactoryWithNullableValueIfNotSetInGlobalEnv(): void + { + $container = new Container([ + 'X_FOO' => function (?string $X_FOO = null) { return $X_FOO ?? 'foo'; } + ]); + + $this->assertEquals('foo', $container->getEnv('X_FOO')); + } + + public function testGetEnvReturnsStringFromRecursiveFactoryWithDefaultValueIfNotSetInGlobalEnv(): void + { + $container = new Container([ + 'X_FOO' => function (string $X_FOO = 'foo') { return $X_FOO; } + ]); + + $this->assertEquals('foo', $container->getEnv('X_FOO')); + } + public function testGetEnvReturnsStringFromPsrContainer(): void { $psr = $this->createMock(ContainerInterface::class); @@ -2338,6 +2369,66 @@ public function testGetEnvThrowsWhenFactoryFunctionExpectsDnfTypeButNoneGiven(): $container->getEnv('X_FOO'); } + public function testGetEnvThrowsWhenRecursiveFactoryReferencesUndefinedVariable(): void + { + $line = __LINE__ + 2; + $container = new Container([ + 'X_UNDEFINED' => function (string $X_UNDEFINED) { return strtoupper($X_UNDEFINED); } + ]); + + $this->expectException(\Error::class); + $this->expectExceptionMessage('Argument #1 ($X_UNDEFINED) of {closure:' . __FILE__ . ':' . $line . '}() for $X_UNDEFINED requires container config with type string, none given'); + $container->getEnv('X_UNDEFINED'); + } + + public function testGetEnvReturnsStringAfterFirstCallThrowsWhenRecursiveFactoryReferencesVariableDefinedOnlyOnSecondCall(): void + { + $line = __LINE__ + 2; + $container = new Container([ + 'X_FOO' => function (string $X_FOO) { return strtoupper($X_FOO); } + ]); + + try { + $container->getEnv('X_FOO'); + $this->fail(); + } catch (\Error $e) { + $this->assertEquals('Argument #1 ($X_FOO) of {closure:' . __FILE__ . ':' . $line . '}() for $X_FOO requires container config with type string, none given', $e->getMessage()); + } + + $_ENV['X_FOO'] = 'defined'; + $ret = $container->getEnv('X_FOO'); + unset($_ENV['X_FOO']); + + $this->assertEquals('DEFINED', $ret); + } + + public function testGetEnvReturnsStringAfterFirstCallThrowsWhenRecursiveFactoryThrowsOnFirstCall(): void + { + $container = new Container([ + 'X_FOO' => function (string $X_FOO) { + static $first = true; + if ($first) { + $first = false; + throw new \RuntimeException('First call'); + } + return strtoupper($X_FOO); + } + ]); + + try { + $_ENV['X_FOO'] = 'defined'; + $container->getEnv('X_FOO'); + $this->fail(); + } catch (\RuntimeException $e) { + $this->assertEquals('First call', $e->getMessage()); + } + + $ret = $container->getEnv('X_FOO'); + unset($_ENV['X_FOO']); + + $this->assertEquals('DEFINED', $ret); + } + public function testGetEnvThrowsWhenFactoryFunctionExpectsNullableIntArgumentButGivenString(): void { $line = __LINE__ + 2;