Skip to content

Commit

Permalink
Merge pull request #184 from clue-labs/env
Browse files Browse the repository at this point in the history
Support loading environment variables from DI container configuration
  • Loading branch information
SimonFrings authored Aug 3, 2022
2 parents 214ce42 + ba8a7ea commit 857b354
Show file tree
Hide file tree
Showing 6 changed files with 479 additions and 52 deletions.
74 changes: 74 additions & 0 deletions docs/best-practices/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
<?php

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

$container = new FrameworkX\Container([
React\MySQL\ConnectionInterface::class => 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"
<?php

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

$container = new FrameworkX\Container([
React\MySQL\ConnectionInterface::class => 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"
<?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
> 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
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
33 changes: 29 additions & 4 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 Expand Up @@ -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);
}

Expand Down Expand Up @@ -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');
}
Expand All @@ -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
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
Loading

0 comments on commit 857b354

Please sign in to comment.