From 4dd692df5b24eb1ea6dac60550539acc225f4105 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sun, 31 Jul 2022 19:55:34 +0200 Subject: [PATCH] Automatically load environment variables for DI container factories --- docs/best-practices/controllers.md | 54 +++++++ src/Container.php | 13 +- tests/ContainerTest.php | 219 +++++++++++++++++++++++++++++ 3 files changed, 282 insertions(+), 4 deletions(-) diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index 0a6f4bd..38ed3fd 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -358,6 +358,60 @@ some manual configuration like this: > namespaced class names like in the previous example. You may also want to make > sure that container variables use unique names prefixed with your vendor name. +All environment variables will be made available as container variables +automatically. You can access their values simply by referencing variables in +all uppercase in any factory function like this: + +=== "Required environment variables" + + ```php title="public/index.php" + function (string $MYSQL_URI) { + // connect to database defined in required $MYSQL_URI environment variable + return (new React\MySQL\Factory())->createLazyConnection($MYSQL_URI); + } + ]); + + + $app = new FrameworkX\App($container); + + // … + ``` + +=== "Optional environment variables" + + ```php title="public/index.php" + function (string $DB_HOST = 'localhost', string $DB_USER = 'root', string $DB_PASS = '', string $DB_NAME = 'acme') { + // connect to database defined in optional $DB_* environment variables + $uri = 'mysql://' . $DB_USER . ':' . rawurlencode($DB_PASS) . '@' . $DB_HOST . '/' . $DB_NAME . '?idle=0.001'; + return (new React\MySQL\Factory())->createLazyConnection($uri); + } + ]); + + $app = new FrameworkX\App($container); + + // … + ``` + +> ℹ️ **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 +> 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), +> or load your variables from a dotenv file (`.env`) using a library such as +> [vlucas/phpdotenv](https://github.com/vlucas/phpdotenv). + The container configuration may also be used to map a class name to a different class name that implements the same interface, either by mapping between two class names or using a factory function that returns a class name. This is diff --git a/src/Container.php b/src/Container.php index 4957b43..5fc6bb7 100644 --- a/src/Container.php +++ b/src/Container.php @@ -222,7 +222,7 @@ private function loadParameter(\ReflectionParameter $parameter, int $depth, bool // load container variables if parameter name is known assert($type === null || $type instanceof \ReflectionNamedType); - if ($allowVariables && \array_key_exists($parameter->getName(), $this->container)) { + if ($allowVariables && (\array_key_exists($parameter->getName(), $this->container) || (isset($_SERVER[$parameter->getName()]) && \preg_match('/^[A-Z][A-Z0-9_]+$/', $parameter->getName())))) { return $this->loadVariable($parameter->getName(), $type === null ? 'mixed' : $type->getName(), $parameter->allowsNull(), $depth); } @@ -264,8 +264,9 @@ private function loadParameter(\ReflectionParameter $parameter, int $depth, bool */ private function loadVariable(string $name, string $type, bool $nullable, int $depth) /*: object|string|int|float|bool|null (PHP 8.0+) */ { - assert(\array_key_exists($name, $this->container)); - if ($this->container[$name] instanceof \Closure) { + assert(\array_key_exists($name, $this->container) || isset($_SERVER[$name])); + + if (($this->container[$name] ?? null) instanceof \Closure) { if ($depth < 1) { throw new \BadMethodCallException('Container variable $' . $name . ' is recursive'); } @@ -282,9 +283,13 @@ private function loadVariable(string $name, string $type, bool $nullable, int $d } $this->container[$name] = $value; + } elseif (\array_key_exists($name, $this->container)) { + $value = $this->container[$name]; + } else { + assert(isset($_SERVER[$name]) && \is_string($_SERVER[$name])); + $value = $_SERVER[$name]; } - $value = $this->container[$name]; assert(\is_object($value) || \is_scalar($value) || $value === null); // allow null values if parameter is marked nullable or untyped or mixed diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php index 7278e41..bfb5b1b 100644 --- a/tests/ContainerTest.php +++ b/tests/ContainerTest.php @@ -1001,6 +1001,225 @@ public function __invoke(ServerRequestInterface $request) $this->assertEquals('{"name":"ADMIN"}', (string) $response->getBody()); } + public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresStringEnvironmentVariable() + { + $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 $FOO) { + return new Response(200, [], json_encode($FOO)); + } + ]); + + $callable = $container->callable(get_class($controller)); + $this->assertInstanceOf(\Closure::class, $callable); + + $_SERVER['FOO'] = 'bar'; + $response = $callable($request); + unset($_SERVER['FOO']); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('"bar"', (string) $response->getBody()); + } + + public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresStringMappedFromFactoryThatRequiresStringEnvironmentVariable() + { + $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 $address) { + return new Response(200, [], json_encode($address)); + }, + 'address' => function (string $FOO) { + return 'http://' . $FOO; + } + ]); + + $callable = $container->callable(get_class($controller)); + $this->assertInstanceOf(\Closure::class, $callable); + + $_SERVER['FOO'] = 'bar'; + $response = $callable($request); + unset($_SERVER['FOO']); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('"http:\/\/bar"', (string) $response->getBody()); + } + + public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresNullableStringEnvironmentVariable() + { + $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 $FOO) { + return new Response(200, [], json_encode($FOO)); + } + ]); + + $callable = $container->callable(get_class($controller)); + $this->assertInstanceOf(\Closure::class, $callable); + + $_SERVER['FOO'] = 'bar'; + $response = $callable($request); + unset($_SERVER['FOO']); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('"bar"', (string) $response->getBody()); + } + + public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresNullableStringEnvironmentVariableAssignsNull() + { + $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 $FOO) { + return new Response(200, [], json_encode($FOO)); + } + ]); + + $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 testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresUntypedEnvironmentVariable() + { + $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 ($FOO) { + return new Response(200, [], json_encode($FOO)); + } + ]); + + $callable = $container->callable(get_class($controller)); + $this->assertInstanceOf(\Closure::class, $callable); + + $_SERVER['FOO'] = 'bar'; + $response = $callable($request); + unset($_SERVER['FOO']); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('"bar"', (string) $response->getBody()); + } + + /** + * @requires PHP 8 + */ + public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresMixedEnvironmentVariable() + { + $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 $FOO) { + return new Response(200, [], json_encode($FOO)); + } + ]); + + $callable = $container->callable(get_class($controller)); + $this->assertInstanceOf(\Closure::class, $callable); + + $_SERVER['FOO'] = 'bar'; + $response = $callable($request); + unset($_SERVER['FOO']); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('"bar"', (string) $response->getBody()); + } + public function testCallableReturnsCallableThatThrowsWhenFactoryReferencesUnknownVariable() { $request = new ServerRequest('GET', 'http://example.com/');