diff --git a/docs/async/coroutines.md b/docs/async/coroutines.md
index 38acbb0..e69971d 100644
--- a/docs/async/coroutines.md
+++ b/docs/async/coroutines.md
@@ -1,9 +1,6 @@
# Coroutines
-> ⚠️ **Feature preview**
->
-> This is a feature preview, i.e. it might not have made it into the current beta.
-> Give feedback to help us prioritize.
+> ⚠️ **Documentation still under construction**
>
> You're seeing an early draft of the documentation that is still in the works.
> Give feedback to help us prioritize.
@@ -13,3 +10,134 @@
* X provides Generator-based coroutines
* Synchronous code structure, yet asynchronous execution
* Generators can be a bit harder to understand, see [Fibers](fibers.md) for future PHP 8.1 API.
+
+=== "Coroutines"
+
+ ```php
+ $app->get('/book/{id:\d+}', function (Psr\Http\Message\ServerRequestInterface $request) use ($db, $twig) {
+ $row = yield $db->query(
+ 'SELECT * FROM books WHERE ID=?',
+ [$request->getAttribute('id')]
+ );
+
+ $html = $twig->render('book.twig', $row);
+
+ return new React\Http\Message\Response(
+ 200,
+ [
+ 'Content-Type' => 'text/html; charset=utf-8'
+ ],
+ $html
+ );
+ });
+ ```
+
+=== "Synchronous (for comparison)"
+
+ ```php
+ $app->get('/book/{id:\d+}', function (Psr\Http\Message\ServerRequestInterface $request) use ($db, $twig) {
+ $row = $db->query(
+ 'SELECT * FROM books WHERE ID=?',
+ [$request->getAttribute('id')]
+ );
+
+ $html = $twig->render('book.twig', $row);
+
+ return new React\Http\Message\Response(
+ 200,
+ [
+ 'Content-Type' => 'text/html; charset=utf-8'
+ ],
+ $html
+ );
+ });
+ ```
+
+This example highlights how async PHP can look pretty much like a normal,
+synchronous code structure.
+The only difference is in how the `yield` statement can be used to *await* an
+async [promise](promises.md).
+In order for this to work, this example assumes an
+[async database](../integrations/database.md) that uses [promises](promises.md).
+
+## Coroutines vs. Promises?
+
+We're the first to admit that [promises](promises.md) can look more complicated,
+so why offer both?
+
+In fact, both styles exist for a reason.
+Promises are used to represent an eventual return value.
+Even when using coroutines, this does not change how the underlying APIs
+(such as a database) still have to return promises.
+
+If you want to *consume* a promise, you get to choose between the promise-based
+API and using coroutines:
+
+=== "Coroutines"
+
+ ```php
+ $app->get('/book/{id:\d+}', function (Psr\Http\Message\ServerRequestInterface $request) use ($db, $twig) {
+ $row = yield $db->query(
+ 'SELECT * FROM books WHERE ID=?',
+ [$request->getAttribute('id')]
+ );
+
+ $html = $twig->render('book.twig', $row);
+
+ return new React\Http\Message\Response(
+ 200,
+ [
+ 'Content-Type' => 'text/html; charset=utf-8'
+ ],
+ $html
+ );
+ });
+ ```
+
+=== "Promises (for comparison)"
+
+ ```php
+ $app->get('/book/{id:\d+}', function (Psr\Http\Message\ServerRequestInterface $request) use ($db, $twig) {
+ return $db->query(
+ 'SELECT * FROM books WHERE ID=?',
+ [$request->getAttribute('id')]
+ )->then(function (array $row) use ($twig) {
+ $html = $twig->render('book.twig', $row);
+
+ return new React\Http\Message\Response(
+ 200,
+ [
+ 'Content-Type' => 'text/html; charset=utf-8'
+ ],
+ $html
+ );
+ });
+ });
+ ```
+
+This example highlights how using coroutines in your controllers can look
+somewhat easier because coroutines hide some of the complexity of async APIs.
+X has a strong focus on simple APIs, so we also support coroutines.
+For this reason, some people may prefer the coroutine-style async execution
+model in their controllers.
+
+At the same time, it should be pointed out that coroutines build on top of
+promises.
+This means that having a good understanding of how async APIs using promises
+work can be somewhat beneficial.
+Indeed this means that code flow could even be harder to understand for some
+people, especially if you're already used to async execution models using
+promise-based APIs.
+
+**Which style is better?**
+We like choice.
+Feel free to use whatever style best works for you.
+
+> 🔮 **Future fiber support in PHP 8.1**
+>
+> In the future, PHP 8.1 will provide native support for [fibers](fibers.md).
+> Once fibers become mainstream, there would be little reason to use
+> Generator-based coroutines anymore.
+> While fibers will help to avoid using promises for many common use cases,
+> promises will still be useful for concurrent execution.
+> See [fibers](fibers.md) for more details.
diff --git a/docs/async/promises.md b/docs/async/promises.md
index c625d30..acbfbbf 100644
--- a/docs/async/promises.md
+++ b/docs/async/promises.md
@@ -9,6 +9,65 @@
* Avoid blocking ([databases](../integrations/database.md), [filesystem](../integrations/filesystem.md), etc.)
* Deferred execution
* Concurrent execution more efficient than [multithreading](child-processes.md)
-* API can be a bit harder (see [Coroutines](coroutines.md) or [Fibers](fibers.md))
-* See [reactphp/promise](https://reactphp.org/promise/)
* Avoid blocking by moving blocking implementation to [child process](child-processes.md)
+
+=== "Promise-based"
+
+ ```php
+ $app->get('/book/{id:\d+}', function (Psr\Http\Message\ServerRequestInterface $request) use ($db, $twig) {
+ return $db->query(
+ 'SELECT * FROM books WHERE ID=?',
+ [$request->getAttribute('id')]
+ )->then(function (array $row) use ($twig) {
+ $html = $twig->render('book.twig', $row);
+
+ return new React\Http\Message\Response(
+ 200,
+ [
+ 'Content-Type' => 'text/html; charset=utf-8'
+ ],
+ $html
+ );
+ });
+ });
+ ```
+
+=== "Synchronous (for comparision)"
+
+ ```php
+ $app->get('/book/{id:\d+}', function (Psr\Http\Message\ServerRequestInterface $request) use ($db, $twig) {
+ $row = $db->query(
+ 'SELECT * FROM books WHERE ID=?',
+ [$request->getAttribute('id')]
+ );
+
+ $html = $twig->render('book.twig', $row);
+
+ return new React\Http\Message\Response(
+ 200,
+ [
+ 'Content-Type' => 'text/html; charset=utf-8'
+ ],
+ $html
+ );
+ });
+ ```
+
+In this example, we assume an [async database](../integrations/database.md)
+adapter that returns a promise which *fulfills* with some data instead of
+directly returning data.
+
+The major feature is that this means that anything that takes some time will
+no longer block the entire execution.
+These non-blocking operations are especially benefitial for anything that incurs
+some kind of I/O, such as
+[database queries](../integrations/database.md), HTTP API requests,
+[filesystem access](../integrations/filesystem.md) and much more.
+If you want to learn more about the promise API, see also
+[reactphp/promise](https://reactphp.org/promise/).
+
+Admittedly, this example also showcases how async PHP can look slightly more
+complicated than a normal, synchronous code structure.
+Because we realize this API can be somewhat harder in some cases, we also
+support [coroutines](coroutines.md) (and in upcoming PHP 8.1 will also support
+[fibers](fibers.md)).
diff --git a/mkdocs.yml b/mkdocs.yml
index a7d595a..a078073 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -16,6 +16,7 @@ theme:
repo: fontawesome/brands/github
markdown_extensions:
+ - pymdownx.tabbed
- pymdownx.highlight
- pymdownx.superfences
- toc:
diff --git a/src/App.php b/src/App.php
index 3b97847..d9796db 100644
--- a/src/App.php
+++ b/src/App.php
@@ -12,6 +12,7 @@
use React\Http\Server as HttpServer;
use React\Http\Message\Response;
use React\Http\Message\ServerRequest;
+use React\Promise\Deferred;
use React\Promise\PromiseInterface;
use React\Socket\Server as SocketServer;
use React\Stream\ReadableStreamInterface;
@@ -336,6 +337,10 @@ private function handleRequest(ServerRequestInterface $request, Dispatcher $disp
return $this->errorHandlerException($e, $handler);
}
+ if ($response instanceof \Generator) {
+ $response = $this->coroutine($response, $handler);
+ }
+
if ($response instanceof ResponseInterface) {
return $response;
} elseif ($response instanceof PromiseInterface) {
@@ -357,6 +362,39 @@ private function handleRequest(ServerRequestInterface $request, Dispatcher $disp
}
} // @codeCoverageIgnore
+ private function coroutine(\Generator $generator, callable $handler): PromiseInterface
+ {
+ $next = null;
+ $deferred = new Deferred();
+ $next = function () use ($generator, &$next, $deferred, $handler) {
+ if (!$generator->valid()) {
+ $deferred->resolve($generator->getReturn());
+ return;
+ }
+
+ $step = $generator->current();
+ if (!$step instanceof PromiseInterface) {
+ $generator = $next = null;
+ $deferred->resolve($this->errorHandlerCoroutine($step, $handler));
+ return;
+ }
+
+ $step->then(function ($value) use ($generator, $next) {
+ $generator->send($value);
+ $next();
+ }, function ($reason) use ($generator, $next) {
+ $generator->throw($reason);
+ $next();
+ })->then(null, function ($e) use ($handler, $deferred) {
+ $deferred->reject($e);
+ });
+ };
+
+ $next();
+
+ return $deferred->promise();
+ }
+
private function logRequestResponse(ServerRequestInterface $request, ResponseInterface $response): void
{
// only log for built-in webserver and PHP development webserver, others have their own access log
@@ -449,6 +487,14 @@ private function errorHandlerResponse($value, callable $handler): ResponseInterf
);
}
+ private function errorHandlerCoroutine($value, callable $handler): ResponseInterface
+ {
+ return $this->error(
+ 500,
+ $this->describeHandler($handler, true) . ' expected coroutine to yield React\Promise\PromiseInterface but got ' . $this->describeType($value) . '
'
+ );
+ }
+
private function describeType($value): string
{
if ($value === null) {
diff --git a/tests/AppTest.php b/tests/AppTest.php
index 05a37fe..9804f25 100644
--- a/tests/AppTest.php
+++ b/tests/AppTest.php
@@ -15,6 +15,7 @@
use React\Promise\PromiseInterface;
use ReflectionMethod;
use ReflectionProperty;
+use React\Promise\Promise;
class AppTest extends TestCase
{
@@ -449,6 +450,159 @@ public function testHandleRequestWithDispatcherWithRouteFoundReturnsPromiseWhich
$this->assertEquals("OK\n", (string) $response->getBody());
}
+ public function testHandleRequestWithDispatcherWithRouteFoundReturnsPendingPromiseWhenHandlerReturnsPendingPromise()
+ {
+ $loop = $this->createMock(LoopInterface::class);
+ $app = new App($loop);
+
+ $request = new ServerRequest('GET', 'http://localhost/users');
+
+ $handler = function () {
+ return new Promise(function () { });
+ };
+
+ $dispatcher = $this->createMock(Dispatcher::class);
+ $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]);
+
+ // $promise = $app->handleRequest($request, $dispatcher);
+ $ref = new ReflectionMethod($app, 'handleRequest');
+ $ref->setAccessible(true);
+ $promise = $ref->invoke($app, $request, $dispatcher);
+
+ /** @var PromiseInterface $promise */
+ $this->assertInstanceOf(PromiseInterface::class, $promise);
+
+ $resolved = false;
+ $promise->then(function () use (&$resolved) {
+ $resolved = true;
+ }, function () use (&$resolved) {
+ $resolved = true;
+ });
+
+ $this->assertFalse($resolved);
+ }
+
+ public function testHandleRequestWithDispatcherWithRouteFoundReturnsPromiseWhichFulfillsWithResponseWhenHandlerReturnsCoroutineWhichReturnsResponseAfterYieldingResolvedPromise()
+ {
+ $loop = $this->createMock(LoopInterface::class);
+ $app = new App($loop);
+
+ $request = new ServerRequest('GET', 'http://localhost/users');
+
+ $handler = function () {
+ $body = yield \React\Promise\resolve("OK\n");
+
+ return new Response(
+ 200,
+ [
+ 'Content-Type' => 'text/html'
+ ],
+ $body
+ );
+ };
+
+ $dispatcher = $this->createMock(Dispatcher::class);
+ $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]);
+
+ // $promise = $app->handleRequest($request, $dispatcher);
+ $ref = new ReflectionMethod($app, 'handleRequest');
+ $ref->setAccessible(true);
+ $promise = $ref->invoke($app, $request, $dispatcher);
+
+ /** @var PromiseInterface $promise */
+ $this->assertInstanceOf(PromiseInterface::class, $promise);
+
+ $response = null;
+ $promise->then(function ($value) use (&$response) {
+ $response = $value;
+ });
+
+ /** @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 testHandleRequestWithDispatcherWithRouteFoundReturnsPromiseWhichFulfillsWithResponseWhenHandlerReturnsCoroutineWhichReturnsResponseAfterCatchingExceptionFromYieldingRejectedPromise()
+ {
+ $loop = $this->createMock(LoopInterface::class);
+ $app = new App($loop);
+
+ $request = new ServerRequest('GET', 'http://localhost/users');
+
+ $handler = function () {
+ $body = '';
+ try {
+ yield \React\Promise\reject(new \RuntimeException("OK\n"));
+ } catch (\RuntimeException $e) {
+ $body = $e->getMessage();
+ }
+
+ return new Response(
+ 200,
+ [
+ 'Content-Type' => 'text/html'
+ ],
+ $body
+ );
+ };
+
+ $dispatcher = $this->createMock(Dispatcher::class);
+ $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]);
+
+ // $promise = $app->handleRequest($request, $dispatcher);
+ $ref = new ReflectionMethod($app, 'handleRequest');
+ $ref->setAccessible(true);
+ $promise = $ref->invoke($app, $request, $dispatcher);
+
+ /** @var PromiseInterface $promise */
+ $this->assertInstanceOf(PromiseInterface::class, $promise);
+
+ $response = null;
+ $promise->then(function ($value) use (&$response) {
+ $response = $value;
+ });
+
+ /** @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 testHandleRequestWithDispatcherWithRouteFoundReturnsPendingPromiseWhenHandlerReturnsCoroutineThatYieldsPendingPromise()
+ {
+ $loop = $this->createMock(LoopInterface::class);
+ $app = new App($loop);
+
+ $request = new ServerRequest('GET', 'http://localhost/users');
+
+ $handler = function () {
+ yield new Promise(function () { });
+ };
+
+ $dispatcher = $this->createMock(Dispatcher::class);
+ $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]);
+
+ // $promise = $app->handleRequest($request, $dispatcher);
+ $ref = new ReflectionMethod($app, 'handleRequest');
+ $ref->setAccessible(true);
+ $promise = $ref->invoke($app, $request, $dispatcher);
+
+ /** @var PromiseInterface $promise */
+ $this->assertInstanceOf(PromiseInterface::class, $promise);
+
+ $resolved = false;
+ $promise->then(function () use (&$resolved) {
+ $resolved = true;
+ }, function () use (&$resolved) {
+ $resolved = true;
+ });
+
+ $this->assertFalse($resolved);
+ }
+
public function testHandleRequestWithDispatcherWithRouteFoundAndRouteVariablesReturnsResponseFromHandlerWithRouteVariablesAssignedAsRequestAttributes()
{
$loop = $this->createMock(LoopInterface::class);
@@ -554,7 +708,7 @@ public function testHandleRequestWithDispatcherWithRouteFoundReturnsPromiseWhich
$line = __LINE__ + 1;
$handler = function () {
- return \React\Promise\reject('');
+ return \React\Promise\reject(null);
};
$dispatcher = $this->createMock(Dispatcher::class);
@@ -580,6 +734,148 @@ public function testHandleRequestWithDispatcherWithRouteFoundReturnsPromiseWhich
$this->assertEquals("500 (Internal Server Error): Request handler (AppTest.php:$line
) returned invalid value (React\Promise\RejectedPromise
)\n", (string) $response->getBody());
}
+ public function testHandleRequestWithDispatcherWithRouteFoundReturnsPromiseWhichFulfillsWithInternalServerErrorResponseWhenHandlerReturnsCoroutineWhichYieldsRejectedPromise()
+ {
+ $loop = $this->createMock(LoopInterface::class);
+ $app = new App($loop);
+
+ $request = new ServerRequest('GET', 'http://localhost/users');
+
+ $line = __LINE__ + 2;
+ $handler = function () {
+ yield \React\Promise\reject(new \RuntimeException('Foo'));
+ };
+
+ $dispatcher = $this->createMock(Dispatcher::class);
+ $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]);
+
+ // $promise = $app->handleRequest($request, $dispatcher);
+ $ref = new ReflectionMethod($app, 'handleRequest');
+ $ref->setAccessible(true);
+ $promise = $ref->invoke($app, $request, $dispatcher);
+
+ /** @var PromiseInterface $promise */
+ $this->assertInstanceOf(PromiseInterface::class, $promise);
+
+ $response = null;
+ $promise->then(function ($value) use (&$response) {
+ $response = $value;
+ });
+
+ /** @var ResponseInterface $response */
+ $this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals(500, $response->getStatusCode());
+ $this->assertEquals('text/html', $response->getHeaderLine('Content-Type'));
+ $this->assertEquals("500 (Internal Server Error): Uncaught RuntimeException
from request handler (AppTest.php:$line
): Foo\n", (string) $response->getBody());
+ }
+
+ public function testHandleRequestWithDispatcherWithRouteFoundReturnsPromiseWhichFulfillsWithInternalServerErrorResponseWhenHandlerReturnsCoroutineWhichThrowsExceptionAfterYielding()
+ {
+ $loop = $this->createMock(LoopInterface::class);
+ $app = new App($loop);
+
+ $request = new ServerRequest('GET', 'http://localhost/users');
+
+ $line = __LINE__ + 3;
+ $handler = function () {
+ yield \React\Promise\resolve(null);
+ throw new \RuntimeException('Foo');
+ };
+
+ $dispatcher = $this->createMock(Dispatcher::class);
+ $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]);
+
+ // $promise = $app->handleRequest($request, $dispatcher);
+ $ref = new ReflectionMethod($app, 'handleRequest');
+ $ref->setAccessible(true);
+ $promise = $ref->invoke($app, $request, $dispatcher);
+
+ /** @var PromiseInterface $promise */
+ $this->assertInstanceOf(PromiseInterface::class, $promise);
+
+ $response = null;
+ $promise->then(function ($value) use (&$response) {
+ $response = $value;
+ });
+
+ /** @var ResponseInterface $response */
+ $this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals(500, $response->getStatusCode());
+ $this->assertEquals('text/html', $response->getHeaderLine('Content-Type'));
+ $this->assertEquals("500 (Internal Server Error): Uncaught RuntimeException
from request handler (AppTest.php:$line
): Foo\n", (string) $response->getBody());
+ }
+
+ public function testHandleRequestWithDispatcherWithRouteFoundReturnsPromiseWhichFulfillsWithInternalServerErrorResponseWhenHandlerReturnsCoroutineWhichReturnsNull()
+ {
+ $loop = $this->createMock(LoopInterface::class);
+ $app = new App($loop);
+
+ $request = new ServerRequest('GET', 'http://localhost/users');
+
+ $line = __LINE__ + 1;
+ $handler = function () {
+ $value = yield \React\Promise\resolve(null);
+ return $value;
+ };
+
+ $dispatcher = $this->createMock(Dispatcher::class);
+ $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]);
+
+ // $promise = $app->handleRequest($request, $dispatcher);
+ $ref = new ReflectionMethod($app, 'handleRequest');
+ $ref->setAccessible(true);
+ $promise = $ref->invoke($app, $request, $dispatcher);
+
+ /** @var PromiseInterface $promise */
+ $this->assertInstanceOf(PromiseInterface::class, $promise);
+
+ $response = null;
+ $promise->then(function ($value) use (&$response) {
+ $response = $value;
+ });
+
+ /** @var ResponseInterface $response */
+ $this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals(500, $response->getStatusCode());
+ $this->assertEquals('text/html', $response->getHeaderLine('Content-Type'));
+ $this->assertEquals("500 (Internal Server Error): Request handler (AppTest.php:$line
) returned invalid value (null
)\n", (string) $response->getBody());
+ }
+
+ public function testHandleRequestWithDispatcherWithRouteFoundReturnsPromiseWhichFulfillsWithInternalServerErrorResponseWhenHandlerReturnsCoroutineWhichYieldsNull()
+ {
+ $loop = $this->createMock(LoopInterface::class);
+ $app = new App($loop);
+
+ $request = new ServerRequest('GET', 'http://localhost/users');
+
+ $line = __LINE__ + 1;
+ $handler = function () {
+ yield null;
+ };
+
+ $dispatcher = $this->createMock(Dispatcher::class);
+ $dispatcher->expects($this->once())->method('dispatch')->with('GET', '/users')->willReturn([\FastRoute\Dispatcher::FOUND, $handler, []]);
+
+ // $promise = $app->handleRequest($request, $dispatcher);
+ $ref = new ReflectionMethod($app, 'handleRequest');
+ $ref->setAccessible(true);
+ $promise = $ref->invoke($app, $request, $dispatcher);
+
+ /** @var PromiseInterface $promise */
+ $this->assertInstanceOf(PromiseInterface::class, $promise);
+
+ $response = null;
+ $promise->then(function ($value) use (&$response) {
+ $response = $value;
+ });
+
+ /** @var ResponseInterface $response */
+ $this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals(500, $response->getStatusCode());
+ $this->assertEquals('text/html', $response->getHeaderLine('Content-Type'));
+ $this->assertEquals("500 (Internal Server Error): Request handler (AppTest.php:$line
) expected coroutine to yield React\Promise\PromiseInterface but got null
\n", (string) $response->getBody());
+ }
+
public function provideInvalidReturnValue()
{
return [