Skip to content

Commit 5a98992

Browse files
committed
Support custom runner with new X_EXPERIMENTAL_RUNNER variable
1 parent 31d9008 commit 5a98992

File tree

5 files changed

+123
-21
lines changed

5 files changed

+123
-21
lines changed

docs/best-practices/controllers.md

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -381,25 +381,30 @@ all uppercase in any factory function like this:
381381
// …
382382
```
383383

384-
=== "Built-in environment variables"
384+
Besides defining custom environment variables, you may also override built-in
385+
environment variables used by X itself like this:
385386

386-
```php title="public/index.php"
387-
<?php
387+
```php title="public/index.php"
388+
<?php
388389

389-
require __DIR__ . '/../vendor/autoload.php';
390+
require __DIR__ . '/../vendor/autoload.php';
390391

391-
$container = new FrameworkX\Container([
392-
// Framework X also uses environment variables internally.
393-
// You may explicitly configure this built-in functionality like this:
394-
// 'X_LISTEN' => '0.0.0.0:8081'
395-
// 'X_LISTEN' => fn(int|string $PORT = 8080) => '0.0.0.0:' . $PORT
396-
'X_LISTEN' => fn(string $X_LISTEN = '127.0.0.1:8080') => $X_LISTEN
397-
]);
392+
$container = new FrameworkX\Container([
393+
// Framework X also uses environment variables internally.
394+
// You may explicitly configure this built-in functionality like this:
395+
// 'X_LISTEN' => '0.0.0.0:8081'
396+
// 'X_LISTEN' => fn(int|string $PORT = 8080) => '0.0.0.0:' . $PORT
397+
'X_LISTEN' => fn(string $X_LISTEN = '127.0.0.1:8080') => $X_LISTEN,
398+
399+
// 'X_EXPERIMENTAL_RUNNER' => AcmeRunner::class
400+
// 'X_EXPERIMENTAL_RUNNER' => fn(bool|string $ACME = false): ?string => $ACME ? AcmeRunner::class : null
401+
'X_EXPERIMENTAL_RUNNER' => fn(?string $X_EXPERIMENTAL_RUNNER = null): ?string => $X_EXPERIMENTAL_RUNNER,
402+
]);
398403

399-
$app = new FrameworkX\App($container);
404+
$app = new FrameworkX\App($container);
400405

401-
// …
402-
```
406+
// …
407+
```
403408

404409
> ℹ️ **Passing environment variables**
405410
>

src/App.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class App
2222
/** @var RouteHandler */
2323
private $router;
2424

25-
/** @var HttpServerRunner|SapiRunner */
25+
/** @var HttpServerRunner|SapiRunner|callable(callable(ServerRequestInterface):(ResponseInterface|PromiseInterface<ResponseInterface>)):void */
2626
private $runner;
2727

2828
/**
@@ -253,8 +253,13 @@ public function redirect(string $route, string $target, int $code = Response::ST
253253
* This is particularly useful because it allows you to run the exact same
254254
* application code in any environment.
255255
*
256+
* For more advanced use cases, this behavior can be overridden by setting
257+
* the `X_EXPERIMENTAL_RUNNER` environment variable to the desired runner
258+
* class name ({@see Container::getRunner()}).
259+
*
256260
* @see HttpServerRunner::__invoke()
257261
* @see SapiRunner::__invoke()
262+
* @see Container::getRunner()
258263
*/
259264
public function run(): void
260265
{

src/Container.php

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -181,14 +181,32 @@ public function getObject(string $class) /*: object (PHP 7.2+) */
181181
/**
182182
* [Internal] Get the app runner appropriate for this environment from container
183183
*
184-
* @return HttpServerRunner|SapiRunner
184+
* By default, this method returns an instance of `HttpServerRunner` when
185+
* running in CLI mode, and an instance of `SapiRunner` when running in a
186+
* traditional web server environment.
187+
*
188+
* For more advanced use cases, this behavior can be overridden by setting
189+
* the `X_EXPERIMENTAL_RUNNER` environment variable to the desired runner
190+
* class name. The specified class must be invokable with the main request
191+
* handler signature. Note that this is an experimental feature and the API
192+
* may be subject to change in future releases.
193+
*
194+
* @return HttpServerRunner|SapiRunner|callable(callable(ServerRequestInterface):(\Psr\Http\Message\ResponseInterface|\React\Promise\PromiseInterface<\Psr\Http\Message\ResponseInterface>)):void
185195
* @throws \TypeError if container config or factory returns an unexpected type
186196
* @throws \Throwable if container factory function throws unexpected exception
187197
* @internal
198+
* @see App::run()
188199
*/
189-
public function getRunner() /*: HttpServerRunner|SapiRunner (PHP 8.0+) */
200+
public function getRunner(): callable /*: HttpServerRunner|SapiRunner|callable (PHP 8.0+) */
190201
{
191-
return $this->getObject(\PHP_SAPI === 'cli' ? HttpServerRunner::class : SapiRunner::class);
202+
// @phpstan-ignore-next-line `getObject()` already performs type checks if `getEnv()` returns an invalid class
203+
$runner = $this->getObject($this->getEnv('X_EXPERIMENTAL_RUNNER') ?? (\PHP_SAPI === 'cli' ? HttpServerRunner::class : SapiRunner::class));
204+
if (!\is_callable($runner)) {
205+
throw new \TypeError(
206+
'Return value of ' . __METHOD__ . '() must be of type callable, ' . $this->gettype($runner) . ' returned'
207+
);
208+
}
209+
return $runner;
192210
}
193211

194212
/**

tests/AppTest.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -933,6 +933,22 @@ public function testRunWillInvokeRunnerFromContainer(): void
933933
$app->run();
934934
}
935935

936+
public function testRunWillInvokeCustomRunnerFromContainerEnvironmentVariable(): void
937+
{
938+
$runner = $this->createMock(HttpServerRunner::class);
939+
$runner->expects($this->once())->method('__invoke');
940+
941+
$container = new Container([
942+
'X_EXPERIMENTAL_RUNNER' => get_class($runner),
943+
get_class($runner) => $runner,
944+
HttpServerRunner::class => function () { throw new \BadFunctionCallException('Should not be called'); }
945+
]);
946+
947+
$app = new App($container);
948+
949+
$app->run();
950+
}
951+
936952
public function testGetMethodAddsGetRouteOnRouter(): void
937953
{
938954
$router = $this->createMock(RouteHandler::class);

tests/ContainerTest.php

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2833,6 +2833,7 @@ public function testGetRunnerReturnsDefaultHttpServerRunnerInstance(): void
28332833

28342834
$this->assertInstanceOf(HttpServerRunner::class, $runner);
28352835

2836+
assert($runner instanceof HttpServerRunner);
28362837
$ref = new \ReflectionProperty($runner, 'listenAddress');
28372838
if (PHP_VERSION_ID < 80100) {
28382839
$ref->setAccessible(true);
@@ -2852,6 +2853,7 @@ public function testGetRunnerReturnsDefaultHttpServerRunnerInstanceWithCustomLis
28522853

28532854
$this->assertInstanceOf(HttpServerRunner::class, $runner);
28542855

2856+
assert($runner instanceof HttpServerRunner);
28552857
$ref = new \ReflectionProperty($runner, 'listenAddress');
28562858
if (PHP_VERSION_ID < 80100) {
28572859
$ref->setAccessible(true);
@@ -2888,7 +2890,10 @@ public function testGetRunnerReturnsHttpServerRunnerInstanceFromPsrContainer():
28882890
$runner = new HttpServerRunner(new LogStreamHandler('php://output'), null);
28892891

28902892
$psr = $this->createMock(ContainerInterface::class);
2891-
$psr->expects($this->once())->method('has')->with(HttpServerRunner::class)->willReturn(true);
2893+
$psr->expects($this->exactly(2))->method('has')->willReturnMap([
2894+
['X_EXPERIMENTAL_RUNNER', false],
2895+
[HttpServerRunner::class, true],
2896+
]);
28922897
$psr->expects($this->once())->method('get')->with(HttpServerRunner::class)->willReturn($runner);
28932898

28942899
assert($psr instanceof ContainerInterface);
@@ -2902,7 +2907,8 @@ public function testGetRunnerReturnsHttpServerRunnerInstanceFromPsrContainer():
29022907
public function testGetRunnerReturnsDefaultHttpServerRunnerInstanceWithDefaultListenAddressIfPsrContainerHasNoEntry(): void
29032908
{
29042909
$psr = $this->createMock(ContainerInterface::class);
2905-
$psr->expects($this->exactly(2))->method('has')->willReturnMap([
2910+
$psr->expects($this->exactly(3))->method('has')->willReturnMap([
2911+
['X_EXPERIMENTAL_RUNNER', false],
29062912
[HttpServerRunner::class, false],
29072913
['X_LISTEN', false],
29082914
]);
@@ -2915,6 +2921,7 @@ public function testGetRunnerReturnsDefaultHttpServerRunnerInstanceWithDefaultLi
29152921

29162922
$this->assertInstanceOf(HttpServerRunner::class, $runner);
29172923

2924+
assert($runner instanceof HttpServerRunner);
29182925
$ref = new \ReflectionProperty($runner, 'listenAddress');
29192926
if (PHP_VERSION_ID < 80100) {
29202927
$ref->setAccessible(true);
@@ -2927,7 +2934,8 @@ public function testGetRunnerReturnsDefaultHttpServerRunnerInstanceWithDefaultLi
29272934
public function testGetRunnerReturnsDefaultHttpServerRunnerInstanceWithCustomListenAddressIfPsrContainerHasNoEntryButCustomListenAddress(): void
29282935
{
29292936
$psr = $this->createMock(ContainerInterface::class);
2930-
$psr->expects($this->exactly(2))->method('has')->willReturnMap([
2937+
$psr->expects($this->exactly(3))->method('has')->willReturnMap([
2938+
['X_EXPERIMENTAL_RUNNER', false],
29312939
[HttpServerRunner::class, false],
29322940
['X_LISTEN', true],
29332941
]);
@@ -2940,6 +2948,7 @@ public function testGetRunnerReturnsDefaultHttpServerRunnerInstanceWithCustomLis
29402948

29412949
$this->assertInstanceOf(HttpServerRunner::class, $runner);
29422950

2951+
assert($runner instanceof HttpServerRunner);
29432952
$ref = new \ReflectionProperty($runner, 'listenAddress');
29442953
if (PHP_VERSION_ID < 80100) {
29452954
$ref->setAccessible(true);
@@ -2949,6 +2958,55 @@ public function testGetRunnerReturnsDefaultHttpServerRunnerInstanceWithCustomLis
29492958
$this->assertEquals('127.0.0.1:8081', $listenAddress);
29502959
}
29512960

2961+
public function testGetRunnerReturnsCustomRunnerInstanceFromEnvironmentVariable(): void
2962+
{
2963+
$runner = new class {
2964+
public function __invoke(): void {}
2965+
};
2966+
2967+
$container = new Container([
2968+
'X_EXPERIMENTAL_RUNNER' => get_class($runner),
2969+
get_class($runner) => $runner
2970+
]);
2971+
2972+
$ret = $container->getRunner();
2973+
2974+
$this->assertSame($runner, $ret);
2975+
}
2976+
2977+
public function testGetRunnerThrowsForInvalidEnvironmentVariableType(): void
2978+
{
2979+
$container = new Container([
2980+
'X_EXPERIMENTAL_RUNNER' => 42
2981+
]);
2982+
2983+
$this->expectException(\TypeError::class);
2984+
$this->expectExceptionMessage('Return value of ' . Container::class . '::getEnv() for $X_EXPERIMENTAL_RUNNER must be of type string|null, int returned');
2985+
$container->getRunner();
2986+
}
2987+
2988+
public function testGetRunnerThrowsForUnknownClassNameInEnvironmentVariable(): void
2989+
{
2990+
$container = new Container([
2991+
'X_EXPERIMENTAL_RUNNER' => 'UnknownClass'
2992+
]);
2993+
2994+
$this->expectException(\Error::class);
2995+
$this->expectExceptionMessage('Class UnknownClass not found');
2996+
$container->getRunner();
2997+
}
2998+
2999+
public function testGetRunnerThrowsForClassNotCallableInEnvironmentVariable(): void
3000+
{
3001+
$container = new Container([
3002+
'X_EXPERIMENTAL_RUNNER' => \stdClass::class
3003+
]);
3004+
3005+
$this->expectException(\TypeError::class);
3006+
$this->expectExceptionMessage('Return value of ' . Container::class . '::getRunner() must be of type callable, stdClass returned');
3007+
$container->getRunner();
3008+
}
3009+
29523010
public function testInvokeContainerAsMiddlewareReturnsFromNextRequestHandler(): void
29533011
{
29543012
$request = new ServerRequest('GET', 'http://example.com/');

0 commit comments

Comments
 (0)