diff --git a/composer.json b/composer.json index 843c60fe..5c6ed3df 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,6 @@ "evenement/evenement": "^3.0 || ^2.0 || ^1.0", "psr/http-message": "^1.0", "react/event-loop": "^1.0 || ^0.5", - "react/http-client": "^0.5.10", "react/promise": "^2.3 || ^1.2.1", "react/promise-stream": "^1.1", "react/socket": "^1.1", diff --git a/src/Client/ChunkedStreamDecoder.php b/src/Client/ChunkedStreamDecoder.php new file mode 100644 index 00000000..02cab52a --- /dev/null +++ b/src/Client/ChunkedStreamDecoder.php @@ -0,0 +1,207 @@ +stream = $stream; + $this->stream->on('data', array($this, 'handleData')); + $this->stream->on('end', array($this, 'handleEnd')); + Util::forwardEvents($this->stream, $this, array( + 'error', + )); + } + + /** @internal */ + public function handleData($data) + { + $this->buffer .= $data; + + do { + $bufferLength = strlen($this->buffer); + $continue = $this->iterateBuffer(); + $iteratedBufferLength = strlen($this->buffer); + } while ( + $continue && + $bufferLength !== $iteratedBufferLength && + $iteratedBufferLength > 0 + ); + + if ($this->buffer === false) { + $this->buffer = ''; + } + } + + protected function iterateBuffer() + { + if (strlen($this->buffer) <= 1) { + return false; + } + + if ($this->nextChunkIsLength) { + $crlfPosition = strpos($this->buffer, static::CRLF); + if ($crlfPosition === false && strlen($this->buffer) > 1024) { + $this->emit('error', array( + new Exception('Chunk length header longer then 1024 bytes'), + )); + $this->close(); + return false; + } + if ($crlfPosition === false) { + return false; // Chunk header hasn't completely come in yet + } + $lengthChunk = substr($this->buffer, 0, $crlfPosition); + if (strpos($lengthChunk, ';') !== false) { + list($lengthChunk) = explode(';', $lengthChunk, 2); + } + if ($lengthChunk !== '') { + $lengthChunk = ltrim(trim($lengthChunk), "0"); + if ($lengthChunk === '') { + // We've reached the end of the stream + $this->reachedEnd = true; + $this->emit('end'); + $this->close(); + return false; + } + } + $this->nextChunkIsLength = false; + if (dechex(@hexdec($lengthChunk)) !== strtolower($lengthChunk)) { + $this->emit('error', array( + new Exception('Unable to validate "' . $lengthChunk . '" as chunk length header'), + )); + $this->close(); + return false; + } + $this->remainingLength = hexdec($lengthChunk); + $this->buffer = substr($this->buffer, $crlfPosition + 2); + return true; + } + + if ($this->remainingLength > 0) { + $chunkLength = $this->getChunkLength(); + if ($chunkLength === 0) { + return true; + } + $this->emit('data', array( + substr($this->buffer, 0, $chunkLength), + $this + )); + $this->remainingLength -= $chunkLength; + $this->buffer = substr($this->buffer, $chunkLength); + return true; + } + + $this->nextChunkIsLength = true; + $this->buffer = substr($this->buffer, 2); + return true; + } + + protected function getChunkLength() + { + $bufferLength = strlen($this->buffer); + + if ($bufferLength >= $this->remainingLength) { + return $this->remainingLength; + } + + return $bufferLength; + } + + public function pause() + { + $this->stream->pause(); + } + + public function resume() + { + $this->stream->resume(); + } + + public function isReadable() + { + return $this->stream->isReadable(); + } + + public function pipe(WritableStreamInterface $dest, array $options = array()) + { + Util::pipe($this, $dest, $options); + + return $dest; + } + + public function close() + { + $this->closed = true; + return $this->stream->close(); + } + + /** @internal */ + public function handleEnd() + { + $this->handleData(''); + + if ($this->closed) { + return; + } + + if ($this->buffer === '' && $this->reachedEnd) { + $this->emit('end'); + $this->close(); + return; + } + + $this->emit( + 'error', + array( + new Exception('Stream ended with incomplete control code') + ) + ); + $this->close(); + } +} diff --git a/src/Client/Client.php b/src/Client/Client.php new file mode 100644 index 00000000..f28ec289 --- /dev/null +++ b/src/Client/Client.php @@ -0,0 +1,31 @@ +connector = $connector; + } + + public function request($method, $url, array $headers = array(), $protocolVersion = '1.0') + { + $requestData = new RequestData($method, $url, $headers, $protocolVersion); + + return new Request($this->connector, $requestData); + } +} diff --git a/src/Client/Request.php b/src/Client/Request.php new file mode 100644 index 00000000..7ebb627f --- /dev/null +++ b/src/Client/Request.php @@ -0,0 +1,295 @@ +connector = $connector; + $this->requestData = $requestData; + } + + public function isWritable() + { + return self::STATE_END > $this->state && !$this->ended; + } + + private function writeHead() + { + $this->state = self::STATE_WRITING_HEAD; + + $requestData = $this->requestData; + $streamRef = &$this->stream; + $stateRef = &$this->state; + $pendingWrites = &$this->pendingWrites; + $that = $this; + + $promise = $this->connect(); + $promise->then( + function (ConnectionInterface $stream) use ($requestData, &$streamRef, &$stateRef, &$pendingWrites, $that) { + $streamRef = $stream; + + $stream->on('drain', array($that, 'handleDrain')); + $stream->on('data', array($that, 'handleData')); + $stream->on('end', array($that, 'handleEnd')); + $stream->on('error', array($that, 'handleError')); + $stream->on('close', array($that, 'handleClose')); + + $headers = (string) $requestData; + + $more = $stream->write($headers . $pendingWrites); + + $stateRef = Request::STATE_HEAD_WRITTEN; + + // clear pending writes if non-empty + if ($pendingWrites !== '') { + $pendingWrites = ''; + + if ($more) { + $that->emit('drain'); + } + } + }, + array($this, 'closeError') + ); + + $this->on('close', function() use ($promise) { + $promise->cancel(); + }); + } + + public function write($data) + { + if (!$this->isWritable()) { + return false; + } + + // write directly to connection stream if already available + if (self::STATE_HEAD_WRITTEN <= $this->state) { + return $this->stream->write($data); + } + + // otherwise buffer and try to establish connection + $this->pendingWrites .= $data; + if (self::STATE_WRITING_HEAD > $this->state) { + $this->writeHead(); + } + + return false; + } + + public function end($data = null) + { + if (!$this->isWritable()) { + return; + } + + if (null !== $data) { + $this->write($data); + } else if (self::STATE_WRITING_HEAD > $this->state) { + $this->writeHead(); + } + + $this->ended = true; + } + + /** @internal */ + public function handleDrain() + { + $this->emit('drain'); + } + + /** @internal */ + public function handleData($data) + { + $this->buffer .= $data; + + // buffer until double CRLF (or double LF for compatibility with legacy servers) + if (false !== strpos($this->buffer, "\r\n\r\n") || false !== strpos($this->buffer, "\n\n")) { + try { + list($response, $bodyChunk) = $this->parseResponse($this->buffer); + } catch (\InvalidArgumentException $exception) { + $this->emit('error', array($exception)); + } + + $this->buffer = null; + + $this->stream->removeListener('drain', array($this, 'handleDrain')); + $this->stream->removeListener('data', array($this, 'handleData')); + $this->stream->removeListener('end', array($this, 'handleEnd')); + $this->stream->removeListener('error', array($this, 'handleError')); + $this->stream->removeListener('close', array($this, 'handleClose')); + + if (!isset($response)) { + return; + } + + $response->on('close', array($this, 'close')); + $that = $this; + $response->on('error', function (\Exception $error) use ($that) { + $that->closeError(new \RuntimeException( + "An error occured in the response", + 0, + $error + )); + }); + + $this->emit('response', array($response, $this)); + + $this->stream->emit('data', array($bodyChunk)); + } + } + + /** @internal */ + public function handleEnd() + { + $this->closeError(new \RuntimeException( + "Connection ended before receiving response" + )); + } + + /** @internal */ + public function handleError(\Exception $error) + { + $this->closeError(new \RuntimeException( + "An error occurred in the underlying stream", + 0, + $error + )); + } + + /** @internal */ + public function handleClose() + { + $this->close(); + } + + /** @internal */ + public function closeError(\Exception $error) + { + if (self::STATE_END <= $this->state) { + return; + } + $this->emit('error', array($error)); + $this->close(); + } + + public function close() + { + if (self::STATE_END <= $this->state) { + return; + } + + $this->state = self::STATE_END; + $this->pendingWrites = ''; + + if ($this->stream) { + $this->stream->close(); + } + + $this->emit('close'); + $this->removeAllListeners(); + } + + protected function parseResponse($data) + { + $psrResponse = gPsr\parse_response($data); + $headers = array_map(function($val) { + if (1 === count($val)) { + $val = $val[0]; + } + + return $val; + }, $psrResponse->getHeaders()); + + $factory = $this->getResponseFactory(); + + $response = $factory( + 'HTTP', + $psrResponse->getProtocolVersion(), + $psrResponse->getStatusCode(), + $psrResponse->getReasonPhrase(), + $headers + ); + + return array($response, (string)($psrResponse->getBody())); + } + + protected function connect() + { + $scheme = $this->requestData->getScheme(); + if ($scheme !== 'https' && $scheme !== 'http') { + return Promise\reject( + new \InvalidArgumentException('Invalid request URL given') + ); + } + + $host = $this->requestData->getHost(); + $port = $this->requestData->getPort(); + + if ($scheme === 'https') { + $host = 'tls://' . $host; + } + + return $this->connector + ->connect($host . ':' . $port); + } + + public function setResponseFactory($factory) + { + $this->responseFactory = $factory; + } + + public function getResponseFactory() + { + if (null === $factory = $this->responseFactory) { + $stream = $this->stream; + + $factory = function ($protocol, $version, $code, $reasonPhrase, $headers) use ($stream) { + return new Response( + $stream, + $protocol, + $version, + $code, + $reasonPhrase, + $headers + ); + }; + + $this->responseFactory = $factory; + } + + return $factory; + } +} diff --git a/src/Client/RequestData.php b/src/Client/RequestData.php new file mode 100644 index 00000000..55efaa9b --- /dev/null +++ b/src/Client/RequestData.php @@ -0,0 +1,128 @@ +method = $method; + $this->url = $url; + $this->headers = $headers; + $this->protocolVersion = $protocolVersion; + } + + private function mergeDefaultheaders(array $headers) + { + $port = ($this->getDefaultPort() === $this->getPort()) ? '' : ":{$this->getPort()}"; + $connectionHeaders = ('1.1' === $this->protocolVersion) ? array('Connection' => 'close') : array(); + $authHeaders = $this->getAuthHeaders(); + + $defaults = array_merge( + array( + 'Host' => $this->getHost().$port, + 'User-Agent' => 'React/alpha', + ), + $connectionHeaders, + $authHeaders + ); + + // remove all defaults that already exist in $headers + $lower = array_change_key_case($headers, CASE_LOWER); + foreach ($defaults as $key => $_) { + if (isset($lower[strtolower($key)])) { + unset($defaults[$key]); + } + } + + return array_merge($defaults, $headers); + } + + public function getScheme() + { + return parse_url($this->url, PHP_URL_SCHEME); + } + + public function getHost() + { + return parse_url($this->url, PHP_URL_HOST); + } + + public function getPort() + { + return (int) parse_url($this->url, PHP_URL_PORT) ?: $this->getDefaultPort(); + } + + public function getDefaultPort() + { + return ('https' === $this->getScheme()) ? 443 : 80; + } + + public function getPath() + { + $path = parse_url($this->url, PHP_URL_PATH); + $queryString = parse_url($this->url, PHP_URL_QUERY); + + // assume "/" path by default, but allow "OPTIONS *" + if ($path === null) { + $path = ($this->method === 'OPTIONS' && $queryString === null) ? '*': '/'; + } + if ($queryString !== null) { + $path .= '?' . $queryString; + } + + return $path; + } + + public function setProtocolVersion($version) + { + $this->protocolVersion = $version; + } + + public function __toString() + { + $headers = $this->mergeDefaultheaders($this->headers); + + $data = ''; + $data .= "{$this->method} {$this->getPath()} HTTP/{$this->protocolVersion}\r\n"; + foreach ($headers as $name => $values) { + foreach ((array)$values as $value) { + $data .= "$name: $value\r\n"; + } + } + $data .= "\r\n"; + + return $data; + } + + private function getUrlUserPass() + { + $components = parse_url($this->url); + + if (isset($components['user'])) { + return array( + 'user' => $components['user'], + 'pass' => isset($components['pass']) ? $components['pass'] : null, + ); + } + } + + private function getAuthHeaders() + { + if (null !== $auth = $this->getUrlUserPass()) { + return array( + 'Authorization' => 'Basic ' . base64_encode($auth['user'].':'.$auth['pass']), + ); + } + + return array(); + } +} diff --git a/src/Client/Response.php b/src/Client/Response.php new file mode 100644 index 00000000..be19eb4c --- /dev/null +++ b/src/Client/Response.php @@ -0,0 +1,175 @@ +stream = $stream; + $this->protocol = $protocol; + $this->version = $version; + $this->code = $code; + $this->reasonPhrase = $reasonPhrase; + $this->headers = $headers; + + if (strtolower($this->getHeaderLine('Transfer-Encoding')) === 'chunked') { + $this->stream = new ChunkedStreamDecoder($stream); + $this->removeHeader('Transfer-Encoding'); + } + + $this->stream->on('data', array($this, 'handleData')); + $this->stream->on('error', array($this, 'handleError')); + $this->stream->on('end', array($this, 'handleEnd')); + $this->stream->on('close', array($this, 'handleClose')); + } + + public function getProtocol() + { + return $this->protocol; + } + + public function getVersion() + { + return $this->version; + } + + public function getCode() + { + return $this->code; + } + + public function getReasonPhrase() + { + return $this->reasonPhrase; + } + + public function getHeaders() + { + return $this->headers; + } + + private function removeHeader($name) + { + foreach ($this->headers as $key => $value) { + if (strcasecmp($name, $key) === 0) { + unset($this->headers[$key]); + break; + } + } + } + + private function getHeader($name) + { + $name = strtolower($name); + $normalized = array_change_key_case($this->headers, CASE_LOWER); + + return isset($normalized[$name]) ? (array)$normalized[$name] : array(); + } + + private function getHeaderLine($name) + { + return implode(', ' , $this->getHeader($name)); + } + + /** @internal */ + public function handleData($data) + { + if ($this->readable) { + $this->emit('data', array($data)); + } + } + + /** @internal */ + public function handleEnd() + { + if (!$this->readable) { + return; + } + $this->emit('end'); + $this->close(); + } + + /** @internal */ + public function handleError(\Exception $error) + { + if (!$this->readable) { + return; + } + $this->emit('error', array(new \RuntimeException( + "An error occurred in the underlying stream", + 0, + $error + ))); + + $this->close(); + } + + /** @internal */ + public function handleClose() + { + $this->close(); + } + + public function close() + { + if (!$this->readable) { + return; + } + + $this->readable = false; + $this->stream->close(); + + $this->emit('close'); + $this->removeAllListeners(); + } + + public function isReadable() + { + return $this->readable; + } + + public function pause() + { + if (!$this->readable) { + return; + } + + $this->stream->pause(); + } + + public function resume() + { + if (!$this->readable) { + return; + } + + $this->stream->resume(); + } + + public function pipe(WritableStreamInterface $dest, array $options = array()) + { + Util::pipe($this, $dest, $options); + + return $dest; + } +} diff --git a/src/Io/Sender.php b/src/Io/Sender.php index e9c0a600..d16b09d0 100644 --- a/src/Io/Sender.php +++ b/src/Io/Sender.php @@ -5,8 +5,8 @@ use React\Http\Message\MessageFactory; use Psr\Http\Message\RequestInterface; use React\EventLoop\LoopInterface; -use React\HttpClient\Client as HttpClient; -use React\HttpClient\Response as ResponseStream; +use React\Http\Client\Client as HttpClient; +use React\Http\Client\Response as ResponseStream; use React\Promise\PromiseInterface; use React\Promise\Deferred; use React\Socket\ConnectorInterface; diff --git a/tests/Client/DecodeChunkedStreamTest.php b/tests/Client/DecodeChunkedStreamTest.php new file mode 100644 index 00000000..f238fb6b --- /dev/null +++ b/tests/Client/DecodeChunkedStreamTest.php @@ -0,0 +1,227 @@ + array( + array("4\r\nWiki\r\n5\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n"), + ), + 'data-set-2' => array( + array("4\r\nWiki\r\n", "5\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n"), + ), + 'data-set-3' => array( + array("4\r\nWiki\r\n", "5\r\n", "pedia\r\ne\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n"), + ), + 'data-set-4' => array( + array("4\r\nWiki\r\n", "5\r\n", "pedia\r\ne\r\n in\r\n", "\r\nchunks.\r\n0\r\n\r\n"), + ), + 'data-set-5' => array( + array("4\r\n", "Wiki\r\n", "5\r\n", "pedia\r\ne\r\n in\r\n", "\r\nchunks.\r\n0\r\n\r\n"), + ), + 'data-set-6' => array( + array("4\r\n", "Wiki\r\n", "5\r\n", "pedia\r\ne; foo=[bar,beer,pool,cue,win,won]\r\n", " in\r\n", "\r\nchunks.\r\n0\r\n\r\n"), + ), + 'header-fields' => array( + array("4; foo=bar\r\n", "Wiki\r\n", "5\r\n", "pedia\r\ne\r\n", " in\r\n", "\r\nchunks.\r\n", "0\r\n\r\n"), + ), + 'character-for-charactrr' => array( + str_split("4\r\nWiki\r\n5\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n"), + ), + 'extra-newline-in-wiki-character-for-chatacter' => array( + str_split("6\r\nWi\r\nki\r\n5\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n"), + "Wi\r\nkipedia in\r\n\r\nchunks." + ), + 'extra-newline-in-wiki' => array( + array("6\r\nWi\r\n", "ki\r\n5\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n"), + "Wi\r\nkipedia in\r\n\r\nchunks." + ), + 'varnish-type-response-1' => array( + array("0017\r\nWikipedia in\r\n\r\nchunks.\r\n0\r\n\r\n") + ), + 'varnish-type-response-2' => array( + array("000017\r\nWikipedia in\r\n\r\nchunks.\r\n0\r\n\r\n") + ), + 'varnish-type-response-3' => array( + array("017\r\nWikipedia in\r\n\r\nchunks.\r\n0\r\n\r\n") + ), + 'varnish-type-response-4' => array( + array("004\r\nWiki\r\n005\r\npedia\r\n00e\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n") + ), + 'varnish-type-response-5' => array( + array("000004\r\nWiki\r\n00005\r\npedia\r\n000e\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n") + ), + 'varnish-type-response-extra-line' => array( + array("006\r\nWi\r\nki\r\n005\r\npedia\r\n00e\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n"), + "Wi\r\nkipedia in\r\n\r\nchunks." + ), + 'varnish-type-response-random' => array( + array(str_repeat("0", rand(0, 10)), "4\r\nWiki\r\n", str_repeat("0", rand(0, 10)), "5\r\npedia\r\n", str_repeat("0", rand(0, 10)), "e\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n") + ), + 'end-chunk-zero-check-1' => array( + array("4\r\nWiki\r\n5\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n00\r\n\r\n") + ), + 'end-chunk-zero-check-2' => array( + array("4\r\nWiki\r\n5\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n000\r\n\r\n") + ), + 'end-chunk-zero-check-3' => array( + array("00004\r\nWiki\r\n005\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n0000\r\n\r\n") + ), + 'uppercase-chunk' => array( + array("4\r\nWiki\r\n5\r\npedia\r\nE\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n"), + ), + 'extra-space-in-length-chunk' => array( + array(" 04 \r\nWiki\r\n5\r\npedia\r\nE\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n"), + ), + 'only-whitespace-is-final-chunk' => array( + array(" \r\n\r\n"), + "" + ) + ); + } + + /** + * @test + * @dataProvider provideChunkedEncoding + */ + public function testChunkedEncoding(array $strings, $expected = "Wikipedia in\r\n\r\nchunks.") + { + $stream = new ThroughStream(); + $response = new ChunkedStreamDecoder($stream); + $buffer = ''; + $response->on('data', function ($data) use (&$buffer) { + $buffer .= $data; + }); + $response->on('error', function ($error) { + $this->fail((string)$error); + }); + foreach ($strings as $string) { + $stream->write($string); + } + $this->assertSame($expected, $buffer); + } + + public function provideInvalidChunkedEncoding() + { + return array( + 'chunk-body-longer-than-header-suggests' => array( + array("4\r\nWiwot40n98w3498tw3049nyn039409t34\r\n", "ki\r\n5\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n"), + ), + 'invalid-header-charactrrs' => array( + str_split("xyz\r\nWi\r\nki\r\n5\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n") + ), + 'header-chunk-to-long' => array( + str_split(str_repeat('a', 2015) . "\r\nWi\r\nki\r\n5\r\npedia\r\ne\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n") + ) + ); + } + + /** + * @test + * @dataProvider provideInvalidChunkedEncoding + */ + public function testInvalidChunkedEncoding(array $strings) + { + $stream = new ThroughStream(); + $response = new ChunkedStreamDecoder($stream); + $response->on('error', function (Exception $exception) { + throw $exception; + }); + + $this->setExpectedException('Exception'); + foreach ($strings as $string) { + $stream->write($string); + } + } + + public function provideZeroChunk() + { + return array( + array('1-zero' => "0\r\n\r\n"), + array('random-zero' => str_repeat("0", rand(2, 10))."\r\n\r\n") + ); + } + + /** + * @test + * @dataProvider provideZeroChunk + */ + public function testHandleEnd($zeroChunk) + { + $ended = false; + $stream = new ThroughStream(); + $response = new ChunkedStreamDecoder($stream); + $response->on('error', function ($error) { + $this->fail((string)$error); + }); + $response->on('end', function () use (&$ended) { + $ended = true; + }); + + $stream->write("4\r\nWiki\r\n".$zeroChunk); + + $this->assertTrue($ended); + } + + public function testHandleEndIncomplete() + { + $exception = null; + $stream = new ThroughStream(); + $response = new ChunkedStreamDecoder($stream); + $response->on('error', function ($e) use (&$exception) { + $exception = $e; + }); + + $stream->end("4\r\nWiki"); + + $this->assertInstanceOf('Exception', $exception); + } + + public function testHandleEndTrailers() + { + $ended = false; + $stream = new ThroughStream(); + $response = new ChunkedStreamDecoder($stream); + $response->on('error', function ($error) { + $this->fail((string)$error); + }); + $response->on('end', function () use (&$ended) { + $ended = true; + }); + + $stream->write("4\r\nWiki\r\n0\r\nabc: def\r\nghi: klm\r\n\r\n"); + + $this->assertTrue($ended); + } + + /** + * @test + * @dataProvider provideZeroChunk + */ + public function testHandleEndEnsureNoError($zeroChunk) + { + $ended = false; + $stream = new ThroughStream(); + $response = new ChunkedStreamDecoder($stream); + $response->on('error', function ($error) { + $this->fail((string)$error); + }); + $response->on('end', function () use (&$ended) { + $ended = true; + }); + + $stream->write("4\r\nWiki\r\n"); + $stream->write($zeroChunk); + $stream->end(); + + $this->assertTrue($ended); + } +} diff --git a/tests/Client/FunctionalIntegrationTest.php b/tests/Client/FunctionalIntegrationTest.php new file mode 100644 index 00000000..d6cc4b0f --- /dev/null +++ b/tests/Client/FunctionalIntegrationTest.php @@ -0,0 +1,170 @@ +on('connection', $this->expectCallableOnce()); + $server->on('connection', function (ConnectionInterface $conn) use ($server) { + $conn->end("HTTP/1.1 200 OK\r\n\r\nOk"); + $server->close(); + }); + $port = parse_url($server->getAddress(), PHP_URL_PORT); + + $client = new Client($loop); + $request = $client->request('GET', 'http://localhost:' . $port); + + $promise = Stream\first($request, 'close'); + $request->end(); + + Block\await($promise, $loop, self::TIMEOUT_LOCAL); + } + + public function testRequestLegacyHttpServerWithOnlyLineFeedReturnsSuccessfulResponse() + { + $loop = Factory::create(); + + $server = new Server(0, $loop); + $server->on('connection', function (ConnectionInterface $conn) use ($server) { + $conn->end("HTTP/1.0 200 OK\n\nbody"); + $server->close(); + }); + + $client = new Client($loop); + $request = $client->request('GET', str_replace('tcp:', 'http:', $server->getAddress())); + + $once = $this->expectCallableOnceWith('body'); + $request->on('response', function (Response $response) use ($once) { + $response->on('data', $once); + }); + + $promise = Stream\first($request, 'close'); + $request->end(); + + Block\await($promise, $loop, self::TIMEOUT_LOCAL); + } + + /** @group internet */ + public function testSuccessfulResponseEmitsEnd() + { + $loop = Factory::create(); + $client = new Client($loop); + + $request = $client->request('GET', 'http://www.google.com/'); + + $once = $this->expectCallableOnce(); + $request->on('response', function (Response $response) use ($once) { + $response->on('end', $once); + }); + + $promise = Stream\first($request, 'close'); + $request->end(); + + Block\await($promise, $loop, self::TIMEOUT_REMOTE); + } + + /** @group internet */ + public function testPostDataReturnsData() + { + $loop = Factory::create(); + $client = new Client($loop); + + $data = str_repeat('.', 33000); + $request = $client->request('POST', 'https://' . (mt_rand(0, 1) === 0 ? 'eu.' : '') . 'httpbin.org/post', array('Content-Length' => strlen($data))); + + $deferred = new Deferred(); + $request->on('response', function (Response $response) use ($deferred) { + $deferred->resolve(Stream\buffer($response)); + }); + + $request->on('error', 'printf'); + $request->on('error', $this->expectCallableNever()); + + $request->end($data); + + $buffer = Block\await($deferred->promise(), $loop, self::TIMEOUT_REMOTE); + + $this->assertNotEquals('', $buffer); + + $parsed = json_decode($buffer, true); + $this->assertTrue(is_array($parsed) && isset($parsed['data'])); + $this->assertEquals(strlen($data), strlen($parsed['data'])); + $this->assertEquals($data, $parsed['data']); + } + + /** @group internet */ + public function testPostJsonReturnsData() + { + $loop = Factory::create(); + $client = new Client($loop); + + $data = json_encode(array('numbers' => range(1, 50))); + $request = $client->request('POST', 'https://httpbin.org/post', array('Content-Length' => strlen($data), 'Content-Type' => 'application/json')); + + $deferred = new Deferred(); + $request->on('response', function (Response $response) use ($deferred) { + $deferred->resolve(Stream\buffer($response)); + }); + + $request->on('error', 'printf'); + $request->on('error', $this->expectCallableNever()); + + $request->end($data); + + $buffer = Block\await($deferred->promise(), $loop, self::TIMEOUT_REMOTE); + + $this->assertNotEquals('', $buffer); + + $parsed = json_decode($buffer, true); + $this->assertTrue(is_array($parsed) && isset($parsed['json'])); + $this->assertEquals(json_decode($data, true), $parsed['json']); + } + + /** @group internet */ + public function testCancelPendingConnectionEmitsClose() + { + $loop = Factory::create(); + $client = new Client($loop); + + $request = $client->request('GET', 'http://www.google.com/'); + $request->on('error', $this->expectCallableNever()); + $request->on('close', $this->expectCallableOnce()); + $request->end(); + $request->close(); + } +} diff --git a/tests/Client/RequestDataTest.php b/tests/Client/RequestDataTest.php new file mode 100644 index 00000000..313e140f --- /dev/null +++ b/tests/Client/RequestDataTest.php @@ -0,0 +1,154 @@ +assertSame($expected, $requestData->__toString()); + } + + /** @test */ + public function toStringReturnsHTTPRequestMessageWithEmptyQueryString() + { + $requestData = new RequestData('GET', 'http://www.example.com/path?hello=world'); + + $expected = "GET /path?hello=world HTTP/1.0\r\n" . + "Host: www.example.com\r\n" . + "User-Agent: React/alpha\r\n" . + "\r\n"; + + $this->assertSame($expected, $requestData->__toString()); + } + + /** @test */ + public function toStringReturnsHTTPRequestMessageWithZeroQueryStringAndRootPath() + { + $requestData = new RequestData('GET', 'http://www.example.com?0'); + + $expected = "GET /?0 HTTP/1.0\r\n" . + "Host: www.example.com\r\n" . + "User-Agent: React/alpha\r\n" . + "\r\n"; + + $this->assertSame($expected, $requestData->__toString()); + } + + /** @test */ + public function toStringReturnsHTTPRequestMessageWithOptionsAbsoluteRequestForm() + { + $requestData = new RequestData('OPTIONS', 'http://www.example.com/'); + + $expected = "OPTIONS / HTTP/1.0\r\n" . + "Host: www.example.com\r\n" . + "User-Agent: React/alpha\r\n" . + "\r\n"; + + $this->assertSame($expected, $requestData->__toString()); + } + + /** @test */ + public function toStringReturnsHTTPRequestMessageWithOptionsAsteriskRequestForm() + { + $requestData = new RequestData('OPTIONS', 'http://www.example.com'); + + $expected = "OPTIONS * HTTP/1.0\r\n" . + "Host: www.example.com\r\n" . + "User-Agent: React/alpha\r\n" . + "\r\n"; + + $this->assertSame($expected, $requestData->__toString()); + } + + /** @test */ + public function toStringReturnsHTTPRequestMessageWithProtocolVersion() + { + $requestData = new RequestData('GET', 'http://www.example.com'); + $requestData->setProtocolVersion('1.1'); + + $expected = "GET / HTTP/1.1\r\n" . + "Host: www.example.com\r\n" . + "User-Agent: React/alpha\r\n" . + "Connection: close\r\n" . + "\r\n"; + + $this->assertSame($expected, $requestData->__toString()); + } + + /** @test */ + public function toStringReturnsHTTPRequestMessageWithHeaders() + { + $requestData = new RequestData('GET', 'http://www.example.com', array( + 'User-Agent' => array(), + 'Via' => array( + 'first', + 'second' + ) + )); + + $expected = "GET / HTTP/1.0\r\n" . + "Host: www.example.com\r\n" . + "Via: first\r\n" . + "Via: second\r\n" . + "\r\n"; + + $this->assertSame($expected, $requestData->__toString()); + } + + /** @test */ + public function toStringReturnsHTTPRequestMessageWithHeadersInCustomCase() + { + $requestData = new RequestData('GET', 'http://www.example.com', array( + 'user-agent' => 'Hello', + 'LAST' => 'World' + )); + + $expected = "GET / HTTP/1.0\r\n" . + "Host: www.example.com\r\n" . + "user-agent: Hello\r\n" . + "LAST: World\r\n" . + "\r\n"; + + $this->assertSame($expected, $requestData->__toString()); + } + + /** @test */ + public function toStringReturnsHTTPRequestMessageWithProtocolVersionThroughConstructor() + { + $requestData = new RequestData('GET', 'http://www.example.com', array(), '1.1'); + + $expected = "GET / HTTP/1.1\r\n" . + "Host: www.example.com\r\n" . + "User-Agent: React/alpha\r\n" . + "Connection: close\r\n" . + "\r\n"; + + $this->assertSame($expected, $requestData->__toString()); + } + + /** @test */ + public function toStringUsesUserPassFromURL() + { + $requestData = new RequestData('GET', 'http://john:dummy@www.example.com'); + + $expected = "GET / HTTP/1.0\r\n" . + "Host: www.example.com\r\n" . + "User-Agent: React/alpha\r\n" . + "Authorization: Basic am9objpkdW1teQ==\r\n" . + "\r\n"; + + $this->assertSame($expected, $requestData->__toString()); + } +} diff --git a/tests/Client/RequestTest.php b/tests/Client/RequestTest.php new file mode 100644 index 00000000..e702d315 --- /dev/null +++ b/tests/Client/RequestTest.php @@ -0,0 +1,714 @@ +stream = $this->getMockBuilder('React\Socket\ConnectionInterface') + ->disableOriginalConstructor() + ->getMock(); + + $this->connector = $this->getMockBuilder('React\Socket\ConnectorInterface') + ->getMock(); + + $this->response = $this->getMockBuilder('React\Http\Client\Response') + ->disableOriginalConstructor() + ->getMock(); + } + + /** @test */ + public function requestShouldBindToStreamEventsAndUseconnector() + { + $requestData = new RequestData('GET', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $this->stream + ->expects($this->at(0)) + ->method('on') + ->with('drain', $this->identicalTo(array($request, 'handleDrain'))); + $this->stream + ->expects($this->at(1)) + ->method('on') + ->with('data', $this->identicalTo(array($request, 'handleData'))); + $this->stream + ->expects($this->at(2)) + ->method('on') + ->with('end', $this->identicalTo(array($request, 'handleEnd'))); + $this->stream + ->expects($this->at(3)) + ->method('on') + ->with('error', $this->identicalTo(array($request, 'handleError'))); + $this->stream + ->expects($this->at(4)) + ->method('on') + ->with('close', $this->identicalTo(array($request, 'handleClose'))); + $this->stream + ->expects($this->at(6)) + ->method('removeListener') + ->with('drain', $this->identicalTo(array($request, 'handleDrain'))); + $this->stream + ->expects($this->at(7)) + ->method('removeListener') + ->with('data', $this->identicalTo(array($request, 'handleData'))); + $this->stream + ->expects($this->at(8)) + ->method('removeListener') + ->with('end', $this->identicalTo(array($request, 'handleEnd'))); + $this->stream + ->expects($this->at(9)) + ->method('removeListener') + ->with('error', $this->identicalTo(array($request, 'handleError'))); + $this->stream + ->expects($this->at(10)) + ->method('removeListener') + ->with('close', $this->identicalTo(array($request, 'handleClose'))); + + $response = $this->response; + + $this->stream->expects($this->once()) + ->method('emit') + ->with('data', $this->identicalTo(array('body'))); + + $response->expects($this->at(0)) + ->method('on') + ->with('close', $this->anything()) + ->will($this->returnCallback(function ($event, $cb) use (&$endCallback) { + $endCallback = $cb; + })); + + $factory = $this->createCallableMock(); + $factory->expects($this->once()) + ->method('__invoke') + ->with('HTTP', '1.0', '200', 'OK', array('Content-Type' => 'text/plain')) + ->will($this->returnValue($response)); + + $request->setResponseFactory($factory); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke') + ->with($response); + + $request->on('response', $handler); + $request->on('end', $this->expectCallableNever()); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke'); + + $request->on('close', $handler); + $request->end(); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("\r\nbody"); + + $this->assertNotNull($endCallback); + call_user_func($endCallback); + } + + /** @test */ + public function requestShouldEmitErrorIfConnectionFails() + { + $requestData = new RequestData('GET', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->rejectedConnectionMock(); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke') + ->with( + $this->isInstanceOf('RuntimeException') + ); + + $request->on('error', $handler); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke'); + + $request->on('close', $handler); + $request->on('end', $this->expectCallableNever()); + + $request->end(); + } + + /** @test */ + public function requestShouldEmitErrorIfConnectionClosesBeforeResponseIsParsed() + { + $requestData = new RequestData('GET', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke') + ->with( + $this->isInstanceOf('RuntimeException') + ); + + $request->on('error', $handler); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke'); + + $request->on('close', $handler); + $request->on('end', $this->expectCallableNever()); + + $request->end(); + $request->handleEnd(); + } + + /** @test */ + public function requestShouldEmitErrorIfConnectionEmitsError() + { + $requestData = new RequestData('GET', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke') + ->with( + $this->isInstanceOf('Exception') + ); + + $request->on('error', $handler); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke'); + + $request->on('close', $handler); + $request->on('end', $this->expectCallableNever()); + + $request->end(); + $request->handleError(new \Exception('test')); + } + + /** @test */ + public function requestShouldEmitErrorIfGuzzleParseThrowsException() + { + $requestData = new RequestData('GET', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke') + ->with( + $this->isInstanceOf('\InvalidArgumentException') + ); + + $request->on('error', $handler); + + $request->end(); + $request->handleData("\r\n\r\n"); + } + + /** + * @test + */ + public function requestShouldEmitErrorIfUrlIsInvalid() + { + $requestData = new RequestData('GET', 'ftp://www.example.com'); + $request = new Request($this->connector, $requestData); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke') + ->with( + $this->isInstanceOf('\InvalidArgumentException') + ); + + $request->on('error', $handler); + + $this->connector->expects($this->never()) + ->method('connect'); + + $request->end(); + } + + /** + * @test + */ + public function requestShouldEmitErrorIfUrlHasNoScheme() + { + $requestData = new RequestData('GET', 'www.example.com'); + $request = new Request($this->connector, $requestData); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke') + ->with( + $this->isInstanceOf('\InvalidArgumentException') + ); + + $request->on('error', $handler); + + $this->connector->expects($this->never()) + ->method('connect'); + + $request->end(); + } + + /** @test */ + public function postRequestShouldSendAPostRequest() + { + $requestData = new RequestData('POST', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $this->stream + ->expects($this->once()) + ->method('write') + ->with($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\nUser-Agent:.*\r\n\r\nsome post data$#")); + + $factory = $this->createCallableMock(); + $factory->expects($this->once()) + ->method('__invoke') + ->will($this->returnValue($this->response)); + + $request->setResponseFactory($factory); + $request->end('some post data'); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("\r\nbody"); + } + + /** @test */ + public function writeWithAPostRequestShouldSendToTheStream() + { + $requestData = new RequestData('POST', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $this->stream + ->expects($this->at(5)) + ->method('write') + ->with($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\nUser-Agent:.*\r\n\r\nsome$#")); + $this->stream + ->expects($this->at(6)) + ->method('write') + ->with($this->identicalTo("post")); + $this->stream + ->expects($this->at(7)) + ->method('write') + ->with($this->identicalTo("data")); + + $factory = $this->createCallableMock(); + $factory->expects($this->once()) + ->method('__invoke') + ->will($this->returnValue($this->response)); + + $request->setResponseFactory($factory); + + $request->write("some"); + $request->write("post"); + $request->end("data"); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("\r\nbody"); + } + + /** @test */ + public function writeWithAPostRequestShouldSendBodyAfterHeadersAndEmitDrainEvent() + { + $requestData = new RequestData('POST', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $resolveConnection = $this->successfulAsyncConnectionMock(); + + $this->stream + ->expects($this->at(5)) + ->method('write') + ->with($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\nUser-Agent:.*\r\n\r\nsomepost$#")) + ->willReturn(true); + $this->stream + ->expects($this->at(6)) + ->method('write') + ->with($this->identicalTo("data")); + + $factory = $this->createCallableMock(); + $factory->expects($this->once()) + ->method('__invoke') + ->will($this->returnValue($this->response)); + + $request->setResponseFactory($factory); + + $this->assertFalse($request->write("some")); + $this->assertFalse($request->write("post")); + + $request->on('drain', $this->expectCallableOnce()); + $request->once('drain', function () use ($request) { + $request->write("data"); + $request->end(); + }); + + $resolveConnection(); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("\r\nbody"); + } + + /** @test */ + public function writeWithAPostRequestShouldForwardDrainEventIfFirstChunkExceedsBuffer() + { + $requestData = new RequestData('POST', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->stream = $this->getMockBuilder('React\Socket\Connection') + ->disableOriginalConstructor() + ->setMethods(array('write')) + ->getMock(); + + $resolveConnection = $this->successfulAsyncConnectionMock(); + + $this->stream + ->expects($this->at(0)) + ->method('write') + ->with($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\nUser-Agent:.*\r\n\r\nsomepost$#")) + ->willReturn(false); + $this->stream + ->expects($this->at(1)) + ->method('write') + ->with($this->identicalTo("data")); + + $factory = $this->createCallableMock(); + $factory->expects($this->once()) + ->method('__invoke') + ->will($this->returnValue($this->response)); + + $request->setResponseFactory($factory); + + $this->assertFalse($request->write("some")); + $this->assertFalse($request->write("post")); + + $request->on('drain', $this->expectCallableOnce()); + $request->once('drain', function () use ($request) { + $request->write("data"); + $request->end(); + }); + + $resolveConnection(); + $this->stream->emit('drain'); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("\r\nbody"); + } + + /** @test */ + public function pipeShouldPipeDataIntoTheRequestBody() + { + $requestData = new RequestData('POST', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $this->stream + ->expects($this->at(5)) + ->method('write') + ->with($this->matchesRegularExpression("#^POST / HTTP/1\.0\r\nHost: www.example.com\r\nUser-Agent:.*\r\n\r\nsome$#")); + $this->stream + ->expects($this->at(6)) + ->method('write') + ->with($this->identicalTo("post")); + $this->stream + ->expects($this->at(7)) + ->method('write') + ->with($this->identicalTo("data")); + + $factory = $this->createCallableMock(); + $factory->expects($this->once()) + ->method('__invoke') + ->will($this->returnValue($this->response)); + + $loop = $this + ->getMockBuilder('React\EventLoop\LoopInterface') + ->getMock(); + + $request->setResponseFactory($factory); + + $stream = fopen('php://memory', 'r+'); + $stream = new DuplexResourceStream($stream, $loop); + + $stream->pipe($request); + $stream->emit('data', array('some')); + $stream->emit('data', array('post')); + $stream->emit('data', array('data')); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("\r\nbody"); + } + + /** + * @test + */ + public function writeShouldStartConnecting() + { + $requestData = new RequestData('POST', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->connector->expects($this->once()) + ->method('connect') + ->with('www.example.com:80') + ->willReturn(new Promise(function () { })); + + $request->write('test'); + } + + /** + * @test + */ + public function endShouldStartConnectingAndChangeStreamIntoNonWritableMode() + { + $requestData = new RequestData('POST', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->connector->expects($this->once()) + ->method('connect') + ->with('www.example.com:80') + ->willReturn(new Promise(function () { })); + + $request->end(); + + $this->assertFalse($request->isWritable()); + } + + /** + * @test + */ + public function closeShouldEmitCloseEvent() + { + $requestData = new RequestData('POST', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $request->on('close', $this->expectCallableOnce()); + $request->close(); + } + + /** + * @test + */ + public function writeAfterCloseReturnsFalse() + { + $requestData = new RequestData('POST', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $request->close(); + + $this->assertFalse($request->isWritable()); + $this->assertFalse($request->write('nope')); + } + + /** + * @test + */ + public function endAfterCloseIsNoOp() + { + $requestData = new RequestData('POST', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->connector->expects($this->never()) + ->method('connect'); + + $request->close(); + $request->end(); + } + + /** + * @test + */ + public function closeShouldCancelPendingConnectionAttempt() + { + $requestData = new RequestData('POST', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $promise = new Promise(function () {}, function () { + throw new \RuntimeException(); + }); + + $this->connector->expects($this->once()) + ->method('connect') + ->with('www.example.com:80') + ->willReturn($promise); + + $request->end(); + + $request->on('error', $this->expectCallableNever()); + $request->on('close', $this->expectCallableOnce()); + + $request->close(); + $request->close(); + } + + /** @test */ + public function requestShouldRelayErrorEventsFromResponse() + { + $requestData = new RequestData('GET', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $response = $this->response; + + $response->expects($this->at(0)) + ->method('on') + ->with('close', $this->anything()); + $response->expects($this->at(1)) + ->method('on') + ->with('error', $this->anything()) + ->will($this->returnCallback(function ($event, $cb) use (&$errorCallback) { + $errorCallback = $cb; + })); + + $factory = $this->createCallableMock(); + $factory->expects($this->once()) + ->method('__invoke') + ->with('HTTP', '1.0', '200', 'OK', array('Content-Type' => 'text/plain')) + ->will($this->returnValue($response)); + + $request->setResponseFactory($factory); + $request->end(); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("\r\nbody"); + + $this->assertNotNull($errorCallback); + call_user_func($errorCallback, new \Exception('test')); + } + + /** @test */ + public function requestShouldRemoveAllListenerAfterClosed() + { + $requestData = new RequestData('GET', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $request->on('close', function () {}); + $this->assertCount(1, $request->listeners('close')); + + $request->close(); + $this->assertCount(0, $request->listeners('close')); + } + + private function successfulConnectionMock() + { + call_user_func($this->successfulAsyncConnectionMock()); + } + + private function successfulAsyncConnectionMock() + { + $deferred = new Deferred(); + + $this->connector + ->expects($this->once()) + ->method('connect') + ->with('www.example.com:80') + ->will($this->returnValue($deferred->promise())); + + $stream = $this->stream; + return function () use ($deferred, $stream) { + $deferred->resolve($stream); + }; + } + + private function rejectedConnectionMock() + { + $this->connector + ->expects($this->once()) + ->method('connect') + ->with('www.example.com:80') + ->will($this->returnValue(new RejectedPromise(new \RuntimeException()))); + } + + /** @test */ + public function multivalueHeader() + { + $requestData = new RequestData('GET', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $response = $this->response; + + $response->expects($this->at(0)) + ->method('on') + ->with('close', $this->anything()); + $response->expects($this->at(1)) + ->method('on') + ->with('error', $this->anything()) + ->will($this->returnCallback(function ($event, $cb) use (&$errorCallback) { + $errorCallback = $cb; + })); + + $factory = $this->createCallableMock(); + $factory->expects($this->once()) + ->method('__invoke') + ->with('HTTP', '1.0', '200', 'OK', array('Content-Type' => 'text/plain', 'X-Xss-Protection' => '1; mode=block', 'Cache-Control' => 'public, must-revalidate, max-age=0')) + ->will($this->returnValue($response)); + + $request->setResponseFactory($factory); + $request->end(); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Content-Type: text/plain\r\n"); + $request->handleData("X-Xss-Protection:1; mode=block\r\n"); + $request->handleData("Cache-Control:public, must-revalidate, max-age=0\r\n"); + $request->handleData("\r\nbody"); + + $this->assertNotNull($errorCallback); + call_user_func($errorCallback, new \Exception('test')); + } + + /** @test */ + public function chunkedStreamDecoder() + { + $requestData = new RequestData('GET', 'http://www.example.com'); + $request = new Request($this->connector, $requestData); + + $this->successfulConnectionMock(); + + $request->end(); + + $this->stream->expects($this->once()) + ->method('emit') + ->with('data', array("1\r\nb\r")); + + $request->handleData("HTTP/1.0 200 OK\r\n"); + $request->handleData("Transfer-Encoding: chunked\r\n"); + $request->handleData("\r\n1\r\nb\r"); + $request->handleData("\n3\t\nody\r\n0\t\n\r\n"); + + } +} diff --git a/tests/Client/ResponseTest.php b/tests/Client/ResponseTest.php new file mode 100644 index 00000000..14467239 --- /dev/null +++ b/tests/Client/ResponseTest.php @@ -0,0 +1,168 @@ +stream = $this->getMockBuilder('React\Stream\DuplexStreamInterface') + ->getMock(); + } + + /** @test */ + public function responseShouldEmitEndEventOnEnd() + { + $this->stream + ->expects($this->at(0)) + ->method('on') + ->with('data', $this->anything()); + $this->stream + ->expects($this->at(1)) + ->method('on') + ->with('error', $this->anything()); + $this->stream + ->expects($this->at(2)) + ->method('on') + ->with('end', $this->anything()); + $this->stream + ->expects($this->at(3)) + ->method('on') + ->with('close', $this->anything()); + + $response = new Response($this->stream, 'HTTP', '1.0', '200', 'OK', array('Content-Type' => 'text/plain')); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke') + ->with('some data'); + + $response->on('data', $handler); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke'); + + $response->on('end', $handler); + + $handler = $this->createCallableMock(); + $handler->expects($this->once()) + ->method('__invoke'); + + $response->on('close', $handler); + + $this->stream + ->expects($this->at(0)) + ->method('close'); + + $response->handleData('some data'); + $response->handleEnd(); + + $this->assertSame( + array( + 'Content-Type' => 'text/plain' + ), + $response->getHeaders() + ); + } + + /** @test */ + public function closedResponseShouldNotBeResumedOrPaused() + { + $response = new Response($this->stream, 'http', '1.0', '200', 'ok', array('content-type' => 'text/plain')); + + $this->stream + ->expects($this->never()) + ->method('pause'); + $this->stream + ->expects($this->never()) + ->method('resume'); + + $response->handleEnd(); + + $response->resume(); + $response->pause(); + + $this->assertSame( + array( + 'content-type' => 'text/plain', + ), + $response->getHeaders() + ); + } + + /** @test */ + public function chunkedEncodingResponse() + { + $stream = new ThroughStream(); + $response = new Response( + $stream, + 'http', + '1.0', + '200', + 'ok', + array( + 'content-type' => 'text/plain', + 'transfer-encoding' => 'chunked', + ) + ); + + $buffer = ''; + $response->on('data', function ($data) use (&$buffer) { + $buffer.= $data; + }); + $this->assertSame('', $buffer); + $stream->write("4; abc=def\r\n"); + $this->assertSame('', $buffer); + $stream->write("Wiki\r\n"); + $this->assertSame('Wiki', $buffer); + + $this->assertSame( + array( + 'content-type' => 'text/plain', + ), + $response->getHeaders() + ); + } + + /** @test */ + public function doubleChunkedEncodingResponseWillBePassedAsIs() + { + $stream = new ThroughStream(); + $response = new Response( + $stream, + 'http', + '1.0', + '200', + 'ok', + array( + 'content-type' => 'text/plain', + 'transfer-encoding' => array( + 'chunked', + 'chunked' + ) + ) + ); + + $this->assertSame( + array( + 'content-type' => 'text/plain', + 'transfer-encoding' => array( + 'chunked', + 'chunked' + ) + ), + $response->getHeaders() + ); + } +} + diff --git a/tests/Io/SenderTest.php b/tests/Io/SenderTest.php index aaf93ce1..8a04d1f3 100644 --- a/tests/Io/SenderTest.php +++ b/tests/Io/SenderTest.php @@ -3,13 +3,13 @@ namespace React\Tests\Http\Io; use Clue\React\Block; +use React\Http\Client\Client as HttpClient; +use React\Http\Client\RequestData; use React\Http\Io\Sender; use React\Http\Message\ReadableBodyStream; -use React\Tests\Http\TestCase; -use React\HttpClient\Client as HttpClient; -use React\HttpClient\RequestData; use React\Promise; use React\Stream\ThroughStream; +use React\Tests\Http\TestCase; use RingCentral\Psr7\Request; class SenderTest extends TestCase @@ -63,13 +63,13 @@ public function testSenderConnectorRejection() public function testSendPostWillAutomaticallySendContentLengthHeader() { - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->with( 'POST', 'http://www.google.com/', array('Host' => 'www.google.com', 'Content-Length' => '5'), '1.1' - )->willReturn($this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock()); + )->willReturn($this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock()); $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); @@ -79,13 +79,13 @@ public function testSendPostWillAutomaticallySendContentLengthHeader() public function testSendPostWillAutomaticallySendContentLengthZeroHeaderForEmptyRequestBody() { - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->with( 'POST', 'http://www.google.com/', array('Host' => 'www.google.com', 'Content-Length' => '0'), '1.1' - )->willReturn($this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock()); + )->willReturn($this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock()); $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); @@ -95,10 +95,10 @@ public function testSendPostWillAutomaticallySendContentLengthZeroHeaderForEmpty public function testSendPostStreamWillAutomaticallySendTransferEncodingChunked() { - $outgoing = $this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock(); + $outgoing = $this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock(); $outgoing->expects($this->once())->method('write')->with(""); - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->with( 'POST', 'http://www.google.com/', @@ -115,11 +115,11 @@ public function testSendPostStreamWillAutomaticallySendTransferEncodingChunked() public function testSendPostStreamWillAutomaticallyPipeChunkEncodeBodyForWriteAndRespectRequestThrottling() { - $outgoing = $this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock(); + $outgoing = $this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock(); $outgoing->expects($this->once())->method('isWritable')->willReturn(true); $outgoing->expects($this->exactly(2))->method('write')->withConsecutive(array(""), array("5\r\nhello\r\n"))->willReturn(false); - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->willReturn($outgoing); $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); @@ -134,12 +134,12 @@ public function testSendPostStreamWillAutomaticallyPipeChunkEncodeBodyForWriteAn public function testSendPostStreamWillAutomaticallyPipeChunkEncodeBodyForEnd() { - $outgoing = $this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock(); + $outgoing = $this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock(); $outgoing->expects($this->once())->method('isWritable')->willReturn(true); $outgoing->expects($this->exactly(2))->method('write')->withConsecutive(array(""), array("0\r\n\r\n"))->willReturn(false); $outgoing->expects($this->once())->method('end')->with(null); - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->willReturn($outgoing); $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); @@ -153,13 +153,13 @@ public function testSendPostStreamWillAutomaticallyPipeChunkEncodeBodyForEnd() public function testSendPostStreamWillRejectWhenRequestBodyEmitsErrorEvent() { - $outgoing = $this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock(); + $outgoing = $this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock(); $outgoing->expects($this->once())->method('isWritable')->willReturn(true); $outgoing->expects($this->once())->method('write')->with("")->willReturn(false); $outgoing->expects($this->never())->method('end'); $outgoing->expects($this->once())->method('close'); - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->willReturn($outgoing); $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); @@ -183,13 +183,13 @@ public function testSendPostStreamWillRejectWhenRequestBodyEmitsErrorEvent() public function testSendPostStreamWillRejectWhenRequestBodyClosesWithoutEnd() { - $outgoing = $this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock(); + $outgoing = $this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock(); $outgoing->expects($this->once())->method('isWritable')->willReturn(true); $outgoing->expects($this->once())->method('write')->with("")->willReturn(false); $outgoing->expects($this->never())->method('end'); $outgoing->expects($this->once())->method('close'); - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->willReturn($outgoing); $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); @@ -211,13 +211,13 @@ public function testSendPostStreamWillRejectWhenRequestBodyClosesWithoutEnd() public function testSendPostStreamWillNotRejectWhenRequestBodyClosesAfterEnd() { - $outgoing = $this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock(); + $outgoing = $this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock(); $outgoing->expects($this->once())->method('isWritable')->willReturn(true); $outgoing->expects($this->exactly(2))->method('write')->withConsecutive(array(""), array("0\r\n\r\n"))->willReturn(false); $outgoing->expects($this->once())->method('end'); $outgoing->expects($this->never())->method('close'); - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->willReturn($outgoing); $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); @@ -239,13 +239,13 @@ public function testSendPostStreamWillNotRejectWhenRequestBodyClosesAfterEnd() public function testSendPostStreamWithExplicitContentLengthWillSendHeaderAsIs() { - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->with( 'POST', 'http://www.google.com/', array('Host' => 'www.google.com', 'Content-Length' => '100'), '1.1' - )->willReturn($this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock()); + )->willReturn($this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock()); $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); @@ -256,13 +256,13 @@ public function testSendPostStreamWithExplicitContentLengthWillSendHeaderAsIs() public function testSendGetWillNotPassContentLengthHeaderForEmptyRequestBody() { - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->with( 'GET', 'http://www.google.com/', array('Host' => 'www.google.com'), '1.1' - )->willReturn($this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock()); + )->willReturn($this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock()); $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); @@ -272,13 +272,13 @@ public function testSendGetWillNotPassContentLengthHeaderForEmptyRequestBody() public function testSendCustomMethodWillNotPassContentLengthHeaderForEmptyRequestBody() { - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->with( 'CUSTOM', 'http://www.google.com/', array('Host' => 'www.google.com'), '1.1' - )->willReturn($this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock()); + )->willReturn($this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock()); $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); @@ -288,13 +288,13 @@ public function testSendCustomMethodWillNotPassContentLengthHeaderForEmptyReques public function testSendCustomMethodWithExplicitContentLengthZeroWillBePassedAsIs() { - $client = $this->getMockBuilder('React\HttpClient\Client')->disableOriginalConstructor()->getMock(); + $client = $this->getMockBuilder('React\Http\Client\Client')->disableOriginalConstructor()->getMock(); $client->expects($this->once())->method('request')->with( 'CUSTOM', 'http://www.google.com/', array('Host' => 'www.google.com', 'Content-Length' => '0'), '1.1' - )->willReturn($this->getMockBuilder('React\HttpClient\Request')->disableOriginalConstructor()->getMock()); + )->willReturn($this->getMockBuilder('React\Http\Client\Request')->disableOriginalConstructor()->getMock()); $sender = new Sender($client, $this->getMockBuilder('React\Http\Message\MessageFactory')->getMock()); @@ -370,7 +370,7 @@ public function provideRequestProtocolVersion() */ public function testRequestProtocolVersion(Request $Request, $method, $uri, $headers, $protocolVersion) { - $http = $this->getMockBuilder('React\HttpClient\Client') + $http = $this->getMockBuilder('React\Http\Client\Client') ->setMethods(array( 'request', )) @@ -378,7 +378,7 @@ public function testRequestProtocolVersion(Request $Request, $method, $uri, $hea $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(), ))->getMock(); - $request = $this->getMockBuilder('React\HttpClient\Request') + $request = $this->getMockBuilder('React\Http\Client\Request') ->setMethods(array()) ->setConstructorArgs(array( $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(),