From 18927826e064cd01083b26cace1dc2efd171f35e Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Fri, 10 Mar 2017 11:12:58 +0100 Subject: [PATCH 1/4] Return Promise with PSR-7 Response resolving --- composer.json | 1 + examples/01-hello-world.php | 12 +- src/ChunkedEncoder.php | 107 ++++++ src/Response.php | 279 ++-------------- src/Server.php | 100 +++++- tests/ChunkedEncoderTest.php | 83 +++++ tests/ResponseTest.php | 632 +---------------------------------- tests/ServerTest.php | 555 ++++++++++++++++++++++++------ 8 files changed, 775 insertions(+), 994 deletions(-) create mode 100644 src/ChunkedEncoder.php create mode 100644 tests/ChunkedEncoderTest.php diff --git a/composer.json b/composer.json index 43608b90..a08eb15f 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "ringcentral/psr7": "^1.2", "react/socket": "^0.5", "react/stream": "^0.6 || ^0.5 || ^0.4.4", + "react/promise": "^2.0 || ^1.1", "evenement/evenement": "^2.0 || ^1.0" }, "autoload": { diff --git a/examples/01-hello-world.php b/examples/01-hello-world.php index 49c12664..12c36a56 100644 --- a/examples/01-hello-world.php +++ b/examples/01-hello-world.php @@ -10,9 +10,15 @@ $loop = Factory::create(); $socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); -$server = new \React\Http\Server($socket, function (RequestInterface $request, Response $response) { - $response->writeHead(200, array('Content-Type' => 'text/plain')); - $response->end("Hello world!\n"); +$server = new \React\Http\Server($socket, function (RequestInterface $request) { + return new Response( + 200, + array( + 'Content-Length' => strlen("Hello world\n"), + 'Content-Type' => 'text/plain' + ), + "Hello world\n" + ); }); echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; diff --git a/src/ChunkedEncoder.php b/src/ChunkedEncoder.php new file mode 100644 index 00000000..69d88ac7 --- /dev/null +++ b/src/ChunkedEncoder.php @@ -0,0 +1,107 @@ +input = $input; + + $this->input->on('data', array($this, 'handleData')); + $this->input->on('end', array($this, 'handleEnd')); + $this->input->on('error', array($this, 'handleError')); + $this->input->on('close', array($this, 'close')); + } + + public function isReadable() + { + return !$this->closed && $this->input->isReadable(); + } + + public function pause() + { + $this->input->pause(); + } + + public function resume() + { + $this->input->resume(); + } + + public function pipe(WritableStreamInterface $dest, array $options = array()) + { + Util::pipe($this, $dest, $options); + + return $dest; + } + + public function close() + { + if ($this->closed) { + return; + } + + $this->closed = true; + + $this->readable = false; + + $this->emit('close'); + + $this->removeAllListeners(); + } + + /** @internal */ + public function handleData($data) + { + if ($data === '') { + return; + } + + $completeChunk = $this->createChunk($data); + + $this->emit('data', array($completeChunk)); + } + + /** @internal */ + public function handleError(\Exception $e) + { + $this->emit('error', array($e)); + $this->close(); + } + + /** @internal */ + public function handleEnd() + { + $this->emit('data', array("0\r\n\r\n")); + + if (!$this->closed) { + $this->emit('end'); + $this->close(); + } + } + + /** + * @param string $data - string to be transformed in an valid + * HTTP encoded chunk string + * @return string + */ + private function createChunk($data) + { + $byteSize = strlen($data); + $byteSize = dechex($byteSize); + $chunkBeginning = $byteSize . "\r\n"; + + return $chunkBeginning . $data . "\r\n"; + } + +} diff --git a/src/Response.php b/src/Response.php index 5442f7a5..49aadf85 100644 --- a/src/Response.php +++ b/src/Response.php @@ -2,262 +2,35 @@ namespace React\Http; -use Evenement\EventEmitter; -use React\Stream\WritableStreamInterface; +use RingCentral\Psr7\Response as Psr7Response; +use React\Stream\ReadableStreamInterface; +use React\Http\HttpBodyStream; /** - * The `Response` class is responsible for streaming the outgoing response body. - * - * It implements the `WritableStreamInterface`. - * - * The constructor is internal, you SHOULD NOT call this yourself. - * The `Server` is responsible for emitting `Request` and `Response` objects. - * - * The `Response` will automatically use the same HTTP protocol version as the - * corresponding `Request`. - * - * HTTP/1.1 responses will automatically apply chunked transfer encoding if - * no `Content-Length` header has been set. - * See `writeHead()` for more details. - * - * See the usage examples and the class outline for details. - * - * @see WritableStreamInterface - * @see Server + * Implementation of the PSR-7 ResponseInterface + * This class is an extension of RingCentral\Psr7\Response. + * The only difference is that this class will accept implemenations + * of the ReactPHPs ReadableStreamInterface for $body. */ -class Response extends EventEmitter implements WritableStreamInterface +class Response extends Psr7Response { - private $conn; - private $protocolVersion; - - private $closed = false; - private $writable = true; - private $headWritten = false; - private $chunkedEncoding = false; - - /** - * The constructor is internal, you SHOULD NOT call this yourself. - * - * The `Server` is responsible for emitting `Request` and `Response` objects. - * - * Constructor parameters may change at any time. - * - * @internal - */ - public function __construct(WritableStreamInterface $conn, $protocolVersion = '1.1') - { - $this->conn = $conn; - $this->protocolVersion = $protocolVersion; - - $that = $this; - $this->conn->on('close', array($this, 'close')); - - $this->conn->on('error', function ($error) use ($that) { - $that->emit('error', array($error)); - }); - - $this->conn->on('drain', function () use ($that) { - $that->emit('drain'); - }); - } - - public function isWritable() - { - return $this->writable; - } - - /** - * Writes the given HTTP message header. - * - * This method MUST be invoked once before calling `write()` or `end()` to send - * the actual HTTP message body: - * - * ```php - * $response->writeHead(200, array( - * 'Content-Type' => 'text/plain' - * )); - * $response->end('Hello World!'); - * ``` - * - * Calling this method more than once will result in an `Exception` - * (unless the response has ended/closed already). - * Calling this method after the response has ended/closed is a NOOP. - * - * Unless you specify a `Content-Length` header yourself, HTTP/1.1 responses - * will automatically use chunked transfer encoding and send the respective header - * (`Transfer-Encoding: chunked`) automatically. If you know the length of your - * body, you MAY specify it like this instead: - * - * ```php - * $data = 'Hello World!'; - * - * $response->writeHead(200, array( - * 'Content-Type' => 'text/plain', - * 'Content-Length' => strlen($data) - * )); - * $response->end($data); - * ``` - * - * Note that it will automatically assume a `X-Powered-By: react/alpha` header - * unless your specify a custom `X-Powered-By` header yourself: - * - * ```php - * $response->writeHead(200, array( - * 'X-Powered-By' => 'PHP 3' - * )); - * ``` - * - * If you do not want to send this header at all, you can use an empty array as - * value like this: - * - * ```php - * $response->writeHead(200, array( - * 'X-Powered-By' => array() - * )); - * ``` - * - * Note that persistent connections (`Connection: keep-alive`) are currently - * not supported. - * As such, HTTP/1.1 response messages will automatically include a - * `Connection: close` header, irrespective of what header values are - * passed explicitly. - * - * @param int $status - * @param array $headers - * @throws \Exception - */ - public function writeHead($status = 200, array $headers = array()) - { - if (!$this->writable) { - return; - } - if ($this->headWritten) { - throw new \Exception('Response head has already been written.'); - } - - $lower = array_change_key_case($headers); - - // assign default "X-Powered-By" header as first for history reasons - if (!isset($lower['x-powered-by'])) { - $headers = array_merge( - array('X-Powered-By' => 'React/alpha'), - $headers - ); - } - - // always remove transfer-encoding - foreach($headers as $name => $value) { - if (strtolower($name) === 'transfer-encoding') { - unset($headers[$name]); - } - } - - // assign date header if no 'date' is given, use the current time where this code is running - if (!isset($lower['date'])) { - // IMF-fixdate = day-name "," SP date1 SP time-of-day SP GMT - $headers['Date'] = gmdate('D, d M Y H:i:s') . ' GMT'; - } - - // assign chunked transfer-encoding if no 'content-length' is given for HTTP/1.1 responses - if (!isset($lower['content-length']) && $this->protocolVersion === '1.1') { - $headers['Transfer-Encoding'] = 'chunked'; - $this->chunkedEncoding = true; - } - - // HTTP/1.1 assumes persistent connection support by default - // we do not support persistent connections, so let the client know - if ($this->protocolVersion === '1.1') { - foreach($headers as $name => $value) { - if (strtolower($name) === 'connection') { - unset($headers[$name]); - } - } - - $headers['Connection'] = 'close'; - } - - $data = $this->formatHead($status, $headers); - $this->conn->write($data); - - $this->headWritten = true; - } - - private function formatHead($status, array $headers) - { - $status = (int) $status; - $text = isset(ResponseCodes::$statusTexts[$status]) ? ResponseCodes::$statusTexts[$status] : ''; - $data = "HTTP/$this->protocolVersion $status $text\r\n"; - - foreach ($headers as $name => $value) { - $name = str_replace(array("\r", "\n"), '', $name); - - foreach ((array) $value as $val) { - $val = str_replace(array("\r", "\n"), '', $val); - - $data .= "$name: $val\r\n"; - } - } - $data .= "\r\n"; - - return $data; - } - - public function write($data) - { - if (!$this->writable) { - return false; - } - if (!$this->headWritten) { - throw new \Exception('Response head has not yet been written.'); - } - - // prefix with chunk length for chunked transfer encoding - if ($this->chunkedEncoding) { - $len = strlen($data); - - // skip empty chunks - if ($len === 0) { - return true; - } - - $data = dechex($len) . "\r\n" . $data . "\r\n"; - } - - return $this->conn->write($data); - } - - public function end($data = null) - { - if (!$this->writable) { - return; - } - if (!$this->headWritten) { - throw new \Exception('Response head has not yet been written.'); - } - - if (null !== $data) { - $this->write($data); - } - - if ($this->chunkedEncoding) { - $this->conn->write("0\r\n\r\n"); - } - - $this->writable = false; - $this->conn->end(); - } - - public function close() - { - if ($this->closed) { - return; - } - - $this->closed = true; - $this->writable = false; - $this->conn->close(); - - $this->emit('close'); - $this->removeAllListeners(); + public function __construct( + $status = 200, + array $headers = array(), + $body = null, + $version = '1.1', + $reason = null + ) { + if ($body instanceof ReadableStreamInterface) { + $body = new HttpBodyStream($body, null); + } + + parent::__construct( + $status, + $headers, + $body, + $version, + $reason + ); } } diff --git a/src/Server.php b/src/Server.php index 7387b8b6..84159a99 100644 --- a/src/Server.php +++ b/src/Server.php @@ -6,6 +6,10 @@ use React\Socket\ServerInterface as SocketServerInterface; use React\Socket\ConnectionInterface; use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use RingCentral; +use React\Stream\ReadableStream; +use React\Promise\Promise; /** * The `Server` class is responsible for handling incoming connections and then @@ -183,8 +187,6 @@ public function handleRequest(ConnectionInterface $conn, RequestInterface $reque } } - $response = new Response($conn, $request->getProtocolVersion()); - $contentLength = 0; $stream = new CloseProtectionStream($conn); if ($request->hasHeader('Transfer-Encoding')) { @@ -226,7 +228,22 @@ public function handleRequest(ConnectionInterface $conn, RequestInterface $reque ); $callback = $this->callback; - $callback($request, $response); + $promise = \React\Promise\resolve($callback($request)); + + $that = $this; + $promise->then( + function ($response) use ($that, $conn, $request) { + if (!$response instanceof ResponseInterface) { + $that->emit('error', array(new \InvalidArgumentException('Invalid response type'))); + return $that->writeError($conn, 500); + } + $that->handleResponse($conn, $response, $request->getProtocolVersion()); + }, + function ($ex) use ($that, $conn) { + $that->emit('error', array($ex)); + return $that->writeError($conn, 500); + } + ); if ($contentLength === 0) { // If Body is empty or Content-Length is 0 and won't emit further data, @@ -244,11 +261,76 @@ public function writeError(ConnectionInterface $conn, $code) $message .= ': ' . ResponseCodes::$statusTexts[$code]; } - $response = new Response($conn); - $response->writeHead($code, array( - 'Content-Length' => strlen($message), - 'Content-Type' => 'text/plain' - )); - $response->end($message); + $response = new Response( + $code, + array( + 'Content-Length' => strlen($message), + 'Content-Type' => 'text/plain' + ), + $message + ); + + $this->handleResponse($conn, $response, '1.1'); + } + + + /** @internal */ + public function handleResponse(ConnectionInterface $connection, ResponseInterface $response, $protocolVersion) + { + $response = $response->withProtocolVersion($protocolVersion); + + // assign default "X-Powered-By" header as first for history reasons + if (!$response->hasHeader('X-Powered-By')) { + $response = $response->withHeader('X-Powered-By', 'React/alpha'); + } + + if ($response->hasHeader('X-Powered-By') && $response->getHeaderLine('X-Powered-By') === ''){ + $response = $response->withoutHeader('X-Powered-By'); + } + + $response = $response->withoutHeader('Transfer-Encoding'); + + // assign date header if no 'date' is given, use the current time where this code is running + if (!$response->hasHeader('Date')) { + // IMF-fixdate = day-name "," SP date1 SP time-of-day SP GMT + $response = $response->withHeader('Date', gmdate('D, d M Y H:i:s') . ' GMT'); + } + + if ($response->hasHeader('Date') && $response->getHeaderLine('Date') === ''){ + $response = $response->withoutHeader('Date'); + } + + if (!$response->getBody() instanceof HttpBodyStream) { + $response = $response->withHeader('Content-Length', $response->getBody()->getSize()); + } elseif (!$response->hasHeader('Content-Length') && $protocolVersion === '1.1') { + // assign chunked transfer-encoding if no 'content-length' is given for HTTP/1.1 responses + $response = $response->withHeader('Transfer-Encoding', 'chunked'); + } + + // HTTP/1.1 assumes persistent connection support by default + // we do not support persistent connections, so let the client know + if ($protocolVersion === '1.1') { + $response = $response->withHeader('Connection', 'close'); + } + + $this->handleResponseBody($response, $connection); + } + + private function handleResponseBody(ResponseInterface $response, ConnectionInterface $connection) + { + if (!$response->getBody() instanceof HttpBodyStream) { + $connection->write(RingCentral\Psr7\str($response)); + return $connection->end(); + } + + $body = $response->getBody(); + $stream = $body; + + if ($response->getHeaderLine('Transfer-Encoding') === 'chunked') { + $stream = new ChunkedEncoder($body); + } + + $connection->write(RingCentral\Psr7\str($response)); + $stream->pipe($connection); } } diff --git a/tests/ChunkedEncoderTest.php b/tests/ChunkedEncoderTest.php new file mode 100644 index 00000000..ca8dc643 --- /dev/null +++ b/tests/ChunkedEncoderTest.php @@ -0,0 +1,83 @@ +input = new ReadableStream(); + $this->chunkedStream = new ChunkedEncoder($this->input); + } + + public function testChunked() + { + $this->chunkedStream->on('data', $this->expectCallableOnce(array("5\r\nhello\r\n"))); + $this->input->emit('data', array('hello')); + } + + public function testEmptyString() + { + $this->chunkedStream->on('data', $this->expectCallableNever()); + $this->input->emit('data', array('')); + } + + public function testBiggerStringToCheckHexValue() + { + $this->chunkedStream->on('data', $this->expectCallableOnce(array("1a\r\nabcdefghijklmnopqrstuvwxyz\r\n"))); + $this->input->emit('data', array('abcdefghijklmnopqrstuvwxyz')); + } + + public function testHandleClose() + { + $this->chunkedStream->on('close', $this->expectCallableOnce()); + + $this->input->close(); + + $this->assertFalse($this->chunkedStream->isReadable()); + } + + public function testHandleError() + { + $this->chunkedStream->on('error', $this->expectCallableOnce()); + $this->chunkedStream->on('close', $this->expectCallableOnce()); + + $this->input->emit('error', array(new \RuntimeException())); + + $this->assertFalse($this->chunkedStream->isReadable()); + } + + public function testPauseStream() + { + $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input->expects($this->once())->method('pause'); + + $parser = new ChunkedEncoder($input); + $parser->pause(); + } + + public function testResumeStream() + { + $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input->expects($this->once())->method('pause'); + + $parser = new ChunkedEncoder($input); + $parser->pause(); + $parser->resume(); + } + + public function testPipeStream() + { + $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + + $ret = $this->chunkedStream->pipe($dest); + + $this->assertSame($dest, $ret); + } +} diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 65fc0598..4d024956 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -3,637 +3,19 @@ namespace React\Tests\Http; use React\Http\Response; -use React\Stream\WritableStream; +use React\Stream\ReadableStream; class ResponseTest extends TestCase { - public function testResponseShouldBeChunkedByDefault() + public function testResponseBodyWillBeHttpBodyStream() { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "Transfer-Encoding: chunked\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array('Date' => array())); - } - - public function testResponseShouldNotBeChunkedWhenProtocolVersionIsNot11() - { - $expected = ''; - $expected .= "HTTP/1.0 200 OK\r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn, '1.0'); - $response->writeHead(200, array('Date' => array())); - } - - public function testResponseShouldBeChunkedEvenWithOtherTransferEncoding() - { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "Transfer-Encoding: chunked\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array('transfer-encoding' => 'custom', 'Date' => array())); - } - - public function testResponseShouldNotBeChunkedWithContentLength() - { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "Content-Length: 22\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array('Content-Length' => 22, 'Date' => array())); - } - - public function testResponseShouldNotBeChunkedWithContentLengthCaseInsensitive() - { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "CONTENT-LENGTH: 0\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array('CONTENT-LENGTH' => 0, 'Date' => array())); - } - - public function testResponseShouldIncludeCustomByPoweredAsFirstHeaderIfGivenExplicitly() - { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "Content-Length: 0\r\n"; - $expected .= "X-POWERED-BY: demo\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array('Content-Length' => 0, 'X-POWERED-BY' => 'demo', 'Date' => array())); - } - - public function testResponseShouldNotIncludePoweredByIfGivenEmptyArray() - { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "Content-Length: 0\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array('Content-Length' => 0, 'X-Powered-By' => array(), 'Date' => array())); - } - - public function testResponseShouldAlwaysIncludeConnectionCloseIrrespectiveOfExplicitValue() - { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "Content-Length: 0\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array('Content-Length' => 0, 'connection' => 'ignored', 'Date' => array())); - } - - /** @expectedException Exception */ - public function testWriteHeadTwiceShouldThrowException() - { - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write'); - - $response = new Response($conn); - $response->writeHead(); - $response->writeHead(); - } - - public function testEndWithoutDataWritesEndChunkAndEndsInput() - { - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->at(4)) - ->method('write') - ->with("0\r\n\r\n"); - $conn - ->expects($this->once()) - ->method('end'); - - $response = new Response($conn); - $response->writeHead(); - $response->end(); - } - - public function testEndWithDataWritesToInputAndEndsInputWithoutData() - { - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->at(4)) - ->method('write') - ->with("3\r\nbye\r\n"); - $conn - ->expects($this->at(5)) - ->method('write') - ->with("0\r\n\r\n"); - $conn - ->expects($this->once()) - ->method('end'); - - $response = new Response($conn); - $response->writeHead(); - $response->end('bye'); - } - - public function testEndWithoutDataWithoutChunkedEncodingWritesNoDataAndEndsInput() - { - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write'); - $conn - ->expects($this->once()) - ->method('end'); - - $response = new Response($conn); - $response->writeHead(200, array('Content-Length' => 0)); - $response->end(); - } - - /** @expectedException Exception */ - public function testEndWithoutHeadShouldThrowException() - { - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->never()) - ->method('end'); - - $response = new Response($conn); - $response->end(); - } - - /** @expectedException Exception */ - public function testWriteWithoutHeadShouldThrowException() - { - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->never()) - ->method('write'); - - $response = new Response($conn); - $response->write('test'); - } - - public function testResponseBodyShouldBeChunkedCorrectly() - { - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->at(4)) - ->method('write') - ->with("5\r\nHello\r\n"); - $conn - ->expects($this->at(5)) - ->method('write') - ->with("1\r\n \r\n"); - $conn - ->expects($this->at(6)) - ->method('write') - ->with("6\r\nWorld\n\r\n"); - $conn - ->expects($this->at(7)) - ->method('write') - ->with("0\r\n\r\n"); - - $response = new Response($conn); - $response->writeHead(); - - $response->write('Hello'); - $response->write(' '); - $response->write("World\n"); - $response->end(); - } - - public function testResponseBodyShouldSkipEmptyChunks() - { - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->at(4)) - ->method('write') - ->with("5\r\nHello\r\n"); - $conn - ->expects($this->at(5)) - ->method('write') - ->with("5\r\nWorld\r\n"); - $conn - ->expects($this->at(6)) - ->method('write') - ->with("0\r\n\r\n"); - - $response = new Response($conn); - $response->writeHead(); - - $response->write('Hello'); - $response->write(''); - $response->write('World'); - $response->end(); - } - - /** @test */ - public function shouldRemoveNewlinesFromHeaders() - { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "FooBar: BazQux\r\n"; - $expected .= "Transfer-Encoding: chunked\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array("Foo\nBar" => "Baz\rQux", 'Date' => array())); - } - - /** @test */ - public function missingStatusCodeTextShouldResultInNumberOnlyStatus() - { - $expected = ''; - $expected .= "HTTP/1.1 700 \r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "Transfer-Encoding: chunked\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(700, array('Date' => array())); + $response = new Response(200, array(), new ReadableStream()); + $this->assertInstanceOf('React\Http\HttpBodyStream', $response->getBody()); } - /** @test */ - public function shouldAllowArrayHeaderValues() + public function testStringBodyWillBePsr7Stream() { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "Set-Cookie: foo=bar\r\n"; - $expected .= "Set-Cookie: bar=baz\r\n"; - $expected .= "Transfer-Encoding: chunked\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array("Set-Cookie" => array("foo=bar", "bar=baz"), 'Date' => array())); - } - - /** @test */ - public function shouldIgnoreHeadersWithNullValues() - { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "Transfer-Encoding: chunked\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array("FooBar" => null, 'Date' => array())); - } - - public function testCloseClosesInputAndEmitsCloseEvent() - { - $input = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); - $input->expects($this->once())->method('close'); - - $response = new Response($input); - - $response->on('close', $this->expectCallableOnce()); - - $response->close(); - } - - public function testClosingInputEmitsCloseEvent() - { - $input = new WritableStream(); - $response = new Response($input); - - $response->on('close', $this->expectCallableOnce()); - - $input->close(); - } - - public function testCloseMultipleTimesEmitsCloseEventOnce() - { - $input = new WritableStream(); - $response = new Response($input); - - $response->on('close', $this->expectCallableOnce()); - - $response->close(); - $response->close(); - } - - public function testIsNotWritableAfterClose() - { - $input = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); - - $response = new Response($input); - - $response->close(); - - $this->assertFalse($response->isWritable()); - } - - public function testCloseAfterEndIsPassedThrough() - { - $input = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); - $input->expects($this->once())->method('end'); - $input->expects($this->once())->method('close'); - - $response = new Response($input); - - $response->writeHead(); - $response->end(); - $response->close(); - } - - public function testWriteAfterCloseIsNoOp() - { - $input = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); - $input->expects($this->once())->method('close'); - $input->expects($this->never())->method('write'); - - $response = new Response($input); - $response->close(); - - $this->assertFalse($response->write('noop')); - } - - public function testWriteHeadAfterCloseIsNoOp() - { - $input = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); - $input->expects($this->once())->method('close'); - $input->expects($this->never())->method('write'); - - $response = new Response($input); - $response->close(); - - $response->writeHead(); - } - - public function testEndAfterCloseIsNoOp() - { - $input = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); - $input->expects($this->once())->method('close'); - $input->expects($this->never())->method('write'); - $input->expects($this->never())->method('end'); - - $response = new Response($input); - $response->close(); - - $response->end('noop'); - } - - public function testErrorEventShouldBeForwardedWithoutClosing() - { - $input = new WritableStream(); - $response = new Response($input); - - $response->on('error', $this->expectCallableOnce()); - $response->on('close', $this->expectCallableNever()); - - $input->emit('error', array(new \RuntimeException())); - } - - public function testDrainEventShouldBeForwarded() - { - $input = new WritableStream(); - $response = new Response($input); - - $response->on('drain', $this->expectCallableOnce()); - - $input->emit('drain'); - } - - public function testContentLengthWillBeRemovedIfTransferEncodingIsGiven() - { - $expectedHeader = ''; - $expectedHeader .= "HTTP/1.1 200 OK\r\n"; - $expectedHeader .= "X-Powered-By: React/alpha\r\n"; - $expectedHeader .= "Content-Length: 4\r\n"; - $expectedHeader .= "Connection: close\r\n"; - $expectedHeader .= "\r\n"; - - $expectedBody = "hello"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->exactly(2)) - ->method('write') - ->withConsecutive( - array($expectedHeader), - array($expectedBody) - ); - - $response = new Response($conn, '1.1'); - $response->writeHead( - 200, - array( - 'Content-Length' => 4, - 'Transfer-Encoding' => 'chunked', - 'Date' => array() - ) - ); - $response->write('hello'); - } - - public function testDateHeaderWillUseServerTime() - { - $buffer = ''; - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->will( - $this->returnCallback( - function ($data) use (&$buffer) { - $buffer .= $data; - } - ) - ); - - $response = new Response($conn); - $response->writeHead(); - - $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); - $this->assertContains("Date:", $buffer); - } - - public function testDateHeaderWithCustomDate() - { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "Date: Tue, 15 Nov 1994 08:12:31 GMT\r\n"; - $expected .= "Transfer-Encoding: chunked\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array("Date" => "Tue, 15 Nov 1994 08:12:31 GMT")); - } - - public function testDateHeaderWillBeRemoved() - { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "Transfer-Encoding: chunked\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array("Date" => array())); + $response = new Response(200, array(), 'hello'); + $this->assertInstanceOf('RingCentral\Psr7\Stream', $response->getBody()); } } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 40434f18..d9917375 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -3,8 +3,10 @@ namespace React\Tests\Http; use React\Http\Server; -use React\Http\Response; use Psr\Http\Message\RequestInterface; +use React\Http\Response; +use React\Stream\ReadableStream; +use React\Promise\Promise; class ServerTest extends TestCase { @@ -31,6 +33,9 @@ public function setUp() ) ->getMock(); + $this->connection->method('isWritable')->willReturn(true); + $this->connection->method('isReadable')->willReturn(true); + $this->socket = new SocketServerStub(); } @@ -47,7 +52,9 @@ public function testRequestEventWillNotBeEmittedForIncompleteHeaders() public function testRequestEventIsEmitted() { - $server = new Server($this->socket, $this->expectCallableOnce()); + $server = new Server($this->socket, function (RequestInterface $request) { + return \React\Promise\resolve(new Response()); + }); $this->socket->emit('connection', array($this->connection)); @@ -59,12 +66,24 @@ public function testRequestEvent() { $i = 0; $requestAssertion = null; - $responseAssertion = null; - $server = new Server($this->socket, function ($request, $response) use (&$i, &$requestAssertion, &$responseAssertion) { + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $server = new Server($this->socket, function ($request) use (&$i, &$requestAssertion) { $i++; $requestAssertion = $request; - $responseAssertion = $response; + return \React\Promise\resolve(new Response()); }); $this->connection @@ -83,13 +102,13 @@ public function testRequestEvent() $this->assertSame('GET', $requestAssertion->getMethod()); $this->assertSame('127.0.0.1', $requestAssertion->remoteAddress); - $this->assertInstanceOf('React\Http\Response', $responseAssertion); } public function testRequestPauseWillbeForwardedToConnection() { $server = new Server($this->socket, function (RequestInterface $request) { $request->getBody()->pause(); + return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->once())->method('pause'); @@ -108,6 +127,7 @@ public function testRequestResumeWillbeForwardedToConnection() { $server = new Server($this->socket, function (RequestInterface $request) { $request->getBody()->resume(); + return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->once())->method('resume'); @@ -121,6 +141,7 @@ public function testRequestCloseWillPauseConnection() { $server = new Server($this->socket, function (RequestInterface $request) { $request->getBody()->close(); + return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->once())->method('pause'); @@ -134,7 +155,9 @@ public function testRequestPauseAfterCloseWillNotBeForwarded() { $server = new Server($this->socket, function (RequestInterface $request) { $request->getBody()->close(); - $request->getBody()->pause(); + $request->getBody()->pause();# + + return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->once())->method('pause'); @@ -149,6 +172,8 @@ public function testRequestResumeAfterCloseWillNotBeForwarded() $server = new Server($this->socket, function (RequestInterface $request) { $request->getBody()->close(); $request->getBody()->resume(); + + return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->once())->method('pause'); @@ -165,6 +190,8 @@ public function testRequestEventWithoutBodyWillNotEmitData() $server = new Server($this->socket, function (RequestInterface $request) use ($never) { $request->getBody()->on('data', $never); + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -179,6 +206,8 @@ public function testRequestEventWithSecondDataEventWillEmitBodyData() $server = new Server($this->socket, function (RequestInterface $request) use ($once) { $request->getBody()->on('data', $once); + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -198,6 +227,8 @@ public function testRequestEventWithPartialBodyWillEmitData() $server = new Server($this->socket, function (RequestInterface $request) use ($once) { $request->getBody()->on('data', $once); + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -216,9 +247,8 @@ public function testRequestEventWithPartialBodyWillEmitData() public function testResponseContainsPoweredByHeader() { - $server = new Server($this->socket, function (RequestInterface $request, Response $response) { - $response->writeHead(); - $response->end(); + $server = new Server($this->socket, function (RequestInterface $request) { + return \React\Promise\resolve(new Response()); }); $buffer = ''; @@ -242,27 +272,11 @@ function ($data) use (&$buffer) { $this->assertContains("\r\nX-Powered-By: React/alpha\r\n", $buffer); } - public function testClosingResponseDoesNotSendAnyData() - { - $server = new Server($this->socket, function (RequestInterface $request, Response $response) { - $response->close(); - }); - - $this->connection->expects($this->never())->method('write'); - $this->connection->expects($this->never())->method('end'); - $this->connection->expects($this->once())->method('close'); - - $this->socket->emit('connection', array($this->connection)); - - $data = $this->createGetRequest(); - $this->connection->emit('data', array($data)); - } - public function testResponseContainsSameRequestProtocolVersionAndChunkedBodyForHttp11() { - $server = new Server($this->socket, function (RequestInterface $request, Response $response) { - $response->writeHead(); - $response->end('bye'); + $server = new Server($this->socket, function (RequestInterface $request) { + $response = new Response(200, array(), 'bye'); + return \React\Promise\resolve($response); }); $buffer = ''; @@ -284,14 +298,14 @@ function ($data) use (&$buffer) { $this->connection->emit('data', array($data)); $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); - $this->assertContains("\r\n\r\n3\r\nbye\r\n0\r\n\r\n", $buffer); + $this->assertContains("bye", $buffer); } public function testResponseContainsSameRequestProtocolVersionAndRawBodyForHttp10() { - $server = new Server($this->socket, function (RequestInterface $request, Response $response) { - $response->writeHead(); - $response->end('bye'); + $server = new Server($this->socket, function (RequestInterface $request) { + $response = new Response(200, array(), 'bye'); + return \React\Promise\resolve($response); }); $buffer = ''; @@ -313,7 +327,8 @@ function ($data) use (&$buffer) { $this->connection->emit('data', array($data)); $this->assertContains("HTTP/1.0 200 OK\r\n", $buffer); - $this->assertContains("\r\n\r\nbye", $buffer); + $this->assertContains("\r\n\r\n", $buffer); + $this->assertContains("bye", $buffer); } public function testRequestInvalidHttpProtocolVersionWillEmitErrorAndSendErrorResponse() @@ -344,8 +359,9 @@ function ($data) use (&$buffer) { $this->assertInstanceOf('InvalidArgumentException', $error); - $this->assertContains("HTTP/1.1 505 HTTP Version Not Supported\r\n", $buffer); - $this->assertContains("\r\n\r\nError 505: HTTP Version Not Supported", $buffer); + $this->assertContains("HTTP/1.1 505 HTTP Version not supported\r\n", $buffer); + $this->assertContains("\r\n\r\n", $buffer); + $this->assertContains("Error 505: HTTP Version Not Supported", $buffer); } public function testRequestOverflowWillEmitErrorAndSendErrorResponse() @@ -420,11 +436,13 @@ public function testBodyDataWillBeSendViaRequestEvent() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -447,12 +465,14 @@ public function testChunkedEncodedRequestWillBeParsedForRequestEvent() $errorEvent = $this->expectCallableNever(); $requestValidation = null; - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); $requestValidation = $request; + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -477,11 +497,13 @@ public function testChunkedEncodedRequestAdditionalDataWontBeEmitted() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); @@ -506,11 +528,13 @@ public function testEmptyChunkedEncodedRequest() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -532,11 +556,13 @@ public function testChunkedIsUpperCase() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); @@ -560,11 +586,13 @@ public function testChunkedIsMixedUpperAndLowerCase() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); @@ -678,7 +706,9 @@ function ($data) use (&$buffer) { public function testRequestHttp10WithoutHostEmitsRequestWithNoError() { - $server = new Server($this->socket, $this->expectCallableOnce()); + $server = new Server($this->socket, function (RequestInterface $request) { + return \React\Promise\resolve(new Response()); + }); $server->on('error', $this->expectCallableNever()); $this->socket->emit('connection', array($this->connection)); @@ -694,11 +724,13 @@ public function testWontEmitFurtherDataWhenContentLengthIsReached() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -722,11 +754,13 @@ public function testWontEmitFurtherDataWhenContentLengthIsReachedSplitted() $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); @@ -754,11 +788,13 @@ public function testContentLengthContainsZeroWillEmitEndEvent() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -779,11 +815,13 @@ public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillB $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -805,11 +843,13 @@ public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillB $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -835,12 +875,14 @@ public function testContentLengthWillBeIgnoredIfTransferEncodingIsSet() $errorEvent = $this->expectCallableNever(); $requestValidation = null; - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); $requestValidation = $request; + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -871,12 +913,14 @@ public function testInvalidContentLengthWillBeIgnoreddIfTransferEncodingIsSet() $errorEvent = $this->expectCallableNever(); $requestValidation = null; - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); $requestValidation = $request; + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -975,8 +1019,9 @@ function ($data) use (&$buffer) { public function testInvalidChunkHeaderResultsInErrorOnRequestStream() { $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); - $server = new Server($this->socket, function ($request, $response) use ($errorEvent){ + $server = new Server($this->socket, function ($request) use ($errorEvent){ $request->getBody()->on('error', $errorEvent); + return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->never())->method('close'); @@ -997,8 +1042,9 @@ public function testInvalidChunkHeaderResultsInErrorOnRequestStream() public function testTooLongChunkHeaderResultsInErrorOnRequestStream() { $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); - $server = new Server($this->socket, function ($request, $response) use ($errorEvent){ + $server = new Server($this->socket, function ($request) use ($errorEvent){ $request->getBody()->on('error', $errorEvent); + return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->never())->method('close'); @@ -1021,8 +1067,9 @@ public function testTooLongChunkHeaderResultsInErrorOnRequestStream() public function testTooLongChunkBodyResultsInErrorOnRequestStream() { $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); - $server = new Server($this->socket, function ($request, $response) use ($errorEvent){ + $server = new Server($this->socket, function ($request) use ($errorEvent){ $request->getBody()->on('error', $errorEvent); + return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->never())->method('close'); @@ -1043,8 +1090,9 @@ public function testTooLongChunkBodyResultsInErrorOnRequestStream() public function testUnexpectedEndOfConnectionWillResultsInErrorOnRequestStream() { $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); - $server = new Server($this->socket, function ($request, $response) use ($errorEvent){ + $server = new Server($this->socket, function ($request) use ($errorEvent){ $request->getBody()->on('error', $errorEvent); + return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->never())->method('close'); @@ -1065,7 +1113,9 @@ public function testUnexpectedEndOfConnectionWillResultsInErrorOnRequestStream() public function testErrorInChunkedDecoderNeverClosesConnection() { - $server = new Server($this->socket, $this->expectCallableOnce()); + $server = new Server($this->socket, function (RequestInterface $request) { + return \React\Promise\resolve(new Response()); + }); $this->connection->expects($this->never())->method('close'); $this->connection->expects($this->once())->method('pause'); @@ -1084,7 +1134,9 @@ public function testErrorInChunkedDecoderNeverClosesConnection() public function testErrorInLengthLimitedStreamNeverClosesConnection() { - $server = new Server($this->socket, $this->expectCallableOnce()); + $server = new Server($this->socket, function (RequestInterface $request) { + return \React\Promise\resolve(new Response()); + }); $this->connection->expects($this->never())->method('close'); $this->connection->expects($this->once())->method('pause'); @@ -1104,8 +1156,9 @@ public function testErrorInLengthLimitedStreamNeverClosesConnection() public function testCloseRequestWillPauseConnection() { - $server = new Server($this->socket, function ($request, $response) { + $server = new Server($this->socket, function ($request) { $request->getBody()->close(); + return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->never())->method('close'); @@ -1124,11 +1177,13 @@ public function testEndEventWillBeEmittedOnSimpleRequest() $endEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function ($request, $response) use ($dataEvent, $closeEvent, $endEvent, $errorEvent){ + $server = new Server($this->socket, function ($request) use ($dataEvent, $closeEvent, $endEvent, $errorEvent){ $request->getBody()->on('data', $dataEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->once())->method('pause'); @@ -1148,11 +1203,13 @@ public function testRequestWithoutDefinedLengthWillIgnoreDataEvent() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -1165,17 +1222,22 @@ public function testRequestWithoutDefinedLengthWillIgnoreDataEvent() public function testResponseWillBeChunkDecodedByDefault() { - $server = new Server($this->socket, function (RequestInterface $request, Response $response) { - $response->writeHead(); - $response->write('hello'); + $stream = new ReadableStream(); + $server = new Server($this->socket, function (RequestInterface $request) use ($stream) { + $response = new Response(200, array(), $stream); + return \React\Promise\resolve($response); }); + $buffer = ''; $this->connection - ->expects($this->exactly(2)) + ->expects($this->any()) ->method('write') - ->withConsecutive( - array($this->anything()), - array("5\r\nhello\r\n") + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) ); $this->socket->emit('connection', array($this->connection)); @@ -1183,25 +1245,30 @@ public function testResponseWillBeChunkDecodedByDefault() $data = $this->createGetRequest(); $this->connection->emit('data', array($data)); + $stream->emit('data', array('hello')); + + $this->assertContains("Transfer-Encoding: chunked", $buffer); + $this->assertContains("hello", $buffer); } public function testContentLengthWillBeRemovedForResponseStream() { - $server = new Server($this->socket, function (RequestInterface $request, Response $response) { - $response->writeHead( + $server = new Server($this->socket, function (RequestInterface $request) { + $response = new Response( 200, array( - 'Content-Length' => 4, + 'Content-Length' => 5, 'Transfer-Encoding' => 'chunked' - ) + ), + 'hello' ); - $response->write('hello'); + return \React\Promise\resolve($response); }); $buffer = ''; $this->connection - ->expects($this->exactly(2)) + ->expects($this->any()) ->method('write') ->will( $this->returnCallback( @@ -1218,40 +1285,43 @@ function ($data) use (&$buffer) { $this->connection->emit('data', array($data)); $this->assertNotContains("Transfer-Encoding: chunked", $buffer); - $this->assertContains("Content-Length: 4", $buffer); + $this->assertContains("Content-Length: 5", $buffer); $this->assertContains("hello", $buffer); } public function testOnlyAllowChunkedEncoding() { - $server = new Server($this->socket, function (RequestInterface $request, Response $response) { - $response->writeHead( + $stream = new ReadableStream(); + $server = new Server($this->socket, function (RequestInterface $request) use ($stream) { + $response = new Response( 200, array( 'Transfer-Encoding' => 'custom' - ) + ), + $stream ); - $response->write('hello'); + return \React\Promise\resolve($response); }); $buffer = ''; $this->connection - ->expects($this->exactly(2)) - ->method('write') - ->will( - $this->returnCallback( - function ($data) use (&$buffer) { - $buffer .= $data; - } - ) - ); + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); $this->connection->emit('data', array($data)); + $stream->emit('data', array('hello')); $this->assertContains('Transfer-Encoding: chunked', $buffer); $this->assertNotContains('Transfer-Encoding: custom', $buffer); @@ -1260,13 +1330,13 @@ function ($data) use (&$buffer) { public function testDateHeaderWillBeAddedWhenNoneIsGiven() { - $server = new Server($this->socket, function (RequestInterface $request, Response $response) { - $response->writeHead(200); + $server = new Server($this->socket, function (RequestInterface $request) { + return \React\Promise\resolve(new Response()); }); $buffer = ''; $this->connection - ->expects($this->once()) + ->expects($this->any()) ->method('write') ->will( $this->returnCallback( @@ -1289,13 +1359,14 @@ function ($data) use (&$buffer) { public function testAddCustomDateHeader() { - $server = new Server($this->socket, function (RequestInterface $request, Response $response) { - $response->writeHead(200, array("Date" => "Tue, 15 Nov 1994 08:12:31 GMT")); + $server = new Server($this->socket, function (RequestInterface $request) { + $response = new Response(200, array("Date" => "Tue, 15 Nov 1994 08:12:31 GMT")); + return \React\Promise\resolve($response); }); $buffer = ''; $this->connection - ->expects($this->once()) + ->expects($this->any()) ->method('write') ->will( $this->returnCallback( @@ -1318,13 +1389,14 @@ function ($data) use (&$buffer) { public function testRemoveDateHeader() { - $server = new Server($this->socket, function (RequestInterface $request, Response $response) { - $response->writeHead(200, array('Date' => array())); + $server = new Server($this->socket, function (RequestInterface $request) { + $response = new Response(200, array('Date' => '')); + return \React\Promise\resolve($response); }); $buffer = ''; $this->connection - ->expects($this->once()) + ->expects($this->any()) ->method('write') ->will( $this->returnCallback( @@ -1382,12 +1454,21 @@ function ($data) use (&$buffer) { public function test100ContinueRequestWillBeHandled() { - $server = new Server($this->socket, $this->expectCallableOnce()); + $server = new Server($this->socket, function (RequestInterface $request) { + return \React\Promise\resolve(new Response()); + }); + $buffer = ''; $this->connection - ->expects($this->once()) + ->expects($this->any()) ->method('write') - ->with("HTTP/1.1 100 Continue\r\n\r\n"); + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); $this->socket->emit('connection', array($this->connection)); @@ -1398,15 +1479,27 @@ public function test100ContinueRequestWillBeHandled() $data .= "\r\n"; $this->connection->emit('data', array($data)); + $this->assertContains("HTTP/1.1 100 Continue\r\n", $buffer); + $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); } public function testContinueWontBeSendForHttp10() { - $server = new Server($this->socket, $this->expectCallableOnce()); + $server = new Server($this->socket, function (RequestInterface $request) { + return \React\Promise\resolve(new Response()); + }); + $buffer = ''; $this->connection - ->expects($this->never()) - ->method('write'); + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); $this->socket->emit('connection', array($this->connection)); @@ -1415,13 +1508,14 @@ public function testContinueWontBeSendForHttp10() $data .= "\r\n"; $this->connection->emit('data', array($data)); + $this->assertContains("HTTP/1.0 200 OK\r\n", $buffer); + $this->assertNotContains("HTTP/1.1 100 Continue\r\n\r\n", $buffer); } public function testContinueWithLaterResponse() { - $server = new Server($this->socket, function (RequestInterface $request, Response $response) { - $response->writeHead(); - $response->end(); + $server = new Server($this->socket, function (RequestInterface $request) { + return \React\Promise\resolve(new Response()); }); @@ -1459,6 +1553,259 @@ public function testInvalidCallbackFunctionLeadsToException() $server = new Server($this->socket, 'invalid'); } + public function testHttpBodyStreamAsBodyWillStreamData() + { + $input = new ReadableStream(); + + $server = new Server($this->socket, function (RequestInterface $request) use ($input) { + $response = new Response(200, array(), $input); + return \React\Promise\resolve($response); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + $input->emit('data', array('1')); + $input->emit('data', array('23')); + + $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertContains("\r\n\r\n", $buffer); + $this->assertContains("1\r\n1\r\n", $buffer); + $this->assertContains("2\r\n23\r\n", $buffer); + } + + public function testHttpBodyStreamWithContentLengthWillStreamTillLength() + { + $input = new ReadableStream(); + + $server = new Server($this->socket, function (RequestInterface $request) use ($input) { + $response = new Response(200, array('Content-Length' => 5), $input); + return \React\Promise\resolve($response); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + $input->emit('data', array('hel')); + $input->emit('data', array('lo')); + + $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertContains("Content-Length: 5\r\n", $buffer); + $this->assertNotContains("Transfer-Encoding", $buffer); + $this->assertContains("\r\n\r\n", $buffer); + $this->assertContains("hello", $buffer); + } + + public function testCallbackFunctionReturnsPromise() + { + $server = new Server($this->socket, function (RequestInterface $request) { + return \React\Promise\resolve(new Response()); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertContains("\r\n\r\n", $buffer); + } + + public function testReturnInvalidTypeWillResultInError() + { + $server = new Server($this->socket, function (RequestInterface $request) { + return "invalid"; + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.0\r\n\r\n"; + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + } + + public function testResolveWrongTypeInPromiseWillResultInError() + { + $server = new Server($this->socket, function (RequestInterface $request) { + return \React\Promise\resolve("invalid"); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.0\r\n\r\n"; + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + } + + public function testRejectedPromiseWillResultInErrorMessage() + { + $server = new Server($this->socket, function (RequestInterface $request) { + return new Promise(function ($resolve, $reject) { + $reject(new \Exception()); + }); + }); + $server->on('error', $this->expectCallableOnce()); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.0\r\n\r\n"; + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + } + + public function testExcpetionInCallbackWillResultInErrorMessage() + { + $server = new Server($this->socket, function (RequestInterface $request) { + return new Promise(function ($resolve, $reject) { + throw new \Exception('Bad call'); + }); + }); + $server->on('error', $this->expectCallableOnce()); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.0\r\n\r\n"; + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + } + + public function testHeaderWillAlwaysBeContentLengthForStringBody() + { + $server = new Server($this->socket, function (RequestInterface $request) { + return new Response(200, array('Transfer-Encoding' => 'chunked'), 'hello'); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.0\r\n\r\n"; + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertContains("Content-Length: 5\r\n", $buffer); + $this->assertContains("hello", $buffer); + + $this->assertNotContains("Transfer-Encoding", $buffer); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From 909b06ccda46eea46eb8d7df674c1130a5deee94 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Mon, 27 Mar 2017 12:32:49 +0200 Subject: [PATCH 2/4] Handle Exception in callback function --- src/Server.php | 19 ++++++-- tests/ServerTest.php | 108 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 5 deletions(-) diff --git a/src/Server.php b/src/Server.php index 84159a99..7e77e60f 100644 --- a/src/Server.php +++ b/src/Server.php @@ -228,19 +228,29 @@ public function handleRequest(ConnectionInterface $conn, RequestInterface $reque ); $callback = $this->callback; - $promise = \React\Promise\resolve($callback($request)); + $promise = new Promise(function ($resolve, $reject) use ($callback, $request) { + $resolve($callback($request)); + }); $that = $this; $promise->then( function ($response) use ($that, $conn, $request) { if (!$response instanceof ResponseInterface) { - $that->emit('error', array(new \InvalidArgumentException('Invalid response type'))); + $message = 'The response callback is expected to resolve with an object implementing Psr\Http\Message\ResponseInterface, but resolved with "%s" instead.'; + $message = sprintf($message, is_object($response) ? get_class($response) : gettype($response)); + $exception = new \RuntimeException($message); + + $that->emit('error', array($exception)); return $that->writeError($conn, 500); } $that->handleResponse($conn, $response, $request->getProtocolVersion()); }, - function ($ex) use ($that, $conn) { - $that->emit('error', array($ex)); + function ($error) use ($that, $conn) { + $message = 'The response callback is expected to resolve with an object implementing Psr\Http\Message\ResponseInterface, but rejected with "%s" instead.'; + $message = sprintf($message, is_object($error) ? get_class($error) : gettype($error)); + $exception = new \RuntimeException($message, null, $error instanceof \Exception ? $error : null); + + $that->emit('error', array($exception)); return $that->writeError($conn, 500); } ); @@ -264,7 +274,6 @@ public function writeError(ConnectionInterface $conn, $code) $response = new Response( $code, array( - 'Content-Length' => strlen($message), 'Content-Type' => 'text/plain' ), $message diff --git a/tests/ServerTest.php b/tests/ServerTest.php index d9917375..a7d58e35 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -1657,6 +1657,11 @@ public function testReturnInvalidTypeWillResultInError() return "invalid"; }); + $exception = null; + $server->on('error', function (\Exception $ex) use (&$exception) { + $exception = $ex; + }); + $buffer = ''; $this->connection ->expects($this->any()) @@ -1678,6 +1683,7 @@ function ($data) use (&$buffer) { $this->connection->emit('data', array($data)); $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + $this->assertInstanceOf('RuntimeException', $exception); } public function testResolveWrongTypeInPromiseWillResultInError() @@ -1806,6 +1812,108 @@ function ($data) use (&$buffer) { $this->assertNotContains("Transfer-Encoding", $buffer); } + public function testReturnRequestWillBeHandled() + { + $server = new Server($this->socket, function (RequestInterface $request) { + return new Response(); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.0\r\n\r\n"; + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); + } + + public function testExceptionThrowInCallBackFunctionWillResultInErrorMessage() + { + $server = new Server($this->socket, function (RequestInterface $request) { + throw new \Exception('hello'); + }); + + $exception = null; + $server->on('error', function (\Exception $ex) use (&$exception) { + $exception = $ex; + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.0\r\n\r\n"; + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RuntimeException', $exception); + $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + $this->assertEquals('hello', $exception->getPrevious()->getMessage()); + } + + public function testRejectOfNonExceptionWillResultInErrorMessage() + { + $server = new Server($this->socket, function (RequestInterface $request) { + return new Promise(function ($resolve, $reject) { + $reject('Invalid type'); + }); + }); + + $exception = null; + $server->on('error', function (\Exception $ex) use (&$exception) { + $exception = $ex; + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.0\r\n\r\n"; + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + $this->assertInstanceOf('RuntimeException', $exception); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From bc4e9bc264ea8994dcec686741a209a8ff23fa31 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Mon, 27 Mar 2017 02:41:15 +0200 Subject: [PATCH 3/4] Adapt examples to always returning a promise --- examples/01-hello-world.php | 2 +- examples/02-count-visitors.php | 9 ++++--- examples/03-stream-response.php | 20 ++++++++++----- examples/04-stream-request.php | 41 ++++++++++++++++++++----------- examples/05-error-handling.php | 35 ++++++++++++++++++++++++++ examples/11-hello-world-https.php | 9 ++++--- 6 files changed, 88 insertions(+), 28 deletions(-) create mode 100644 examples/05-error-handling.php diff --git a/examples/01-hello-world.php b/examples/01-hello-world.php index 12c36a56..ed84af11 100644 --- a/examples/01-hello-world.php +++ b/examples/01-hello-world.php @@ -4,6 +4,7 @@ use React\Socket\Server; use React\Http\Response; use Psr\Http\Message\RequestInterface; +use React\Promise\Promise; require __DIR__ . '/../vendor/autoload.php'; @@ -14,7 +15,6 @@ return new Response( 200, array( - 'Content-Length' => strlen("Hello world\n"), 'Content-Type' => 'text/plain' ), "Hello world\n" diff --git a/examples/02-count-visitors.php b/examples/02-count-visitors.php index df4bda06..2c384a3b 100644 --- a/examples/02-count-visitors.php +++ b/examples/02-count-visitors.php @@ -11,9 +11,12 @@ $socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); $counter = 0; -$server = new \React\Http\Server($socket, function (RequestInterface $request, Response $response) use (&$counter) { - $response->writeHead(200, array('Content-Type' => 'text/plain')); - $response->end("Welcome number " . ++$counter . "!\n"); +$server = new \React\Http\Server($socket, function (RequestInterface $request) use (&$counter) { + return new Response( + 200, + array('Content-Type' => 'text/plain'), + "Welcome number " . ++$counter . "!\n" + ); }); echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; diff --git a/examples/03-stream-response.php b/examples/03-stream-response.php index 49617999..5fd990e8 100644 --- a/examples/03-stream-response.php +++ b/examples/03-stream-response.php @@ -4,22 +4,30 @@ use React\Socket\Server; use React\Http\Response; use Psr\Http\Message\RequestInterface; +use React\Stream\ReadableStream; require __DIR__ . '/../vendor/autoload.php'; $loop = Factory::create(); $socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); -$server = new \React\Http\Server($socket, function (RequestInterface $request, Response $response) use ($loop) { - $response->writeHead(200, array('Content-Type' => 'text/plain')); +$server = new \React\Http\Server($socket, function (RequestInterface $request) use ($loop) { + $stream = new ReadableStream(); - $timer = $loop->addPeriodicTimer(0.5, function () use ($response) { - $response->write(microtime(true) . PHP_EOL); + $timer = $loop->addPeriodicTimer(0.5, function () use ($stream) { + $stream->emit('data', array(microtime(true) . PHP_EOL)); }); - $loop->addTimer(5, function() use ($loop, $timer, $response) { + + $loop->addTimer(5, function() use ($loop, $timer, $stream) { $loop->cancelTimer($timer); - $response->end(); + $stream->emit('end'); }); + + return new Response( + 200, + array('Content-Type' => 'text/plain'), + $stream + ); }); echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; diff --git a/examples/04-stream-request.php b/examples/04-stream-request.php index 9bd26c1e..481162ef 100644 --- a/examples/04-stream-request.php +++ b/examples/04-stream-request.php @@ -4,27 +4,38 @@ use React\Socket\Server; use React\Http\Response; use Psr\Http\Message\RequestInterface; +use React\Promise\Promise; require __DIR__ . '/../vendor/autoload.php'; $loop = Factory::create(); $socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); -$server = new \React\Http\Server($socket, function (RequestInterface $request, Response $response) { - $contentLength = 0; - $request->getBody()->on('data', function ($data) use (&$contentLength) { - $contentLength += strlen($data); - }); - - $request->getBody()->on('end', function () use ($response, &$contentLength){ - $response->writeHead(200, array('Content-Type' => 'text/plain')); - $response->end("The length of the submitted request body is: " . $contentLength); - }); - - // an error occures e.g. on invalid chunked encoded data or an unexpected 'end' event - $request->getBody()->on('error', function (\Exception $exception) use ($response, &$contentLength) { - $response->writeHead(400, array('Content-Type' => 'text/plain')); - $response->end("An error occured while reading at length: " . $contentLength); +$server = new \React\Http\Server($socket, function (RequestInterface $request) { + return new Promise(function ($resolve, $reject) use ($request) { + $contentLength = 0; + $request->getBody()->on('data', function ($data) use (&$contentLength) { + $contentLength += strlen($data); + }); + + $request->getBody()->on('end', function () use ($resolve, &$contentLength){ + $response = new Response( + 200, + array('Content-Type' => 'text/plain'), + "The length of the submitted request body is: " . $contentLength + ); + $resolve($response); + }); + + // an error occures e.g. on invalid chunked encoded data or an unexpected 'end' event + $request->getBody()->on('error', function (\Exception $exception) use ($resolve, &$contentLength) { + $response = new Response( + 400, + array('Content-Type' => 'text/plain'), + "An error occured while reading at length: " . $contentLength + ); + $resolve($response); + }); }); }); diff --git a/examples/05-error-handling.php b/examples/05-error-handling.php new file mode 100644 index 00000000..29f54b92 --- /dev/null +++ b/examples/05-error-handling.php @@ -0,0 +1,35 @@ + 'text/plain'), + "Hello World!\n" + ); + + $resolve($response); + }); +}); + +echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; + +$loop->run(); diff --git a/examples/11-hello-world-https.php b/examples/11-hello-world-https.php index 29e5aab2..191e7d62 100644 --- a/examples/11-hello-world-https.php +++ b/examples/11-hello-world-https.php @@ -14,9 +14,12 @@ 'local_cert' => isset($argv[2]) ? $argv[2] : __DIR__ . '/localhost.pem' )); -$server = new \React\Http\Server($socket, function (RequestInterface $request, Response $response) { - $response->writeHead(200, array('Content-Type' => 'text/plain')); - $response->end("Hello world!\n"); +$server = new \React\Http\Server($socket, function (RequestInterface $request) { + return new Response( + 200, + array('Content-Type' => 'text/plain'), + "Hello world!\n" + ); }); //$socket->on('error', 'printf'); From ce27ec6d93d8d884a7261d9a5677da64c5eda191 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Fri, 24 Mar 2017 13:57:30 +0100 Subject: [PATCH 4/4] Update README --- README.md | 279 ++++++++++++++++++++++++++++++++++--------------- src/Server.php | 34 +++--- 2 files changed, 214 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index 611fc5b9..c7e14212 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,6 @@ Event-driven, streaming plaintext HTTP and secure HTTPS server for [ReactPHP](ht * [Server](#server) * [Request](#request) * [Response](#response) - * [writeHead()](#writehead) * [Install](#install) * [Tests](#tests) * [License](#license) @@ -24,9 +23,12 @@ This is an HTTP server which responds with `Hello World` to every request. $loop = React\EventLoop\Factory::create(); $socket = new React\Socket\Server(8080, $loop); -$http = new Server($socket, function (RequestInterface $request, Response $response) { - $response->writeHead(200, array('Content-Type' => 'text/plain')); - $response->end("Hello World!\n"); +$http = new Server($socket, function (RequestInterface $request) { + return new Response( + 200, + array('Content-Type' => 'text/plain'), + "Hello World!\n" + ); }); $loop->run(); @@ -52,9 +54,12 @@ constructor with the respective [request](#request) and ```php $socket = new React\Socket\Server(8080, $loop); -$http = new Server($socket, function (RequestInterface $request, Response $response) { - $response->writeHead(200, array('Content-Type' => 'text/plain')); - $response->end("Hello World!\n"); +$http = new Server($socket, function (RequestInterface $request) { + return new Response( + 200, + array('Content-Type' => 'text/plain'), + "Hello World!\n" + ); }); ``` @@ -70,9 +75,12 @@ $socket = new React\Socket\SecureServer($socket, $loop, array( 'local_cert' => __DIR__ . '/localhost.pem' )); -$http = new Server($socket, function (RequestInterface $request, Response $response) { - $response->writeHead(200, array('Content-Type' => 'text/plain')); - $response->end("Hello World!\n"); +$http = new Server($socket, function (RequestInterface $request) { + return new Response( + 200, + array('Content-Type' => 'text/plain'), + "Hello World!\n" + ); }); ``` @@ -102,6 +110,22 @@ $http->on('error', function (Exception $e) { }); ``` +The server will also emit an `error` event if you return an invalid +type in the callback function or have a unhandled `Exception`. +If your callback function throws an exception, +the `Server` will emit a `RuntimeException` and add the thrown exception +as previous: + +```php +$http->on('error', function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; + if ($e->getPrevious() !== null) { + $previousException = $e->getPrevious(); + echo $previousException->getMessage() . PHP_EOL; + } +}); +``` + Note that the request object can also emit an error. Check out [request](#request) for more details. @@ -117,10 +141,15 @@ This request object implements the and will be passed to the callback function like this. ```php -$http = new Server($socket, function (RequestInterface $request, Response $response) { - $response->writeHead(200, array('Content-Type' => 'text/plain')); - $response->write("The method of the request is: " . $request->getMethod()); - $response->end("The requested path is: " . $request->getUri()->getPath()); +$http = new Server($socket, function (RequestInterface $request) { + $body = "The method of the request is: " . $request->getMethod(); + $body .= "The requested path is: " . $request->getUri()->getPath(); + + return new Response( + 200, + array('Content-Type' => 'text/plain'), + $body + ); }); ``` @@ -155,22 +184,31 @@ Instead, you should use the `ReactPHP ReadableStreamInterface` which gives you access to the incoming request body as the individual chunks arrive: ```php -$http = new Server($socket, function (RequestInterface $request, Response $response) { - $contentLength = 0; - $body = $request->getBody(); - $body->on('data', function ($data) use (&$contentLength) { - $contentLength += strlen($data); - }); - - $body->on('end', function () use ($response, &$contentLength){ - $response->writeHead(200, array('Content-Type' => 'text/plain')); - $response->end("The length of the submitted request body is: " . $contentLength); - }); - - // an error occures e.g. on invalid chunked encoded data or an unexpected 'end' event - $body->on('error', function (\Exception $exception) use ($response, &$contentLength) { - $response->writeHead(400, array('Content-Type' => 'text/plain')); - $response->end("An error occured while reading at length: " . $contentLength); +$http = new Server($socket, function (RequestInterface $request) { + return new Promise(function ($resolve, $reject) use ($request) { + $contentLength = 0; + $request->getBody()->on('data', function ($data) use (&$contentLength) { + $contentLength += strlen($data); + }); + + $request->getBody()->on('end', function () use ($resolve, &$contentLength){ + $response = new Response( + 200, + array('Content-Type' => 'text/plain'), + "The length of the submitted request body is: " . $contentLength + ); + $resolve($response); + }); + + // an error occures e.g. on invalid chunked encoded data or an unexpected 'end' event + $request->getBody()->on('error', function (\Exception $exception) use ($resolve, &$contentLength) { + $response = new Response( + 400, + array('Content-Type' => 'text/plain'), + "An error occured while reading at length: " . $contentLength + ); + $resolve($response); + }); }); }); ``` @@ -210,109 +248,176 @@ Note that this value may be `null` if the request body size is unknown in advance because the request message uses chunked transfer encoding. ```php -$http = new Server($socket, function (RequestInterface $request, Response $response) { +$http = new Server($socket, function (RequestInterface $request) { $size = $request->getBody()->getSize(); if ($size === null) { - $response->writeHead(411, array('Content-Type' => 'text/plain')); - $response->write('The request does not contain an explicit length.'); - $response->write('This server does not accept chunked transfer encoding.'); - $response->end(); - return; + $body = 'The request does not contain an explicit length.'; + $body .= 'This server does not accept chunked transfer encoding.'; + + return new Response( + 411, + array('Content-Type' => 'text/plain'), + $body + ); } - $response->writeHead(200, array('Content-Type' => 'text/plain')); - $response->end("Request body size: " . $size . " bytes\n"); + + return new Response( + 200, + array('Content-Type' => 'text/plain'), + "Request body size: " . $size . " bytes\n" + ); }); ``` ### Response -The `Response` class is responsible for streaming the outgoing response body. +The callback function passed to the constructor of the [Server](#server) +is responsible for processing the request and returning a response, +which will be delivered to the client. +This function MUST return an instance imlementing +[PSR-7 ResponseInterface](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message.md#33-psrhttpmessageresponseinterface) +object or a +[ReactPHP Promise](https://github.com/reactphp/promise#reactpromise) +which will resolve a `PSR-7 ResponseInterface` object. + +You will find a `Response` class +which implements the `PSR-7 ResponseInterface` in this project. +We use instantiation of this class in our projects, +but feel free to use any implemantation of the +`PSR-7 ResponseInterface` you prefer. -It implements the `WritableStreamInterface`. - -See also [example #3](examples) for more details. +```php +$http = new Server($socket, function (RequestInterface $request) { + return new Response( + 200, + array('Content-Type' => 'text/plain'), + "Hello World!\n" + ); +}); +``` -The constructor is internal, you SHOULD NOT call this yourself. -The `Server` is responsible for emitting `Request` and `Response` objects. +The example above returns the response directly, because it needs +no time to be processed. +Using a database, the file system or long calculations +(in fact every action that will take >=1ms) to create your +response, will slow down the server. +To prevent this you SHOULD use a +[ReactPHP Promise](https://github.com/reactphp/promise#reactpromise). +This example shows how such a long-term action could look like: -The `Response` will automatically use the same HTTP protocol version as the -corresponding `Request`. +```php +$server = new \React\Http\Server($socket, function (RequestInterface $request) use ($loop) { + return new Promise(function ($resolve, $reject) use ($request, $loop) { + $loop->addTimer(1.5, function() use ($loop, $resolve) { + $response = new Response( + 200, + array('Content-Type' => 'text/plain'), + "Hello world" + ); + $resolve($response); + }); + }); +}); +``` -HTTP/1.1 responses will automatically apply chunked transfer encoding if -no `Content-Length` header has been set. -See [`writeHead()`](#writehead) for more details. +The above example will create a response after 1.5 second. +This example shows that you need a promise, +if your response needs time to created. +The `ReactPHP Promise` will resolve in a `Response` object when the request +body ends. -See the above usage example and the class outline for details. +The `Response` class in this project supports to add an instance which implements the +[ReactPHP ReadableStreamInterface](https://github.com/reactphp/stream#readablestreaminterface) +for the response body. +So you are able stream data directly into the response body. +Note that other implementations of the `PSR-7 ResponseInterface` likely +only support string. -#### writeHead() +```php +$server = new Server($socket, function (RequestInterface $request) use ($loop) { + $stream = new ReadableStream(); -The `writeHead(int $status = 200, array $headers = array(): void` method can be used to -write the given HTTP message header. + $timer = $loop->addPeriodicTimer(0.5, function () use ($stream) { + $stream->emit('data', array(microtime(true) . PHP_EOL)); + }); -This method MUST be invoked once before calling `write()` or `end()` to send -the actual HTTP message body: + $loop->addTimer(5, function() use ($loop, $timer, $stream) { + $loop->cancelTimer($timer); + $stream->emit('end'); + }); -```php -$response->writeHead(200, array( - 'Content-Type' => 'text/plain' -)); -$response->end('Hello World!'); + return new Response(200, array('Content-Type' => 'text/plain'), $stream); +}); ``` -Calling this method more than once will result in an `Exception` -(unless the response has ended/closed already). -Calling this method after the response has ended/closed is a NOOP. +The above example will emit every 0.5 seconds the current Unix timestamp +with microseconds as float to the client and will end after 5 seconds. +This is just a example you could use of the streaming, +you could also send a big amount of data via little chunks +or use it for body data that needs to calculated. -Unless you specify a `Content-Length` header yourself, HTTP/1.1 responses -will automatically use chunked transfer encoding and send the respective header +If the response body is a `string` a `Content-Length` header will be added automatically. +Unless you specify a `Content-Length` header for a ReactPHP `ReadableStreamInterface` +response body yourself, HTTP/1.1 responses will automatically use chunked transfer encoding +and send the respective header (`Transfer-Encoding: chunked`) automatically. The server is responsible for handling `Transfer-Encoding` so you SHOULD NOT pass it yourself. -If you know the length of your body, you MAY specify it like this instead: +If you know the length of your stream body, you MAY specify it like this instead: ```php -$data = 'Hello World!'; - -$response->writeHead(200, array( - 'Content-Type' => 'text/plain', - 'Content-Length' => strlen($data) -)); -$response->end($data); +$stream = new ReadableStream() +$server = new Server($socket, function (RequestInterface $request) use ($loop, $stream) { + return new Response( + 200, + array( + 'Content-Length' => '5', + 'Content-Type' => 'text/plain', + ), + $stream + ); +}); ``` +An invalid return value or an unhandled `Exception` in the code of the callback +function, will result in an `500 Internal Server Error` message. +Make sure to catch `Exceptions` to create own response messages. + +After the return in the callback function the response will be processed by the `Server`. +The `Server` will add the protocol version of the request, so you don't have to. A `Date` header will be automatically added with the system date and time if none is given. You can add a custom `Date` header yourself like this: ```php -$response->writeHead(200, array( - 'Date' => date('D, d M Y H:i:s T') -)); +$server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array('Date' => date('D, d M Y H:i:s T'))); +}); ``` If you don't have a appropriate clock to rely on, you should -unset this header with an empty array: +unset this header with an empty string: ```php -$response->writeHead(200, array( - 'Date' => array() -)); +$server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array('Date' => '')); +}); ``` Note that it will automatically assume a `X-Powered-By: react/alpha` header unless your specify a custom `X-Powered-By` header yourself: ```php -$response->writeHead(200, array( - 'X-Powered-By' => 'PHP 3' -)); +$server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array('X-Powered-By' => 'PHP 3')); +}); ``` -If you do not want to send this header at all, you can use an empty array as +If you do not want to send this header at all, you can use an empty string as value like this: ```php -$response->writeHead(200, array( - 'X-Powered-By' => array() -)); +$server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array('X-Powered-By' => '')); +}); ``` Note that persistent connections (`Connection: keep-alive`) are currently diff --git a/src/Server.php b/src/Server.php index 7e77e60f..83bcabd7 100644 --- a/src/Server.php +++ b/src/Server.php @@ -20,18 +20,23 @@ * as HTTP. * * For each request, it executes the callback function passed to the - * constructor with the respective [`Request`](#request) and - * [`Response`](#response) objects: + * constructor with the respective [request](#request) and + * [response](#response) objects: * * ```php * $socket = new React\Socket\Server(8080, $loop); * - * $http = new Server($socket, function (RequestInterface $request, Response $response) { - * $response->writeHead(200, array('Content-Type' => 'text/plain')); - * $response->end("Hello World!\n"); + * $http = new Server($socket, function (RequestInterface $request) { + * return new Response( + * 200, + * array('Content-Type' => 'text/plain'), + * "Hello World!\n" + * ); * }); * ``` * + * See also the [first example](examples) for more details. + * * Similarly, you can also attach this to a * [`React\Socket\SecureServer`](https://github.com/reactphp/socket#secureserver) * in order to start a secure HTTPS server like this: @@ -42,12 +47,17 @@ * 'local_cert' => __DIR__ . '/localhost.pem' * )); * - * $http = new Server($socket, function (RequestInterface $request, Response $response) { - * $response->writeHead(200, array('Content-Type' => 'text/plain')); - * $response->end("Hello World!\n"); + * $http = new Server($socket, function (RequestInterface $request) { + * return new Response( + * 200, + * array('Content-Type' => 'text/plain'), + * "Hello World!\n" + * ); * }); * ``` * + * See also [example #11](examples) for more details. + * * When HTTP/1.1 clients want to send a bigger request body, they MAY send only * the request headers with an additional `Expect: 100-continue` header and * wait before sending the actual (large) message body. @@ -57,8 +67,8 @@ * The [Response](#response) still needs to be created as described in the * examples above. * - * See also [`Request`](#request) and [`Response`](#response) - * for more details(e.g. the request data body). + * See also [request](#request) and [response](#response) + * for more details (e.g. the request data body). * * The `Server` supports both HTTP/1.1 and HTTP/1.0 request messages. * If a client sends an invalid request message, uses an invalid HTTP protocol @@ -72,8 +82,8 @@ * }); * ``` * - * The request object can also emit an error. Checkout [Request](#request) - * for more details. + * Note that the request object can also emit an error. + * Check out [request](#request) for more details. * * @see Request * @see Response