From ba8a7ea397ad63743c50a4bbfb8a0f44fde9cb64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Mon, 1 Aug 2022 13:28:37 +0200 Subject: [PATCH] Support loading environment variables from `Container` --- docs/best-practices/controllers.md | 20 ++++++ docs/best-practices/deployment.md | 9 +++ src/App.php | 21 ++++--- src/Container.php | 20 ++++++ tests/AppTest.php | 77 ++++++++++++----------- tests/ContainerTest.php | 98 ++++++++++++++++++++++++++++++ 6 files changed, 197 insertions(+), 48 deletions(-) diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index 38ed3fd..3b256bf 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -402,6 +402,26 @@ all uppercase in any factory function like this: // … ``` +=== "Built-in environment variables" + + ```php title="public/index.php" + '0.0.0.0:8081' + // 'X_LISTEN' => fn(?string $PORT = '8080') => '0.0.0.0:' . $PORT + 'X_LISTEN' => '127.0.0.1:8080' + ]); + + $app = new FrameworkX\App($container); + + // … + ``` + > ℹ️ **Passing environment variables** > > All environment variables defined on the process level will be made available diff --git a/docs/best-practices/deployment.md b/docs/best-practices/deployment.md index 33f7ef9..69c915a 100644 --- a/docs/best-practices/deployment.md +++ b/docs/best-practices/deployment.md @@ -277,6 +277,15 @@ or `[::]` IPv6 address like this: $ X_LISTEN=0.0.0.0:8080 php public/index.php ``` +> ℹ️ **Saving environment variables** +> +> For temporary testing purposes, you may explicitly `export` your environment +> variables on the command like above. As a more permanent solution, you may +> want to save your environment variables in your [systemd configuration](#systemd), +> [Docker settings](#docker-containers), load your variables from a dotenv file +> (`.env`) using a library such as [vlucas/phpdotenv](https://github.com/vlucas/phpdotenv), +> or use an explicit [Container configuration](controllers.md#container-configuration). + ### Memory limit X is carefully designed to minimize memory usage. Depending on your application diff --git a/src/App.php b/src/App.php index 5efb4c9..cd4b16c 100644 --- a/src/App.php +++ b/src/App.php @@ -27,6 +27,9 @@ class App /** @var SapiHandler */ private $sapi; + /** @var Container */ + private $container; + /** * Instantiate new X application * @@ -46,19 +49,19 @@ public function __construct(...$middleware) // new MiddlewareHandler([$fiberHandler, $accessLogHandler, $errorHandler, ...$middleware, $routeHandler]) $handlers = []; - $container = $needsErrorHandler = new Container(); + $this->container = $needsErrorHandler = new Container(); // only log for built-in webserver and PHP development webserver by default, others have their own access log - $needsAccessLog = (\PHP_SAPI === 'cli' || \PHP_SAPI === 'cli-server') ? $container : null; + $needsAccessLog = (\PHP_SAPI === 'cli' || \PHP_SAPI === 'cli-server') ? $this->container : null; if ($middleware) { $needsErrorHandlerNext = false; foreach ($middleware as $handler) { // load AccessLogHandler and ErrorHandler instance from last Container if ($handler === AccessLogHandler::class) { - $handler = $container->getAccessLogHandler(); + $handler = $this->container->getAccessLogHandler(); } elseif ($handler === ErrorHandler::class) { - $handler = $container->getErrorHandler(); + $handler = $this->container->getErrorHandler(); } // ensure AccessLogHandler is always followed by ErrorHandler @@ -69,14 +72,14 @@ public function __construct(...$middleware) if ($handler instanceof Container) { // remember last Container to load any following class names - $container = $handler; + $this->container = $handler; // add default ErrorHandler from last Container before adding any other handlers, may be followed by other Container instances (unlikely) if (!$handlers) { - $needsErrorHandler = $needsAccessLog = $container; + $needsErrorHandler = $needsAccessLog = $this->container; } } elseif (!\is_callable($handler)) { - $handlers[] = $container->callable($handler); + $handlers[] = $this->container->callable($handler); } else { // don't need a default ErrorHandler if we're adding one as first handler or AccessLogHandler as first followed by one if ($needsErrorHandler && ($handler instanceof ErrorHandler || $handler instanceof AccessLogHandler) && !$handlers) { @@ -109,7 +112,7 @@ public function __construct(...$middleware) \array_unshift($handlers, new FiberHandler()); // @codeCoverageIgnore } - $this->router = new RouteHandler($container); + $this->router = new RouteHandler($this->container); $handlers[] = $this->router; $this->handler = new MiddlewareHandler($handlers); $this->sapi = new SapiHandler(); @@ -232,7 +235,7 @@ private function runLoop() return $this->handleRequest($request); }); - $listen = $_SERVER['X_LISTEN'] ?? '127.0.0.1:8080'; + $listen = $this->container->getEnv('X_LISTEN') ?? '127.0.0.1:8080'; $socket = new SocketServer($listen); $http->listen($socket); diff --git a/src/Container.php b/src/Container.php index 5fc6bb7..7c4521b 100644 --- a/src/Container.php +++ b/src/Container.php @@ -91,6 +91,26 @@ public function callable(string $class): callable }; } + /** @internal */ + public function getEnv(string $name): ?string + { + assert(\preg_match('/^[A-Z][A-Z0-9_]+$/', $name) === 1); + + if (\is_array($this->container) && \array_key_exists($name, $this->container)) { + $value = $this->loadVariable($name, 'mixed', true, 64); + } elseif ($this->container instanceof ContainerInterface && $this->container->has($name)) { + $value = $this->container->get($name); + } else { + $value = $_SERVER[$name] ?? null; + } + + if (!\is_string($value) && $value !== null) { + throw new \TypeError('Environment variable $' . $name . ' expected type string|null, but got ' . (\is_object($value) ? \get_class($value) : \gettype($value))); + } + + return $value; + } + /** @internal */ public function getAccessLogHandler(): AccessLogHandler { diff --git a/tests/AppTest.php b/tests/AppTest.php index efe2333..481e52f 100644 --- a/tests/AppTest.php +++ b/tests/AppTest.php @@ -38,29 +38,6 @@ class AppTest extends TestCase { - /** - * @var array - */ - private $serverArgs; - - protected function setUp(): void - { - // Store a snapshot of $_SERVER - $this->serverArgs = $_SERVER; - } - - protected function tearDown(): void - { - // Restore $_SERVER as it was before - foreach ($_SERVER as $key => $value) { - if (!\array_key_exists($key, $this->serverArgs)) { - unset($_SERVER[$key]); - continue; - } - $_SERVER[$key] = $value; - } - } - public function testConstructWithMiddlewareAssignsGivenMiddleware() { $middleware = function () { }; @@ -626,14 +603,17 @@ public function testRunWillReportListeningAddressAndRunLoopWithSocketServer() $app->run(); } - public function testRunWillReportListeningAddressFromEnvironmentAndRunLoopWithSocketServer() + public function testRunWillReportListeningAddressFromContainerEnvironmentAndRunLoopWithSocketServer() { $socket = @stream_socket_server('127.0.0.1:0'); $addr = stream_socket_get_name($socket, false); fclose($socket); - $_SERVER['X_LISTEN'] = $addr; - $app = new App(); + $container = new Container([ + 'X_LISTEN' => $addr + ]); + + $app = new App($container); // lovely: remove socket server on next tick to terminate loop Loop::futureTick(function () { @@ -650,10 +630,13 @@ public function testRunWillReportListeningAddressFromEnvironmentAndRunLoopWithSo $app->run(); } - public function testRunWillReportListeningAddressFromEnvironmentWithRandomPortAndRunLoopWithSocketServer() + public function testRunWillReportListeningAddressFromContainerEnvironmentWithRandomPortAndRunLoopWithSocketServer() { - $_SERVER['X_LISTEN'] = '127.0.0.1:0'; - $app = new App(); + $container = new Container([ + 'X_LISTEN' => '127.0.0.1:0' + ]); + + $app = new App($container); // lovely: remove socket server on next tick to terminate loop Loop::futureTick(function () { @@ -672,8 +655,11 @@ public function testRunWillReportListeningAddressFromEnvironmentWithRandomPortAn public function testRunWillRestartLoopUntilSocketIsClosed() { - $_SERVER['X_LISTEN'] = '127.0.0.1:0'; - $app = new App(); + $container = new Container([ + 'X_LISTEN' => '127.0.0.1:0' + ]); + + $app = new App($container); // lovely: remove socket server on next tick to terminate loop Loop::futureTick(function () { @@ -700,8 +686,11 @@ public function testRunWillRestartLoopUntilSocketIsClosed() */ public function testRunWillStopWhenReceivingSigint() { - $_SERVER['X_LISTEN'] = '127.0.0.1:0'; - $app = new App(); + $container = new Container([ + 'X_LISTEN' => '127.0.0.1:0' + ]); + + $app = new App($container); Loop::futureTick(function () { posix_kill(getmypid(), defined('SIGINT') ? SIGINT : 2); @@ -717,8 +706,11 @@ public function testRunWillStopWhenReceivingSigint() */ public function testRunWillStopWhenReceivingSigterm() { - $_SERVER['X_LISTEN'] = '127.0.0.1:0'; - $app = new App(); + $container = new Container([ + 'X_LISTEN' => '127.0.0.1:0' + ]); + + $app = new App($container); Loop::futureTick(function () { posix_kill(getmypid(), defined('SIGTERM') ? SIGTERM : 15); @@ -730,8 +722,12 @@ public function testRunWillStopWhenReceivingSigterm() public function testRunAppWithEmptyAddressThrows() { - $_SERVER['X_LISTEN'] = ''; - $app = new App(); + $container = new Container([ + 'X_LISTEN' => '' + ]); + + $app = new App($container); + $this->expectException(\InvalidArgumentException::class); $app->run(); @@ -746,8 +742,11 @@ public function testRunAppWithBusyPortThrows() $this->markTestSkipped('System does not prevent listening on same address twice'); } - $_SERVER['X_LISTEN'] = $addr; - $app = new App(); + $container = new Container([ + 'X_LISTEN' => $addr + ]); + + $app = new App($container); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Failed to listen on'); diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php index bfb5b1b..5c5ea8b 100644 --- a/tests/ContainerTest.php +++ b/tests/ContainerTest.php @@ -1951,6 +1951,104 @@ public function testCallableReturnsCallableThatThrowsWhenFactoryReturnsInvalidCl $callable($request); } + public function testGetEnvReturnsNullWhenEnvironmentDoesNotExist() + { + $container = new Container([]); + + $this->assertNull($container->getEnv('X_FOO')); + } + + public function testGetEnvReturnsStringFromMap() + { + $container = new Container([ + 'X_FOO' => 'bar' + ]); + + $this->assertEquals('bar', $container->getEnv('X_FOO')); + } + + public function testGetEnvReturnsStringFromMapFactory() + { + $container = new Container([ + 'X_FOO' => function (string $bar) { return $bar; }, + 'bar' => 'bar' + ]); + + $this->assertEquals('bar', $container->getEnv('X_FOO')); + } + + public function testGetEnvReturnsStringFromGlobalServerIfNotSetInMap() + { + $container = new Container([]); + + $_SERVER['X_FOO'] = 'bar'; + $ret = $container->getEnv('X_FOO'); + unset($_SERVER['X_FOO']); + + $this->assertEquals('bar', $ret); + } + + public function testGetEnvReturnsStringFromPsrContainer() + { + $psr = $this->createMock(ContainerInterface::class); + $psr->expects($this->once())->method('has')->with('X_FOO')->willReturn(true); + $psr->expects($this->once())->method('get')->with('X_FOO')->willReturn('bar'); + + $container = new Container($psr); + + $this->assertEquals('bar', $container->getEnv('X_FOO')); + } + + public function testGetEnvReturnsNullIfPsrContainerHasNoEntry() + { + $psr = $this->createMock(ContainerInterface::class); + $psr->expects($this->once())->method('has')->with('X_FOO')->willReturn(false); + $psr->expects($this->never())->method('get'); + + $container = new Container($psr); + + $this->assertNull($container->getEnv('X_FOO')); + } + + public function testGetEnvReturnsStringFromGlobalServerIfPsrContainerHasNoEntry() + { + $psr = $this->createMock(ContainerInterface::class); + $psr->expects($this->once())->method('has')->with('X_FOO')->willReturn(false); + $psr->expects($this->never())->method('get'); + + $container = new Container($psr); + + $_SERVER['X_FOO'] = 'bar'; + $ret = $container->getEnv('X_FOO'); + unset($_SERVER['X_FOO']); + + $this->assertEquals('bar', $ret); + } + + public function testGetEnvThrowsIfMapContainsInvalidType() + { + $container = new Container([ + 'X_FOO' => false + ]); + + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Environment variable $X_FOO expected type string|null, but got boolean'); + $container->getEnv('X_FOO'); + } + + public function testGetEnvThrowsIfMapPsrContainerReturnsInvalidType() + { + $psr = $this->createMock(ContainerInterface::class); + $psr->expects($this->once())->method('has')->with('X_FOO')->willReturn(true); + $psr->expects($this->once())->method('get')->with('X_FOO')->willReturn(42); + + $container = new Container($psr); + + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Environment variable $X_FOO expected type string|null, but got integer'); + $container->getEnv('X_FOO'); + } + public function testGetAccessLogHandlerReturnsDefaultAccessLogHandlerInstance() { $container = new Container([]);