From 42a12fa1fe13cb0ae0c404a26d31a6dfbc84c1ed Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Thu, 9 Feb 2017 16:58:11 +0100 Subject: [PATCH 1/8] Add ChunkedDecoder class --- src/ChunkedDecoder.php | 196 ++++++++++++++++++++++++++++++++++ tests/ChunkedDecoderTest.php | 200 +++++++++++++++++++++++++++++++++++ 2 files changed, 396 insertions(+) create mode 100644 src/ChunkedDecoder.php create mode 100644 tests/ChunkedDecoderTest.php diff --git a/src/ChunkedDecoder.php b/src/ChunkedDecoder.php new file mode 100644 index 00000000..cef4b1a5 --- /dev/null +++ b/src/ChunkedDecoder.php @@ -0,0 +1,196 @@ +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->input->close(); + + $this->emit('close'); + $this->removeAllListeners(); + } + + /** + * Extracts the hexadecimal header and removes it from the given data string + * + * @param string $data - complete or incomplete chunked string + * @return string + */ + private function handleChunkHeader($data) + { + $hexValue = strtok($this->buffer . $data, static::CRLF); + if ($this->isLineComplete($this->buffer . $data, $hexValue, strlen($hexValue))) { + + if (dechex(hexdec($hexValue)) != $hexValue) { + $this->emit('error', array(new \Exception('Unable to identify ' . $hexValue . 'as hexadecimal number'))); + $this->close(); + return; + } + + $this->chunkSize = hexdec($hexValue); + $this->chunkHeaderComplete = true; + + $data = substr($this->buffer . $data, strlen($hexValue) + 2); + $this->buffer = ''; + // Chunk header is complete + return $data; + } + + $this->buffer .= $data; + $data = ''; + // Chunk header isn't complete, buffer + return $data; + } + + /** + * Extracts the chunk data and removes it from the income data string + * + * @param unknown $data - string without the hexadecimal header + * @return string + */ + private function handleChunkData($data) + { + $chunk = substr($this->buffer . $data, 0, $this->chunkSize); + $this->actualChunksize = strlen($chunk); + + if ($this->chunkSize == $this->actualChunksize) { + $data = $this->sendChunk($data, $chunk); + } elseif ($this->actualChunksize < $this->chunkSize) { + $this->buffer .= $data; + $data = ''; + } + + return $data; + } + + /** + * Sends the chunk or ends the stream + * + * @param string $data - incomed data stream the chunk will be removed from this string + * @param string $chunk - chunk which will be emitted + * @return string - rest data string + */ + private function sendChunk($data, $chunk) + { + if ($this->chunkSize == 0 && $this->isLineComplete($this->buffer . $data, $chunk, $this->chunkSize)) { + $this->emit('end', array()); + return; + } + + if (!$this->isLineComplete($this->buffer . $data, $chunk, $this->chunkSize)) { + $this->emit('error', array(new \Exception('Chunk doesn\'t end with new line delimiter'))); + $this->close(); + return; + } + + $data = substr($this->buffer . $data, $this->chunkSize + 2); + $this->emit('data', array($chunk)); + + $this->buffer = ''; + $this->chunkSize = 0; + $this->chunkHeaderComplete = false; + + return $data; + } + + /** + * Checks if the given chunk is ending with a "\r\n" at the start of the data string + * + * @param string $data - complete data string + * @param string $chunk - string which should end with "\r\n" + * @param unknown $length - possible length of the data chunk + * @return boolean + */ + private function isLineComplete($data, $chunk, $length) + { + if (substr($data, 0, $length + 2) == $chunk . static::CRLF) { + return true; + } + return false; + } + + /** @internal */ + public function handleEnd() + { + if (! $this->closed) { + $this->emit('end'); + $this->close(); + } + } + + /** @internal */ + public function handleError(\Exception $e) + { + $this->emit('error', array($e)); + $this->close(); + } + + /** @internal */ + public function handleData($data) + { + while (strlen($data) != 0) { + if (! $this->chunkHeaderComplete) { + $data = $this->handleChunkHeader($data); + } + // Not 'else', chunkHeaderComplete can change in 'handleChunkHeader' + if ($this->chunkHeaderComplete) { + $data = $this->handleChunkData($data); + } + } + } +} diff --git a/tests/ChunkedDecoderTest.php b/tests/ChunkedDecoderTest.php new file mode 100644 index 00000000..73bb2130 --- /dev/null +++ b/tests/ChunkedDecoderTest.php @@ -0,0 +1,200 @@ +input = new ReadableStream(); + $this->parser = new ChunkedDecoder($this->input); + } + + public function testSimpleChunk() + { + $this->parser->on('data', $this->expectCallableOnceWith('hello')); + $this->input->emit('data', array("5\r\nhello\r\n")); + } + + public function testTwoChunks() + { + $this->parser->on('data', $this->expectCallableConsecutive(2, array('hello', 'bla'))); + $this->input->emit('data', array("5\r\nhello\r\n3\r\nbla\r\n")); + } + + public function testEnd() + { + $this->parser->on('end', $this->expectCallableOnce(array())); + $this->input->emit('data', array("0\r\n\r\n")); + } + + public function testParameterWithEnd() + { + $this->parser->on('data', $this->expectCallableConsecutive(2, array('hello', 'bla'))); + $this->parser->on('end', $this->expectCallableOnce(array())); + $this->input->emit('data', array("5\r\nhello\r\n3\r\nbla\r\n0\r\n\r\n")); + } + + public function testInvalidChunk() + { + $this->parser->on('data', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableOnce(array())); + $this->input->emit('data', array("bla\r\n")); + } + + public function testNeverEnd() + { + $this->parser->on('end', $this->expectCallableNever()); + $this->input->emit('data', array("0\r\n")); + } + + public function testWrongChunkHex() + { + $this->parser->on('error', $this->expectCallableOnce(array())); + $this->input->emit('data', array("2\r\na\r\n5\r\nhello\r\n")); + } + + public function testSplittedChunk() + { + $this->parser->on('data', $this->expectCallableOnceWith('welt')); + + $this->input->emit('data', array("4\r\n")); + $this->input->emit('data', array("welt\r\n")); + } + + public function testSplittedHeader() + { + $this->parser->on('data', $this->expectCallableOnceWith('welt')); + + $this->input->emit('data', array("4")); + $this->input->emit('data', array("\r\nwelt\r\n")); + } + + public function testSplittedBoth() + { + $this->parser->on('data', $this->expectCallableOnceWith('welt')); + + $this->input->emit('data', array("4")); + $this->input->emit('data', array("\r\n")); + $this->input->emit('data', array("welt\r\n")); + } + + public function testCompletlySplitted() + { + $this->parser->on('data', $this->expectCallableOnceWith('welt')); + + $this->input->emit('data', array("4")); + $this->input->emit('data', array("\r\n")); + $this->input->emit('data', array("we")); + $this->input->emit('data', array("lt\r\n")); + } + + public function testMixed() + { + $this->parser->on('data', $this->expectCallableConsecutive(2, array('welt', 'hello'))); + + $this->input->emit('data', array("4")); + $this->input->emit('data', array("\r\n")); + $this->input->emit('data', array("we")); + $this->input->emit('data', array("lt\r\n")); + $this->input->emit('data', array("5\r\nhello\r\n")); + } + + public function testBigger() + { + $this->parser->on('data', $this->expectCallableConsecutive(2, array('abcdeabcdeabcdea', 'hello'))); + + $this->input->emit('data', array("1")); + $this->input->emit('data', array("0")); + $this->input->emit('data', array("\r\n")); + $this->input->emit('data', array("abcdeabcdeabcdea\r\n")); + $this->input->emit('data', array("5\r\nhello\r\n")); + } + + public function testOneUnfinished() + { + $this->parser->on('data', $this->expectCallableOnceWith('bla')); + + $this->input->emit('data', array("3\r\n")); + $this->input->emit('data', array("bla\r\n")); + $this->input->emit('data', array("5\r\nhello")); + } + + public function testHandleError() + { + $this->parser->on('error', $this->expectCallableOnce()); + $this->parser->on('close', $this->expectCallableOnce()); + + $this->input->emit('error', array(new \RuntimeException())); + + $this->assertFalse($this->parser->isReadable()); + } + + public function testPauseStream() + { + $input = $this->getMock('React\Stream\ReadableStreamInterface'); + $input->expects($this->once())->method('pause'); + + $parser = new ChunkedDecoder($input); + $parser->pause(); + } + + public function testResumeStream() + { + $input = $this->getMock('React\Stream\ReadableStreamInterface'); + $input->expects($this->once())->method('pause'); + + $parser = new ChunkedDecoder($input); + $parser->pause(); + $parser->resume(); + } + + public function testPipeStream() + { + $dest = $this->getMock('React\Stream\WritableStreamInterface'); + + $ret = $this->parser->pipe($dest); + + $this->assertSame($dest, $ret); + } + + public function testHandleClose() + { + $this->parser->on('close', $this->expectCallableOnce()); + + $this->input->close(); + $this->input->emit('end', array()); + + $this->assertFalse($this->parser->isReadable()); + } + + public function testOutputStreamCanCloseInputStream() + { + $input = new ReadableStream(); + $input->on('close', $this->expectCallableOnce()); + + $stream = new ChunkedDecoder($input); + $stream->on('close', $this->expectCallableOnce()); + + $stream->close(); + + $this->assertFalse($input->isReadable()); + } + + private function expectCallableConsecutive($numberOfCalls, array $with) + { + $mock = $this->createCallableMock(); + + for ($i = 0; $i < $numberOfCalls; $i++) { + $mock + ->expects($this->at($i)) + ->method('__invoke') + ->with($this->equalTo($with[$i])); + } + + return $mock; + } +} From 2e7d1e39826156dc84991c0a30ef04319ddd4d5c Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Thu, 9 Feb 2017 16:59:57 +0100 Subject: [PATCH 2/8] Add new method to TestCase --- tests/TestCase.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/TestCase.php b/tests/TestCase.php index 24fe27f2..a33044a6 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -34,6 +34,17 @@ protected function expectCallableNever() return $mock; } + protected function expectCallableOnceWith($value) + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->equalTo($value)); + + return $mock; + } + protected function createCallableMock() { return $this From 8e70e6d9f0662885af1032217eeaabfc5525b4a7 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Thu, 9 Feb 2017 17:00:17 +0100 Subject: [PATCH 3/8] Implement request decoding for requests --- src/Server.php | 31 +++++++++------ tests/ServerTest.php | 89 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 11 deletions(-) diff --git a/src/Server.php b/src/Server.php index f6e45a53..8fe3c066 100644 --- a/src/Server.php +++ b/src/Server.php @@ -22,7 +22,9 @@ public function __construct(SocketServerInterface $io) // TODO: multipart parsing $parser = new RequestHeaderParser(); - $parser->on('headers', function (Request $request, $bodyBuffer) use ($conn, $parser, $that) { + $listener = array($parser, 'feed'); + + $parser->on('headers', function (Request $request, $bodyBuffer) use ($conn, $parser, $that, $listener) { // attach remote ip to the request as metadata $request->remoteAddress = $conn->getRemoteAddress(); @@ -30,18 +32,11 @@ public function __construct(SocketServerInterface $io) $request->on('pause', array($conn, 'pause')); $request->on('resume', array($conn, 'resume')); - $that->handleRequest($conn, $request, $bodyBuffer); + $conn->removeListener('data', $listener); - $conn->removeListener('data', array($parser, 'feed')); - $conn->on('end', function () use ($request) { - $request->emit('end'); - }); - $conn->on('data', function ($data) use ($request) { - $request->emit('data', array($data)); - }); + $that->handleRequest($conn, $request, $bodyBuffer); }); - $listener = array($parser, 'feed'); $conn->on('data', $listener); $parser->on('error', function() use ($conn, $listener, $that) { // TODO: return 400 response @@ -62,7 +57,21 @@ public function handleRequest(ConnectionInterface $conn, Request $request, $body return; } + $header = $request->getHeaders(); + $stream = $conn; + if (!empty($header['Transfer-Encoding']) && $header['Transfer-Encoding'] === 'chunked') { + $stream = new ChunkedDecoder($conn); + } + + $stream->on('data', function ($data) use ($request) { + $request->emit('data', array($data)); + }); + + $stream->on('end', function () use ($request) { + $request->emit('end', array()); + }); + $this->emit('request', array($request, $response)); - $request->emit('data', array($bodyBuffer)); + $conn->emit('data', array($bodyBuffer)); } } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index b9234521..0d96e27e 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -155,6 +155,95 @@ public function testParserErrorEmitted() $this->connection->expects($this->never())->method('write'); } + public function testBodyDataWillBeSendViaRequestEvent() + { + $server = new Server($this->socket); + + $that = $this; + $server->on('request', function (Request $request, Response $response) use ($that) { + $request->on('data', $that->expectCallableOnceWith('hello')); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: 5\r\n"; + $data .= "\r\n"; + $data .= "hello"; + + $this->connection->emit('data', array($data)); + } + + public function testChunkedEncodedRequestWillBeParsedForRequestEvent() + { + $server = new Server($this->socket); + + $that = $this; + $server->on('request', function (Request $request, Response $response) use ($that) { + $request->on('data', $that->expectCallableOnceWith('hello')); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Transfer-Encoding: chunked\r\n"; + $data .= "\r\n"; + $data .= "5\r\nhello\r\n"; + $data .= "0\r\n\r\n"; + + $this->connection->emit('data', array($data)); + } + + public function testChunkedEncodedRequestAdditionalDataWontBeEmitted() + { + $server = new Server($this->socket); + + $that = $this; + $server->on('request', function (Request $request, Response $response) use ($that) { + $request->on('data', $that->expectCallableOnce()); + $request->on('end', $that->expectCallableOnce()); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Transfer-Encoding: chunked\r\n"; + $data .= "\r\n"; + $data .= "5\r\nhello\r\n"; + $data .= "0\r\n\r\n"; + $data .= "2\r\nhi\r\n"; + + $this->connection->emit('data', array($data)); + } + + public function testEmptyChunkedEncodedRequest() + { + $server = new Server($this->socket); + + $that = $this; + $server->on('request', function (Request $request, Response $response) use ($that) { + $request->on('data', $that->expectCallableNever()); + $request->on('end', $that->expectCallableOnce()); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Transfer-Encoding: chunked\r\n"; + $data .= "\r\n"; + $data .= "0\r\n\r\n"; + + $this->connection->emit('data', array($data)); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From 4eea7d1741c12b208be2d792d3f6cc8d34076e37 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Fri, 10 Feb 2017 15:44:30 +0100 Subject: [PATCH 4/8] Chunked must be last element of Transfer-Encoding and CaseInsensitve --- src/Server.php | 9 +++-- tests/ServerTest.php | 90 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 82 insertions(+), 17 deletions(-) diff --git a/src/Server.php b/src/Server.php index 8fe3c066..add44d6e 100644 --- a/src/Server.php +++ b/src/Server.php @@ -57,10 +57,13 @@ public function handleRequest(ConnectionInterface $conn, Request $request, $body return; } - $header = $request->getHeaders(); $stream = $conn; - if (!empty($header['Transfer-Encoding']) && $header['Transfer-Encoding'] === 'chunked') { - $stream = new ChunkedDecoder($conn); + if ($request->hasHeader('Transfer-Encoding')) { + $transferEncodingHeader = $request->getHeader('Transfer-Encoding'); + // 'chunked' must always be the final value of 'Transfer-Encoding' according to: https://tools.ietf.org/html/rfc7230#section-3.3.1 + if (strtolower(end($transferEncodingHeader)) === 'chunked') { + $stream = new ChunkedDecoder($conn); + } } $stream->on('data', function ($data) use ($request) { diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 0d96e27e..51680bd3 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -159,9 +159,11 @@ public function testBodyDataWillBeSendViaRequestEvent() { $server = new Server($this->socket); - $that = $this; - $server->on('request', function (Request $request, Response $response) use ($that) { - $request->on('data', $that->expectCallableOnceWith('hello')); + $buffer = ''; + $server->on('request', function (Request $request, Response $response) use (&$buffer) { + $request->on('data', function ($data) use (&$buffer) { + $buffer .= $data; + }); }); $this->socket->emit('connection', array($this->connection)); @@ -174,15 +176,19 @@ public function testBodyDataWillBeSendViaRequestEvent() $data .= "hello"; $this->connection->emit('data', array($data)); + + $this->assertEquals('hello', $buffer); } public function testChunkedEncodedRequestWillBeParsedForRequestEvent() { $server = new Server($this->socket); - $that = $this; - $server->on('request', function (Request $request, Response $response) use ($that) { - $request->on('data', $that->expectCallableOnceWith('hello')); + $buffer = ''; + $server->on('request', function (Request $request, Response $response) use (&$buffer) { + $request->on('data', function ($data) use (&$buffer) { + $buffer .= $data; + }); }); $this->socket->emit('connection', array($this->connection)); @@ -196,16 +202,19 @@ public function testChunkedEncodedRequestWillBeParsedForRequestEvent() $data .= "0\r\n\r\n"; $this->connection->emit('data', array($data)); + + $this->assertEquals('hello', $buffer); } public function testChunkedEncodedRequestAdditionalDataWontBeEmitted() { $server = new Server($this->socket); - $that = $this; - $server->on('request', function (Request $request, Response $response) use ($that) { - $request->on('data', $that->expectCallableOnce()); - $request->on('end', $that->expectCallableOnce()); + $buffer = ''; + $server->on('request', function (Request $request, Response $response) use (&$buffer) { + $request->on('data', function ($data) use (&$buffer) { + $buffer .= $data; + }); }); $this->socket->emit('connection', array($this->connection)); @@ -220,16 +229,18 @@ public function testChunkedEncodedRequestAdditionalDataWontBeEmitted() $data .= "2\r\nhi\r\n"; $this->connection->emit('data', array($data)); + $this->assertEquals('hello', $buffer); } public function testEmptyChunkedEncodedRequest() { $server = new Server($this->socket); - $that = $this; - $server->on('request', function (Request $request, Response $response) use ($that) { - $request->on('data', $that->expectCallableNever()); - $request->on('end', $that->expectCallableOnce()); + $buffer = ''; + $server->on('request', function (Request $request, Response $response) use (&$buffer) { + $request->on('data', function ($data) use (&$buffer) { + $buffer .= $data; + }); }); $this->socket->emit('connection', array($this->connection)); @@ -242,6 +253,57 @@ public function testEmptyChunkedEncodedRequest() $data .= "0\r\n\r\n"; $this->connection->emit('data', array($data)); + $this->assertEquals('', $buffer); + } + + public function testChunkedIsUpperCase() + { + $server = new Server($this->socket); + + $buffer = ''; + $server->on('request', function (Request $request, Response $response) use (&$buffer) { + $request->on('data', function ($data) use (&$buffer) { + $buffer .= $data; + }); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Transfer-Encoding: CHUNKED\r\n"; + $data .= "\r\n"; + $data .= "5\r\nhello\r\n"; + $data .= "0\r\n\r\n"; + + $this->connection->emit('data', array($data)); + $this->assertEquals('hello', $buffer); + } + + public function testChunkedIsMixedUpperAndLowerCase() + { + $server = new Server($this->socket); + + $buffer = ''; + $server->on('request', function (Request $request, Response $response) use (&$buffer) { + $request->on('data', function ($data) use (&$buffer) { + $buffer .= $data; + }); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Transfer-Encoding: CHunKeD\r\n"; + $data .= "\r\n"; + $data .= "5\r\nhello\r\n"; + $data .= "0\r\n\r\n"; + + $this->connection->emit('data', array($data)); + $this->assertEquals('hello', $buffer); } private function createGetRequest() From 31ef4cb1c114953173f21b42f24780fc0380d8d6 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Fri, 10 Feb 2017 15:44:57 +0100 Subject: [PATCH 5/8] Add @internal --- src/ChunkedDecoder.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ChunkedDecoder.php b/src/ChunkedDecoder.php index cef4b1a5..747224c7 100644 --- a/src/ChunkedDecoder.php +++ b/src/ChunkedDecoder.php @@ -7,6 +7,7 @@ use React\Stream\Util; use Exception; +/** @internal */ class ChunkedDecoder extends EventEmitter implements ReadableStreamInterface { const CRLF = "\r\n"; From dec22911b7c421aaa63fd75f9f9728cc3c016ef0 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Sat, 11 Feb 2017 07:29:55 +0100 Subject: [PATCH 6/8] Add TestCase method --- tests/ChunkedDecoderTest.php | 14 -------------- tests/TestCase.php | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/ChunkedDecoderTest.php b/tests/ChunkedDecoderTest.php index 73bb2130..d48f634d 100644 --- a/tests/ChunkedDecoderTest.php +++ b/tests/ChunkedDecoderTest.php @@ -183,18 +183,4 @@ public function testOutputStreamCanCloseInputStream() $this->assertFalse($input->isReadable()); } - - private function expectCallableConsecutive($numberOfCalls, array $with) - { - $mock = $this->createCallableMock(); - - for ($i = 0; $i < $numberOfCalls; $i++) { - $mock - ->expects($this->at($i)) - ->method('__invoke') - ->with($this->equalTo($with[$i])); - } - - return $mock; - } } diff --git a/tests/TestCase.php b/tests/TestCase.php index a33044a6..ee6d3191 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -45,6 +45,20 @@ protected function expectCallableOnceWith($value) return $mock; } + protected function expectCallableConsecutive($numberOfCalls, array $with) + { + $mock = $this->createCallableMock(); + + for ($i = 0; $i < $numberOfCalls; $i++) { + $mock + ->expects($this->at($i)) + ->method('__invoke') + ->with($this->equalTo($with[$i])); + } + + return $mock; + } + protected function createCallableMock() { return $this From 0e508772d077c3c5df6fb4d4535b8c0b4bb7eb11 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Sat, 11 Feb 2017 07:30:53 +0100 Subject: [PATCH 7/8] Reworked tests --- tests/ServerTest.php | 140 +++++++++++++++++++++++++++++++------------ 1 file changed, 102 insertions(+), 38 deletions(-) diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 51680bd3..61623e8b 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -159,11 +159,12 @@ public function testBodyDataWillBeSendViaRequestEvent() { $server = new Server($this->socket); - $buffer = ''; - $server->on('request', function (Request $request, Response $response) use (&$buffer) { - $request->on('data', function ($data) use (&$buffer) { - $buffer .= $data; - }); + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); }); $this->socket->emit('connection', array($this->connection)); @@ -176,19 +177,18 @@ public function testBodyDataWillBeSendViaRequestEvent() $data .= "hello"; $this->connection->emit('data', array($data)); - - $this->assertEquals('hello', $buffer); } public function testChunkedEncodedRequestWillBeParsedForRequestEvent() { $server = new Server($this->socket); - $buffer = ''; - $server->on('request', function (Request $request, Response $response) use (&$buffer) { - $request->on('data', function ($data) use (&$buffer) { - $buffer .= $data; - }); + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); }); $this->socket->emit('connection', array($this->connection)); @@ -202,19 +202,18 @@ public function testChunkedEncodedRequestWillBeParsedForRequestEvent() $data .= "0\r\n\r\n"; $this->connection->emit('data', array($data)); - - $this->assertEquals('hello', $buffer); } public function testChunkedEncodedRequestAdditionalDataWontBeEmitted() { $server = new Server($this->socket); - $buffer = ''; - $server->on('request', function (Request $request, Response $response) use (&$buffer) { - $request->on('data', function ($data) use (&$buffer) { - $buffer .= $data; - }); + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); }); $this->socket->emit('connection', array($this->connection)); @@ -229,18 +228,71 @@ public function testChunkedEncodedRequestAdditionalDataWontBeEmitted() $data .= "2\r\nhi\r\n"; $this->connection->emit('data', array($data)); - $this->assertEquals('hello', $buffer); } public function testEmptyChunkedEncodedRequest() { $server = new Server($this->socket); - $buffer = ''; - $server->on('request', function (Request $request, Response $response) use (&$buffer) { - $request->on('data', function ($data) use (&$buffer) { - $buffer .= $data; - }); + $dataEvent = $this->expectCallableNever(); + $endEvent = $this->expectCallableOnce(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Transfer-Encoding: chunked\r\n"; + $data .= "\r\n"; + $data .= "0\r\n\r\n"; + + $this->connection->emit('data', array($data)); + } + + public function testOneChunkWillBeEmittedDelayed() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Transfer-Encoding: chunked\r\n"; + $data .= "\r\n"; + $data .= "5\r\nhel"; + + $this->connection->emit('data', array($data)); + + $data = "lo\r\n"; + $data .= "0\r\n\r\n"; + + $this->connection->emit('data', array($data)); + } + + public function testEmitTwoChunksDelayed() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableConsecutive(2, array('hello', 'world')); + $endEvent = $this->expectCallableOnce(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); }); $this->socket->emit('connection', array($this->connection)); @@ -250,21 +302,30 @@ public function testEmptyChunkedEncodedRequest() $data .= "Connection: close\r\n"; $data .= "Transfer-Encoding: chunked\r\n"; $data .= "\r\n"; + $data .= "5\r\nhello\r\n"; + + $this->connection->emit('data', array($data)); + + $data = "5\r\nworld\r\n"; $data .= "0\r\n\r\n"; $this->connection->emit('data', array($data)); - $this->assertEquals('', $buffer); } + /** + * All transfer-coding names are case-insensitive according to: + * https://tools.ietf.org/html/rfc7230#section-4 + */ public function testChunkedIsUpperCase() { $server = new Server($this->socket); - $buffer = ''; - $server->on('request', function (Request $request, Response $response) use (&$buffer) { - $request->on('data', function ($data) use (&$buffer) { - $buffer .= $data; - }); + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); }); $this->socket->emit('connection', array($this->connection)); @@ -278,18 +339,22 @@ public function testChunkedIsUpperCase() $data .= "0\r\n\r\n"; $this->connection->emit('data', array($data)); - $this->assertEquals('hello', $buffer); } + /** + * All transfer-coding names are case-insensitive according to: + * https://tools.ietf.org/html/rfc7230#section-4 + */ public function testChunkedIsMixedUpperAndLowerCase() { $server = new Server($this->socket); - $buffer = ''; - $server->on('request', function (Request $request, Response $response) use (&$buffer) { - $request->on('data', function ($data) use (&$buffer) { - $buffer .= $data; - }); + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); }); $this->socket->emit('connection', array($this->connection)); @@ -303,7 +368,6 @@ public function testChunkedIsMixedUpperAndLowerCase() $data .= "0\r\n\r\n"; $this->connection->emit('data', array($data)); - $this->assertEquals('hello', $buffer); } private function createGetRequest() From 5bfc4458f913326c91ee59a4cc88c9d908fcfb4a Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Sun, 12 Feb 2017 22:25:23 +0100 Subject: [PATCH 8/8] Reworked chunked decoder --- src/ChunkedDecoder.php | 143 +++++++++++------------------------ tests/ChunkedDecoderTest.php | 136 ++++++++++++++++++++++----------- 2 files changed, 135 insertions(+), 144 deletions(-) diff --git a/src/ChunkedDecoder.php b/src/ChunkedDecoder.php index 747224c7..e9b68608 100644 --- a/src/ChunkedDecoder.php +++ b/src/ChunkedDecoder.php @@ -17,19 +17,17 @@ class ChunkedDecoder extends EventEmitter implements ReadableStreamInterface private $buffer = ''; private $chunkSize = 0; private $actualChunksize = 0; - private $chunkHeaderComplete = false; public function __construct(ReadableStreamInterface $input) { $this->input = $input; - $this->input->on('data', array($this, 'handleData')); + $this->input->on('data', array($this, 'handleChunkHeader')); $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(); @@ -58,6 +56,8 @@ public function close() return; } + $this->buffer = ''; + $this->closed = true; $this->input->close(); @@ -66,109 +66,70 @@ public function close() $this->removeAllListeners(); } - /** - * Extracts the hexadecimal header and removes it from the given data string - * - * @param string $data - complete or incomplete chunked string - * @return string - */ - private function handleChunkHeader($data) + + /** @internal */ + public function handleChunkHeader($data) { - $hexValue = strtok($this->buffer . $data, static::CRLF); - if ($this->isLineComplete($this->buffer . $data, $hexValue, strlen($hexValue))) { + $this->buffer .= $data; - if (dechex(hexdec($hexValue)) != $hexValue) { - $this->emit('error', array(new \Exception('Unable to identify ' . $hexValue . 'as hexadecimal number'))); - $this->close(); - return; - } + $hexValue = strtok($this->buffer, static::CRLF); + if (dechex(hexdec($hexValue)) != $hexValue) { + $this->emit('error', array(new \Exception('Unable to identify ' . $hexValue . 'as hexadecimal number'))); + $this->close(); + return; + } + if (strpos($this->buffer, static::CRLF) !== false) { $this->chunkSize = hexdec($hexValue); - $this->chunkHeaderComplete = true; - $data = substr($this->buffer . $data, strlen($hexValue) + 2); + $data = substr($this->buffer, strlen($hexValue) + 2); $this->buffer = ''; // Chunk header is complete - return $data; + $this->input->removeListener('data', array($this, 'handleChunkHeader')); + $this->input->on('data', array($this, 'handleChunkData')); + if ($data !== '') { + $this->input->emit('data', array($data)); + } } - - $this->buffer .= $data; - $data = ''; - // Chunk header isn't complete, buffer - return $data; } - /** - * Extracts the chunk data and removes it from the income data string - * - * @param unknown $data - string without the hexadecimal header - * @return string - */ - private function handleChunkData($data) + /** @internal */ + public function handleChunkData($data) { - $chunk = substr($this->buffer . $data, 0, $this->chunkSize); + $this->buffer .= $data; + $chunk = substr($this->buffer, 0, $this->chunkSize); $this->actualChunksize = strlen($chunk); - if ($this->chunkSize == $this->actualChunksize) { - $data = $this->sendChunk($data, $chunk); - } elseif ($this->actualChunksize < $this->chunkSize) { - $this->buffer .= $data; - $data = ''; - } - - return $data; - } - - /** - * Sends the chunk or ends the stream - * - * @param string $data - incomed data stream the chunk will be removed from this string - * @param string $chunk - chunk which will be emitted - * @return string - rest data string - */ - private function sendChunk($data, $chunk) - { - if ($this->chunkSize == 0 && $this->isLineComplete($this->buffer . $data, $chunk, $this->chunkSize)) { - $this->emit('end', array()); - return; - } - - if (!$this->isLineComplete($this->buffer . $data, $chunk, $this->chunkSize)) { - $this->emit('error', array(new \Exception('Chunk doesn\'t end with new line delimiter'))); - $this->close(); - return; - } - - $data = substr($this->buffer . $data, $this->chunkSize + 2); - $this->emit('data', array($chunk)); + if ($this->chunkSize === $this->actualChunksize) { + if ($this->chunkSize === 0 && strpos($this->buffer, static::CRLF) !== false) { + $this->emit('end', array()); + $this->close(); + return; + } - $this->buffer = ''; - $this->chunkSize = 0; - $this->chunkHeaderComplete = false; + if (strpos($this->buffer, static::CRLF) === false) { + return; + } - return $data; - } + $data = substr($this->buffer , $this->chunkSize + 2); + $this->emit('data', array($chunk)); - /** - * Checks if the given chunk is ending with a "\r\n" at the start of the data string - * - * @param string $data - complete data string - * @param string $chunk - string which should end with "\r\n" - * @param unknown $length - possible length of the data chunk - * @return boolean - */ - private function isLineComplete($data, $chunk, $length) - { - if (substr($data, 0, $length + 2) == $chunk . static::CRLF) { - return true; + $this->buffer = ''; + $this->chunkSize = 0; + // chunk body is complete + $this->input->removeListener('data', array($this, 'handleChunkData')); + $this->input->on('data', array($this, 'handleChunkHeader')); + if ($data !== '') { + $this->input->emit('data', array($data)); + } } - return false; } /** @internal */ public function handleEnd() { - if (! $this->closed) { + if (!$this->closed) { + $this->buffer = ''; $this->emit('end'); $this->close(); } @@ -180,18 +141,4 @@ public function handleError(\Exception $e) $this->emit('error', array($e)); $this->close(); } - - /** @internal */ - public function handleData($data) - { - while (strlen($data) != 0) { - if (! $this->chunkHeaderComplete) { - $data = $this->handleChunkHeader($data); - } - // Not 'else', chunkHeaderComplete can change in 'handleChunkHeader' - if ($this->chunkHeaderComplete) { - $data = $this->handleChunkData($data); - } - } - } } diff --git a/tests/ChunkedDecoderTest.php b/tests/ChunkedDecoderTest.php index d48f634d..98a719cd 100644 --- a/tests/ChunkedDecoderTest.php +++ b/tests/ChunkedDecoderTest.php @@ -16,50 +16,74 @@ public function setUp() public function testSimpleChunk() { $this->parser->on('data', $this->expectCallableOnceWith('hello')); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableNever()); + $this->input->emit('data', array("5\r\nhello\r\n")); } public function testTwoChunks() { - $this->parser->on('data', $this->expectCallableConsecutive(2, array('hello', 'bla'))); - $this->input->emit('data', array("5\r\nhello\r\n3\r\nbla\r\n")); + $this->parser->on('data', $this->expectCallableConsecutive(2, array('hello', 'bla'))); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableNever()); + + $this->input->emit('data', array("5\r\nhello\r\n3\r\nbla\r\n")); } public function testEnd() { - $this->parser->on('end', $this->expectCallableOnce(array())); - $this->input->emit('data', array("0\r\n\r\n")); + $this->parser->on('end', $this->expectCallableOnce()); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('error', $this->expectCallableNever()); + + $this->input->emit('data', array("0\r\n\r\n")); } public function testParameterWithEnd() { - $this->parser->on('data', $this->expectCallableConsecutive(2, array('hello', 'bla'))); - $this->parser->on('end', $this->expectCallableOnce(array())); - $this->input->emit('data', array("5\r\nhello\r\n3\r\nbla\r\n0\r\n\r\n")); + $this->parser->on('data', $this->expectCallableConsecutive(2, array('hello', 'bla'))); + $this->parser->on('end', $this->expectCallableOnce()); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('error', $this->expectCallableNever()); + + $this->input->emit('data', array("5\r\nhello\r\n3\r\nbla\r\n0\r\n\r\n")); } public function testInvalidChunk() { - $this->parser->on('data', $this->expectCallableNever()); - $this->parser->on('error', $this->expectCallableOnce(array())); - $this->input->emit('data', array("bla\r\n")); + $this->parser->on('data', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('error', $this->expectCallableOnce()); + + $this->input->emit('data', array("bla\r\n")); } public function testNeverEnd() { - $this->parser->on('end', $this->expectCallableNever()); - $this->input->emit('data', array("0\r\n")); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableNever()); + + $this->input->emit('data', array("0\r\n")); } public function testWrongChunkHex() { - $this->parser->on('error', $this->expectCallableOnce(array())); - $this->input->emit('data', array("2\r\na\r\n5\r\nhello\r\n")); + $this->parser->on('error', $this->expectCallableOnce()); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + + $this->input->emit('data', array("2\r\na\r\n5\r\nhello\r\n")); } public function testSplittedChunk() { - $this->parser->on('data', $this->expectCallableOnceWith('welt')); + $this->parser->on('data', $this->expectCallableOnceWith('welt')); + $this->parser->on('close', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableNever()); $this->input->emit('data', array("4\r\n")); $this->input->emit('data', array("welt\r\n")); @@ -67,66 +91,86 @@ public function testSplittedChunk() public function testSplittedHeader() { - $this->parser->on('data', $this->expectCallableOnceWith('welt')); + $this->parser->on('data', $this->expectCallableOnceWith('welt')); + $this->parser->on('close', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever());# + $this->parser->on('error', $this->expectCallableNever()); + - $this->input->emit('data', array("4")); - $this->input->emit('data', array("\r\nwelt\r\n")); + $this->input->emit('data', array("4")); + $this->input->emit('data', array("\r\nwelt\r\n")); } public function testSplittedBoth() { - $this->parser->on('data', $this->expectCallableOnceWith('welt')); + $this->parser->on('data', $this->expectCallableOnceWith('welt')); + $this->parser->on('close', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableNever()); - $this->input->emit('data', array("4")); - $this->input->emit('data', array("\r\n")); - $this->input->emit('data', array("welt\r\n")); + $this->input->emit('data', array("4")); + $this->input->emit('data', array("\r\n")); + $this->input->emit('data', array("welt\r\n")); } public function testCompletlySplitted() { - $this->parser->on('data', $this->expectCallableOnceWith('welt')); + $this->parser->on('data', $this->expectCallableOnceWith('welt')); + $this->parser->on('close', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableNever()); - $this->input->emit('data', array("4")); - $this->input->emit('data', array("\r\n")); - $this->input->emit('data', array("we")); - $this->input->emit('data', array("lt\r\n")); + $this->input->emit('data', array("4")); + $this->input->emit('data', array("\r\n")); + $this->input->emit('data', array("we")); + $this->input->emit('data', array("lt\r\n")); } public function testMixed() { - $this->parser->on('data', $this->expectCallableConsecutive(2, array('welt', 'hello'))); + $this->parser->on('data', $this->expectCallableConsecutive(2, array('welt', 'hello'))); + $this->parser->on('close', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableNever()); - $this->input->emit('data', array("4")); - $this->input->emit('data', array("\r\n")); - $this->input->emit('data', array("we")); - $this->input->emit('data', array("lt\r\n")); - $this->input->emit('data', array("5\r\nhello\r\n")); + $this->input->emit('data', array("4")); + $this->input->emit('data', array("\r\n")); + $this->input->emit('data', array("we")); + $this->input->emit('data', array("lt\r\n")); + $this->input->emit('data', array("5\r\nhello\r\n")); } public function testBigger() { - $this->parser->on('data', $this->expectCallableConsecutive(2, array('abcdeabcdeabcdea', 'hello'))); + $this->parser->on('data', $this->expectCallableConsecutive(2, array('abcdeabcdeabcdea', 'hello'))); + $this->parser->on('close', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableNever()); - $this->input->emit('data', array("1")); - $this->input->emit('data', array("0")); - $this->input->emit('data', array("\r\n")); - $this->input->emit('data', array("abcdeabcdeabcdea\r\n")); - $this->input->emit('data', array("5\r\nhello\r\n")); + $this->input->emit('data', array("1")); + $this->input->emit('data', array("0")); + $this->input->emit('data', array("\r\n")); + $this->input->emit('data', array("abcdeabcdeabcdea\r\n")); + $this->input->emit('data', array("5\r\nhello\r\n")); } public function testOneUnfinished() { - $this->parser->on('data', $this->expectCallableOnceWith('bla')); + $this->parser->on('data', $this->expectCallableOnceWith('bla')); + $this->parser->on('close', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableNever()); - $this->input->emit('data', array("3\r\n")); - $this->input->emit('data', array("bla\r\n")); - $this->input->emit('data', array("5\r\nhello")); + $this->input->emit('data', array("3\r\n")); + $this->input->emit('data', array("bla\r\n")); + $this->input->emit('data', array("5\r\nhello")); } public function testHandleError() { $this->parser->on('error', $this->expectCallableOnce()); $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); $this->input->emit('error', array(new \RuntimeException())); @@ -163,10 +207,10 @@ public function testPipeStream() public function testHandleClose() { - $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('close', $this->expectCallableOnce()); - $this->input->close(); - $this->input->emit('end', array()); + $this->input->close(); + $this->input->emit('end', array()); $this->assertFalse($this->parser->isReadable()); }