Skip to content

Commit

Permalink
Support loading environment variables from Container
Browse files Browse the repository at this point in the history
  • Loading branch information
clue committed Aug 2, 2022
1 parent 4dd692d commit ba8a7ea
Show file tree
Hide file tree
Showing 6 changed files with 197 additions and 48 deletions.
20 changes: 20 additions & 0 deletions docs/best-practices/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,26 @@ all uppercase in any factory function like this:
// …
```

=== "Built-in environment variables"

```php title="public/index.php"
<?php

require __DIR__ . '/../vendor/autoload.php';

$container = new FrameworkX\Container([
// 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' => '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
Expand Down
9 changes: 9 additions & 0 deletions docs/best-practices/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 12 additions & 9 deletions src/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ class App
/** @var SapiHandler */
private $sapi;

/** @var Container */
private $container;

/**
* Instantiate new X application
*
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
20 changes: 20 additions & 0 deletions src/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
77 changes: 38 additions & 39 deletions tests/AppTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 () { };
Expand Down Expand Up @@ -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 () {
Expand All @@ -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 () {
Expand All @@ -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 () {
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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();
Expand All @@ -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');
Expand Down
98 changes: 98 additions & 0 deletions tests/ContainerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down

0 comments on commit ba8a7ea

Please sign in to comment.