diff --git a/composer.json b/composer.json index c18e466..09420da 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,8 @@ "react/promise": "^2.7" }, "require-dev": { - "phpunit/phpunit": "^9.5 || ^7.5" + "phpunit/phpunit": "^9.5 || ^7.5", + "react/async": "^4@dev || ^3@dev" }, "autoload": { "psr-4": { diff --git a/examples/index.php b/examples/index.php index 5f87b49..b16a3b8 100644 --- a/examples/index.php +++ b/examples/index.php @@ -24,6 +24,22 @@ ); }); +$app->get('/sleep/promise', function () { + return React\Promise\Timer\sleep(0.1)->then(function () { + return React\Http\Message\Response::plaintext("OK\n"); + }); +}); +$app->get('/sleep/coroutine', function () { + yield React\Promise\Timer\sleep(0.1); + return React\Http\Message\Response::plaintext("OK\n"); +}); +if (PHP_VERSION_ID >= 80100 && function_exists('React\Async\async')) { // requires PHP 8.1+ with react/async 4+ + $app->get('/sleep/fiber', function () { + React\Async\await(React\Promise\Timer\sleep(0.1)); + return React\Http\Message\Response::plaintext("OK\n"); + }); +} + $app->get('/uri[/{path:.*}]', function (ServerRequestInterface $request) { return React\Http\Message\Response::plaintext( (string) $request->getUri() . "\n" diff --git a/src/App.php b/src/App.php index cf8ed4e..bb95ebc 100644 --- a/src/App.php +++ b/src/App.php @@ -52,7 +52,7 @@ public function __construct(...$middleware) } } - // new MiddlewareHandler([$accessLogHandler, $errorHandler, ...$middleware, $routeHandler]) + // new MiddlewareHandler([$fiberHandler, $accessLogHandler, $errorHandler, ...$middleware, $routeHandler]) \array_unshift($middleware, $errorHandler); // only log for built-in webserver and PHP development webserver by default, others have their own access log @@ -60,6 +60,11 @@ public function __construct(...$middleware) \array_unshift($middleware, new AccessLogHandler()); } + // automatically start new fiber for each request on PHP 8.1+ + if (\PHP_VERSION_ID >= 80100) { + \array_unshift($middleware, new FiberHandler()); // @codeCoverageIgnore + } + $this->router = new RouteHandler($container); $middleware[] = $this->router; $this->handler = new MiddlewareHandler($middleware); diff --git a/src/FiberHandler.php b/src/FiberHandler.php new file mode 100644 index 0000000..bc432dd --- /dev/null +++ b/src/FiberHandler.php @@ -0,0 +1,60 @@ +|\Generator + * Returns a `ResponseInterface` from the next request handler in the + * chain. If the next request handler returns immediately, this method + * will return immediately. If the next request handler suspends the + * fiber (see `await()`), this method will return a `PromiseInterface` + * that is fulfilled with a `ResponseInterface` when the fiber is + * terminated successfully. If the next request handler returns a + * promise, this method will return a promise that follows its + * resolution. If the next request handler returns a Generator-based + * coroutine, this method returns a `Generator`. This method never + * throws or resolves a rejected promise. If the handler fails, it will + * be turned into a valid error response before returning. + * @throws void + */ + public function __invoke(ServerRequestInterface $request, callable $next): mixed + { + $deferred = null; + $fiber = new \Fiber(function () use ($request, $next, &$deferred) { + $response = $next($request); + assert($response instanceof ResponseInterface || $response instanceof PromiseInterface || $response instanceof \Generator); + + if ($deferred !== null) { + $deferred->resolve($response); + } + + return $response; + }); + + $fiber->start(); + if ($fiber->isTerminated()) { + return $fiber->getReturn(); + } + + $deferred = new Deferred(); + return $deferred->promise(); + } +} diff --git a/tests/AppMiddlewareTest.php b/tests/AppMiddlewareTest.php index d891851..35fec4d 100644 --- a/tests/AppMiddlewareTest.php +++ b/tests/AppMiddlewareTest.php @@ -2,7 +2,9 @@ namespace FrameworkX\Tests; +use FrameworkX\AccessLogHandler; use FrameworkX\App; +use FrameworkX\FiberHandler; use FrameworkX\RouteHandler; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; @@ -788,8 +790,20 @@ private function createAppWithoutLogger(...$middleware): App $ref->setAccessible(true); $handlers = $ref->getValue($middleware); - unset($handlers[0]); - $ref->setValue($middleware, array_values($handlers)); + if (PHP_VERSION_ID >= 80100) { + $first = array_shift($handlers); + $this->assertInstanceOf(FiberHandler::class, $first); + + $next = array_shift($handlers); + $this->assertInstanceOf(AccessLogHandler::class, $next); + + array_unshift($handlers, $next, $first); + } + + $first = array_shift($handlers); + $this->assertInstanceOf(AccessLogHandler::class, $first); + + $ref->setValue($middleware, $handlers); return $app; } diff --git a/tests/AppTest.php b/tests/AppTest.php index 7733ff5..e527e02 100644 --- a/tests/AppTest.php +++ b/tests/AppTest.php @@ -6,6 +6,7 @@ use FrameworkX\App; use FrameworkX\Container; use FrameworkX\ErrorHandler; +use FrameworkX\FiberHandler; use FrameworkX\MiddlewareHandler; use FrameworkX\RouteHandler; use FrameworkX\SapiHandler; @@ -26,10 +27,12 @@ use React\EventLoop\Loop; use React\Http\Message\Response; use React\Http\Message\ServerRequest; +use React\Promise\Deferred; use React\Promise\Promise; use React\Promise\PromiseInterface; use ReflectionMethod; use ReflectionProperty; +use function React\Async\await; use function React\Promise\reject; use function React\Promise\resolve; @@ -72,6 +75,11 @@ public function testConstructWithMiddlewareAssignsGivenMiddleware() $ref->setAccessible(true); $handlers = $ref->getValue($handler); + if (PHP_VERSION_ID >= 80100) { + $first = array_shift($handlers); + $this->assertInstanceOf(FiberHandler::class, $first); + } + $this->assertCount(4, $handlers); $this->assertInstanceOf(AccessLogHandler::class, $handlers[0]); $this->assertInstanceOf(ErrorHandler::class, $handlers[1]); @@ -93,6 +101,11 @@ public function testConstructWithContainerAssignsContainerForRouteHandlerOnly() $ref->setAccessible(true); $handlers = $ref->getValue($handler); + if (PHP_VERSION_ID >= 80100) { + $first = array_shift($handlers); + $this->assertInstanceOf(FiberHandler::class, $first); + } + $this->assertCount(3, $handlers); $this->assertInstanceOf(AccessLogHandler::class, $handlers[0]); $this->assertInstanceOf(ErrorHandler::class, $handlers[1]); @@ -122,6 +135,11 @@ public function testConstructWithContainerAndMiddlewareClassNameAssignsCallableF $ref->setAccessible(true); $handlers = $ref->getValue($handler); + if (PHP_VERSION_ID >= 80100) { + $first = array_shift($handlers); + $this->assertInstanceOf(FiberHandler::class, $first); + } + $this->assertCount(4, $handlers); $this->assertInstanceOf(AccessLogHandler::class, $handlers[0]); $this->assertInstanceOf(ErrorHandler::class, $handlers[1]); @@ -820,6 +838,57 @@ public function testHandleRequestWithMatchingRouteReturnsPendingPromiseWhenHandl $this->assertFalse($resolved); } + public function testHandleRequestWithMatchingRouteReturnsPromiseResolvingWithResponseWhenHandlerReturnsResponseAfterAwaitingPromiseResolvingWithResponse() + { + if (PHP_VERSION_ID < 80100 || !function_exists('React\Async\async')) { + $this->markTestSkipped('Requires PHP 8.1+ with react/async 4+'); + } + + $app = $this->createAppWithoutLogger(); + + $deferred = new Deferred(); + + $app->get('/users', function () use ($deferred) { + return await($deferred->promise()); + }); + + $request = new ServerRequest('GET', 'http://localhost/users'); + + // $promise = $app->handleRequest($request); + $ref = new ReflectionMethod($app, 'handleRequest'); + $ref->setAccessible(true); + $promise = $ref->invoke($app, $request); + + /** @var PromiseInterface $promise */ + $this->assertInstanceOf(PromiseInterface::class, $promise); + + $response = null; + $promise->then(function ($value) use (&$response) { + $response = $value; + }); + + $this->assertNull($response); + + $deferred->resolve(new Response( + 200, + [ + 'Content-Type' => 'text/html' + ], + "OK\n" + )); + + // await next tick: https://github.com/reactphp/async/issues/27 + await(new Promise(function ($resolve) { + Loop::futureTick($resolve); + })); + + /** @var ResponseInterface $response */ + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); + $this->assertEquals("OK\n", (string) $response->getBody()); + } + public function testHandleRequestWithMatchingRouteAndRouteVariablesReturnsResponseFromHandlerWithRouteVariablesAssignedAsRequestAttributes() { $app = $this->createAppWithoutLogger(); @@ -1047,6 +1116,58 @@ public function testHandleRequestWithMatchingRouteReturnsPromiseWhichFulfillsWit $this->assertStringContainsString("
Expected request handler to return Psr\Http\Message\ResponseInterface but got uncaught RuntimeException with message Foo in AppTest.php:$line.
The requested page failed to load, please try again later.
\n", (string) $response->getBody()); + $this->assertStringContainsString("Expected request handler to return Psr\Http\Message\ResponseInterface but got uncaught RuntimeException with message Foo in AppTest.php:$line.
Unable to load error"
out=$(curl -v $base/error/null 2>&1); match "HTTP/.* 500" && match -iP "Content-Type: text/html; charset=utf-8[\r\n]"
+out=$(curl -v $base/sleep/promise 2>&1); match "HTTP/.* 200" && match -iP "Content-Type: text/plain; charset=utf-8[\r\n]"
+out=$(curl -v $base/sleep/coroutine 2>&1); match "HTTP/.* 200" && match -iP "Content-Type: text/plain; charset=utf-8[\r\n]"
+out=$(curl -v $base/sleep/fiber 2>&1); skipif "HTTP/.* 404" && match "HTTP/.* 200" && match -iP "Content-Type: text/plain; charset=utf-8[\r\n]" # skip PHP < 8.1
+
out=$(curl -v $base/uri 2>&1); match "HTTP/.* 200" && match "$base/uri"
out=$(curl -v $base/uri/ 2>&1); match "HTTP/.* 200" && match "$base/uri/"
out=$(curl -v $base/uri/foo 2>&1); match "HTTP/.* 200" && match "$base/uri/foo"