diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index 0a6f4bd..3b256bf 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -358,6 +358,80 @@ 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); + + // … + ``` + +=== "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 +> 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/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 4957b43..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 { @@ -222,7 +242,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 +284,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 +303,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/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 7278e41..5c5ea8b 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/'); @@ -1732,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([]);