From abe28ae4ca5c5d3bd2f75eeaca87513c72762c4d Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Wed, 5 Oct 2016 08:42:42 +0200 Subject: [PATCH 001/128] Multipart parser (not updated to new interface yet) --- src/StreamingBodyParser/MultipartParser.php | 258 ++++++++++++++++++ .../MultipartParserTest.php | 184 +++++++++++++ 2 files changed, 442 insertions(+) create mode 100644 src/StreamingBodyParser/MultipartParser.php create mode 100644 tests/StreamingBodyParser/MultipartParserTest.php diff --git a/src/StreamingBodyParser/MultipartParser.php b/src/StreamingBodyParser/MultipartParser.php new file mode 100644 index 00000000..3c6665d1 --- /dev/null +++ b/src/StreamingBodyParser/MultipartParser.php @@ -0,0 +1,258 @@ +request = $request; + $headers = $this->request->getHeaders(); + $headers = array_change_key_case($headers, CASE_LOWER); + preg_match('/boundary="?(.*)"?$/', $headers['content-type'], $matches); + + $dataMethod = 'findBoundary'; + if (isset($matches[1])) { + $this->setBoundary($matches[1]); + $dataMethod = 'onData'; + } + $this->request->on('data', [$this, $dataMethod]); + } + + protected function setBoundary($boundary) + { + $this->boundary = $boundary; + $this->ending = $this->boundary . "--\r\n"; + $this->endingSize = strlen($this->ending); + } + + public function findBoundary($data) + { + $this->buffer .= $data; + + if (substr($this->buffer, 0, 3) === '---' && strpos($this->buffer, "\r\n") !== false) { + $boundary = substr($this->buffer, 2, strpos($this->buffer, "\r\n")); + $boundary = substr($boundary, 0, -2); + $this->setBoundary($boundary); + $this->request->removeListener('data', [$this, 'findBoundary']); + $this->request->on('data', [$this, 'onData']); + } + } + + public function onData($data) + { + $this->buffer .= $data; + $ending = strpos($this->buffer, $this->ending) == strlen($this->buffer) - $this->endingSize; + + if ( + strrpos($this->buffer, $this->boundary) < strrpos($this->buffer, "\r\n\r\n") || $ending + ) { + $this->parseBuffer(); + } + + if ($ending) { + $this->emit('end'); + } + } + + protected function parseBuffer() + { + $chunks = preg_split('/-+' . $this->boundary . '/', $this->buffer); + $this->buffer = array_pop($chunks); + foreach ($chunks as $chunk) { + $this->parseChunk(ltrim($chunk)); + } + + $split = explode("\r\n\r\n", $this->buffer); + if (count($split) <= 1) { + return; + } + + $chunks = preg_split('/-+' . $this->boundary . '/', trim($split[0]), -1, PREG_SPLIT_NO_EMPTY); + $headers = $this->parseHeaders(trim($chunks[0])); + if (isset($headers['content-disposition']) && $this->headerStartsWith($headers['content-disposition'], 'filename')) { + $this->parseFile($headers, $split[1]); + $this->buffer = ''; + } + } + + protected function parseChunk($chunk) + { + if ($chunk == '') { + return; + } + + list ($header, $body) = explode("\r\n\r\n", $chunk); + $headers = $this->parseHeaders($header); + + if (!isset($headers['content-disposition'])) { + return; + } + + if ($this->headerStartsWith($headers['content-disposition'], 'filename')) { + $this->parseFile($headers, $body, false); + return; + } + + if ($this->headerStartsWith($headers['content-disposition'], 'name')) { + $this->parsePost($headers, $body); + return; + } + } + + protected function parseFile($headers, $body, $streaming = true) + { + if ( + !$this->headerContains($headers['content-disposition'], 'name=') || + !$this->headerContains($headers['content-disposition'], 'filename=') + ) { + return; + } + + $stream = new ThroughStream(); + $this->emit('file', [ + $this->getFieldFromHeader($headers['content-disposition'], 'name'), + new File( + $this->getFieldFromHeader($headers['content-disposition'], 'filename'), + $headers['content-type'][0], + $stream + ), + $headers, + ]); + + if (!$streaming) { + $stream->end($body); + return; + } + + $this->request->removeListener('data', [$this, 'onData']); + $this->request->on('data', $this->chunkStreamFunc($stream)); + $stream->write($body); + } + + protected function chunkStreamFunc(ThroughStream $stream) + { + $buffer = ''; + $func = function($data) use (&$func, &$buffer, $stream) { + $buffer .= $data; + if (strpos($buffer, $this->boundary) !== false) { + $chunks = preg_split('/-+' . $this->boundary . '/', $buffer); + $chunk = array_shift($chunks); + $stream->end($chunk); + + $this->request->removeListener('data', $func); + $this->request->on('data', [$this, 'onData']); + + if (count($chunks) == 1) { + array_unshift($chunks, ''); + } + + $this->onData(implode('-' . $this->boundary, $chunks)); + return; + } + + if (strlen($buffer) >= strlen($this->boundary) * 3) { + $stream->write($buffer); + $buffer = ''; + } + }; + return $func; + } + + protected function parsePost($headers, $body) + { + foreach ($headers['content-disposition'] as $part) { + if (strpos($part, 'name') === 0) { + preg_match('/name="?(.*)"$/', $part, $matches); + $this->emit('post', [ + $matches[1], + trim($body), + $headers, + ]); + } + } + } + + protected function parseHeaders($header) + { + $headers = []; + + foreach (explode("\r\n", trim($header)) as $line) { + list($key, $values) = explode(':', $line, 2); + $key = trim($key); + $key = strtolower($key); + $values = explode(';', $values); + $values = array_map('trim', $values); + $headers[$key] = $values; + } + + return $headers; + } + + protected function headerStartsWith(array $header, $needle) + { + foreach ($header as $part) { + if (strpos($part, $needle) === 0) { + return true; + } + } + + return false; + } + + protected function headerContains(array $header, $needle) + { + foreach ($header as $part) { + if (strpos($part, $needle) !== false) { + return true; + } + } + + return false; + } + + protected function getFieldFromHeader(array $header, $field) + { + foreach ($header as $part) { + if (strpos($part, $field) === 0) { + preg_match('/' . $field . '="?(.*)"$/', $part, $matches); + return $matches[1]; + } + } + + return ''; + } +} diff --git a/tests/StreamingBodyParser/MultipartParserTest.php b/tests/StreamingBodyParser/MultipartParserTest.php new file mode 100644 index 00000000..27863388 --- /dev/null +++ b/tests/StreamingBodyParser/MultipartParserTest.php @@ -0,0 +1,184 @@ + 'multipart/mixed; boundary=' . $boundary, + ]); + + $parser = new MultipartParser($request); + $parser->on('post', function ($key, $value) use (&$post) { + $post[$key] = $value; + }); + $parser->on('file', function ($name, FileInterface $file) use (&$files) { + $files[] = [$name, $file]; + }); + + $data = "--$boundary\r\n"; + $data .= "Content-Disposition: form-data; name=\"users[one]\"\r\n"; + $data .= "\r\n"; + $data .= "single\r\n"; + $data .= "--$boundary\r\n"; + $data .= "Content-Disposition: form-data; name=\"users[two]\"\r\n"; + $data .= "\r\n"; + $data .= "second\r\n"; + $data .= "--$boundary--\r\n"; + + $request->emit('data', [$data]); + + $this->assertEmpty($files); + $this->assertEquals( + [ + 'users[one]' => 'single', + 'users[two]' => 'second', + ], + $post + ); + } + + public function testFileUpload() + { + $files = []; + $post = []; + + $boundary = "---------------------------12758086162038677464950549563"; + + $request = new Request('POST', 'http://example.com/', [], 1.1, [ + 'Content-Type' => 'multipart/form-data', + ]); + + $multipart = new MultipartParser($request); + + $multipart->on('post', function ($key, $value) use (&$post) { + $post[] = [$key => $value]; + }); + $multipart->on('file', function ($name, FileInterface $file, $headers) use (&$files) { + $files[] = [$name, $file, $headers]; + }); + + $file = base64_decode("R0lGODlhAQABAIAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=="); + + $data = "--$boundary\r\n"; + $data .= "Content-Disposition: form-data; name=\"users[one]\"\r\n"; + $data .= "\r\n"; + $data .= "single\r\n"; + $data .= "--$boundary\r\n"; + $data .= "Content-Disposition: form-data; name=\"users[two]\"\r\n"; + $data .= "\r\n"; + $data .= "second\r\n"; + $request->emit('data', [$data]); + $request->emit('data', ["--$boundary\r\n"]); + $request->emit('data', ["Content-disposition: form-data; name=\"user\"\r\n"]); + $request->emit('data', ["\r\n"]); + $request->emit('data', ["single\r\n"]); + $request->emit('data', ["--$boundary\r\n"]); + $request->emit('data', ["content-Disposition: form-data; name=\"user2\"\r\n"]); + $request->emit('data', ["\r\n"]); + $request->emit('data', ["second\r\n"]); + $request->emit('data', ["--$boundary\r\n"]); + $request->emit('data', ["Content-Disposition: form-data; name=\"users[]\"\r\n"]); + $request->emit('data', ["\r\n"]); + $request->emit('data', ["first in array\r\n"]); + $request->emit('data', ["--$boundary\r\n"]); + $request->emit('data', ["Content-Disposition: form-data; name=\"users[]\"\r\n"]); + $request->emit('data', ["\r\n"]); + $request->emit('data', ["second in array\r\n"]); + $request->emit('data', ["--$boundary\r\n"]); + $request->emit('data', ["Content-Disposition: form-data; name=\"file\"; filename=\"Us er.php\"\r\n"]); + $request->emit('data', ["Content-type: text/php\r\n"]); + $request->emit('data', ["\r\n"]); + $request->emit('data', ["emit('data', ["\r\n"]); + $line = "--$boundary"; + $lines = str_split($line, round(strlen($line) / 2)); + $request->emit('data', [$lines[0]]); + $request->emit('data', [$lines[1]]); + $request->emit('data', ["\r\n"]); + $request->emit('data', ["Content-Disposition: form-data; name=\"files[]\"; filename=\"blank.gif\"\r\n"]); + $request->emit('data', ["content-Type: image/gif\r\n"]); + $request->emit('data', ["X-Foo-Bar: base64\r\n"]); + $request->emit('data', ["\r\n"]); + $request->emit('data', [$file . "\r\n"]); + $request->emit('data', ["--$boundary\r\n"]); + $request->emit('data', ["Content-Disposition: form-data; name=\"files[]\"; filename=\"User.php\"\r\n" . + "Content-Type: text/php\r\n" . + "\r\n" . + "emit('data', ["\r\n"]); + $request->emit('data', ["--$boundary--\r\n"]); + + $this->assertEquals(6, count($post)); + $this->assertEquals( + [ + ['users[one]' => 'single'], + ['users[two]' => 'second'], + ['user' => 'single'], + ['user2' => 'second'], + ['users[]' => 'first in array'], + ['users[]' => 'second in array'], + ], + $post + ); + + $this->assertEquals(3, count($files)); + $this->assertEquals('file', $files[0][0]); + $this->assertEquals('Us er.php', $files[0][1]->getFilename()); + $this->assertEquals('text/php', $files[0][1]->getContentType()); + $this->assertEquals([ + 'content-disposition' => [ + 'form-data', + 'name="file"', + 'filename="Us er.php"', + ], + 'content-type' => [ + 'text/php', + ], + ], $files[0][2]); + + $this->assertEquals('files[]', $files[1][0]); + $this->assertEquals('blank.gif', $files[1][1]->getFilename()); + $this->assertEquals('image/gif', $files[1][1]->getContentType()); + $this->assertEquals([ + 'content-disposition' => [ + 'form-data', + 'name="files[]"', + 'filename="blank.gif"', + ], + 'content-type' => [ + 'image/gif', + ], + 'x-foo-bar' => [ + 'base64', + ], + ], $files[1][2]); + + $this->assertEquals('files[]', $files[2][0]); + $this->assertEquals('User.php', $files[2][1]->getFilename()); + $this->assertEquals('text/php', $files[2][1]->getContentType()); + $this->assertEquals([ + 'content-disposition' => [ + 'form-data', + 'name="files[]"', + 'filename="User.php"', + ], + 'content-type' => [ + 'text/php', + ], + ], $files[2][2]); + } +} From 55dcb6e81f61f9d27042a274cb3749376f79aa33 Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Wed, 5 Oct 2016 17:16:01 +0200 Subject: [PATCH 002/128] Implement cancelable parser --- src/StreamingBodyParser/MultipartParser.php | 49 +++++++++++++++++---- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/src/StreamingBodyParser/MultipartParser.php b/src/StreamingBodyParser/MultipartParser.php index 3c6665d1..2beb8874 100644 --- a/src/StreamingBodyParser/MultipartParser.php +++ b/src/StreamingBodyParser/MultipartParser.php @@ -5,6 +5,8 @@ use Evenement\EventEmitterTrait; use React\Http\File; use React\Http\Request; +use React\Promise\CancellablePromiseInterface; +use React\Promise\Deferred; use React\Stream\ThroughStream; class MultipartParser implements ParserInterface @@ -36,9 +38,31 @@ class MultipartParser implements ParserInterface */ protected $request; + /** + * @var CancellablePromiseInterface + */ + protected $promise; + + /** + * @var callable + */ + protected $onDataCallable; + + /** + * @param Request $request + * @return ParserInterface + */ + public static function create(Request $request) + { + return new static($request); + } - public function __construct(Request $request) + private function __construct(Request $request) { + $this->promise = (new Deferred(function () { + $this->request->removeListener('data', $this->onDataCallable); + $this->request->close(); + }))->promise(); $this->request = $request; $headers = $this->request->getHeaders(); $headers = array_change_key_case($headers, CASE_LOWER); @@ -49,7 +73,7 @@ public function __construct(Request $request) $this->setBoundary($matches[1]); $dataMethod = 'onData'; } - $this->request->on('data', [$this, $dataMethod]); + $this->setOnDataListener([$this, $dataMethod]); } protected function setBoundary($boundary) @@ -67,8 +91,7 @@ public function findBoundary($data) $boundary = substr($this->buffer, 2, strpos($this->buffer, "\r\n")); $boundary = substr($boundary, 0, -2); $this->setBoundary($boundary); - $this->request->removeListener('data', [$this, 'findBoundary']); - $this->request->on('data', [$this, 'onData']); + $this->setOnDataListener([$this, 'onData']); } } @@ -158,8 +181,7 @@ protected function parseFile($headers, $body, $streaming = true) return; } - $this->request->removeListener('data', [$this, 'onData']); - $this->request->on('data', $this->chunkStreamFunc($stream)); + $this->setOnDataListener($this->chunkStreamFunc($stream)); $stream->write($body); } @@ -173,8 +195,7 @@ protected function chunkStreamFunc(ThroughStream $stream) $chunk = array_shift($chunks); $stream->end($chunk); - $this->request->removeListener('data', $func); - $this->request->on('data', [$this, 'onData']); + $this->setOnDataListener([$this, 'onData']); if (count($chunks) == 1) { array_unshift($chunks, ''); @@ -255,4 +276,16 @@ protected function getFieldFromHeader(array $header, $field) return ''; } + + protected function setOnDataListener(callable $callable) + { + $this->request->removeListener('data', $this->onDataCallable); + $this->onDataCallable = $callable; + $this->request->on('data', $this->onDataCallable); + } + + public function cancel() + { + $this->promise->cancel(); + } } From 5ef8f763d5e8d0835578e82c25988c1ec1da386a Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Wed, 9 Nov 2016 14:11:00 +0100 Subject: [PATCH 003/128] Remove all listeners after emitting error in RequestHeaderParser (#68) * Ensure removeAllListeners on all error event in RequestHeaderParser * Ensure all error events from RequestHeaderParser emit $this as second item in event * Reverted emitting $this with error --- src/RequestHeaderParser.php | 2 +- tests/RequestHeaderParserTest.php | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php index 4e4db46f..ca9d57f7 100644 --- a/src/RequestHeaderParser.php +++ b/src/RequestHeaderParser.php @@ -19,7 +19,7 @@ public function feed($data) { if (strlen($this->buffer) + strlen($data) > $this->maxSize) { $this->emit('error', array(new \OverflowException("Maximum header size of {$this->maxSize} exceeded."), $this)); - + $this->removeAllListeners(); return; } diff --git a/tests/RequestHeaderParserTest.php b/tests/RequestHeaderParserTest.php index b2fa87dc..807af42c 100644 --- a/tests/RequestHeaderParserTest.php +++ b/tests/RequestHeaderParserTest.php @@ -99,18 +99,26 @@ public function testHeadersEventShouldParsePathAndQueryString() public function testHeaderOverflowShouldEmitError() { $error = null; + $passedParser = null; $parser = new RequestHeaderParser(); $parser->on('headers', $this->expectCallableNever()); - $parser->on('error', function ($message) use (&$error) { + $parser->on('error', function ($message, $parser) use (&$error, &$passedParser) { $error = $message; + $passedParser = $parser; }); + $this->assertSame(1, count($parser->listeners('headers'))); + $this->assertSame(1, count($parser->listeners('error'))); + $data = str_repeat('A', 4097); $parser->feed($data); $this->assertInstanceOf('OverflowException', $error); $this->assertSame('Maximum header size of 4096 exceeded.', $error->getMessage()); + $this->assertSame($parser, $passedParser); + $this->assertSame(0, count($parser->listeners('headers'))); + $this->assertSame(0, count($parser->listeners('error'))); } public function testGuzzleRequestParseException() From 03855338ea9de238ee19af3d0859c974b824f90c Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Wed, 9 Nov 2016 16:20:39 +0100 Subject: [PATCH 004/128] Added 0.4.2 to changelog --- CHANGELOG.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c66b2ad6..c0167b52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # Changelog -## 0.4.1 (2014-05-21) +## 0.4.2 (2016-11-09) + +* Remove all listeners after emitting error in RequestHeaderParser #68 @WyriHaximus +* Catch Guzzle parse request errors #65 @WyriHaximus +* Remove branch-alias definition as per reactphp/react#343 #58 @WyriHaximus +* Add functional example to ease getting started #64 by @clue +* Naming, immutable array manipulation #37 @cboden + +## 0.4.1 (2015-05-21) + * Replaced guzzle/parser with guzzlehttp/psr7 by @cboden * FIX Continue Header by @iannsp * Missing type hint by @marenzo From 56f09cd2629e8bef8550ea06746a0e1369d31f6b Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Wed, 30 Nov 2016 19:03:45 +0100 Subject: [PATCH 005/128] Removed testing against HHVM nightly (#66) * Test against PHP 7.1 and not against HHVM nightly anymore * Removed 7.1 to not overly bloat the test matrix * Changed 7.0 back to 7 so the effective changes are only what the PR is about now --- .travis.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index f5449647..8414e4d0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,13 +6,11 @@ php: - 5.6 - 7 - hhvm - - hhvm-nightly matrix: allow_failures: - php: 7 - php: hhvm - - php: hhvm-nightly before_script: - composer install --dev --prefer-source From 409defbd8d81f594aa8284021261af415a6a194a Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Fri, 16 Dec 2016 17:26:47 +0100 Subject: [PATCH 006/128] Typo in $reques, should be $request --- examples/01-hello-world.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/01-hello-world.php b/examples/01-hello-world.php index 0a8e9d56..70107616 100644 --- a/examples/01-hello-world.php +++ b/examples/01-hello-world.php @@ -11,7 +11,7 @@ $socket = new Server($loop); $server = new \React\Http\Server($socket); -$server->on('request', function (Request $reques, Response $response) { +$server->on('request', function (Request $request, Response $response) { $response->writeHead(200, array('Content-Type' => 'text/plain')); $response->end("Hello world!\n"); }); From a993fd1eace3044e63e0a8c85ba814442a70b364 Mon Sep 17 00:00:00 2001 From: Bohdan Yurov Date: Sun, 5 Feb 2017 20:33:03 +0200 Subject: [PATCH 007/128] Fixes #81 issue: data listener is removed if HeaderParser emits error (#83) --- src/Server.php | 8 +++++++- tests/ServerTest.php | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/Server.php b/src/Server.php index e1abfd8e..49535060 100644 --- a/src/Server.php +++ b/src/Server.php @@ -42,7 +42,13 @@ public function __construct(SocketServerInterface $io) }); }); - $conn->on('data', array($parser, 'feed')); + $listener = [$parser, 'feed']; + $conn->on('data', $listener); + $parser->on('error', function() use ($conn, $listener) { + // TODO: return 400 response + $conn->removeListener('data', $listener); + $this->emit('error', func_get_args()); + }); }); } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 65c13ccb..552b562f 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -2,6 +2,7 @@ namespace React\Tests\Http; +use React\Http\RequestHeaderParser; use React\Http\Server; class ServerTest extends TestCase @@ -66,6 +67,28 @@ public function testResponseContainsPoweredByHeader() $this->assertContains("\r\nX-Powered-By: React/alpha\r\n", $conn->getData()); } + public function testParserErrorEmitted() + { + $io = new ServerStub(); + + $error = null; + $server = new Server($io); + $server->on('headers', $this->expectCallableNever()); + $server->on('error', function ($message) use (&$error) { + $error = $message; + }); + + $conn = new ConnectionStub(); + $io->emit('connection', [$conn]); + + $data = $this->createGetRequest(); + $data = str_pad($data, 4096 * 4); + $conn->emit('data', [$data]); + + $this->assertInstanceOf('OverflowException', $error); + $this->assertEquals('', $conn->getData()); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From a05c1c25f024b612afd630a121ca97118c4f80f5 Mon Sep 17 00:00:00 2001 From: Dan Revel Date: Sun, 5 Feb 2017 09:34:57 -0800 Subject: [PATCH 008/128] check max header size --- src/RequestHeaderParser.php | 17 ++++++++++++----- tests/RequestHeaderParserTest.php | 27 +++++++++++++++++++++++++++ tests/ServerTest.php | 4 ++-- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php index ca9d57f7..af70ed1b 100644 --- a/src/RequestHeaderParser.php +++ b/src/RequestHeaderParser.php @@ -17,21 +17,28 @@ class RequestHeaderParser extends EventEmitter public function feed($data) { - if (strlen($this->buffer) + strlen($data) > $this->maxSize) { + $this->buffer .= $data; + + $endOfHeader = strpos($this->buffer, "\r\n\r\n"); + + if (false !== $endOfHeader) { + $currentHeaderSize = $endOfHeader; + } else { + $currentHeaderSize = strlen($this->buffer); + } + + if ($currentHeaderSize > $this->maxSize) { $this->emit('error', array(new \OverflowException("Maximum header size of {$this->maxSize} exceeded."), $this)); $this->removeAllListeners(); return; } - $this->buffer .= $data; - - if (false !== strpos($this->buffer, "\r\n\r\n")) { + if (false !== $endOfHeader) { try { $this->parseAndEmitRequest(); } catch (Exception $exception) { $this->emit('error', [$exception]); } - $this->removeAllListeners(); } } diff --git a/tests/RequestHeaderParserTest.php b/tests/RequestHeaderParserTest.php index 807af42c..2c22c4a5 100644 --- a/tests/RequestHeaderParserTest.php +++ b/tests/RequestHeaderParserTest.php @@ -121,6 +121,33 @@ public function testHeaderOverflowShouldEmitError() $this->assertSame(0, count($parser->listeners('error'))); } + public function testHeaderOverflowShouldNotEmitErrorWhenDataExceedsMaxHeaderSize() + { + $request = null; + $bodyBuffer = null; + + $parser = new RequestHeaderParser(); + $parser->on('headers', function ($parsedRequest, $parsedBodyBuffer) use (&$request, &$bodyBuffer) { + $request = $parsedRequest; + $bodyBuffer = $parsedBodyBuffer; + }); + + $data = $this->createAdvancedPostRequest(); + $body = str_repeat('A', 4097 - strlen($data)); + $data .= $body; + + $parser->feed($data); + + $headers = array( + 'Host' => 'example.com:80', + 'User-Agent' => 'react/alpha', + 'Connection' => 'close', + ); + $this->assertSame($headers, $request->getHeaders()); + + $this->assertSame($body, $bodyBuffer); + } + public function testGuzzleRequestParseException() { $error = null; diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 552b562f..190d6fa2 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -81,8 +81,8 @@ public function testParserErrorEmitted() $conn = new ConnectionStub(); $io->emit('connection', [$conn]); - $data = $this->createGetRequest(); - $data = str_pad($data, 4096 * 4); + $data = "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-DATA: "; + $data .= str_repeat('A', 4097 - strlen($data)) . "\r\n\r\n"; $conn->emit('data', [$data]); $this->assertInstanceOf('OverflowException', $error); From 12a8282cb548325f9f5478fc494de32a99f16881 Mon Sep 17 00:00:00 2001 From: Dan Revel Date: Sun, 5 Feb 2017 23:37:29 -0800 Subject: [PATCH 009/128] add phpunit 4.8 to require-dev, force travisci to use local phpunit --- .travis.yml | 2 +- composer.json | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 8414e4d0..55c8e85f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,4 +16,4 @@ before_script: - composer install --dev --prefer-source script: - - phpunit --coverage-text + - ./vendor/bin/phpunit --coverage-text diff --git a/composer.json b/composer.json index c186927d..cfc6f04a 100644 --- a/composer.json +++ b/composer.json @@ -14,5 +14,8 @@ "psr-4": { "React\\Http\\": "src" } + }, + "require-dev": { + "phpunit/phpunit": "~4.8" } } From 49b5794e82942d6ce87a79a11f0f2264812e35b6 Mon Sep 17 00:00:00 2001 From: Dan Revel Date: Sun, 5 Feb 2017 23:58:28 -0800 Subject: [PATCH 010/128] replace getMock for forward compatibility --- tests/ResponseTest.php | 40 ++++++++++++++++++++++++++++++---------- tests/TestCase.php | 4 +++- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 7692fb83..11407f28 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -14,7 +14,9 @@ public function testResponseShouldBeChunkedByDefault() $expected .= "Transfer-Encoding: chunked\r\n"; $expected .= "\r\n"; - $conn = $this->getMock('React\Socket\ConnectionInterface'); + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); $conn ->expects($this->once()) ->method('write') @@ -32,7 +34,9 @@ public function testResponseShouldNotBeChunkedWithContentLength() $expected .= "Content-Length: 22\r\n"; $expected .= "\r\n"; - $conn = $this->getMock('React\Socket\ConnectionInterface'); + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); $conn ->expects($this->once()) ->method('write') @@ -44,7 +48,9 @@ public function testResponseShouldNotBeChunkedWithContentLength() public function testResponseBodyShouldBeChunkedCorrectly() { - $conn = $this->getMock('React\Socket\ConnectionInterface'); + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); $conn ->expects($this->at(4)) ->method('write') @@ -75,7 +81,9 @@ public function testResponseShouldEmitEndOnStreamEnd() { $ended = false; - $conn = $this->getMock('React\Socket\ConnectionInterface'); + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); $response = new Response($conn); $response->on('end', function () use (&$ended) { @@ -89,7 +97,9 @@ public function testResponseShouldEmitEndOnStreamEnd() /** @test */ public function writeContinueShouldSendContinueLineBeforeRealHeaders() { - $conn = $this->getMock('React\Socket\ConnectionInterface'); + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); $conn ->expects($this->at(3)) ->method('write') @@ -107,7 +117,9 @@ public function writeContinueShouldSendContinueLineBeforeRealHeaders() /** @test */ public function shouldForwardEndDrainAndErrorEvents() { - $conn = $this->getMock('React\Socket\ConnectionInterface'); + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); $conn ->expects($this->at(0)) ->method('on') @@ -134,7 +146,9 @@ public function shouldRemoveNewlinesFromHeaders() $expected .= "Transfer-Encoding: chunked\r\n"; $expected .= "\r\n"; - $conn = $this->getMock('React\Socket\ConnectionInterface'); + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); $conn ->expects($this->once()) ->method('write') @@ -153,7 +167,9 @@ public function missingStatusCodeTextShouldResultInNumberOnlyStatus() $expected .= "Transfer-Encoding: chunked\r\n"; $expected .= "\r\n"; - $conn = $this->getMock('React\Socket\ConnectionInterface'); + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); $conn ->expects($this->once()) ->method('write') @@ -174,7 +190,9 @@ public function shouldAllowArrayHeaderValues() $expected .= "Transfer-Encoding: chunked\r\n"; $expected .= "\r\n"; - $conn = $this->getMock('React\Socket\ConnectionInterface'); + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); $conn ->expects($this->once()) ->method('write') @@ -193,7 +211,9 @@ public function shouldIgnoreHeadersWithNullValues() $expected .= "Transfer-Encoding: chunked\r\n"; $expected .= "\r\n"; - $conn = $this->getMock('React\Socket\ConnectionInterface'); + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); $conn ->expects($this->once()) ->method('write') diff --git a/tests/TestCase.php b/tests/TestCase.php index a08675c9..24fe27f2 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -36,6 +36,8 @@ protected function expectCallableNever() protected function createCallableMock() { - return $this->getMock('React\Tests\Http\CallableStub'); + return $this + ->getMockBuilder('React\Tests\Http\CallableStub') + ->getMock(); } } From d1c3356c481278e5eca7cb00df7720c9285fa59b Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Tue, 7 Feb 2017 12:47:10 +0100 Subject: [PATCH 011/128] ServerInterface is unnecessary --- src/Server.php | 2 +- src/ServerInterface.php | 9 --------- 2 files changed, 1 insertion(+), 10 deletions(-) delete mode 100644 src/ServerInterface.php diff --git a/src/Server.php b/src/Server.php index 49535060..4af205e5 100644 --- a/src/Server.php +++ b/src/Server.php @@ -7,7 +7,7 @@ use React\Socket\ConnectionInterface; /** @event request */ -class Server extends EventEmitter implements ServerInterface +class Server extends EventEmitter { private $io; diff --git a/src/ServerInterface.php b/src/ServerInterface.php deleted file mode 100644 index 56dd61fe..00000000 --- a/src/ServerInterface.php +++ /dev/null @@ -1,9 +0,0 @@ - Date: Tue, 7 Feb 2017 12:02:29 +0100 Subject: [PATCH 012/128] Fix assertion in ServerTests --- tests/ServerTest.php | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 190d6fa2..66880260 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -2,8 +2,9 @@ namespace React\Tests\Http; -use React\Http\RequestHeaderParser; use React\Http\Server; +use React\Http\Response; +use React\Http\Request; class ServerTest extends TestCase { @@ -26,17 +27,14 @@ public function testRequestEvent() $io = new ServerStub(); $i = 0; + $requestAssertion = null; + $responseAssertion = null; $server = new Server($io); - $server->on('request', function ($request, $response) use (&$i) { + $server->on('request', function (Request $request, Response $response) use (&$i, &$requestAssertion, &$responseAssertion) { $i++; - - $this->assertInstanceOf('React\Http\Request', $request); - $this->assertSame('/', $request->getPath()); - $this->assertSame('GET', $request->getMethod()); - $this->assertSame('127.0.0.1', $request->remoteAddress); - - $this->assertInstanceOf('React\Http\Response', $response); + $requestAssertion = $request; + $responseAssertion = $response; }); $conn = new ConnectionStub(); @@ -46,6 +44,12 @@ public function testRequestEvent() $conn->emit('data', array($data)); $this->assertSame(1, $i); + $this->assertInstanceOf('React\Http\Request', $requestAssertion); + $this->assertSame('/', $requestAssertion->getPath()); + $this->assertSame('GET', $requestAssertion->getMethod()); + $this->assertSame('127.0.0.1', $requestAssertion->remoteAddress); + + $this->assertInstanceOf('React\Http\Response', $responseAssertion); } public function testResponseContainsPoweredByHeader() @@ -53,7 +57,7 @@ public function testResponseContainsPoweredByHeader() $io = new ServerStub(); $server = new Server($io); - $server->on('request', function ($request, $response) { + $server->on('request', function (Request $request, Response $response) { $response->writeHead(); $response->end(); }); From 9b10bd421c8c8cfa06d620113cbbbfd934e38599 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Tue, 7 Feb 2017 13:01:18 +0100 Subject: [PATCH 013/128] Remove unneeded type hints --- tests/ServerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 66880260..1ea905ca 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -31,7 +31,7 @@ public function testRequestEvent() $responseAssertion = null; $server = new Server($io); - $server->on('request', function (Request $request, Response $response) use (&$i, &$requestAssertion, &$responseAssertion) { + $server->on('request', function ($request, $response) use (&$i, &$requestAssertion, &$responseAssertion) { $i++; $requestAssertion = $request; $responseAssertion = $response; From 30ff0bfa2a9006b3d4f2a9365117fe7f203a74dc Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Thu, 9 Feb 2017 11:59:50 +0100 Subject: [PATCH 014/128] Remove stubs from server tests --- tests/ServerTest.php | 79 +++++++++++++++++++++++++++++++------------- 1 file changed, 56 insertions(+), 23 deletions(-) diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 1ea905ca..204ed072 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -5,43 +5,72 @@ use React\Http\Server; use React\Http\Response; use React\Http\Request; +use React\Socket\Server as Socket; class ServerTest extends TestCase { + private $connection; + private $loop; + + public function setUp() + { + $this->loop = \React\EventLoop\Factory::create(); + + $this->connection = $this->getMockBuilder('React\Socket\Connection') + ->disableOriginalConstructor() + ->setMethods( + array( + 'write', + 'end', + 'close', + 'pause', + 'resume', + 'isReadable', + 'isWritable', + 'getRemoteAddress', + 'pipe' + ) + ) + ->getMock(); + } + public function testRequestEventIsEmitted() { - $io = new ServerStub(); + $socket = new Socket($this->loop); - $server = new Server($io); + $server = new Server($socket); $server->on('request', $this->expectCallableOnce()); - $conn = new ConnectionStub(); - $io->emit('connection', array($conn)); + $socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); - $conn->emit('data', array($data)); + $this->connection->emit('data', array($data)); } public function testRequestEvent() { - $io = new ServerStub(); + $socket = new Socket($this->loop); $i = 0; $requestAssertion = null; $responseAssertion = null; - $server = new Server($io); + $server = new Server($socket); $server->on('request', function ($request, $response) use (&$i, &$requestAssertion, &$responseAssertion) { $i++; $requestAssertion = $request; $responseAssertion = $response; }); - $conn = new ConnectionStub(); - $io->emit('connection', array($conn)); + $this->connection + ->expects($this->once()) + ->method('getRemoteAddress') + ->willReturn('127.0.0.1'); + + $socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); - $conn->emit('data', array($data)); + $this->connection->emit('data', array($data)); $this->assertSame(1, $i); $this->assertInstanceOf('React\Http\Request', $requestAssertion); @@ -54,43 +83,47 @@ public function testRequestEvent() public function testResponseContainsPoweredByHeader() { - $io = new ServerStub(); + $socket = new Socket($this->loop); - $server = new Server($io); + $server = new Server($socket); $server->on('request', function (Request $request, Response $response) { $response->writeHead(); $response->end(); }); - $conn = new ConnectionStub(); - $io->emit('connection', array($conn)); + $this->connection + ->expects($this->exactly(2)) + ->method('write') + ->withConsecutive( + array($this->equalTo("HTTP/1.1 200 OK\r\nX-Powered-By: React/alpha\r\nTransfer-Encoding: chunked\r\n\r\n")), + array($this->equalTo("0\r\n\r\n")) + ); - $data = $this->createGetRequest(); - $conn->emit('data', array($data)); + $socket->emit('connection', array($this->connection)); - $this->assertContains("\r\nX-Powered-By: React/alpha\r\n", $conn->getData()); + $data = $this->createGetRequest(); + $this->connection->emit('data', array($data)); } public function testParserErrorEmitted() { - $io = new ServerStub(); + $socket = new Socket($this->loop); $error = null; - $server = new Server($io); + $server = new Server($socket); $server->on('headers', $this->expectCallableNever()); $server->on('error', function ($message) use (&$error) { $error = $message; }); - $conn = new ConnectionStub(); - $io->emit('connection', [$conn]); + $socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-DATA: "; $data .= str_repeat('A', 4097 - strlen($data)) . "\r\n\r\n"; - $conn->emit('data', [$data]); + $this->connection->emit('data', [$data]); $this->assertInstanceOf('OverflowException', $error); - $this->assertEquals('', $conn->getData()); + $this->connection->expects($this->never())->method('write'); } private function createGetRequest() From 7c51dd32e4e51c4c30a25157818b04c775e5f4fe Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Thu, 9 Feb 2017 12:06:16 +0100 Subject: [PATCH 015/128] Remove unneeded stubs --- tests/ConnectionStub.php | 63 ---------------------------------------- tests/ServerStub.php | 22 -------------- 2 files changed, 85 deletions(-) delete mode 100644 tests/ConnectionStub.php delete mode 100644 tests/ServerStub.php diff --git a/tests/ConnectionStub.php b/tests/ConnectionStub.php deleted file mode 100644 index 9ddfb052..00000000 --- a/tests/ConnectionStub.php +++ /dev/null @@ -1,63 +0,0 @@ -data .= $data; - - return true; - } - - public function end($data = null) - { - } - - public function close() - { - } - - public function getData() - { - return $this->data; - } - - public function getRemoteAddress() - { - return '127.0.0.1'; - } -} diff --git a/tests/ServerStub.php b/tests/ServerStub.php deleted file mode 100644 index fc55e972..00000000 --- a/tests/ServerStub.php +++ /dev/null @@ -1,22 +0,0 @@ - Date: Thu, 9 Feb 2017 13:26:23 +0100 Subject: [PATCH 016/128] First class support for PHP7 and HHVM --- .travis.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.travis.yml b/.travis.yml index 55c8e85f..94d55bc3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,13 +7,8 @@ php: - 7 - hhvm -matrix: - allow_failures: - - php: 7 - - php: hhvm - -before_script: - - composer install --dev --prefer-source +install: + - composer install --no-interaction script: - ./vendor/bin/phpunit --coverage-text From 9d32b26d9d34df1e49d206b449a02872b9518749 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Thu, 9 Feb 2017 14:15:36 +0100 Subject: [PATCH 017/128] Mock React\Socket\Server in tests --- tests/ServerTest.php | 49 ++++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 204ed072..5cef7cfc 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -5,17 +5,14 @@ use React\Http\Server; use React\Http\Response; use React\Http\Request; -use React\Socket\Server as Socket; class ServerTest extends TestCase { private $connection; - private $loop; + private $socket; public function setUp() { - $this->loop = \React\EventLoop\Factory::create(); - $this->connection = $this->getMockBuilder('React\Socket\Connection') ->disableOriginalConstructor() ->setMethods( @@ -32,16 +29,19 @@ public function setUp() ) ) ->getMock(); + + $this->socket = $this->getMockBuilder('React\Socket\Server') + ->disableOriginalConstructor() + ->setMethods(null) + ->getMock(); } public function testRequestEventIsEmitted() { - $socket = new Socket($this->loop); - - $server = new Server($socket); + $server = new Server($this->socket); $server->on('request', $this->expectCallableOnce()); - $socket->emit('connection', array($this->connection)); + $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); $this->connection->emit('data', array($data)); @@ -49,13 +49,11 @@ public function testRequestEventIsEmitted() public function testRequestEvent() { - $socket = new Socket($this->loop); - $i = 0; $requestAssertion = null; $responseAssertion = null; - $server = new Server($socket); + $server = new Server($this->socket); $server->on('request', function ($request, $response) use (&$i, &$requestAssertion, &$responseAssertion) { $i++; $requestAssertion = $request; @@ -67,7 +65,7 @@ public function testRequestEvent() ->method('getRemoteAddress') ->willReturn('127.0.0.1'); - $socket->emit('connection', array($this->connection)); + $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); $this->connection->emit('data', array($data)); @@ -83,40 +81,43 @@ public function testRequestEvent() public function testResponseContainsPoweredByHeader() { - $socket = new Socket($this->loop); - - $server = new Server($socket); + $server = new Server($this->socket); $server->on('request', function (Request $request, Response $response) { $response->writeHead(); $response->end(); }); + $buffer = ''; + $this->connection - ->expects($this->exactly(2)) + ->expects($this->any()) ->method('write') - ->withConsecutive( - array($this->equalTo("HTTP/1.1 200 OK\r\nX-Powered-By: React/alpha\r\nTransfer-Encoding: chunked\r\n\r\n")), - array($this->equalTo("0\r\n\r\n")) + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) ); - $socket->emit('connection', array($this->connection)); + $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); $this->connection->emit('data', array($data)); + + $this->assertContains("\r\nX-Powered-By: React/alpha\r\n", $buffer); } public function testParserErrorEmitted() { - $socket = new Socket($this->loop); - $error = null; - $server = new Server($socket); + $server = new Server($this->socket); $server->on('headers', $this->expectCallableNever()); $server->on('error', function ($message) use (&$error) { $error = $message; }); - $socket->emit('connection', array($this->connection)); + $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-DATA: "; $data .= str_repeat('A', 4097 - strlen($data)) . "\r\n\r\n"; From 474a540931cc4e396b6cbbaa2a194c043f407f9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 1 Apr 2016 12:04:40 +0200 Subject: [PATCH 018/128] 2016-04-01: Let's support PHP 5.3, again.. --- .travis.yml | 1 + composer.json | 6 +++--- src/RequestHeaderParser.php | 6 +++--- src/Response.php | 16 ++++++++-------- src/Server.php | 13 +++++++------ tests/ServerTest.php | 2 +- 6 files changed, 23 insertions(+), 21 deletions(-) diff --git a/.travis.yml b/.travis.yml index 94d55bc3..db37918b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,7 @@ language: php php: + - 5.3 - 5.4 - 5.5 - 5.6 diff --git a/composer.json b/composer.json index cfc6f04a..3d431527 100644 --- a/composer.json +++ b/composer.json @@ -4,11 +4,11 @@ "keywords": ["http"], "license": "MIT", "require": { - "php": ">=5.4.0", - "guzzlehttp/psr7": "^1.0", + "php": ">=5.3.0", + "ringcentral/psr7": "^1.0", "react/socket": "^0.4", "react/stream": "^0.4", - "evenement/evenement": "^2.0" + "evenement/evenement": "^2.0 || ^1.0" }, "autoload": { "psr-4": { diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php index af70ed1b..7c44ab02 100644 --- a/src/RequestHeaderParser.php +++ b/src/RequestHeaderParser.php @@ -4,7 +4,7 @@ use Evenement\EventEmitter; use Exception; -use GuzzleHttp\Psr7 as g7; +use RingCentral\Psr7 as g7; /** * @event headers @@ -37,7 +37,7 @@ public function feed($data) try { $this->parseAndEmitRequest(); } catch (Exception $exception) { - $this->emit('error', [$exception]); + $this->emit('error', array($exception)); } $this->removeAllListeners(); } @@ -55,7 +55,7 @@ public function parseRequest($data) $psrRequest = g7\parse_request($headers); - $parsedQuery = []; + $parsedQuery = array(); $queryString = $psrRequest->getUri()->getQuery(); if ($queryString) { parse_str($queryString, $parsedQuery); diff --git a/src/Response.php b/src/Response.php index 36e375fc..bab5fc0f 100644 --- a/src/Response.php +++ b/src/Response.php @@ -17,18 +17,18 @@ class Response extends EventEmitter implements WritableStreamInterface public function __construct(ConnectionInterface $conn) { $this->conn = $conn; - - $this->conn->on('end', function () { - $this->close(); + $that = $this; + $this->conn->on('end', function () use ($that) { + $that->close(); }); - $this->conn->on('error', function ($error) { - $this->emit('error', array($error, $this)); - $this->close(); + $this->conn->on('error', function ($error) use ($that) { + $that->emit('error', array($error, $that)); + $that->close(); }); - $this->conn->on('drain', function () { - $this->emit('drain'); + $this->conn->on('drain', function () use ($that) { + $that->emit('drain'); }); } diff --git a/src/Server.php b/src/Server.php index 4af205e5..6e9c8d95 100644 --- a/src/Server.php +++ b/src/Server.php @@ -14,18 +14,19 @@ class Server extends EventEmitter public function __construct(SocketServerInterface $io) { $this->io = $io; + $that = $this; - $this->io->on('connection', function (ConnectionInterface $conn) { + $this->io->on('connection', function (ConnectionInterface $conn) use ($that) { // TODO: http 1.1 keep-alive // TODO: chunked transfer encoding (also for outgoing data) // TODO: multipart parsing $parser = new RequestHeaderParser(); - $parser->on('headers', function (Request $request, $bodyBuffer) use ($conn, $parser) { + $parser->on('headers', function (Request $request, $bodyBuffer) use ($conn, $parser, $that) { // attach remote ip to the request as metadata $request->remoteAddress = $conn->getRemoteAddress(); - $this->handleRequest($conn, $request, $bodyBuffer); + $that->handleRequest($conn, $request, $bodyBuffer); $conn->removeListener('data', array($parser, 'feed')); $conn->on('end', function () use ($request) { @@ -42,12 +43,12 @@ public function __construct(SocketServerInterface $io) }); }); - $listener = [$parser, 'feed']; + $listener = array($parser, 'feed'); $conn->on('data', $listener); - $parser->on('error', function() use ($conn, $listener) { + $parser->on('error', function() use ($conn, $listener, $that) { // TODO: return 400 response $conn->removeListener('data', $listener); - $this->emit('error', func_get_args()); + $that->emit('error', func_get_args()); }); }); } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 5cef7cfc..e79347e9 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -121,7 +121,7 @@ public function testParserErrorEmitted() $data = "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-DATA: "; $data .= str_repeat('A', 4097 - strlen($data)) . "\r\n\r\n"; - $this->connection->emit('data', [$data]); + $this->connection->emit('data', array($data)); $this->assertInstanceOf('OverflowException', $error); $this->connection->expects($this->never())->method('write'); From 91f327f49af6b6c982c558026655a3436f7395bc Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Fri, 10 Feb 2017 07:45:16 +0100 Subject: [PATCH 019/128] Added v0.4.3 to the changelog --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0167b52..27639e09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.4.3 (2017-02-10) + +* First class support for PHP7 and HHVM #102 @clue +* Improve compatibility with legacy versions #101 @clue +* Remove unneeded stubs from tests #100 @legionth +* Replace PHPUnit's getMock() for forward compatibility #93 @nopolabs +* Add PHPUnit 4.8 to require-dev #92 @nopolabs +* Fix checking maximum header size, do not take start of body into account #88 @nopolabs +* data listener is removed if HeaderParser emits error #83 @nick4fake +* Removed testing against HHVM nightly #66 @WyriHaximus + ## 0.4.2 (2016-11-09) * Remove all listeners after emitting error in RequestHeaderParser #68 @WyriHaximus From b8bf67c1384d48f9b8c0849c97d8e3f97c3c673b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 10 Feb 2017 12:36:14 +0100 Subject: [PATCH 020/128] Prepare v0.4.3 release --- CHANGELOG.md | 21 +++++++++++++-------- README.md | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27639e09..8068f512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,19 @@ ## 0.4.3 (2017-02-10) -* First class support for PHP7 and HHVM #102 @clue -* Improve compatibility with legacy versions #101 @clue -* Remove unneeded stubs from tests #100 @legionth -* Replace PHPUnit's getMock() for forward compatibility #93 @nopolabs -* Add PHPUnit 4.8 to require-dev #92 @nopolabs -* Fix checking maximum header size, do not take start of body into account #88 @nopolabs -* data listener is removed if HeaderParser emits error #83 @nick4fake -* Removed testing against HHVM nightly #66 @WyriHaximus +* Fix: Do not take start of body into account when checking maximum header size + (#88 by @nopolabs) + +* Fix: Remove `data` listener if `HeaderParser` emits an error + (#83 by @nick4fake) + +* First class support for PHP 5.3 through PHP 7 and HHVM + (#101 and #102 by @clue, #66 by @WyriHaximus) + +* Improve test suite by adding PHPUnit to require-dev, + improving forward compatibility with newer PHPUnit versions + and replacing unneeded test stubs + (#92 and #93 by @nopolabs, #100 by @legionth) ## 0.4.2 (2016-11-09) diff --git a/README.md b/README.md index 9bad8ad4..f73f9ab7 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,35 @@ $loop->run(); ``` See also the [examples](examples). + +## Install + +The recommended way to install this library is [through Composer](http://getcomposer.org). +[New to Composer?](http://getcomposer.org/doc/00-intro.md) + +This will install the latest supported version: + +```bash +$ composer require react/http:^0.4.3 +``` + +More details about version upgrades can be found in the [CHANGELOG](CHANGELOG.md). + +## Tests + +To run the test suite, you first need to clone this repo and then install all +dependencies [through Composer](http://getcomposer.org): + +```bash +$ composer install +``` + +To run the test suite, go to the project root and run: + +```bash +$ php vendor/bin/phpunit +``` + +## License + +MIT, see [LICENSE file](LICENSE). From 1d40eb7714d38015c11ba97878771bf09eee1607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 13 Oct 2016 17:14:16 +0200 Subject: [PATCH 021/128] =?UTF-8?q?Add=20request=20header=20accessors=20(?= =?UTF-8?q?=C3=A0=20la=20PSR-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 45 ++++++++++++++++++++++++++++++++++ src/Request.php | 56 +++++++++++++++++++++++++++++++++++++++++++ tests/RequestTest.php | 34 ++++++++++++++++++++++++++ 3 files changed, 135 insertions(+) diff --git a/README.md b/README.md index f73f9ab7..1a8cafe6 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,51 @@ $loop->run(); See also the [examples](examples). +## Usage + +### Server + +See the above usage example and the class outline for details. + +### Request + +See the above usage example and the class outline for details. + +#### getHeaders() + +The `getHeaders(): array` method can be used to +return ALL headers. + +This will return an (possibly empty) assoc array with header names as +key and header values as value. The header value will be a string if +there's only a single value or an array of strings if this header has +multiple values. + +Note that this differs from the PSR-7 implementation of this method. + +#### getHeader() + +The `getHeader(string $name): string[]` method can be used to +retrieve a message header value by the given case-insensitive name. + +Returns a list of all values for this header name or an empty array if header was not found + +#### getHeaderLine() + +The `getHeaderLine(string $name): string` method can be used to +retrieve a comma-separated string of the values for a single header. + +Returns a comma-separated list of all values for this header name or an empty string if header was not found + +#### hasHeader() + +The `hasHeader(string $name): bool` method can be used to +check if a header exists by the given case-insensitive name. + +### Response + +See the above usage example and the class outline for details. + ## Install The recommended way to install this library is [through Composer](http://getcomposer.org). diff --git a/src/Request.php b/src/Request.php index 605b909e..af11fc91 100644 --- a/src/Request.php +++ b/src/Request.php @@ -48,11 +48,67 @@ public function getHttpVersion() return $this->httpVersion; } + /** + * Returns ALL headers + * + * This will return an (possibly empty) assoc array with header names as + * key and header values as value. The header value will be a string if + * there's only a single value or an array of strings if this header has + * multiple values. + * + * Note that this differs from the PSR-7 implementation of this method. + * + * @return array + */ public function getHeaders() { return $this->headers; } + /** + * Retrieves a message header value by the given case-insensitive name. + * + * @param string $name + * @return string[] a list of all values for this header name or an empty array if header was not found + */ + public function getHeader($name) + { + $found = array(); + + $name = strtolower($name); + foreach ($this->headers as $key => $value) { + if (strtolower($key) === $name) { + foreach((array)$value as $one) { + $found []= $one; + } + } + } + + return $found; + } + + /** + * Retrieves a comma-separated string of the values for a single header. + * + * @param string $name + * @return string a comma-separated list of all values for this header name or an empty string if header was not found + */ + public function getHeaderLine($name) + { + return implode(', ', $this->getHeader($name)); + } + + /** + * Checks if a header exists by the given case-insensitive name. + * + * @param string $name + * @return bool + */ + public function hasHeader($name) + { + return !!$this->getHeader($name); + } + public function expectsContinue() { return isset($this->headers['Expect']) && '100-continue' === $this->headers['Expect']; diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 1ad85221..d7dd2496 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -23,4 +23,38 @@ public function expectsContinueShouldBeTrueIfContinueExpected() $this->assertTrue($request->expectsContinue()); } + + public function testEmptyHeader() + { + $request = new Request('GET', '/'); + + $this->assertEquals(array(), $request->getHeaders()); + $this->assertFalse($request->hasHeader('Test')); + $this->assertEquals(array(), $request->getHeader('Test')); + $this->assertEquals('', $request->getHeaderLine('Test')); + } + + public function testHeaderIsCaseInsensitive() + { + $request = new Request('GET', '/', array(), '1.1', array( + 'TEST' => 'Yes', + )); + + $this->assertEquals(array('TEST' => 'Yes'), $request->getHeaders()); + $this->assertTrue($request->hasHeader('Test')); + $this->assertEquals(array('Yes'), $request->getHeader('Test')); + $this->assertEquals('Yes', $request->getHeaderLine('Test')); + } + + public function testHeaderWithMultipleValues() + { + $request = new Request('GET', '/', array(), '1.1', array( + 'Test' => array('a', 'b'), + )); + + $this->assertEquals(array('Test' => array('a', 'b')), $request->getHeaders()); + $this->assertTrue($request->hasHeader('Test')); + $this->assertEquals(array('a', 'b'), $request->getHeader('Test')); + $this->assertEquals('a, b', $request->getHeaderLine('Test')); + } } From fb82059319a40cefe618bffa8593cc2ced0513a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 9 Feb 2017 21:48:59 +0100 Subject: [PATCH 022/128] Be explicit about differences with PSR-7 --- README.md | 14 ++++++++------ src/Request.php | 16 +++++++++------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 1a8cafe6..1076eac6 100644 --- a/README.md +++ b/README.md @@ -49,14 +49,16 @@ See the above usage example and the class outline for details. #### getHeaders() The `getHeaders(): array` method can be used to -return ALL headers. +return an array with ALL headers. -This will return an (possibly empty) assoc array with header names as -key and header values as value. The header value will be a string if -there's only a single value or an array of strings if this header has -multiple values. +The keys represent the header name in the exact case in which they were +originally specified. The values will be a string if there's only a single +value for the respective header name or an array of strings if this header +has multiple values. -Note that this differs from the PSR-7 implementation of this method. +> Note that this differs from the PSR-7 implementation of this method, +which always returns an array for each header name, even if it only has a +single value. #### getHeader() diff --git a/src/Request.php b/src/Request.php index af11fc91..ec2041d6 100644 --- a/src/Request.php +++ b/src/Request.php @@ -49,14 +49,16 @@ public function getHttpVersion() } /** - * Returns ALL headers + * Returns an array with ALL headers * - * This will return an (possibly empty) assoc array with header names as - * key and header values as value. The header value will be a string if - * there's only a single value or an array of strings if this header has - * multiple values. + * The keys represent the header name in the exact case in which they were + * originally specified. The values will be a string if there's only a single + * value for the respective header name or an array of strings if this header + * has multiple values. * - * Note that this differs from the PSR-7 implementation of this method. + * Note that this differs from the PSR-7 implementation of this method, + * which always returns an array for each header name, even if it only has a + * single value. * * @return array */ @@ -79,7 +81,7 @@ public function getHeader($name) foreach ($this->headers as $key => $value) { if (strtolower($key) === $name) { foreach((array)$value as $one) { - $found []= $one; + $found[] = $one; } } } From 6c3b91826d7fb2e814a6ac430c72cc7ab8daeaab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 9 Feb 2017 15:11:46 +0100 Subject: [PATCH 023/128] Fix headers to be handled case insensitive --- src/Request.php | 2 +- src/Response.php | 24 +++++++++++--- tests/RequestTest.php | 9 ++++++ tests/ResponseTest.php | 72 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 6 deletions(-) diff --git a/src/Request.php b/src/Request.php index ec2041d6..bde145b0 100644 --- a/src/Request.php +++ b/src/Request.php @@ -113,7 +113,7 @@ public function hasHeader($name) public function expectsContinue() { - return isset($this->headers['Expect']) && '100-continue' === $this->headers['Expect']; + return '100-continue' === $this->getHeaderLine('Expect'); } public function isReadable() diff --git a/src/Response.php b/src/Response.php index bab5fc0f..851aeb98 100644 --- a/src/Response.php +++ b/src/Response.php @@ -52,15 +52,29 @@ public function writeHead($status = 200, array $headers = array()) throw new \Exception('Response head has already been written.'); } - if (isset($headers['Content-Length'])) { + $lower = array_change_key_case($headers); + + // disable chunked encoding if content-length is given + if (isset($lower['content-length'])) { $this->chunkedEncoding = false; } - $headers = array_merge( - array('X-Powered-By' => 'React/alpha'), - $headers - ); + // assign default "X-Powered-By" header as first for history reasons + if (!isset($lower['x-powered-by'])) { + $headers = array_merge( + array('X-Powered-By' => 'React/alpha'), + $headers + ); + } + + // assign chunked transfer-encoding if chunked encoding is used if ($this->chunkedEncoding) { + foreach($headers as $name => $value) { + if (strtolower($name) === 'transfer-encoding') { + unset($headers[$name]); + } + } + $headers['Transfer-Encoding'] = 'chunked'; } diff --git a/tests/RequestTest.php b/tests/RequestTest.php index d7dd2496..004f7891 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -24,6 +24,15 @@ public function expectsContinueShouldBeTrueIfContinueExpected() $this->assertTrue($request->expectsContinue()); } + /** @test */ + public function expectsContinueShouldBeTrueIfContinueExpectedCaseInsensitive() + { + $headers = array('EXPECT' => '100-continue'); + $request = new Request('GET', '/', array(), '1.1', $headers); + + $this->assertTrue($request->expectsContinue()); + } + public function testEmptyHeader() { $request = new Request('GET', '/'); diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 11407f28..20ccfa6a 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -26,6 +26,25 @@ public function testResponseShouldBeChunkedByDefault() $response->writeHead(); } + public function testResponseShouldBeChunkedEvenWithOtherTransferEncoding() + { + $expected = ''; + $expected .= "HTTP/1.1 200 OK\r\n"; + $expected .= "X-Powered-By: React/alpha\r\n"; + $expected .= "Transfer-Encoding: chunked\r\n"; + $expected .= "\r\n"; + + $conn = $this->getMock('React\Socket\ConnectionInterface'); + $conn + ->expects($this->once()) + ->method('write') + ->with($expected); + + $response = new Response($conn); + $response->writeHead(200, array('transfer-encoding' => 'custom')); + } + + public function testResponseShouldNotBeChunkedWithContentLength() { $expected = ''; @@ -46,6 +65,59 @@ public function testResponseShouldNotBeChunkedWithContentLength() $response->writeHead(200, array('Content-Length' => 22)); } + public function testResponseShouldNotBeChunkedWithContentLengthCaseInsensitive() + { + $expected = ''; + $expected .= "HTTP/1.1 200 OK\r\n"; + $expected .= "X-Powered-By: React/alpha\r\n"; + $expected .= "CONTENT-LENGTH: 0\r\n"; + $expected .= "\r\n"; + + $conn = $this->getMock('React\Socket\ConnectionInterface'); + $conn + ->expects($this->once()) + ->method('write') + ->with($expected); + + $response = new Response($conn); + $response->writeHead(200, array('CONTENT-LENGTH' => 0)); + } + + public function testResponseShouldIncludeCustomByPoweredAsFirstHeaderIfGivenExplicitly() + { + $expected = ''; + $expected .= "HTTP/1.1 200 OK\r\n"; + $expected .= "Content-Length: 0\r\n"; + $expected .= "X-POWERED-BY: demo\r\n"; + $expected .= "\r\n"; + + $conn = $this->getMock('React\Socket\ConnectionInterface'); + $conn + ->expects($this->once()) + ->method('write') + ->with($expected); + + $response = new Response($conn); + $response->writeHead(200, array('Content-Length' => 0, 'X-POWERED-BY' => 'demo')); + } + + public function testResponseShouldNotIncludePoweredByIfGivenEmptyArray() + { + $expected = ''; + $expected .= "HTTP/1.1 200 OK\r\n"; + $expected .= "Content-Length: 0\r\n"; + $expected .= "\r\n"; + + $conn = $this->getMock('React\Socket\ConnectionInterface'); + $conn + ->expects($this->once()) + ->method('write') + ->with($expected); + + $response = new Response($conn); + $response->writeHead(200, array('Content-Length' => 0, 'X-Powered-By' => array())); + } + public function testResponseBodyShouldBeChunkedCorrectly() { $conn = $this From 92918e86268ee768d4811d40594b5d3729cdad1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 10 Feb 2017 13:57:58 +0100 Subject: [PATCH 024/128] Documentation for writeContinue() and writeHead() --- README.md | 105 +++++++++++++++++++++++++++++++++++++++++++++++ src/Request.php | 10 +++++ src/Response.php | 91 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+) diff --git a/README.md b/README.md index 1076eac6..8caa3741 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,11 @@ See the above usage example and the class outline for details. ### Request +The `Request` class is responsible for streaming the incoming request body +and contains meta data which was parsed from the request headers. + +It implements the `ReadableStreamInterface`. + See the above usage example and the class outline for details. #### getHeaders() @@ -79,10 +84,110 @@ Returns a comma-separated list of all values for this header name or an empty st The `hasHeader(string $name): bool` method can be used to check if a header exists by the given case-insensitive name. +#### expectsContinue() + +The `expectsContinue(): bool` method can be used to +check if the request headers contain the `Expect: 100-continue` header. + +This header MAY be included when an HTTP/1.1 client wants to send a bigger +request body. +See [`writeContinue()`](#writecontinue) for more details. + ### Response +The `Response` class is responsible for streaming the outgoing response body. + +It implements the `WritableStreamInterface`. + See the above usage example and the class outline for details. +#### writeContinue() + +The `writeContinue(): void` method can be used to +send an intermediary `HTTP/1.1 100 continue` response. + +This is a feature that is implemented by *many* HTTP/1.1 clients. +When clients want to send a bigger request body, they MAY send only the request +headers with an additional `Expect: 100-continue` header and wait before +sending the actual (large) message body. + +The server side MAY use this header to verify if the request message is +acceptable by checking the request headers (such as `Content-Length` or HTTP +authentication) and then ask the client to continue with sending the message body. +Otherwise, the server can send a normal HTTP response message and save the +client from transfering the whole body at all. + +This method is mostly useful in combination with the +[`expectsContinue()`](#expectscontinue) method like this: + +```php +$http->on('request', function (Request $request, Response $response) { + if ($request->expectsContinue()) { + $response->writeContinue(); + } + + $response->writeHead(200, array('Content-Type' => 'text/plain')); + $response->end("Hello World!\n"); +}); +``` + +Note that calling this method is strictly optional. +If you do not use it, then the client MUST continue sending the request body +after waiting some time. + +This method MUST NOT be invoked after calling `writeHead()`. +Calling this method after sending the headers will result in an `Exception`. + +#### writeHead() + +The `writeHead(int $status = 200, array $headers = array(): void` method can be used to +write the given HTTP message header. + +This method MUST be invoked once before calling `write()` or `end()` to send +the actual HTTP message body: + +```php +$response->writeHead(200, array( + 'Content-Type' => 'text/plain' +)); +$response->end('Hello World!'); +``` + +Calling this method more than once will result in an `Exception`. + +Unless you specify a `Content-Length` header yourself, the response message +will automatically use chunked transfer encoding and send the respective header +(`Transfer-Encoding: chunked`) automatically. If you know the length of your +body, you MAY specify it like this instead: + +```php +$data = 'Hello World!'; + +$response->writeHead(200, array( + 'Content-Type' => 'text/plain', + 'Content-Length' => strlen($data) +)); +$response->end($data); +``` + +Note that it will automatically assume a `X-Powered-By: react/alpha` header +unless your specify a custom `X-Powered-By` header yourself: + +```php +$response->writeHead(200, array( + 'X-Powered-By' => 'PHP 3' +)); +``` + +If you do not want to send this header at all, you can use an empty array as +value like this: + +```php +$response->writeHead(200, array( + 'X-Powered-By' => array() +)); +``` + ## Install The recommended way to install this library is [through Composer](http://getcomposer.org). diff --git a/src/Request.php b/src/Request.php index bde145b0..e6745dda 100644 --- a/src/Request.php +++ b/src/Request.php @@ -111,6 +111,16 @@ public function hasHeader($name) return !!$this->getHeader($name); } + /** + * Checks if the request headers contain the `Expect: 100-continue` header. + * + * This header MAY be included when an HTTP/1.1 client wants to send a bigger + * request body. + * See [`writeContinue()`] for more details. + * + * @return bool + * @see Response::writeContinue() + */ public function expectsContinue() { return '100-continue' === $this->getHeaderLine('Expect'); diff --git a/src/Response.php b/src/Response.php index 851aeb98..34c0606d 100644 --- a/src/Response.php +++ b/src/Response.php @@ -37,6 +37,45 @@ public function isWritable() return $this->writable; } + /** + * Sends an intermediary `HTTP/1.1 100 continue` response. + * + * This is a feature that is implemented by *many* HTTP/1.1 clients. + * When clients want to send a bigger request body, they MAY send only the request + * headers with an additional `Expect: 100-continue` header and wait before + * sending the actual (large) message body. + * + * The server side MAY use this header to verify if the request message is + * acceptable by checking the request headers (such as `Content-Length` or HTTP + * authentication) and then ask the client to continue with sending the message body. + * Otherwise, the server can send a normal HTTP response message and save the + * client from transfering the whole body at all. + * + * This method is mostly useful in combination with the + * [`expectsContinue()`] method like this: + * + * ```php + * $http->on('request', function (Request $request, Response $response) { + * if ($request->expectsContinue()) { + * $response->writeContinue(); + * } + * + * $response->writeHead(200, array('Content-Type' => 'text/plain')); + * $response->end("Hello World!\n"); + * }); + * ``` + * + * Note that calling this method is strictly optional. + * If you do not use it, then the client MUST continue sending the request body + * after waiting some time. + * + * This method MUST NOT be invoked after calling `writeHead()`. + * Calling this method after sending the headers will result in an `Exception`. + * + * @return void + * @throws \Exception + * @see Request::expectsContinue() + */ public function writeContinue() { if ($this->headWritten) { @@ -46,6 +85,58 @@ public function writeContinue() $this->conn->write("HTTP/1.1 100 Continue\r\n\r\n"); } + /** + * Writes the given HTTP message header. + * + * This method MUST be invoked once before calling `write()` or `end()` to send + * the actual HTTP message body: + * + * ```php + * $response->writeHead(200, array( + * 'Content-Type' => 'text/plain' + * )); + * $response->end('Hello World!'); + * ``` + * + * Calling this method more than once will result in an `Exception`. + * + * Unless you specify a `Content-Length` header yourself, the response message + * will automatically use chunked transfer encoding and send the respective header + * (`Transfer-Encoding: chunked`) automatically. If you know the length of your + * body, you MAY specify it like this instead: + * + * ```php + * $data = 'Hello World!'; + * + * $response->writeHead(200, array( + * 'Content-Type' => 'text/plain', + * 'Content-Length' => strlen($data) + * )); + * $response->end($data); + * ``` + * + * Note that it will automatically assume a `X-Powered-By: react/alpha` header + * unless your specify a custom `X-Powered-By` header yourself: + * + * ```php + * $response->writeHead(200, array( + * 'X-Powered-By' => 'PHP 3' + * )); + * ``` + * + * If you do not want to send this header at all, you can use an empty array as + * value like this: + * + * ```php + * $response->writeHead(200, array( + * 'X-Powered-By' => array() + * )); + * ``` + * + * @param int $status + * @param array $headers + * @throws \Exception + */ public function writeHead($status = 200, array $headers = array()) { if ($this->headWritten) { From 680b6a8042322de4c0b4a0e1ab052d501405c064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 10 Feb 2017 15:53:54 +0100 Subject: [PATCH 025/128] Forward pause/resume from request to connection --- src/Server.php | 10 ++++------ tests/ServerTest.php | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/Server.php b/src/Server.php index 6e9c8d95..f631ac2e 100644 --- a/src/Server.php +++ b/src/Server.php @@ -26,6 +26,10 @@ public function __construct(SocketServerInterface $io) // attach remote ip to the request as metadata $request->remoteAddress = $conn->getRemoteAddress(); + // forward pause/resume calls to underlying connection + $request->on('pause', array($conn, 'pause')); + $request->on('resume', array($conn, 'resume')); + $that->handleRequest($conn, $request, $bodyBuffer); $conn->removeListener('data', array($parser, 'feed')); @@ -35,12 +39,6 @@ public function __construct(SocketServerInterface $io) $conn->on('data', function ($data) use ($request) { $request->emit('data', array($data)); }); - $request->on('pause', function () use ($conn) { - $conn->emit('pause'); - }); - $request->on('resume', function () use ($conn) { - $conn->emit('resume'); - }); }); $listener = array($parser, 'feed'); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index e79347e9..b9234521 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -79,6 +79,34 @@ public function testRequestEvent() $this->assertInstanceOf('React\Http\Response', $responseAssertion); } + public function testRequestPauseWillbeForwardedToConnection() + { + $server = new Server($this->socket); + $server->on('request', function (Request $request) { + $request->pause(); + }); + + $this->connection->expects($this->once())->method('pause'); + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + $this->connection->emit('data', array($data)); + } + + public function testRequestResumeWillbeForwardedToConnection() + { + $server = new Server($this->socket); + $server->on('request', function (Request $request) { + $request->resume(); + }); + + $this->connection->expects($this->once())->method('resume'); + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + $this->connection->emit('data', array($data)); + } + public function testResponseContainsPoweredByHeader() { $server = new Server($this->socket); From fa954ac02263857bf4f75018f8cc88a6a4572d58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 10 Feb 2017 16:17:25 +0100 Subject: [PATCH 026/128] The Expect field-value is case-insensitive. --- src/Request.php | 2 +- tests/RequestTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Request.php b/src/Request.php index e6745dda..af98ed24 100644 --- a/src/Request.php +++ b/src/Request.php @@ -123,7 +123,7 @@ public function hasHeader($name) */ public function expectsContinue() { - return '100-continue' === $this->getHeaderLine('Expect'); + return '100-continue' === strtolower($this->getHeaderLine('Expect')); } public function isReadable() diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 004f7891..ee79ec1f 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -27,7 +27,7 @@ public function expectsContinueShouldBeTrueIfContinueExpected() /** @test */ public function expectsContinueShouldBeTrueIfContinueExpectedCaseInsensitive() { - $headers = array('EXPECT' => '100-continue'); + $headers = array('EXPECT' => '100-CONTINUE'); $request = new Request('GET', '/', array(), '1.1', $headers); $this->assertTrue($request->expectsContinue()); From 227aafc175fa2e55394c1506b4b46cddf95811dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 10 Feb 2017 16:22:16 +0100 Subject: [PATCH 027/128] Ignore 100-continue expectation for HTTP/1.0 requests --- README.md | 3 +++ src/Request.php | 5 ++++- tests/RequestTest.php | 9 +++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8caa3741..04db55aa 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,9 @@ This header MAY be included when an HTTP/1.1 client wants to send a bigger request body. See [`writeContinue()`](#writecontinue) for more details. +This will always be `false` for HTTP/1.0 requests, regardless of what +any header values say. + ### Response The `Response` class is responsible for streaming the outgoing response body. diff --git a/src/Request.php b/src/Request.php index af98ed24..c9afe22a 100644 --- a/src/Request.php +++ b/src/Request.php @@ -118,12 +118,15 @@ public function hasHeader($name) * request body. * See [`writeContinue()`] for more details. * + * This will always be `false` for HTTP/1.0 requests, regardless of what + * any header values say. + * * @return bool * @see Response::writeContinue() */ public function expectsContinue() { - return '100-continue' === strtolower($this->getHeaderLine('Expect')); + return $this->httpVersion !== '1.0' && '100-continue' === strtolower($this->getHeaderLine('Expect')); } public function isReadable() diff --git a/tests/RequestTest.php b/tests/RequestTest.php index ee79ec1f..e5696749 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -33,6 +33,15 @@ public function expectsContinueShouldBeTrueIfContinueExpectedCaseInsensitive() $this->assertTrue($request->expectsContinue()); } + /** @test */ + public function expectsContinueShouldBeFalseForHttp10() + { + $headers = array('Expect' => '100-continue'); + $request = new Request('GET', '/', array(), '1.0', $headers); + + $this->assertFalse($request->expectsContinue()); + } + public function testEmptyHeader() { $request = new Request('GET', '/'); From 3915a6703b04eab8d87a8056f1143b50f8fb777b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 9 Feb 2017 16:43:22 +0100 Subject: [PATCH 028/128] Do not emit empty data events --- composer.json | 2 +- src/Server.php | 5 +++- tests/ServerTest.php | 68 ++++++++++++++++++++++++++++++++++++++++++++ tests/TestCase.php | 11 +++++++ 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 3d431527..3cee9484 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ "php": ">=5.3.0", "ringcentral/psr7": "^1.0", "react/socket": "^0.4", - "react/stream": "^0.4", + "react/stream": "^0.4.4", "evenement/evenement": "^2.0 || ^1.0" }, "autoload": { diff --git a/src/Server.php b/src/Server.php index f631ac2e..fb94af6c 100644 --- a/src/Server.php +++ b/src/Server.php @@ -63,6 +63,9 @@ public function handleRequest(ConnectionInterface $conn, Request $request, $body } $this->emit('request', array($request, $response)); - $request->emit('data', array($bodyBuffer)); + + if ($bodyBuffer !== '') { + $request->emit('data', array($bodyBuffer)); + } } } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index b9234521..6974e2c9 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -36,6 +36,18 @@ public function setUp() ->getMock(); } + public function testRequestEventWillNotBeEmittedForIncompleteHeaders() + { + $server = new Server($this->socket); + $server->on('request', $this->expectCallableNever()); + + $this->socket->emit('connection', array($this->connection)); + + $data = ''; + $data .= "GET / HTTP/1.1\r\n"; + $this->connection->emit('data', array($data)); + } + public function testRequestEventIsEmitted() { $server = new Server($this->socket); @@ -107,6 +119,62 @@ public function testRequestResumeWillbeForwardedToConnection() $this->connection->emit('data', array($data)); } + public function testRequestEventWithoutBodyWillNotEmitData() + { + $never = $this->expectCallableNever(); + + $server = new Server($this->socket); + $server->on('request', function (Request $request) use ($never) { + $request->on('data', $never); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + $this->connection->emit('data', array($data)); + } + + public function testRequestEventWithSecondDataEventWillEmitBodyData() + { + $once = $this->expectCallableOnceWith('incomplete'); + + $server = new Server($this->socket); + $server->on('request', function (Request $request) use ($once) { + $request->on('data', $once); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = ''; + $data .= "POST / HTTP/1.1\r\n"; + $data .= "Content-Length: 100\r\n"; + $data .= "\r\n"; + $data .= "incomplete"; + $this->connection->emit('data', array($data)); + } + + public function testRequestEventWithPartialBodyWillEmitData() + { + $once = $this->expectCallableOnceWith('incomplete'); + + $server = new Server($this->socket); + $server->on('request', function (Request $request) use ($once) { + $request->on('data', $once); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = ''; + $data .= "POST / HTTP/1.1\r\n"; + $data .= "Content-Length: 100\r\n"; + $data .= "\r\n"; + $this->connection->emit('data', array($data)); + + $data = ''; + $data .= "incomplete"; + $this->connection->emit('data', array($data)); + } + public function testResponseContainsPoweredByHeader() { $server = new Server($this->socket); diff --git a/tests/TestCase.php b/tests/TestCase.php index 24fe27f2..73bb401e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -24,6 +24,17 @@ protected function expectCallableOnce() return $mock; } + protected function expectCallableOnceWith($value) + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($value); + + return $mock; + } + protected function expectCallableNever() { $mock = $this->createCallableMock(); From 6348a9187178f2469bc2810880c2f330d5013652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 9 Feb 2017 22:51:48 +0100 Subject: [PATCH 029/128] Ignore empty writes to not mess up chunked transfer encoding --- src/Response.php | 14 +++++++++----- tests/ResponseTest.php | 27 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/Response.php b/src/Response.php index 34c0606d..8b4af6fa 100644 --- a/src/Response.php +++ b/src/Response.php @@ -201,15 +201,19 @@ public function write($data) throw new \Exception('Response head has not yet been written.'); } + // prefix with chunk length for chunked transfer encoding if ($this->chunkedEncoding) { $len = strlen($data); - $chunk = dechex($len)."\r\n".$data."\r\n"; - $flushed = $this->conn->write($chunk); - } else { - $flushed = $this->conn->write($data); + + // skip empty chunks + if ($len === 0) { + return true; + } + + $data = dechex($len) . "\r\n" . $data . "\r\n"; } - return $flushed; + return $this->conn->write($data); } public function end($data = null) diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 20ccfa6a..ad2222e8 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -149,6 +149,33 @@ public function testResponseBodyShouldBeChunkedCorrectly() $response->end(); } + public function testResponseBodyShouldSkipEmptyChunks() + { + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); + $conn + ->expects($this->at(4)) + ->method('write') + ->with("5\r\nHello\r\n"); + $conn + ->expects($this->at(5)) + ->method('write') + ->with("5\r\nWorld\r\n"); + $conn + ->expects($this->at(6)) + ->method('write') + ->with("0\r\n\r\n"); + + $response = new Response($conn); + $response->writeHead(); + + $response->write('Hello'); + $response->write(''); + $response->write('World'); + $response->end(); + } + public function testResponseShouldEmitEndOnStreamEnd() { $ended = false; From 5875cc5d1479dc96d2612474c2869ef2cd556bce Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 11 Feb 2017 15:50:36 +0100 Subject: [PATCH 030/128] Upgrade ringcentral/psr7 to 1.2 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 3cee9484..a8d1770c 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "license": "MIT", "require": { "php": ">=5.3.0", - "ringcentral/psr7": "^1.0", + "ringcentral/psr7": "^1.2", "react/socket": "^0.4", "react/stream": "^0.4.4", "evenement/evenement": "^2.0 || ^1.0" From 09927d4e72cdc538fbefdece6b6fdc66dd305e55 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 11 Feb 2017 17:01:18 +0100 Subject: [PATCH 031/128] Fix minimum phpunit version and allow v5 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index a8d1770c..f1fea030 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,6 @@ } }, "require-dev": { - "phpunit/phpunit": "~4.8" + "phpunit/phpunit": "^4.8.10||^5.0" } } From 0c82c80194f0ba94f3bcf6417ca9794100539107 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 12 Feb 2017 13:52:19 +0100 Subject: [PATCH 032/128] PhpUnit 5 compatibility --- tests/ResponseTest.php | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index ad2222e8..27948504 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -34,7 +34,9 @@ public function testResponseShouldBeChunkedEvenWithOtherTransferEncoding() $expected .= "Transfer-Encoding: chunked\r\n"; $expected .= "\r\n"; - $conn = $this->getMock('React\Socket\ConnectionInterface'); + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); $conn ->expects($this->once()) ->method('write') @@ -73,7 +75,9 @@ public function testResponseShouldNotBeChunkedWithContentLengthCaseInsensitive() $expected .= "CONTENT-LENGTH: 0\r\n"; $expected .= "\r\n"; - $conn = $this->getMock('React\Socket\ConnectionInterface'); + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); $conn ->expects($this->once()) ->method('write') @@ -91,7 +95,9 @@ public function testResponseShouldIncludeCustomByPoweredAsFirstHeaderIfGivenExpl $expected .= "X-POWERED-BY: demo\r\n"; $expected .= "\r\n"; - $conn = $this->getMock('React\Socket\ConnectionInterface'); + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); $conn ->expects($this->once()) ->method('write') @@ -108,7 +114,9 @@ public function testResponseShouldNotIncludePoweredByIfGivenEmptyArray() $expected .= "Content-Length: 0\r\n"; $expected .= "\r\n"; - $conn = $this->getMock('React\Socket\ConnectionInterface'); + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); $conn ->expects($this->once()) ->method('write') From 39e6d86b64a663a36b77be3257e0a49982329a53 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 11 Feb 2017 15:55:12 +0100 Subject: [PATCH 033/128] Test lowest version constraints --- .travis.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.travis.yml b/.travis.yml index db37918b..f67b7d54 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,8 +8,18 @@ php: - 7 - hhvm +matrix: + include: + - php: 5.3 + env: + - DEPENDENCIES=lowest + - php: 7.0 + env: + - DEPENDENCIES=lowest + install: - composer install --no-interaction + - if [ "$DEPENDENCIES" = "lowest" ]; then composer update --prefer-lowest -n; fi script: - ./vendor/bin/phpunit --coverage-text From 1229a7d905605c057d5c3cd1380be68401d77d45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Mon, 13 Feb 2017 15:12:50 +0100 Subject: [PATCH 034/128] Prepare v0.4.4 release --- CHANGELOG.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8068f512..851b1d65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,50 @@ # Changelog +## 0.4.4 (2017-02-13) + +* Feature: Add request header accessors (à la PSR-7) + (#103 by @clue) + + ```php + // get value of host header + $host = $request->getHeaderLine('Host'); + + // get list of all cookie headers + $cookies = $request->getHeader('Cookie'); + ``` + +* Feature: Forward `pause()` and `resume()` from `Request` to underlying connection + (#110 by @clue) + + ```php + // support back-pressure when piping request into slower destination + $request->pipe($dest); + + // manually pause/resume request + $request->pause(); + $request->resume(); + ``` + +* Fix: Fix `100-continue` to be handled case-insensitive and ignore it for HTTP/1.0. + Similarly, outgoing response headers are now handled case-insensitive, e.g + we no longer apply chunked transfer encoding with mixed-case `Content-Length`. + (#107 by @clue) + + ```php + // now handled case-insensitive + $request->expectsContinue(); + + // now works just like properly-cased header + $response->writeHead($status, array('content-length' => 0)); + ``` + +* Fix: Do not emit empty `data` events and ignore empty writes in order to + not mess up chunked transfer encoding + (#108 and #112 by @clue) + +* Lock and test minimum required dependency versions and support PHPUnit v5 + (#113, #115 and #114 by @andig) + ## 0.4.3 (2017-02-10) * Fix: Do not take start of body into account when checking maximum header size diff --git a/README.md b/README.md index 04db55aa..19dab73e 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ The recommended way to install this library is [through Composer](http://getcomp This will install the latest supported version: ```bash -$ composer require react/http:^0.4.3 +$ composer require react/http:^0.4.4 ``` More details about version upgrades can be found in the [CHANGELOG](CHANGELOG.md). From 6c112ef5d40720f4a5d374f715ada1825934d557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sun, 5 Feb 2017 15:59:57 +0100 Subject: [PATCH 035/128] Update Socket component to v0.5 --- README.md | 3 +-- composer.json | 2 +- examples/01-hello-world.php | 6 ++---- src/Server.php | 5 ++++- tests/ServerTest.php | 6 ++---- tests/SocketServerStub.php | 19 +++++++++++++++++++ 6 files changed, 29 insertions(+), 12 deletions(-) create mode 100644 tests/SocketServerStub.php diff --git a/README.md b/README.md index 19dab73e..f8917f27 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This is an HTTP server which responds with `Hello World` to every request. ```php $loop = React\EventLoop\Factory::create(); -$socket = new React\Socket\Server($loop); +$socket = new React\Socket\Server(8080, $loop); $http = new React\Http\Server($socket); $http->on('request', function ($request, $response) { @@ -30,7 +30,6 @@ $http->on('request', function ($request, $response) { $response->end("Hello World!\n"); }); -$socket->listen(1337); $loop->run(); ``` diff --git a/composer.json b/composer.json index f1fea030..40c0582b 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ "require": { "php": ">=5.3.0", "ringcentral/psr7": "^1.2", - "react/socket": "^0.4", + "react/socket": "^0.5", "react/stream": "^0.4.4", "evenement/evenement": "^2.0 || ^1.0" }, diff --git a/examples/01-hello-world.php b/examples/01-hello-world.php index 70107616..424a9c1e 100644 --- a/examples/01-hello-world.php +++ b/examples/01-hello-world.php @@ -8,7 +8,7 @@ require __DIR__ . '/../vendor/autoload.php'; $loop = Factory::create(); -$socket = new Server($loop); +$socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); $server = new \React\Http\Server($socket); $server->on('request', function (Request $request, Response $response) { @@ -16,8 +16,6 @@ $response->end("Hello world!\n"); }); -$socket->listen(isset($argv[1]) ? $argv[1] : 0, '0.0.0.0'); - -echo 'Listening on ' . $socket->getPort() . PHP_EOL; +echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; $loop->run(); diff --git a/src/Server.php b/src/Server.php index fb94af6c..06e65347 100644 --- a/src/Server.php +++ b/src/Server.php @@ -24,7 +24,10 @@ public function __construct(SocketServerInterface $io) $parser = new RequestHeaderParser(); $parser->on('headers', function (Request $request, $bodyBuffer) use ($conn, $parser, $that) { // attach remote ip to the request as metadata - $request->remoteAddress = $conn->getRemoteAddress(); + $request->remoteAddress = trim( + parse_url('tcp://' . $conn->getRemoteAddress(), PHP_URL_HOST), + '[]' + ); // forward pause/resume calls to underlying connection $request->on('pause', array($conn, 'pause')); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 6974e2c9..0b934da3 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -25,15 +25,13 @@ public function setUp() 'isReadable', 'isWritable', 'getRemoteAddress', + 'getLocalAddress', 'pipe' ) ) ->getMock(); - $this->socket = $this->getMockBuilder('React\Socket\Server') - ->disableOriginalConstructor() - ->setMethods(null) - ->getMock(); + $this->socket = new SocketServerStub(); } public function testRequestEventWillNotBeEmittedForIncompleteHeaders() diff --git a/tests/SocketServerStub.php b/tests/SocketServerStub.php new file mode 100644 index 00000000..bdbb7ac2 --- /dev/null +++ b/tests/SocketServerStub.php @@ -0,0 +1,19 @@ + Date: Tue, 14 Feb 2017 22:22:49 +0100 Subject: [PATCH 036/128] Change Request methods to be in line with PSR-7 * Rename getQuery() to getQueryParams() * Rename getHttpVersion() to getProtocolVersion() * Change `getHeaders()` to always return an array of string values for each header --- README.md | 29 ++++++++++++++++++++------- src/Request.php | 33 ++++++++++++++++++++++--------- src/RequestHeaderParser.php | 10 +--------- tests/RequestHeaderParserTest.php | 22 ++++++++++----------- tests/RequestTest.php | 10 +++++----- 5 files changed, 63 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index f8917f27..08235428 100644 --- a/README.md +++ b/README.md @@ -50,19 +50,34 @@ It implements the `ReadableStreamInterface`. See the above usage example and the class outline for details. +#### getMethod() + +The `getMethod(): string` method can be used to +return the request method. + +#### getPath() + +The `getPath(): string` method can be used to +return the request path. + +#### getQueryParams() + +The `getQueryParams(): array` method can be used to +return an array with all query parameters ($_GET). + +#### getProtocolVersion() + +The `getProtocolVersion(): string` method can be used to +return the HTTP protocol version (such as "1.0" or "1.1"). + #### getHeaders() The `getHeaders(): array` method can be used to return an array with ALL headers. The keys represent the header name in the exact case in which they were -originally specified. The values will be a string if there's only a single -value for the respective header name or an array of strings if this header -has multiple values. - -> Note that this differs from the PSR-7 implementation of this method, -which always returns an array for each header name, even if it only has a -single value. +originally specified. The values will be an array of strings for each +value for the respective header name. #### getHeader() diff --git a/src/Request.php b/src/Request.php index c9afe22a..55d2d1ea 100644 --- a/src/Request.php +++ b/src/Request.php @@ -28,22 +28,42 @@ public function __construct($method, $path, $query = array(), $httpVersion = '1. $this->headers = $headers; } + /** + * Returns the request method + * + * @return string + */ public function getMethod() { return $this->method; } + /** + * Returns the request path + * + * @return string + */ public function getPath() { return $this->path; } - public function getQuery() + /** + * Returns an array with all query parameters ($_GET) + * + * @return array + */ + public function getQueryParams() { return $this->query; } - public function getHttpVersion() + /** + * Returns the HTTP protocol version (such as "1.0" or "1.1") + * + * @return string + */ + public function getProtocolVersion() { return $this->httpVersion; } @@ -52,13 +72,8 @@ public function getHttpVersion() * Returns an array with ALL headers * * The keys represent the header name in the exact case in which they were - * originally specified. The values will be a string if there's only a single - * value for the respective header name or an array of strings if this header - * has multiple values. - * - * Note that this differs from the PSR-7 implementation of this method, - * which always returns an array for each header name, even if it only has a - * single value. + * originally specified. The values will be an array of strings for each + * value for the respective header name. * * @return array */ diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php index 7c44ab02..63816bf4 100644 --- a/src/RequestHeaderParser.php +++ b/src/RequestHeaderParser.php @@ -61,20 +61,12 @@ public function parseRequest($data) parse_str($queryString, $parsedQuery); } - $headers = array_map(function($val) { - if (1 === count($val)) { - $val = $val[0]; - } - - return $val; - }, $psrRequest->getHeaders()); - $request = new Request( $psrRequest->getMethod(), $psrRequest->getUri()->getPath(), $parsedQuery, $psrRequest->getProtocolVersion(), - $headers + $psrRequest->getHeaders() ); return array($request, $bodyBuffer); diff --git a/tests/RequestHeaderParserTest.php b/tests/RequestHeaderParserTest.php index 2c22c4a5..1193e220 100644 --- a/tests/RequestHeaderParserTest.php +++ b/tests/RequestHeaderParserTest.php @@ -48,9 +48,9 @@ public function testHeadersEventShouldReturnRequestAndBodyBuffer() $this->assertInstanceOf('React\Http\Request', $request); $this->assertSame('GET', $request->getMethod()); $this->assertSame('/', $request->getPath()); - $this->assertSame(array(), $request->getQuery()); - $this->assertSame('1.1', $request->getHttpVersion()); - $this->assertSame(array('Host' => 'example.com:80', 'Connection' => 'close'), $request->getHeaders()); + $this->assertSame(array(), $request->getQueryParams()); + $this->assertSame('1.1', $request->getProtocolVersion()); + $this->assertSame(array('Host' => array('example.com:80'), 'Connection' => array('close')), $request->getHeaders()); $this->assertSame('RANDOM DATA', $bodyBuffer); } @@ -86,12 +86,12 @@ public function testHeadersEventShouldParsePathAndQueryString() $this->assertInstanceOf('React\Http\Request', $request); $this->assertSame('POST', $request->getMethod()); $this->assertSame('/foo', $request->getPath()); - $this->assertSame(array('bar' => 'baz'), $request->getQuery()); - $this->assertSame('1.1', $request->getHttpVersion()); + $this->assertSame(array('bar' => 'baz'), $request->getQueryParams()); + $this->assertSame('1.1', $request->getProtocolVersion()); $headers = array( - 'Host' => 'example.com:80', - 'User-Agent' => 'react/alpha', - 'Connection' => 'close', + 'Host' => array('example.com:80'), + 'User-Agent' => array('react/alpha'), + 'Connection' => array('close'), ); $this->assertSame($headers, $request->getHeaders()); } @@ -139,9 +139,9 @@ public function testHeaderOverflowShouldNotEmitErrorWhenDataExceedsMaxHeaderSize $parser->feed($data); $headers = array( - 'Host' => 'example.com:80', - 'User-Agent' => 'react/alpha', - 'Connection' => 'close', + 'Host' => array('example.com:80'), + 'User-Agent' => array('react/alpha'), + 'Connection' => array('close'), ); $this->assertSame($headers, $request->getHeaders()); diff --git a/tests/RequestTest.php b/tests/RequestTest.php index e5696749..7e630a3e 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -18,7 +18,7 @@ public function expectsContinueShouldBeFalseByDefault() /** @test */ public function expectsContinueShouldBeTrueIfContinueExpected() { - $headers = array('Expect' => '100-continue'); + $headers = array('Expect' => array('100-continue')); $request = new Request('GET', '/', array(), '1.1', $headers); $this->assertTrue($request->expectsContinue()); @@ -27,7 +27,7 @@ public function expectsContinueShouldBeTrueIfContinueExpected() /** @test */ public function expectsContinueShouldBeTrueIfContinueExpectedCaseInsensitive() { - $headers = array('EXPECT' => '100-CONTINUE'); + $headers = array('EXPECT' => array('100-CONTINUE')); $request = new Request('GET', '/', array(), '1.1', $headers); $this->assertTrue($request->expectsContinue()); @@ -36,7 +36,7 @@ public function expectsContinueShouldBeTrueIfContinueExpectedCaseInsensitive() /** @test */ public function expectsContinueShouldBeFalseForHttp10() { - $headers = array('Expect' => '100-continue'); + $headers = array('Expect' => array('100-continue')); $request = new Request('GET', '/', array(), '1.0', $headers); $this->assertFalse($request->expectsContinue()); @@ -55,10 +55,10 @@ public function testEmptyHeader() public function testHeaderIsCaseInsensitive() { $request = new Request('GET', '/', array(), '1.1', array( - 'TEST' => 'Yes', + 'TEST' => array('Yes'), )); - $this->assertEquals(array('TEST' => 'Yes'), $request->getHeaders()); + $this->assertEquals(array('TEST' => array('Yes')), $request->getHeaders()); $this->assertTrue($request->hasHeader('Test')); $this->assertEquals(array('Yes'), $request->getHeader('Test')); $this->assertEquals('Yes', $request->getHeaderLine('Test')); From a55482ae884324b39417c33c3717416dfafcf1b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Wed, 15 Feb 2017 09:08:58 +0100 Subject: [PATCH 037/128] Mark internal APIs as internal or private --- README.md | 33 +++++++++++++++++++++++++++-- src/Request.php | 23 ++++++++++++++++++++ src/RequestHeaderParser.php | 6 ++++-- src/Response.php | 22 +++++++++++++++++++ src/ResponseCodes.php | 2 ++ src/Server.php | 42 ++++++++++++++++++++++++++++++++++++- 6 files changed, 123 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 08235428..e2e60d81 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ $loop = React\EventLoop\Factory::create(); $socket = new React\Socket\Server(8080, $loop); $http = new React\Http\Server($socket); -$http->on('request', function ($request, $response) { +$http->on('request', function (Request $request, Response $response) { $response->writeHead(200, array('Content-Type' => 'text/plain')); $response->end("Hello World!\n"); }); @@ -39,7 +39,30 @@ See also the [examples](examples). ### Server -See the above usage example and the class outline for details. +The `Server` class is responsible for handling incoming connections and then +emit a `request` event for each incoming HTTP request. + +It attaches itself to an instance of `React\Socket\ServerInterface` which +emits underlying streaming connections in order to then parse incoming data +as HTTP: + +```php +$socket = new React\Socket\Server(8080, $loop); + +$http = new React\Http\Server($socket); +``` + +For each incoming connection, it emits a `request` event with the respective +[`Request`](#request) and [`Response`](#response) objects: + +```php +$http->on('request', function (Request $request, Response $response) { + $response->writeHead(200, array('Content-Type' => 'text/plain')); + $response->end("Hello World!\n"); +}); +``` + +See also [`Request`](#request) and [`Response`](#response) for more details. ### Request @@ -48,6 +71,9 @@ and contains meta data which was parsed from the request headers. It implements the `ReadableStreamInterface`. +The constructor is internal, you SHOULD NOT call this yourself. +The `Server` is responsible for emitting `Request` and `Response` objects. + See the above usage example and the class outline for details. #### getMethod() @@ -116,6 +142,9 @@ The `Response` class is responsible for streaming the outgoing response body. It implements the `WritableStreamInterface`. +The constructor is internal, you SHOULD NOT call this yourself. +The `Server` is responsible for emitting `Request` and `Response` objects. + See the above usage example and the class outline for details. #### writeContinue() diff --git a/src/Request.php b/src/Request.php index 55d2d1ea..27a11668 100644 --- a/src/Request.php +++ b/src/Request.php @@ -7,6 +7,20 @@ use React\Stream\WritableStreamInterface; use React\Stream\Util; +/** + * The `Request` class is responsible for streaming the incoming request body + * and contains meta data which was parsed from the request headers. + * + * It implements the `ReadableStreamInterface`. + * + * The constructor is internal, you SHOULD NOT call this yourself. + * The `Server` is responsible for emitting `Request` and `Response` objects. + * + * See the usage examples and the class outline for details. + * + * @see ReadableStreamInterface + * @see Server + */ class Request extends EventEmitter implements ReadableStreamInterface { private $readable = true; @@ -19,6 +33,15 @@ class Request extends EventEmitter implements ReadableStreamInterface // metadata, implicitly added externally public $remoteAddress; + /** + * The constructor is internal, you SHOULD NOT call this yourself. + * + * The `Server` is responsible for emitting `Request` and `Response` objects. + * + * Constructor parameters may change at any time. + * + * @internal + */ public function __construct($method, $path, $query = array(), $httpVersion = '1.1', $headers = array()) { $this->method = $method; diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php index 63816bf4..b75fadbf 100644 --- a/src/RequestHeaderParser.php +++ b/src/RequestHeaderParser.php @@ -9,6 +9,8 @@ /** * @event headers * @event error + * + * @internal */ class RequestHeaderParser extends EventEmitter { @@ -43,13 +45,13 @@ public function feed($data) } } - protected function parseAndEmitRequest() + private function parseAndEmitRequest() { list($request, $bodyBuffer) = $this->parseRequest($this->buffer); $this->emit('headers', array($request, $bodyBuffer)); } - public function parseRequest($data) + private function parseRequest($data) { list($headers, $bodyBuffer) = explode("\r\n\r\n", $data, 2); diff --git a/src/Response.php b/src/Response.php index 8b4af6fa..91ae4358 100644 --- a/src/Response.php +++ b/src/Response.php @@ -6,6 +6,19 @@ use React\Socket\ConnectionInterface; use React\Stream\WritableStreamInterface; +/** + * The `Response` class is responsible for streaming the outgoing response body. + * + * It implements the `WritableStreamInterface`. + * + * The constructor is internal, you SHOULD NOT call this yourself. + * The `Server` is responsible for emitting `Request` and `Response` objects. + * + * See the usage examples and the class outline for details. + * + * @see WritableStreamInterface + * @see Server + */ class Response extends EventEmitter implements WritableStreamInterface { private $closed = false; @@ -14,6 +27,15 @@ class Response extends EventEmitter implements WritableStreamInterface private $headWritten = false; private $chunkedEncoding = true; + /** + * The constructor is internal, you SHOULD NOT call this yourself. + * + * The `Server` is responsible for emitting `Request` and `Response` objects. + * + * Constructor parameters may change at any time. + * + * @internal + */ public function __construct(ConnectionInterface $conn) { $this->conn = $conn; diff --git a/src/ResponseCodes.php b/src/ResponseCodes.php index ae241ded..27b29435 100644 --- a/src/ResponseCodes.php +++ b/src/ResponseCodes.php @@ -4,6 +4,8 @@ /** * This is copy-pasted from Symfony2's Response class + * + * @internal */ class ResponseCodes { diff --git a/src/Server.php b/src/Server.php index 06e65347..933bc109 100644 --- a/src/Server.php +++ b/src/Server.php @@ -6,11 +6,50 @@ use React\Socket\ServerInterface as SocketServerInterface; use React\Socket\ConnectionInterface; -/** @event request */ +/** + * The `Server` class is responsible for handling incoming connections and then + * emit a `request` event for each incoming HTTP request. + * + * ```php + * $socket = new React\Socket\Server(8080, $loop); + * + * $http = new React\Http\Server($socket); + * ``` + * + * For each incoming connection, it emits a `request` event with the respective + * [`Request`](#request) and [`Response`](#response) objects: + * + * ```php + * $http->on('request', function (Request $request, Response $response) { + * $response->writeHead(200, array('Content-Type' => 'text/plain')); + * $response->end("Hello World!\n"); + * }); + * ``` + * + * See also [`Request`](#request) and [`Response`](#response) for more details. + * + * @see Request + * @see Response + */ class Server extends EventEmitter { private $io; + /** + * Creates a HTTP server that accepts connections from the given socket. + * + * It attaches itself to an instance of `React\Socket\ServerInterface` which + * emits underlying streaming connections in order to then parse incoming data + * as HTTP: + * + * ```php + * $socket = new React\Socket\Server(8080, $loop); + * + * $http = new React\Http\Server($socket); + * ``` + * + * @param \React\Socket\ServerInterface $io + */ public function __construct(SocketServerInterface $io) { $this->io = $io; @@ -54,6 +93,7 @@ public function __construct(SocketServerInterface $io) }); } + /** @internal */ public function handleRequest(ConnectionInterface $conn, Request $request, $bodyBuffer) { $response = new Response($conn); From c82b959a921da51c748f79ae66f839690d820bb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sun, 5 Feb 2017 15:48:19 +0100 Subject: [PATCH 038/128] Secure HTTPS server example --- README.md | 13 ++++++++ examples/02-hello-world-https.php | 27 +++++++++++++++++ examples/localhost.pem | 49 +++++++++++++++++++++++++++++++ src/Server.php | 13 ++++++++ 4 files changed, 102 insertions(+) create mode 100644 examples/02-hello-world-https.php create mode 100644 examples/localhost.pem diff --git a/README.md b/README.md index e2e60d81..96d14f09 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,19 @@ $socket = new React\Socket\Server(8080, $loop); $http = new React\Http\Server($socket); ``` +Similarly, you can also attach this to a +[`React\Socket\SecureServer`](https://github.com/reactphp/socket#secureserver) +in order to start a secure HTTPS server like this: + +```php +$socket = new Server(8080, $loop); +$socket = new SecureServer($socket, $loop, array( + 'local_cert' => __DIR__ . '/localhost.pem' +)); + +$http = new React\Http\Server($socket); +``` + For each incoming connection, it emits a `request` event with the respective [`Request`](#request) and [`Response`](#response) objects: diff --git a/examples/02-hello-world-https.php b/examples/02-hello-world-https.php new file mode 100644 index 00000000..c017a196 --- /dev/null +++ b/examples/02-hello-world-https.php @@ -0,0 +1,27 @@ + isset($argv[2]) ? $argv[2] : __DIR__ . '/localhost.pem' +)); + +$server = new \React\Http\Server($socket); +$server->on('request', function (Request $reques, Response $response) { + $response->writeHead(200, array('Content-Type' => 'text/plain')); + $response->end("Hello world!\n"); +}); + +//$socket->on('error', 'printf'); + +echo 'Listening on https://' . $socket->getAddress() . PHP_EOL; + +$loop->run(); diff --git a/examples/localhost.pem b/examples/localhost.pem new file mode 100644 index 00000000..be692792 --- /dev/null +++ b/examples/localhost.pem @@ -0,0 +1,49 @@ +-----BEGIN CERTIFICATE----- +MIIDfTCCAmWgAwIBAgIBADANBgkqhkiG9w0BAQUFADBZMRIwEAYDVQQDDAkxMjcu +MC4wLjExCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQK +DBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMwMTQ1OTA2WhcNMjYx +MjI4MTQ1OTA2WjBZMRIwEAYDVQQDDAkxMjcuMC4wLjExCzAJBgNVBAYTAkFVMRMw +EQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0 +eSBMdGQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC8SZWNS+Ktg0Py +W8dx5uXZ+ZUawd3wnzLMHW7EhoUpIrIdp3kDU9NezF68dOhPMJY/Kh+6btRCxWXN +2OVTqS5Xi826j3TSE07iF83JRLeveW0PcodjUBd+RzdwCWWo2pfMJz4v7x1wu1c9 +zNi6JxxpDAXTFSB4GiWsI4tFu2XmMRhfm6LRK4WPfsZIJKokdiG5fKSPDn7nrVj0 +UUXr2eBsEAzdwL14U9+mwbLdaAkz3qK3fqi8sEC09lEWm95gKMOhkQf5qvXODtT4 +wdVrrKDTyehLv0xaItnUDnXzrkMBU5QS9TQzzqSW6ZaBsSxtONEFUiXiN9dtyXsY +YCUE54G/AgMBAAGjUDBOMB0GA1UdDgQWBBQ2GRz3QsQzdXaTMnPVCKfpigA10DAf +BgNVHSMEGDAWgBQ2GRz3QsQzdXaTMnPVCKfpigA10DAMBgNVHRMEBTADAQH/MA0G +CSqGSIb3DQEBBQUAA4IBAQA77iZ4KrpPY18Ezjt0mngYAuAxunKddXYdLZ2khywN +0uI/VzYnkFVtrsC7y2jLHSxlmE2/viPPGZDUplENV2acN6JNW+tlt7/bsrQHDQw3 +7VCF27EWiDxHsaghhLkqC+kcop5YR5c0oDQTdEWEKSbow2zayUXDYbRRs76SClTe +824Yul+Ts8Mka+AX2PXDg47iZ84fJRN/nKavcJUTJ2iS1uYw0GNnFMge/uwsfMR3 +V47qN0X5emky8fcq99FlMCbcy0gHAeSWAjClgr2dd2i0LDatUbj7YmdmFcskOgII +IwGfvuWR2yPevYGAE0QgFeLHniN3RW8zmpnX/XtrJ4a7 +-----END CERTIFICATE----- +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC8SZWNS+Ktg0Py +W8dx5uXZ+ZUawd3wnzLMHW7EhoUpIrIdp3kDU9NezF68dOhPMJY/Kh+6btRCxWXN +2OVTqS5Xi826j3TSE07iF83JRLeveW0PcodjUBd+RzdwCWWo2pfMJz4v7x1wu1c9 +zNi6JxxpDAXTFSB4GiWsI4tFu2XmMRhfm6LRK4WPfsZIJKokdiG5fKSPDn7nrVj0 +UUXr2eBsEAzdwL14U9+mwbLdaAkz3qK3fqi8sEC09lEWm95gKMOhkQf5qvXODtT4 +wdVrrKDTyehLv0xaItnUDnXzrkMBU5QS9TQzzqSW6ZaBsSxtONEFUiXiN9dtyXsY +YCUE54G/AgMBAAECggEBAKiO/3FE1CMddkCLZVtUp8ShqJgRokx9WI5ecwFApAkV +ZHsjqDQQYRNmxhDUX/w0tOzLGyhde2xjJyZG29YviKsbHwu6zYwbeOzy/mkGOaK/ +g6DmmMmRs9Z6juifoQCu4GIFZ6il2adIL2vF7OeJh+eKudQj/7NFRSB7mXzNrQWK +tZY3eux5zXWmio7pgZrx1HFZQiiL9NVLwT9J7oBnaoO3fREiu5J2xBpljG9Cr0j1 +LLiVLhukWJYRlHDtGt1CzI9w8iKo44PCRzpKyxpbsOrQxeSyEWUYQRv9VHA59LC7 +tVAJTbnTX1BNHkGZkOkoOpoZLwBaM2XbbDtcOGCAZMECgYEA+mTURFQ85/pxawvk +9ndqZ+5He1u/bMLYIJDp0hdB/vgD+vw3gb2UyRwp0I6Wc6Si4FEEnbY7L0pzWsiR +43CpLs+cyLfnD9NycuIasxs5fKb/1s1nGTkRAp7x9x/ZTtEf8v4YTmmMXFHzdo7V +pv+czO89ppEDkxEtMf/b5SifhO8CgYEAwIDIUvXLduGhL+RPDwjc2SKdydXGV6om +OEdt/V8oS801Z7k8l3gHXFm7zL/MpHmh9cag+F9dHK42kw2RSjDGsBlXXiAO1Z0I +2A34OdPw/kow8fmIKWTMu3+28Kca+3RmUqeyaq0vazQ/bWMO9px+Ud3YfLo1Tn5I +li0MecAx8DECgYEAvsLceKYYtL83c09fg2oc1ctSCCgw4WJcGAtvJ9DyRZacKbXH +b/+H/+OF8879zmKqd+0hcCnqUzAMTCisBLPLIM+o6b45ufPkqKObpcJi/JWaKgLY +vf2c+Psw6o4IF6T5Cz4MNIjzF06UBknxecYZpoPJ20F1kLCwVvxPgfl99l8CgYAb +XfOcv67WTstgiJ+oroTfJamy+P5ClkDqvVTosW+EHz9ZaJ8xlXHOcj9do2LPey9I +Rp250azmF+pQS5x9JKQKgv/FtN8HBVUtigbhCb14GUoODICMCfWFLmnumoMefnTR +iV+3BLn6Dqp5vZxx+NuIffZ5/Or5JsDhALSGVomC8QKBgAi3Z/dNQrDHfkXMNn/L ++EAoLuAbFgLs76r9VGgNaRQ/q5gex2bZEGoBj4Sxvs95NUIcfD9wKT7FF8HdxARv +y3o6Bfc8Xp9So9SlFXrje+gkdEJ0rQR67d+XBuJZh86bXJHVrMwpoNL+ahLGdVSe +81oh1uCH1YPLM29hPyaohxL8 +-----END PRIVATE KEY----- diff --git a/src/Server.php b/src/Server.php index 933bc109..4c7eaf9f 100644 --- a/src/Server.php +++ b/src/Server.php @@ -48,6 +48,19 @@ class Server extends EventEmitter * $http = new React\Http\Server($socket); * ``` * + * Similarly, you can also attach this to a + * [`React\Socket\SecureServer`](https://github.com/reactphp/socket#secureserver) + * in order to start a secure HTTPS server like this: + * + * ```php + * $socket = new Server(8080, $loop); + * $socket = new SecureServer($socket, $loop, array( + * 'local_cert' => __DIR__ . '/localhost.pem' + * )); + * + * $http = new React\Http\Server($socket); + * ``` + * * @param \React\Socket\ServerInterface $io */ public function __construct(SocketServerInterface $io) From eff46ec979046d1f9bd7606679d5090135674766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 16 Feb 2017 16:01:02 +0100 Subject: [PATCH 039/128] Prepare v0.5.0 release --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ README.md | 39 +++++++++++++++++++++++++-------------- composer.json | 4 ++-- 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 851b1d65..2c6a1735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## 0.5.0 (2017-02-16) + +* Feature / BC break: Change `Request` methods to be in line with PSR-7 + (#117 by @clue) + * Rename `getQuery()` to `getQueryParams()` + * Rename `getHttpVersion()` to `getProtocolVersion()` + * Change `getHeaders()` to always return an array of string values + for each header + +* Feature / BC break: Update Socket component to v0.5 and + add secure HTTPS server support + (#90 and #119 by @clue) + + ```php + // old plaintext HTTP server + $socket = new React\Socket\Server($loop); + $socket->listen(8080, '127.0.0.1'); + $http = new React\Http\Server($socket); + + // new plaintext HTTP server + $socket = new React\Socket\Server('127.0.0.1:8080', $loop); + $http = new React\Http\Server($socket); + + // new secure HTTPS server + $socket = new React\Socket\Server('127.0.0.1:8080', $loop); + $socket = new React\Socket\SecureServer($socket, $loop, array( + 'local_cert' => __DIR__ . '/localhost.pem' + )); + $http = new React\Http\Server($socket); + ``` + +* BC break: Mark internal APIs as internal or private and + remove unneeded `ServerInterface` + (#118 by @clue, #95 by @legionth) + ## 0.4.4 (2017-02-13) * Feature: Add request header accessors (à la PSR-7) diff --git a/README.md b/README.md index 96d14f09..cffa7da5 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,30 @@ [![Build Status](https://secure.travis-ci.org/reactphp/http.png?branch=master)](http://travis-ci.org/reactphp/http) [![Code Climate](https://codeclimate.com/github/reactphp/http/badges/gpa.svg)](https://codeclimate.com/github/reactphp/http) -Library for building an evented http server. - -This component builds on top of the `Socket` component to implement HTTP. Here -are the main concepts: - -* **Server**: Attaches itself to an instance of - `React\Socket\ServerInterface`, parses any incoming data as HTTP, emits a - `request` event for each request. -* **Request**: A `ReadableStream` which streams the request body and contains - meta data which was parsed from the request header. -* **Response** A `WritableStream` which streams the response body. You can set - the status code and response headers via the `writeHead()` method. - +Event-driven, streaming plaintext HTTP and secure HTTPS server for [ReactPHP](https://reactphp.org/) + +**Table of Contents** + +* [Quickstart example](#quickstart-example) +* [Usage](#usage) + * [Server](#server) + * [Request](#request) + * [getMethod()](#getmethod) + * [getQueryParams()](#getqueryparams] + * [getProtocolVersion()](#getprotocolversion) + * [getHeaders()](#getheaders) + * [getHeader()](#getheader) + * [getHeaderLine()](#getheaderline) + * [hasHeader()](#hasheader) + * [expectsContinue()](#expectscontinue) + * [Response](#response) + * [writeContinue()](#writecontinue) + * [writeHead()](#writehead) +* [Install](#install) +* [Tests](#tests) +* [License](#license) + +> Note: This project is in beta stage! Feel free to report any issues you encounter. ## Quickstart example @@ -255,7 +266,7 @@ The recommended way to install this library is [through Composer](http://getcomp This will install the latest supported version: ```bash -$ composer require react/http:^0.4.4 +$ composer require react/http:^0.5 ``` More details about version upgrades can be found in the [CHANGELOG](CHANGELOG.md). diff --git a/composer.json b/composer.json index 40c0582b..a7ca133b 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "react/http", - "description": "Library for building an evented http server.", - "keywords": ["http"], + "description": "Event-driven, streaming plaintext HTTP and secure HTTPS server for ReactPHP", + "keywords": ["event-driven", "streaming", "HTTP", "HTTPS", "server", "ReactPHP"], "license": "MIT", "require": { "php": ">=5.3.0", From 4781e6bfa0d287ec52712f060fb6771ea6b2f5f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 16 Feb 2017 16:02:28 +0100 Subject: [PATCH 040/128] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cffa7da5..aefcd2b9 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Event-driven, streaming plaintext HTTP and secure HTTPS server for [ReactPHP](ht * [Server](#server) * [Request](#request) * [getMethod()](#getmethod) - * [getQueryParams()](#getqueryparams] + * [getQueryParams()](#getqueryparams) * [getProtocolVersion()](#getprotocolversion) * [getHeaders()](#getheaders) * [getHeader()](#getheader) From 2eeebb635d7a221319b1141149e0ce86eb32c386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 16 Feb 2017 23:52:57 +0100 Subject: [PATCH 041/128] Register all event listeners before notifying other listeners --- src/Server.php | 81 +++++++++++++++++++++++++------------------------- 1 file changed, 40 insertions(+), 41 deletions(-) diff --git a/src/Server.php b/src/Server.php index 4c7eaf9f..6762d6b9 100644 --- a/src/Server.php +++ b/src/Server.php @@ -33,8 +33,6 @@ */ class Server extends EventEmitter { - private $io; - /** * Creates a HTTP server that accepts connections from the given socket. * @@ -65,49 +63,36 @@ class Server extends EventEmitter */ public function __construct(SocketServerInterface $io) { - $this->io = $io; - $that = $this; - - $this->io->on('connection', function (ConnectionInterface $conn) use ($that) { - // TODO: http 1.1 keep-alive - // TODO: chunked transfer encoding (also for outgoing data) - // TODO: multipart parsing - - $parser = new RequestHeaderParser(); - $parser->on('headers', function (Request $request, $bodyBuffer) use ($conn, $parser, $that) { - // attach remote ip to the request as metadata - $request->remoteAddress = trim( - parse_url('tcp://' . $conn->getRemoteAddress(), PHP_URL_HOST), - '[]' - ); + $io->on('connection', array($this, 'handleConnection')); + } - // forward pause/resume calls to underlying connection - $request->on('pause', array($conn, 'pause')); - $request->on('resume', array($conn, 'resume')); + /** @internal */ + public function handleConnection(ConnectionInterface $conn) + { + $that = $this; + $parser = new RequestHeaderParser(); + $listener = array($parser, 'feed'); + $parser->on('headers', function (Request $request, $bodyBuffer) use ($conn, $listener, $parser, $that) { + // parsing request completed => stop feeding parser + $conn->removeListener('data', $listener); - $that->handleRequest($conn, $request, $bodyBuffer); + $that->handleRequest($conn, $request); - $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)); - }); - }); + if ($bodyBuffer !== '') { + $request->emit('data', array($bodyBuffer)); + } + }); - $listener = array($parser, 'feed'); - $conn->on('data', $listener); - $parser->on('error', function() use ($conn, $listener, $that) { - // TODO: return 400 response - $conn->removeListener('data', $listener); - $that->emit('error', func_get_args()); - }); + $conn->on('data', $listener); + $parser->on('error', function() use ($conn, $listener, $that) { + // TODO: return 400 response + $conn->removeListener('data', $listener); + $that->emit('error', func_get_args()); }); } /** @internal */ - public function handleRequest(ConnectionInterface $conn, Request $request, $bodyBuffer) + public function handleRequest(ConnectionInterface $conn, Request $request) { $response = new Response($conn); $response->on('close', array($request, 'close')); @@ -118,10 +103,24 @@ public function handleRequest(ConnectionInterface $conn, Request $request, $body return; } - $this->emit('request', array($request, $response)); + // attach remote ip to the request as metadata + $request->remoteAddress = trim( + parse_url('tcp://' . $conn->getRemoteAddress(), PHP_URL_HOST), + '[]' + ); - if ($bodyBuffer !== '') { - $request->emit('data', array($bodyBuffer)); - } + // forward pause/resume calls to underlying connection + $request->on('pause', array($conn, 'pause')); + $request->on('resume', array($conn, 'resume')); + + // forward connection events to request + $conn->on('end', function () use ($request) { + $request->emit('end'); + }); + $conn->on('data', function ($data) use ($request) { + $request->emit('data', array($data)); + }); + + $this->emit('request', array($request, $response)); } } From 7d550d48fe9f9510d53812d42f2b815db5516d12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 17 Feb 2017 00:51:18 +0100 Subject: [PATCH 042/128] Send HTTP status code 400 for invalid requests --- src/Server.php | 26 +++++++++++++++++++++++--- tests/ServerTest.php | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/Server.php b/src/Server.php index 6762d6b9..3a68a914 100644 --- a/src/Server.php +++ b/src/Server.php @@ -84,10 +84,14 @@ public function handleConnection(ConnectionInterface $conn) }); $conn->on('data', $listener); - $parser->on('error', function() use ($conn, $listener, $that) { - // TODO: return 400 response + $parser->on('error', function(\Exception $e) use ($conn, $listener, $that) { $conn->removeListener('data', $listener); - $that->emit('error', func_get_args()); + $that->emit('error', array($e)); + + $that->writeError( + $conn, + 400 + ); }); } @@ -123,4 +127,20 @@ public function handleRequest(ConnectionInterface $conn, Request $request) $this->emit('request', array($request, $response)); } + + /** @internal */ + public function writeError(ConnectionInterface $conn, $code) + { + $message = 'Error ' . $code; + if (isset(ResponseCodes::$statusTexts[$code])) { + $message .= ': ' . ResponseCodes::$statusTexts[$code]; + } + + $response = new Response($conn); + $response->writeHead($code, array( + 'Content-Length' => strlen($message), + 'Content-Type' => 'text/plain' + )); + $response->end($message); + } } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 0b934da3..305bb31a 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -206,7 +206,6 @@ public function testParserErrorEmitted() { $error = null; $server = new Server($this->socket); - $server->on('headers', $this->expectCallableNever()); $server->on('error', function ($message) use (&$error) { $error = $message; }); @@ -218,7 +217,38 @@ public function testParserErrorEmitted() $this->connection->emit('data', array($data)); $this->assertInstanceOf('OverflowException', $error); - $this->connection->expects($this->never())->method('write'); + } + + public function testRequestInvalidWillEmitErrorAndSendErrorResponse() + { + $error = null; + $server = new Server($this->socket); + $server->on('error', function ($message) use (&$error) { + $error = $message; + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "bad request\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('InvalidArgumentException', $error); + + $this->assertContains("HTTP/1.1 400 Bad Request\r\n", $buffer); + $this->assertContains("\r\n\r\nError 400: Bad Request", $buffer); } private function createGetRequest() From 50955ca7ad626405b2844c4a53c6732b0fe8b006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 17 Feb 2017 13:04:52 +0100 Subject: [PATCH 043/128] Closing request will now stop reading from connection --- src/Server.php | 6 ++++++ tests/ServerTest.php | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/Server.php b/src/Server.php index 3a68a914..97f5e174 100644 --- a/src/Server.php +++ b/src/Server.php @@ -117,6 +117,12 @@ public function handleRequest(ConnectionInterface $conn, Request $request) $request->on('pause', array($conn, 'pause')); $request->on('resume', array($conn, 'resume')); + // closing the request currently emits an "end" event + // stop reading from the connection by pausing it + $request->on('end', function () use ($conn) { + $conn->pause(); + }); + // forward connection events to request $conn->on('end', function () use ($request) { $request->emit('end'); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 305bb31a..d2ff7aef 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -117,6 +117,20 @@ public function testRequestResumeWillbeForwardedToConnection() $this->connection->emit('data', array($data)); } + public function testRequestCloseWillPauseConnection() + { + $server = new Server($this->socket); + $server->on('request', function (Request $request) { + $request->close(); + }); + + $this->connection->expects($this->once())->method('pause'); + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + $this->connection->emit('data', array($data)); + } + public function testRequestEventWithoutBodyWillNotEmitData() { $never = $this->expectCallableNever(); From a96c34920bd4626c654045d4d66b6d84638a3f56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 18 Feb 2017 01:22:44 +0100 Subject: [PATCH 044/128] Documentation for invalid request messages --- README.md | 9 +++++++++ src/Server.php | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/README.md b/README.md index aefcd2b9..88549055 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,15 @@ $http->on('request', function (Request $request, Response $response) { See also [`Request`](#request) and [`Response`](#response) for more details. +If a client sends an invalid request message, it will emit an `error` event, +send an HTTP error response to the client and close the connection: + +```php +$http->on('error', function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + ### Request The `Request` class is responsible for streaming the incoming request body diff --git a/src/Server.php b/src/Server.php index 97f5e174..1a33bcbd 100644 --- a/src/Server.php +++ b/src/Server.php @@ -28,6 +28,15 @@ * * See also [`Request`](#request) and [`Response`](#response) for more details. * + * If a client sends an invalid request message, it will emit an `error` event, + * send an HTTP error response to the client and close the connection: + * + * ```php + * $http->on('error', function (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * }); + * ``` + * * @see Request * @see Response */ From 0fdbc7cfe6c09c47e7eeeca2677af4d62c9c8a13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 18 Feb 2017 00:21:25 +0100 Subject: [PATCH 045/128] Response uses same HTTP protocol version as corresponding request --- README.md | 3 +++ src/Response.php | 13 +++++++--- src/Server.php | 2 +- tests/ResponseTest.php | 21 ++++++++++++++- tests/ServerTest.php | 58 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 88549055..be1d0dce 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,9 @@ It implements the `WritableStreamInterface`. The constructor is internal, you SHOULD NOT call this yourself. The `Server` is responsible for emitting `Request` and `Response` objects. +The `Response` will automatically use the same HTTP protocol version as the +corresponding `Request`. + See the above usage example and the class outline for details. #### writeContinue() diff --git a/src/Response.php b/src/Response.php index 91ae4358..541ff92d 100644 --- a/src/Response.php +++ b/src/Response.php @@ -14,6 +14,9 @@ * The constructor is internal, you SHOULD NOT call this yourself. * The `Server` is responsible for emitting `Request` and `Response` objects. * + * The `Response` will automatically use the same HTTP protocol version as the + * corresponding `Request`. + * * See the usage examples and the class outline for details. * * @see WritableStreamInterface @@ -21,9 +24,11 @@ */ class Response extends EventEmitter implements WritableStreamInterface { + private $conn; + private $protocolVersion; + private $closed = false; private $writable = true; - private $conn; private $headWritten = false; private $chunkedEncoding = true; @@ -36,9 +41,11 @@ class Response extends EventEmitter implements WritableStreamInterface * * @internal */ - public function __construct(ConnectionInterface $conn) + public function __construct(ConnectionInterface $conn, $protocolVersion = '1.1') { $this->conn = $conn; + $this->protocolVersion = $protocolVersion; + $that = $this; $this->conn->on('end', function () use ($that) { $that->close(); @@ -201,7 +208,7 @@ private function formatHead($status, array $headers) { $status = (int) $status; $text = isset(ResponseCodes::$statusTexts[$status]) ? ResponseCodes::$statusTexts[$status] : ''; - $data = "HTTP/1.1 $status $text\r\n"; + $data = "HTTP/$this->protocolVersion $status $text\r\n"; foreach ($headers as $name => $value) { $name = str_replace(array("\r", "\n"), '', $name); diff --git a/src/Server.php b/src/Server.php index 1a33bcbd..f96ec24d 100644 --- a/src/Server.php +++ b/src/Server.php @@ -107,7 +107,7 @@ public function handleConnection(ConnectionInterface $conn) /** @internal */ public function handleRequest(ConnectionInterface $conn, Request $request) { - $response = new Response($conn); + $response = new Response($conn, $request->getProtocolVersion()); $response->on('close', array($request, 'close')); if (!$this->listeners('request')) { diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 27948504..470d8bed 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -26,6 +26,26 @@ public function testResponseShouldBeChunkedByDefault() $response->writeHead(); } + public function testResponseShouldUseGivenProtocolVersion() + { + $expected = ''; + $expected .= "HTTP/1.0 200 OK\r\n"; + $expected .= "X-Powered-By: React/alpha\r\n"; + $expected .= "Transfer-Encoding: chunked\r\n"; + $expected .= "\r\n"; + + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); + $conn + ->expects($this->once()) + ->method('write') + ->with($expected); + + $response = new Response($conn, '1.0'); + $response->writeHead(); + } + public function testResponseShouldBeChunkedEvenWithOtherTransferEncoding() { $expected = ''; @@ -46,7 +66,6 @@ public function testResponseShouldBeChunkedEvenWithOtherTransferEncoding() $response->writeHead(200, array('transfer-encoding' => 'custom')); } - public function testResponseShouldNotBeChunkedWithContentLength() { $expected = ''; diff --git a/tests/ServerTest.php b/tests/ServerTest.php index d2ff7aef..a643d602 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -216,6 +216,64 @@ function ($data) use (&$buffer) { $this->assertContains("\r\nX-Powered-By: React/alpha\r\n", $buffer); } + public function testResponseContainsSameRequestProtocolVersionForHttp11() + { + $server = new Server($this->socket); + $server->on('request', function (Request $request, Response $response) { + $response->writeHead(); + $response->end(); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); + } + + public function testResponseContainsSameRequestProtocolVersionForHttp10() + { + $server = new Server($this->socket); + $server->on('request', function (Request $request, Response $response) { + $response->writeHead(); + $response->end(); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.0\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.0 200 OK\r\n", $buffer); + } + public function testParserErrorEmitted() { $error = null; From 6028c819b566b3b46b78803c89c4a4fe3740dd96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 18 Feb 2017 00:41:38 +0100 Subject: [PATCH 046/128] Apply chunked transfer encoding only for HTTP/1.1 responses by default --- README.md | 6 +++++- src/Response.php | 18 +++++++++--------- tests/ResponseTest.php | 3 +-- tests/ServerTest.php | 10 ++++++---- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index be1d0dce..ce27e302 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,10 @@ The `Server` is responsible for emitting `Request` and `Response` objects. The `Response` will automatically use the same HTTP protocol version as the corresponding `Request`. +HTTP/1.1 responses will automatically apply chunked transfer encoding if +no `Content-Length` header has been set. +See [`writeHead()`](#writehead) for more details. + See the above usage example and the class outline for details. #### writeContinue() @@ -237,7 +241,7 @@ $response->end('Hello World!'); Calling this method more than once will result in an `Exception`. -Unless you specify a `Content-Length` header yourself, the response message +Unless you specify a `Content-Length` header yourself, HTTP/1.1 responses will automatically use chunked transfer encoding and send the respective header (`Transfer-Encoding: chunked`) automatically. If you know the length of your body, you MAY specify it like this instead: diff --git a/src/Response.php b/src/Response.php index 541ff92d..e05cec2c 100644 --- a/src/Response.php +++ b/src/Response.php @@ -17,6 +17,10 @@ * The `Response` will automatically use the same HTTP protocol version as the * corresponding `Request`. * + * HTTP/1.1 responses will automatically apply chunked transfer encoding if + * no `Content-Length` header has been set. + * See `writeHead()` for more details. + * * See the usage examples and the class outline for details. * * @see WritableStreamInterface @@ -30,7 +34,7 @@ class Response extends EventEmitter implements WritableStreamInterface private $closed = false; private $writable = true; private $headWritten = false; - private $chunkedEncoding = true; + private $chunkedEncoding = false; /** * The constructor is internal, you SHOULD NOT call this yourself. @@ -129,7 +133,7 @@ public function writeContinue() * * Calling this method more than once will result in an `Exception`. * - * Unless you specify a `Content-Length` header yourself, the response message + * Unless you specify a `Content-Length` header yourself, HTTP/1.1 responses * will automatically use chunked transfer encoding and send the respective header * (`Transfer-Encoding: chunked`) automatically. If you know the length of your * body, you MAY specify it like this instead: @@ -174,11 +178,6 @@ public function writeHead($status = 200, array $headers = array()) $lower = array_change_key_case($headers); - // disable chunked encoding if content-length is given - if (isset($lower['content-length'])) { - $this->chunkedEncoding = false; - } - // assign default "X-Powered-By" header as first for history reasons if (!isset($lower['x-powered-by'])) { $headers = array_merge( @@ -187,8 +186,8 @@ public function writeHead($status = 200, array $headers = array()) ); } - // assign chunked transfer-encoding if chunked encoding is used - if ($this->chunkedEncoding) { + // assign chunked transfer-encoding if no 'content-length' is given for HTTP/1.1 responses + if (!isset($lower['content-length']) && $this->protocolVersion === '1.1') { foreach($headers as $name => $value) { if (strtolower($name) === 'transfer-encoding') { unset($headers[$name]); @@ -196,6 +195,7 @@ public function writeHead($status = 200, array $headers = array()) } $headers['Transfer-Encoding'] = 'chunked'; + $this->chunkedEncoding = true; } $data = $this->formatHead($status, $headers); diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 470d8bed..69a16001 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -26,12 +26,11 @@ public function testResponseShouldBeChunkedByDefault() $response->writeHead(); } - public function testResponseShouldUseGivenProtocolVersion() + public function testResponseShouldNotBeChunkedWhenProtocolVersionIsNot11() { $expected = ''; $expected .= "HTTP/1.0 200 OK\r\n"; $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "Transfer-Encoding: chunked\r\n"; $expected .= "\r\n"; $conn = $this diff --git a/tests/ServerTest.php b/tests/ServerTest.php index a643d602..7a8d0bf7 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -216,12 +216,12 @@ function ($data) use (&$buffer) { $this->assertContains("\r\nX-Powered-By: React/alpha\r\n", $buffer); } - public function testResponseContainsSameRequestProtocolVersionForHttp11() + public function testResponseContainsSameRequestProtocolVersionAndChunkedBodyForHttp11() { $server = new Server($this->socket); $server->on('request', function (Request $request, Response $response) { $response->writeHead(); - $response->end(); + $response->end('bye'); }); $buffer = ''; @@ -243,14 +243,15 @@ function ($data) use (&$buffer) { $this->connection->emit('data', array($data)); $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertContains("\r\n\r\n3\r\nbye\r\n0\r\n\r\n", $buffer); } - public function testResponseContainsSameRequestProtocolVersionForHttp10() + public function testResponseContainsSameRequestProtocolVersionAndRawBodyForHttp10() { $server = new Server($this->socket); $server->on('request', function (Request $request, Response $response) { $response->writeHead(); - $response->end(); + $response->end('bye'); }); $buffer = ''; @@ -272,6 +273,7 @@ function ($data) use (&$buffer) { $this->connection->emit('data', array($data)); $this->assertContains("HTTP/1.0 200 OK\r\n", $buffer); + $this->assertContains("\r\n\r\nbye", $buffer); } public function testParserErrorEmitted() From 90a90e9ad6c0ec67079e8563e4738be53bd96d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 18 Feb 2017 00:51:10 +0100 Subject: [PATCH 047/128] Ensure writeContinue() only works for HTTP/1.1 messages --- README.md | 15 +++++++++------ src/Response.php | 14 ++++++++++---- tests/ResponseTest.php | 14 ++++++++++++++ 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ce27e302..7e53f7ae 100644 --- a/README.md +++ b/README.md @@ -217,12 +217,15 @@ $http->on('request', function (Request $request, Response $response) { }); ``` -Note that calling this method is strictly optional. -If you do not use it, then the client MUST continue sending the request body -after waiting some time. - -This method MUST NOT be invoked after calling `writeHead()`. -Calling this method after sending the headers will result in an `Exception`. +Note that calling this method is strictly optional for HTTP/1.1 responses. +If you do not use it, then a HTTP/1.1 client MUST continue sending the +request body after waiting some time. + +This method MUST NOT be invoked after calling [`writeHead()`](#writehead). +This method MUST NOT be invoked if this is not a HTTP/1.1 response +(please check [`expectsContinue()`](#expectscontinue) as above). +Calling this method after sending the headers or if this is not a HTTP/1.1 +response is an error that will result in an `Exception`. #### writeHead() diff --git a/src/Response.php b/src/Response.php index e05cec2c..952ae0d6 100644 --- a/src/Response.php +++ b/src/Response.php @@ -98,12 +98,15 @@ public function isWritable() * }); * ``` * - * Note that calling this method is strictly optional. - * If you do not use it, then the client MUST continue sending the request body - * after waiting some time. + * Note that calling this method is strictly optional for HTTP/1.1 responses. + * If you do not use it, then a HTTP/1.1 client MUST continue sending the + * request body after waiting some time. * * This method MUST NOT be invoked after calling `writeHead()`. - * Calling this method after sending the headers will result in an `Exception`. + * This method MUST NOT be invoked if this is not a HTTP/1.1 response + * (please check [`expectsContinue()`] as above). + * Calling this method after sending the headers or if this is not a HTTP/1.1 + * response is an error that will result in an `Exception`. * * @return void * @throws \Exception @@ -111,6 +114,9 @@ public function isWritable() */ public function writeContinue() { + if ($this->protocolVersion !== '1.1') { + throw new \Exception('Continue requires a HTTP/1.1 message'); + } if ($this->headWritten) { throw new \Exception('Response head has already been written.'); } diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 69a16001..6200934b 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -239,6 +239,20 @@ public function writeContinueShouldSendContinueLineBeforeRealHeaders() $response->writeHead(); } + /** + * @test + * @expectedException Exception + */ + public function writeContinueShouldThrowForHttp10() + { + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); + + $response = new Response($conn, '1.0'); + $response->writeContinue(); + } + /** @test */ public function shouldForwardEndDrainAndErrorEvents() { From b6e196471d471ab096bd662f9981ba0aab2bc352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 17 Feb 2017 23:05:46 +0100 Subject: [PATCH 048/128] Only support HTTP/1.1 and HTTP/1.0 requests --- README.md | 6 ++++-- src/Server.php | 12 ++++++++++-- tests/ServerTest.php | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7e53f7ae..26e37536 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,10 @@ $http->on('request', function (Request $request, Response $response) { See also [`Request`](#request) and [`Response`](#response) for more details. -If a client sends an invalid request message, it will emit an `error` event, -send an HTTP error response to the client and close the connection: +The `Server` supports both HTTP/1.1 and HTTP/1.0 request messages. +If a client sends an invalid request message or uses an invalid HTTP protocol +version, it will emit an `error` event, send an HTTP error response to the +client and close the connection: ```php $http->on('error', function (Exception $e) { diff --git a/src/Server.php b/src/Server.php index f96ec24d..6fef9290 100644 --- a/src/Server.php +++ b/src/Server.php @@ -28,8 +28,10 @@ * * See also [`Request`](#request) and [`Response`](#response) for more details. * - * If a client sends an invalid request message, it will emit an `error` event, - * send an HTTP error response to the client and close the connection: + * The `Server` supports both HTTP/1.1 and HTTP/1.0 request messages. + * If a client sends an invalid request message or uses an invalid HTTP protocol + * version, it will emit an `error` event, send an HTTP error response to the + * client and close the connection: * * ```php * $http->on('error', function (Exception $e) { @@ -107,6 +109,12 @@ public function handleConnection(ConnectionInterface $conn) /** @internal */ public function handleRequest(ConnectionInterface $conn, Request $request) { + // only support HTTP/1.1 and HTTP/1.0 requests + if ($request->getProtocolVersion() !== '1.1' && $request->getProtocolVersion() !== '1.0') { + $this->emit('error', array(new \InvalidArgumentException('Received request with invalid protocol version'))); + return $this->writeError($conn, 505); + } + $response = new Response($conn, $request->getProtocolVersion()); $response->on('close', array($request, 'close')); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 7a8d0bf7..9538eb5a 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -276,6 +276,38 @@ function ($data) use (&$buffer) { $this->assertContains("\r\n\r\nbye", $buffer); } + public function testRequestInvalidHttpProtocolVersionWillEmitErrorAndSendErrorResponse() + { + $error = null; + $server = new Server($this->socket); + $server->on('error', function ($message) use (&$error) { + $error = $message; + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.2\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('InvalidArgumentException', $error); + + $this->assertContains("HTTP/1.1 505 HTTP Version Not Supported\r\n", $buffer); + $this->assertContains("\r\n\r\nError 505: HTTP Version Not Supported", $buffer); + } + public function testParserErrorEmitted() { $error = null; From f027f188e0088e826560676671d0ca38cca8f8ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sun, 19 Feb 2017 13:49:39 +0100 Subject: [PATCH 049/128] Explicitly send Connection: close header for HTTP/1.1 messages Persistent connections (`Connection: keep-alive`) are currently not supported, so make sure we let the client know. --- README.md | 6 ++++++ src/Response.php | 18 ++++++++++++++++++ tests/ResponseTest.php | 31 +++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/README.md b/README.md index 26e37536..5aec2a4c 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,12 @@ $response->writeHead(200, array( )); ``` +Note that persistent connections (`Connection: keep-alive`) are currently +not supported. +As such, HTTP/1.1 response messages will automatically include a +`Connection: close` header, irrespective of what header values are +passed explicitly. + ## Install The recommended way to install this library is [through Composer](http://getcomposer.org). diff --git a/src/Response.php b/src/Response.php index 952ae0d6..9483523d 100644 --- a/src/Response.php +++ b/src/Response.php @@ -172,6 +172,12 @@ public function writeContinue() * )); * ``` * + * Note that persistent connections (`Connection: keep-alive`) are currently + * not supported. + * As such, HTTP/1.1 response messages will automatically include a + * `Connection: close` header, irrespective of what header values are + * passed explicitly. + * * @param int $status * @param array $headers * @throws \Exception @@ -204,6 +210,18 @@ public function writeHead($status = 200, array $headers = array()) $this->chunkedEncoding = true; } + // HTTP/1.1 assumes persistent connection support by default + // we do not support persistent connections, so let the client know + if ($this->protocolVersion === '1.1') { + foreach($headers as $name => $value) { + if (strtolower($name) === 'connection') { + unset($headers[$name]); + } + } + + $headers['Connection'] = 'close'; + } + $data = $this->formatHead($status, $headers); $this->conn->write($data); diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 6200934b..8c3e9077 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -12,6 +12,7 @@ public function testResponseShouldBeChunkedByDefault() $expected .= "HTTP/1.1 200 OK\r\n"; $expected .= "X-Powered-By: React/alpha\r\n"; $expected .= "Transfer-Encoding: chunked\r\n"; + $expected .= "Connection: close\r\n"; $expected .= "\r\n"; $conn = $this @@ -51,6 +52,7 @@ public function testResponseShouldBeChunkedEvenWithOtherTransferEncoding() $expected .= "HTTP/1.1 200 OK\r\n"; $expected .= "X-Powered-By: React/alpha\r\n"; $expected .= "Transfer-Encoding: chunked\r\n"; + $expected .= "Connection: close\r\n"; $expected .= "\r\n"; $conn = $this @@ -71,6 +73,7 @@ public function testResponseShouldNotBeChunkedWithContentLength() $expected .= "HTTP/1.1 200 OK\r\n"; $expected .= "X-Powered-By: React/alpha\r\n"; $expected .= "Content-Length: 22\r\n"; + $expected .= "Connection: close\r\n"; $expected .= "\r\n"; $conn = $this @@ -91,6 +94,7 @@ public function testResponseShouldNotBeChunkedWithContentLengthCaseInsensitive() $expected .= "HTTP/1.1 200 OK\r\n"; $expected .= "X-Powered-By: React/alpha\r\n"; $expected .= "CONTENT-LENGTH: 0\r\n"; + $expected .= "Connection: close\r\n"; $expected .= "\r\n"; $conn = $this @@ -111,6 +115,7 @@ public function testResponseShouldIncludeCustomByPoweredAsFirstHeaderIfGivenExpl $expected .= "HTTP/1.1 200 OK\r\n"; $expected .= "Content-Length: 0\r\n"; $expected .= "X-POWERED-BY: demo\r\n"; + $expected .= "Connection: close\r\n"; $expected .= "\r\n"; $conn = $this @@ -130,6 +135,7 @@ public function testResponseShouldNotIncludePoweredByIfGivenEmptyArray() $expected = ''; $expected .= "HTTP/1.1 200 OK\r\n"; $expected .= "Content-Length: 0\r\n"; + $expected .= "Connection: close\r\n"; $expected .= "\r\n"; $conn = $this @@ -144,6 +150,27 @@ public function testResponseShouldNotIncludePoweredByIfGivenEmptyArray() $response->writeHead(200, array('Content-Length' => 0, 'X-Powered-By' => array())); } + public function testResponseShouldAlwaysIncludeConnectionCloseIrrespectiveOfExplicitValue() + { + $expected = ''; + $expected .= "HTTP/1.1 200 OK\r\n"; + $expected .= "X-Powered-By: React/alpha\r\n"; + $expected .= "Content-Length: 0\r\n"; + $expected .= "Connection: close\r\n"; + $expected .= "\r\n"; + + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); + $conn + ->expects($this->once()) + ->method('write') + ->with($expected); + + $response = new Response($conn); + $response->writeHead(200, array('Content-Length' => 0, 'connection' => 'ignored')); + } + public function testResponseBodyShouldBeChunkedCorrectly() { $conn = $this @@ -283,6 +310,7 @@ public function shouldRemoveNewlinesFromHeaders() $expected .= "X-Powered-By: React/alpha\r\n"; $expected .= "FooBar: BazQux\r\n"; $expected .= "Transfer-Encoding: chunked\r\n"; + $expected .= "Connection: close\r\n"; $expected .= "\r\n"; $conn = $this @@ -304,6 +332,7 @@ public function missingStatusCodeTextShouldResultInNumberOnlyStatus() $expected .= "HTTP/1.1 700 \r\n"; $expected .= "X-Powered-By: React/alpha\r\n"; $expected .= "Transfer-Encoding: chunked\r\n"; + $expected .= "Connection: close\r\n"; $expected .= "\r\n"; $conn = $this @@ -327,6 +356,7 @@ public function shouldAllowArrayHeaderValues() $expected .= "Set-Cookie: foo=bar\r\n"; $expected .= "Set-Cookie: bar=baz\r\n"; $expected .= "Transfer-Encoding: chunked\r\n"; + $expected .= "Connection: close\r\n"; $expected .= "\r\n"; $conn = $this @@ -348,6 +378,7 @@ public function shouldIgnoreHeadersWithNullValues() $expected .= "HTTP/1.1 200 OK\r\n"; $expected .= "X-Powered-By: React/alpha\r\n"; $expected .= "Transfer-Encoding: chunked\r\n"; + $expected .= "Connection: close\r\n"; $expected .= "\r\n"; $conn = $this From 7b9bcbf90949edb14e8712fb5ba7bc9c19f79feb Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Sat, 11 Feb 2017 07:29:55 +0100 Subject: [PATCH 050/128] Add TestCase methods --- tests/TestCase.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/TestCase.php b/tests/TestCase.php index 73bb401e..74ad0bc7 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -45,6 +45,20 @@ protected function expectCallableNever() 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 6d06957cb73ec0bdd1b2a73278dc763aa512fa05 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Mon, 13 Feb 2017 15:10:02 +0100 Subject: [PATCH 051/128] Add Chunked Decoder class Fix Endless loop Fix Add chunk size check and chunk extension handling Handle potential test cases Add ChunkedDecoder Tests Handle potential threat Rename variable Added test to add verify single characters can be emitted Fixing remarks Use Mockbuilder --- src/ChunkedDecoder.php | 158 ++++++++++++++ tests/ChunkedDecoderTest.php | 399 +++++++++++++++++++++++++++++++++++ 2 files changed, 557 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..1d3bc16f --- /dev/null +++ b/src/ChunkedDecoder.php @@ -0,0 +1,158 @@ +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->buffer = ''; + + $this->closed = true; + + $this->input->close(); + + $this->emit('close'); + $this->removeAllListeners(); + } + + /** @internal */ + public function handleEnd() + { + if (!$this->closed) { + $this->handleError(new \Exception('Unexpected end event')); + } + } + + /** @internal */ + public function handleError(\Exception $e) + { + $this->emit('error', array($e)); + $this->close(); + } + + /** @internal */ + public function handleData($data) + { + $this->buffer .= $data; + + while ($this->buffer !== '') { + if (!$this->headerCompleted) { + $positionCrlf = strpos($this->buffer, static::CRLF); + + if ($positionCrlf === false) { + // Header shouldn't be bigger than 1024 bytes + if (isset($this->buffer[static::MAX_CHUNK_HEADER_SIZE])) { + $this->handleError(new \Exception('Chunk header size inclusive extension bigger than' . static::MAX_CHUNK_HEADER_SIZE. ' bytes')); + } + return; + } + + $header = strtolower((string)substr($this->buffer, 0, $positionCrlf)); + $hexValue = $header; + + if (strpos($header, ';') !== false) { + $array = explode(';', $header); + $hexValue = $array[0]; + } + + $this->chunkSize = hexdec($hexValue); + if (dechex($this->chunkSize) !== $hexValue) { + $this->handleError(new \Exception($hexValue . ' is not a valid hexadecimal number')); + return; + } + + $this->buffer = (string)substr($this->buffer, $positionCrlf + 2); + $this->headerCompleted = true; + if ($this->buffer === '') { + return; + } + } + + $chunk = (string)substr($this->buffer, 0, $this->chunkSize - $this->transferredSize); + + if ($chunk !== '') { + $this->transferredSize += strlen($chunk); + $this->emit('data', array($chunk)); + $this->buffer = (string)substr($this->buffer, strlen($chunk)); + } + + $positionCrlf = strpos($this->buffer, static::CRLF); + + if ($positionCrlf === 0) { + if ($this->chunkSize === 0) { + $this->emit('end'); + $this->close(); + return; + } + $this->chunkSize = 0; + $this->headerCompleted = false; + $this->transferredSize = 0; + $this->buffer = (string)substr($this->buffer, 2); + } + + if ($positionCrlf !== 0 && $this->chunkSize === $this->transferredSize && strlen($this->buffer) > 2) { + // the first 2 characters are not CLRF, send error event + $this->handleError(new \Exception('Chunk does not end with a CLRF')); + return; + } + + if ($positionCrlf !== 0 && strlen($this->buffer) < 2) { + // No CLRF found, wait for additional data which could be a CLRF + return; + } + } + } +} diff --git a/tests/ChunkedDecoderTest.php b/tests/ChunkedDecoderTest.php new file mode 100644 index 00000000..6f0c3048 --- /dev/null +++ b/tests/ChunkedDecoderTest.php @@ -0,0 +1,399 @@ +input = new ReadableStream(); + $this->parser = new ChunkedDecoder($this->input); + } + + 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->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()); + $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()); + $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('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->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()); + $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('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")); + } + + public function testSplittedHeader() + { + $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")); + } + + public function testSplittedBoth() + { + $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")); + } + + public function testCompletlySplitted() + { + $this->parser->on('data', $this->expectCallableConsecutive(2, array('we', 'lt'))); + $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")); + } + + public function testMixed() + { + $this->parser->on('data', $this->expectCallableConsecutive(3, array('we', 'lt', '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")); + } + + public function testBigger() + { + $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")); + } + + public function testOneUnfinished() + { + $this->parser->on('data', $this->expectCallableConsecutive(2, array('bla', 'hello'))); + $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")); + } + + public function testChunkIsBiggerThenExpected() + { + $this->parser->on('data', $this->expectCallableOnceWith('hello')); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableOnce()); + + $this->input->emit('data', array("5\r\n")); + $this->input->emit('data', array("hello world\r\n")); + } + + public function testHandleUnexpectedEnd() + { + $this->parser->on('data', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableOnce()); + + $this->input->emit('end'); + } + + public function testExtensionWillBeIgnored() + { + $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;hello=world;foo=bar\r\nbla")); + } + + public function testChunkHeaderIsTooBig() + { + $this->parser->on('data', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableOnce()); + + $data = ''; + for ($i = 0; $i < 1025; $i++) { + $data .= 'a'; + } + $this->input->emit('data', array($data)); + } + + public function testChunkIsMaximumSize() + { + $this->parser->on('data', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableOnce()); + + $data = ''; + for ($i = 0; $i < 1024; $i++) { + $data .= 'a'; + } + $data .= "\r\n"; + + $this->input->emit('data', array($data)); + } + + public function testLateCrlf() + { + $this->parser->on('data', $this->expectCallableOnceWith('late')); + $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\nlate")); + $this->input->emit('data', array("\r")); + $this->input->emit('data', array("\n")); + } + + public function testNoCrlfInChunk() + { + $this->parser->on('data', $this->expectCallableOnceWith('no')); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableOnce()); + + $this->input->emit('data', array("2\r\nno crlf")); + } + + public function testNoCrlfInChunkSplitted() + { + $this->parser->on('data', $this->expectCallableOnceWith('no')); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableOnce()); + + $this->input->emit('data', array("2\r\n")); + $this->input->emit('data', array("no")); + $this->input->emit('data', array("further")); + $this->input->emit('data', array("clrf")); + } + + public function testEmitEmptyChunkBody() + { + $this->parser->on('data', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableNever()); + + $this->input->emit('data', array("2\r\n")); + $this->input->emit('data', array("")); + $this->input->emit('data', array("")); + } + + public function testEmitCrlfAsChunkBody() + { + $this->parser->on('data', $this->expectCallableOnceWith("\r\n")); + $this->parser->on('close', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableNever()); + + $this->input->emit('data', array("2\r\n")); + $this->input->emit('data', array("\r\n")); + $this->input->emit('data', array("\r\n")); + } + + public function testNegativeHeader() + { + $this->parser->on('data', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableOnce()); + + $this->input->emit('data', array("-2\r\n")); + } + + public function testHexDecimalInBodyIsPotentialThread() + { + $this->parser->on('data', $this->expectCallableOnce('test')); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableOnce()); + + $this->input->emit('data', array("4\r\ntest5\r\nworld")); + } + + public function testHexDecimalInBodyIsPotentialThreadSplitted() + { + $this->parser->on('data', $this->expectCallableOnce('test')); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableOnce()); + + $this->input->emit('data', array("4")); + $this->input->emit('data', array("\r\n")); + $this->input->emit('data', array("test")); + $this->input->emit('data', array("5")); + $this->input->emit('data', array("\r\n")); + $this->input->emit('data', array("world")); + } + + public function testEmitSingleCharacter() + { + $this->parser->on('data', $this->expectCallableConsecutive(4, array('t', 'e', 's', 't'))); + $this->parser->on('close', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableOnce()); + $this->parser->on('error', $this->expectCallableNever()); + + $array = str_split("4\r\ntest\r\n0\r\n\r\n"); + + foreach ($array as $character) { + $this->input->emit('data', array($character)); + } + } + + 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())); + + $this->assertFalse($this->parser->isReadable()); + } + + public function testPauseStream() + { + $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input->expects($this->once())->method('pause'); + + $parser = new ChunkedDecoder($input); + $parser->pause(); + } + + public function testResumeStream() + { + $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input->expects($this->once())->method('pause'); + + $parser = new ChunkedDecoder($input); + $parser->pause(); + $parser->resume(); + } + + public function testPipeStream() + { + $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + + $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()); + } +} From 4378fe8b68ea9e89420714f6deba67842d93182a Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Fri, 10 Feb 2017 15:44:30 +0100 Subject: [PATCH 052/128] Add ChunkedDecoder to Server Add ServerTest Fix Order --- src/Server.php | 16 +++- tests/ServerTest.php | 173 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 3 deletions(-) diff --git a/src/Server.php b/src/Server.php index 6fef9290..9c6bc331 100644 --- a/src/Server.php +++ b/src/Server.php @@ -90,7 +90,7 @@ public function handleConnection(ConnectionInterface $conn) $that->handleRequest($conn, $request); if ($bodyBuffer !== '') { - $request->emit('data', array($bodyBuffer)); + $conn->emit('data', array($bodyBuffer)); } }); @@ -130,6 +130,15 @@ public function handleRequest(ConnectionInterface $conn, Request $request) '[]' ); + $stream = $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); + } + } + // forward pause/resume calls to underlying connection $request->on('pause', array($conn, 'pause')); $request->on('resume', array($conn, 'resume')); @@ -141,10 +150,11 @@ public function handleRequest(ConnectionInterface $conn, Request $request) }); // forward connection events to request - $conn->on('end', function () use ($request) { + $stream->on('end', function () use ($request) { $request->emit('end'); }); - $conn->on('data', function ($data) use ($request) { + + $stream->on('data', function ($data) use ($request) { $request->emit('data', array($data)); }); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 9538eb5a..47ca3f34 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -357,6 +357,179 @@ function ($data) use (&$buffer) { $this->assertContains("\r\n\r\nError 400: Bad Request", $buffer); } + public function testBodyDataWillBeSendViaRequestEvent() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableNever(); + $closeEvent = $this->expectCallableNever(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $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); + + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableNever(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $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); + + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableNever(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $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); + + $dataEvent = $this->expectCallableNever(); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableNever(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $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 testChunkedIsUpperCase() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableNever(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $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 testChunkedIsMixedUpperAndLowerCase() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableNever(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $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)); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From 5a01f55cfa6fda515ae4e8b82e015cbc8499ef12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 17 Feb 2017 23:02:19 +0100 Subject: [PATCH 053/128] Validate Host header for HTTP/1.1 requests --- src/Server.php | 18 +++++++ tests/ServerTest.php | 111 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/src/Server.php b/src/Server.php index 9c6bc331..1ffb3e69 100644 --- a/src/Server.php +++ b/src/Server.php @@ -115,6 +115,24 @@ public function handleRequest(ConnectionInterface $conn, Request $request) return $this->writeError($conn, 505); } + // HTTP/1.1 requests MUST include a valid host header (host and optional port) + // https://tools.ietf.org/html/rfc7230#section-5.4 + if ($request->getProtocolVersion() === '1.1') { + $parts = parse_url('http://' . $request->getHeaderLine('Host')); + + // make sure value contains valid host component (IP or hostname) + if (!$parts || !isset($parts['scheme'], $parts['host'])) { + $parts = false; + } + + // make sure value does not contain any other URI component + unset($parts['scheme'], $parts['host'], $parts['port']); + if ($parts === false || $parts) { + $this->emit('error', array(new \InvalidArgumentException('Invalid Host header for HTTP/1.1 request'))); + return $this->writeError($conn, 400); + } + } + $response = new Response($conn, $request->getProtocolVersion()); $response->on('close', array($request, 'close')); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 47ca3f34..713990b8 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -159,6 +159,7 @@ public function testRequestEventWithSecondDataEventWillEmitBodyData() $data = ''; $data .= "POST / HTTP/1.1\r\n"; + $data .= "Host: localhost\r\n"; $data .= "Content-Length: 100\r\n"; $data .= "\r\n"; $data .= "incomplete"; @@ -178,6 +179,7 @@ public function testRequestEventWithPartialBodyWillEmitData() $data = ''; $data .= "POST / HTTP/1.1\r\n"; + $data .= "Host: localhost\r\n"; $data .= "Content-Length: 100\r\n"; $data .= "\r\n"; $this->connection->emit('data', array($data)); @@ -239,7 +241,7 @@ function ($data) use (&$buffer) { $this->socket->emit('connection', array($this->connection)); - $data = "GET / HTTP/1.1\r\n\r\n"; + $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; $this->connection->emit('data', array($data)); $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); @@ -526,7 +528,114 @@ public function testChunkedIsMixedUpperAndLowerCase() $data .= "\r\n"; $data .= "5\r\nhello\r\n"; $data .= "0\r\n\r\n"; + $this->connection->emit('data', array($data)); + } + + public function testRequestHttp11WithoutHostWillEmitErrorAndSendErrorResponse() + { + $error = null; + $server = new Server($this->socket); + $server->on('error', function ($message) use (&$error) { + $error = $message; + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('InvalidArgumentException', $error); + + $this->assertContains("HTTP/1.1 400 Bad Request\r\n", $buffer); + $this->assertContains("\r\n\r\nError 400: Bad Request", $buffer); + } + + public function testRequestHttp11WithMalformedHostWillEmitErrorAndSendErrorResponse() + { + $error = null; + $server = new Server($this->socket); + $server->on('error', function ($message) use (&$error) { + $error = $message; + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\nHost: ///\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('InvalidArgumentException', $error); + + $this->assertContains("HTTP/1.1 400 Bad Request\r\n", $buffer); + $this->assertContains("\r\n\r\nError 400: Bad Request", $buffer); + } + + public function testRequestHttp11WithInvalidHostUriComponentsWillEmitErrorAndSendErrorResponse() + { + $error = null; + $server = new Server($this->socket); + $server->on('error', function ($message) use (&$error) { + $error = $message; + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\nHost: localhost:80/test\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('InvalidArgumentException', $error); + + $this->assertContains("HTTP/1.1 400 Bad Request\r\n", $buffer); + $this->assertContains("\r\n\r\nError 400: Bad Request", $buffer); + } + + public function testRequestHttp10WithoutHostEmitsRequestWithNoError() + { + $server = new Server($this->socket); + $server->on('request', $this->expectCallableOnce()); + $server->on('error', $this->expectCallableNever()); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.0\r\n\r\n"; $this->connection->emit('data', array($data)); } From b4302467a507634ecd0ceb93d0d65bb515b9336c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 16 Feb 2017 20:02:48 +0100 Subject: [PATCH 054/128] The Server should always have a `request` listener --- README.md | 4 ++++ src/Server.php | 10 ++++------ tests/ServerTest.php | 22 ++++++++++++++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5aec2a4c..a703c046 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,10 @@ $http->on('request', function (Request $request, Response $response) { See also [`Request`](#request) and [`Response`](#response) for more details. +> Note that you SHOULD always listen for the `request` event. +Failing to do so will result in the server parsing the incoming request, +but never sending a response back to the client. + The `Server` supports both HTTP/1.1 and HTTP/1.0 request messages. If a client sends an invalid request message or uses an invalid HTTP protocol version, it will emit an `error` event, send an HTTP error response to the diff --git a/src/Server.php b/src/Server.php index 1ffb3e69..e9dd70ab 100644 --- a/src/Server.php +++ b/src/Server.php @@ -28,6 +28,10 @@ * * See also [`Request`](#request) and [`Response`](#response) for more details. * + * > Note that you SHOULD always listen for the `request` event. + * Failing to do so will result in the server parsing the incoming request, + * but never sending a response back to the client. + * * The `Server` supports both HTTP/1.1 and HTTP/1.0 request messages. * If a client sends an invalid request message or uses an invalid HTTP protocol * version, it will emit an `error` event, send an HTTP error response to the @@ -136,12 +140,6 @@ public function handleRequest(ConnectionInterface $conn, Request $request) $response = new Response($conn, $request->getProtocolVersion()); $response->on('close', array($request, 'close')); - if (!$this->listeners('request')) { - $response->end(); - - return; - } - // attach remote ip to the request as metadata $request->remoteAddress = trim( parse_url('tcp://' . $conn->getRemoteAddress(), PHP_URL_HOST), diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 713990b8..47d0f04d 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -310,6 +310,28 @@ function ($data) use (&$buffer) { $this->assertContains("\r\n\r\nError 505: HTTP Version Not Supported", $buffer); } + public function testServerWithNoRequestListenerDoesNotSendAnythingToConnection() + { + $server = new Server($this->socket); + + $this->connection + ->expects($this->never()) + ->method('write'); + + $this->connection + ->expects($this->never()) + ->method('end'); + + $this->connection + ->expects($this->never()) + ->method('close'); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + $this->connection->emit('data', array($data)); + } + public function testParserErrorEmitted() { $error = null; From fdc753108f897803566d0ffb7ad5a1d99b0ed9c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Mon, 20 Feb 2017 13:06:52 +0100 Subject: [PATCH 055/128] Request closes after forwarding close event --- src/Request.php | 14 +++++++++++++- src/Server.php | 14 ++++++-------- tests/RequestTest.php | 39 +++++++++++++++++++++++++++++++++++++++ tests/ServerTest.php | 41 ++++++++++++++++++++++++++++++++++++----- 4 files changed, 94 insertions(+), 14 deletions(-) diff --git a/src/Request.php b/src/Request.php index 27a11668..46bc3639 100644 --- a/src/Request.php +++ b/src/Request.php @@ -174,18 +174,30 @@ public function isReadable() public function pause() { + if (!$this->readable) { + return; + } + $this->emit('pause'); } public function resume() { + if (!$this->readable) { + return; + } + $this->emit('resume'); } public function close() { + if (!$this->readable) { + return; + } + $this->readable = false; - $this->emit('end'); + $this->emit('close'); $this->removeAllListeners(); } diff --git a/src/Server.php b/src/Server.php index e9dd70ab..267eea17 100644 --- a/src/Server.php +++ b/src/Server.php @@ -159,17 +159,15 @@ public function handleRequest(ConnectionInterface $conn, Request $request) $request->on('pause', array($conn, 'pause')); $request->on('resume', array($conn, 'resume')); - // closing the request currently emits an "end" event - // stop reading from the connection by pausing it - $request->on('end', function () use ($conn) { - $conn->pause(); - }); + // request closed => stop reading from the stream by pausing it + // stream closed => close request + $request->on('close', array($stream, 'pause')); + $stream->on('close', array($request, 'close')); - // forward connection events to request - $stream->on('end', function () use ($request) { + // forward data and end events from body stream to request + $stream->on('end', function() use ($request) { $request->emit('end'); }); - $stream->on('data', function ($data) use ($request) { $request->emit('data', array($data)); }); diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 7e630a3e..940a6a68 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -75,4 +75,43 @@ public function testHeaderWithMultipleValues() $this->assertEquals(array('a', 'b'), $request->getHeader('Test')); $this->assertEquals('a, b', $request->getHeaderLine('Test')); } + + public function testCloseEmitsCloseEvent() + { + $request = new Request('GET', '/'); + + $request->on('close', $this->expectCallableOnce()); + + $request->close(); + } + + public function testCloseMultipleTimesEmitsCloseEventOnce() + { + $request = new Request('GET', '/'); + + $request->on('close', $this->expectCallableOnce()); + + $request->close(); + $request->close(); + } + + public function testIsNotReadableAfterClose() + { + $request = new Request('GET', '/'); + + $request->close(); + + $this->assertFalse($request->isReadable()); + } + + public function testPipeReturnsDest() + { + $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + + $request = new Request('GET', '/'); + + $ret = $request->pipe($dest); + + $this->assertSame($dest, $ret); + } } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 47d0f04d..bfb5c01a 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -131,6 +131,37 @@ public function testRequestCloseWillPauseConnection() $this->connection->emit('data', array($data)); } + public function testRequestPauseAfterCloseWillNotBeForwarded() + { + $server = new Server($this->socket); + $server->on('request', function (Request $request) { + $request->close(); + $request->pause(); + }); + + $this->connection->expects($this->once())->method('pause'); + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + $this->connection->emit('data', array($data)); + } + + public function testRequestResumeAfterCloseWillNotBeForwarded() + { + $server = new Server($this->socket); + $server->on('request', function (Request $request) { + $request->close(); + $request->resume(); + }); + + $this->connection->expects($this->once())->method('pause'); + $this->connection->expects($this->never())->method('resume'); + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + $this->connection->emit('data', array($data)); + } + public function testRequestEventWithoutBodyWillNotEmitData() { $never = $this->expectCallableNever(); @@ -415,7 +446,7 @@ public function testChunkedEncodedRequestWillBeParsedForRequestEvent() $dataEvent = $this->expectCallableOnceWith('hello'); $endEvent = $this->expectCallableOnce(); - $closeEvent = $this->expectCallableNever(); + $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { @@ -444,7 +475,7 @@ public function testChunkedEncodedRequestAdditionalDataWontBeEmitted() $dataEvent = $this->expectCallableOnceWith('hello'); $endEvent = $this->expectCallableOnce(); - $closeEvent = $this->expectCallableNever(); + $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { @@ -474,7 +505,7 @@ public function testEmptyChunkedEncodedRequest() $dataEvent = $this->expectCallableNever(); $endEvent = $this->expectCallableOnce(); - $closeEvent = $this->expectCallableNever(); + $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { @@ -502,7 +533,7 @@ public function testChunkedIsUpperCase() $dataEvent = $this->expectCallableOnceWith('hello'); $endEvent = $this->expectCallableOnce(); - $closeEvent = $this->expectCallableNever(); + $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { @@ -531,7 +562,7 @@ public function testChunkedIsMixedUpperAndLowerCase() $dataEvent = $this->expectCallableOnceWith('hello'); $endEvent = $this->expectCallableOnce(); - $closeEvent = $this->expectCallableNever(); + $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { From 5ab993a5d8bfb452865f3c444e8ef20e9b1cca7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 17 Feb 2017 00:53:44 +0100 Subject: [PATCH 056/128] Send HTTP status code 431 if request header is too large --- src/Server.php | 2 +- tests/ServerTest.php | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Server.php b/src/Server.php index 267eea17..5fc182d2 100644 --- a/src/Server.php +++ b/src/Server.php @@ -105,7 +105,7 @@ public function handleConnection(ConnectionInterface $conn) $that->writeError( $conn, - 400 + ($e instanceof \OverflowException) ? 431 : 400 ); }); } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index bfb5c01a..83330108 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -363,7 +363,7 @@ public function testServerWithNoRequestListenerDoesNotSendAnythingToConnection() $this->connection->emit('data', array($data)); } - public function testParserErrorEmitted() + public function testRequestOverflowWillEmitErrorAndSendErrorResponse() { $error = null; $server = new Server($this->socket); @@ -371,6 +371,19 @@ public function testParserErrorEmitted() $error = $message; }); + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-DATA: "; @@ -378,6 +391,9 @@ public function testParserErrorEmitted() $this->connection->emit('data', array($data)); $this->assertInstanceOf('OverflowException', $error); + + $this->assertContains("HTTP/1.1 431 Request Header Fields Too Large\r\n", $buffer); + $this->assertContains("\r\n\r\nError 431: Request Header Fields Too Large", $buffer); } public function testRequestInvalidWillEmitErrorAndSendErrorResponse() From e7ccaca4b9033bbc2d27cd1ec478329059320df9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Mon, 20 Feb 2017 15:14:52 +0100 Subject: [PATCH 057/128] Response closes after forwarding close event --- README.md | 8 +- src/Response.php | 41 +++++-- tests/ResponseTest.php | 264 ++++++++++++++++++++++++++++++++++++----- tests/ServerTest.php | 17 +++ 4 files changed, 285 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index a703c046..39f3b901 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,9 @@ This method MUST NOT be invoked after calling [`writeHead()`](#writehead). This method MUST NOT be invoked if this is not a HTTP/1.1 response (please check [`expectsContinue()`](#expectscontinue) as above). Calling this method after sending the headers or if this is not a HTTP/1.1 -response is an error that will result in an `Exception`. +response is an error that will result in an `Exception` +(unless the response has ended/closed already). +Calling this method after the response has ended/closed is a NOOP. #### writeHead() @@ -248,7 +250,9 @@ $response->writeHead(200, array( $response->end('Hello World!'); ``` -Calling this method more than once will result in an `Exception`. +Calling this method more than once will result in an `Exception` +(unless the response has ended/closed already). +Calling this method after the response has ended/closed is a NOOP. Unless you specify a `Content-Length` header yourself, HTTP/1.1 responses will automatically use chunked transfer encoding and send the respective header diff --git a/src/Response.php b/src/Response.php index 9483523d..3283d2a7 100644 --- a/src/Response.php +++ b/src/Response.php @@ -3,7 +3,6 @@ namespace React\Http; use Evenement\EventEmitter; -use React\Socket\ConnectionInterface; use React\Stream\WritableStreamInterface; /** @@ -45,19 +44,16 @@ class Response extends EventEmitter implements WritableStreamInterface * * @internal */ - public function __construct(ConnectionInterface $conn, $protocolVersion = '1.1') + public function __construct(WritableStreamInterface $conn, $protocolVersion = '1.1') { $this->conn = $conn; $this->protocolVersion = $protocolVersion; $that = $this; - $this->conn->on('end', function () use ($that) { - $that->close(); - }); + $this->conn->on('close', array($this, 'close')); $this->conn->on('error', function ($error) use ($that) { - $that->emit('error', array($error, $that)); - $that->close(); + $that->emit('error', array($error)); }); $this->conn->on('drain', function () use ($that) { @@ -106,7 +102,9 @@ public function isWritable() * This method MUST NOT be invoked if this is not a HTTP/1.1 response * (please check [`expectsContinue()`] as above). * Calling this method after sending the headers or if this is not a HTTP/1.1 - * response is an error that will result in an `Exception`. + * response is an error that will result in an `Exception` + * (unless the response has ended/closed already). + * Calling this method after the response has ended/closed is a NOOP. * * @return void * @throws \Exception @@ -114,6 +112,9 @@ public function isWritable() */ public function writeContinue() { + if (!$this->writable) { + return; + } if ($this->protocolVersion !== '1.1') { throw new \Exception('Continue requires a HTTP/1.1 message'); } @@ -137,7 +138,9 @@ public function writeContinue() * $response->end('Hello World!'); * ``` * - * Calling this method more than once will result in an `Exception`. + * Calling this method more than once will result in an `Exception` + * (unless the response has ended/closed already). + * Calling this method after the response has ended/closed is a NOOP. * * Unless you specify a `Content-Length` header yourself, HTTP/1.1 responses * will automatically use chunked transfer encoding and send the respective header @@ -184,6 +187,9 @@ public function writeContinue() */ public function writeHead($status = 200, array $headers = array()) { + if (!$this->writable) { + return; + } if ($this->headWritten) { throw new \Exception('Response head has already been written.'); } @@ -250,6 +256,9 @@ private function formatHead($status, array $headers) public function write($data) { + if (!$this->writable) { + return false; + } if (!$this->headWritten) { throw new \Exception('Response head has not yet been written.'); } @@ -271,6 +280,13 @@ public function write($data) public function end($data = null) { + if (!$this->writable) { + return; + } + if (!$this->headWritten) { + throw new \Exception('Response head has not yet been written.'); + } + if (null !== $data) { $this->write($data); } @@ -279,8 +295,7 @@ public function end($data = null) $this->conn->write("0\r\n\r\n"); } - $this->emit('end'); - $this->removeAllListeners(); + $this->writable = false; $this->conn->end(); } @@ -291,10 +306,10 @@ public function close() } $this->closed = true; - $this->writable = false; + $this->conn->close(); + $this->emit('close'); $this->removeAllListeners(); - $this->conn->close(); } } diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 8c3e9077..841bcdd9 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -3,6 +3,7 @@ namespace React\Tests\Http; use React\Http\Response; +use React\Stream\WritableStream; class ResponseTest extends TestCase { @@ -171,6 +172,106 @@ public function testResponseShouldAlwaysIncludeConnectionCloseIrrespectiveOfExpl $response->writeHead(200, array('Content-Length' => 0, 'connection' => 'ignored')); } + /** @expectedException Exception */ + public function testWriteHeadTwiceShouldThrowException() + { + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); + $conn + ->expects($this->once()) + ->method('write'); + + $response = new Response($conn); + $response->writeHead(); + $response->writeHead(); + } + + public function testEndWithoutDataWritesEndChunkAndEndsInput() + { + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); + $conn + ->expects($this->at(4)) + ->method('write') + ->with("0\r\n\r\n"); + $conn + ->expects($this->once()) + ->method('end'); + + $response = new Response($conn); + $response->writeHead(); + $response->end(); + } + + public function testEndWithDataWritesToInputAndEndsInputWithoutData() + { + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); + $conn + ->expects($this->at(4)) + ->method('write') + ->with("3\r\nbye\r\n"); + $conn + ->expects($this->at(5)) + ->method('write') + ->with("0\r\n\r\n"); + $conn + ->expects($this->once()) + ->method('end'); + + $response = new Response($conn); + $response->writeHead(); + $response->end('bye'); + } + + public function testEndWithoutDataWithoutChunkedEncodingWritesNoDataAndEndsInput() + { + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); + $conn + ->expects($this->once()) + ->method('write'); + $conn + ->expects($this->once()) + ->method('end'); + + $response = new Response($conn); + $response->writeHead(200, array('Content-Length' => 0)); + $response->end(); + } + + /** @expectedException Exception */ + public function testEndWithoutHeadShouldThrowException() + { + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); + $conn + ->expects($this->never()) + ->method('end'); + + $response = new Response($conn); + $response->end(); + } + + /** @expectedException Exception */ + public function testWriteWithoutHeadShouldThrowException() + { + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); + $conn + ->expects($this->never()) + ->method('write'); + + $response = new Response($conn); + $response->write('test'); + } + public function testResponseBodyShouldBeChunkedCorrectly() { $conn = $this @@ -229,23 +330,6 @@ public function testResponseBodyShouldSkipEmptyChunks() $response->end(); } - public function testResponseShouldEmitEndOnStreamEnd() - { - $ended = false; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $response = new Response($conn); - - $response->on('end', function () use (&$ended) { - $ended = true; - }); - $response->end(); - - $this->assertTrue($ended); - } - /** @test */ public function writeContinueShouldSendContinueLineBeforeRealHeaders() { @@ -280,26 +364,19 @@ public function writeContinueShouldThrowForHttp10() $response->writeContinue(); } - /** @test */ - public function shouldForwardEndDrainAndErrorEvents() + /** @expectedException Exception */ + public function testWriteContinueAfterWriteHeadShouldThrowException() { $conn = $this ->getMockBuilder('React\Socket\ConnectionInterface') ->getMock(); $conn - ->expects($this->at(0)) - ->method('on') - ->with('end', $this->isInstanceOf('Closure')); - $conn - ->expects($this->at(1)) - ->method('on') - ->with('error', $this->isInstanceOf('Closure')); - $conn - ->expects($this->at(2)) - ->method('on') - ->with('drain', $this->isInstanceOf('Closure')); + ->expects($this->once()) + ->method('write'); $response = new Response($conn); + $response->writeHead(); + $response->writeContinue(); } /** @test */ @@ -392,4 +469,131 @@ public function shouldIgnoreHeadersWithNullValues() $response = new Response($conn); $response->writeHead(200, array("FooBar" => null)); } + + public function testCloseClosesInputAndEmitsCloseEvent() + { + $input = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + $input->expects($this->once())->method('close'); + + $response = new Response($input); + + $response->on('close', $this->expectCallableOnce()); + + $response->close(); + } + + public function testClosingInputEmitsCloseEvent() + { + $input = new WritableStream(); + $response = new Response($input); + + $response->on('close', $this->expectCallableOnce()); + + $input->close(); + } + + public function testCloseMultipleTimesEmitsCloseEventOnce() + { + $input = new WritableStream(); + $response = new Response($input); + + $response->on('close', $this->expectCallableOnce()); + + $response->close(); + $response->close(); + } + + public function testIsNotWritableAfterClose() + { + $input = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + + $response = new Response($input); + + $response->close(); + + $this->assertFalse($response->isWritable()); + } + + public function testCloseAfterEndIsPassedThrough() + { + $input = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + $input->expects($this->once())->method('end'); + $input->expects($this->once())->method('close'); + + $response = new Response($input); + + $response->writeHead(); + $response->end(); + $response->close(); + } + + public function testWriteAfterCloseIsNoOp() + { + $input = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + $input->expects($this->once())->method('close'); + $input->expects($this->never())->method('write'); + + $response = new Response($input); + $response->close(); + + $this->assertFalse($response->write('noop')); + } + + public function testWriteHeadAfterCloseIsNoOp() + { + $input = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + $input->expects($this->once())->method('close'); + $input->expects($this->never())->method('write'); + + $response = new Response($input); + $response->close(); + + $response->writeHead(); + } + + public function testWriteContinueAfterCloseIsNoOp() + { + $input = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + $input->expects($this->once())->method('close'); + $input->expects($this->never())->method('write'); + + $response = new Response($input); + $response->close(); + + $response->writeContinue(); + } + + public function testEndAfterCloseIsNoOp() + { + $input = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + $input->expects($this->once())->method('close'); + $input->expects($this->never())->method('write'); + $input->expects($this->never())->method('end'); + + $response = new Response($input); + $response->close(); + + $response->end('noop'); + } + + public function testErrorEventShouldBeForwardedWithoutClosing() + { + $input = new WritableStream(); + $response = new Response($input); + + $response->on('error', $this->expectCallableOnce()); + $response->on('close', $this->expectCallableNever()); + + $input->emit('error', array(new \RuntimeException())); + } + + public function testDrainEventShouldBeForwarded() + { + $input = new WritableStream(); + $response = new Response($input); + + $response->on('drain', $this->expectCallableOnce()); + + $input->emit('drain'); + } } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 83330108..a7abc7f2 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -249,6 +249,23 @@ function ($data) use (&$buffer) { $this->assertContains("\r\nX-Powered-By: React/alpha\r\n", $buffer); } + public function testClosingResponseDoesNotSendAnyData() + { + $server = new Server($this->socket); + $server->on('request', function (Request $request, Response $response) { + $response->close(); + }); + + $this->connection->expects($this->never())->method('write'); + $this->connection->expects($this->never())->method('end'); + $this->connection->expects($this->once())->method('close'); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + $this->connection->emit('data', array($data)); + } + public function testResponseContainsSameRequestProtocolVersionAndChunkedBodyForHttp11() { $server = new Server($this->socket); From 68aeafecb8437bb06eb274efada89a30d3c515ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 21 Feb 2017 10:07:27 +0100 Subject: [PATCH 058/128] RequestHeaderParser returns PSR-7 request --- src/RequestHeaderParser.php | 16 +--------------- src/Server.php | 32 ++++++++++++++++++++++--------- tests/RequestHeaderParserTest.php | 10 ++++------ tests/ServerTest.php | 1 + 4 files changed, 29 insertions(+), 30 deletions(-) diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php index b75fadbf..d9feda1a 100644 --- a/src/RequestHeaderParser.php +++ b/src/RequestHeaderParser.php @@ -55,21 +55,7 @@ private function parseRequest($data) { list($headers, $bodyBuffer) = explode("\r\n\r\n", $data, 2); - $psrRequest = g7\parse_request($headers); - - $parsedQuery = array(); - $queryString = $psrRequest->getUri()->getQuery(); - if ($queryString) { - parse_str($queryString, $parsedQuery); - } - - $request = new Request( - $psrRequest->getMethod(), - $psrRequest->getUri()->getPath(), - $parsedQuery, - $psrRequest->getProtocolVersion(), - $psrRequest->getHeaders() - ); + $request = g7\parse_request($headers); return array($request, $bodyBuffer); } diff --git a/src/Server.php b/src/Server.php index 5fc182d2..67d46888 100644 --- a/src/Server.php +++ b/src/Server.php @@ -5,6 +5,7 @@ use Evenement\EventEmitter; use React\Socket\ServerInterface as SocketServerInterface; use React\Socket\ConnectionInterface; +use Psr\Http\Message\RequestInterface; /** * The `Server` class is responsible for handling incoming connections and then @@ -87,7 +88,7 @@ public function handleConnection(ConnectionInterface $conn) $that = $this; $parser = new RequestHeaderParser(); $listener = array($parser, 'feed'); - $parser->on('headers', function (Request $request, $bodyBuffer) use ($conn, $listener, $parser, $that) { + $parser->on('headers', function (RequestInterface $request, $bodyBuffer) use ($conn, $listener, $parser, $that) { // parsing request completed => stop feeding parser $conn->removeListener('data', $listener); @@ -111,7 +112,7 @@ public function handleConnection(ConnectionInterface $conn) } /** @internal */ - public function handleRequest(ConnectionInterface $conn, Request $request) + public function handleRequest(ConnectionInterface $conn, RequestInterface $request) { // only support HTTP/1.1 and HTTP/1.0 requests if ($request->getProtocolVersion() !== '1.1' && $request->getProtocolVersion() !== '1.0') { @@ -138,13 +139,6 @@ public function handleRequest(ConnectionInterface $conn, Request $request) } $response = new Response($conn, $request->getProtocolVersion()); - $response->on('close', array($request, 'close')); - - // attach remote ip to the request as metadata - $request->remoteAddress = trim( - parse_url('tcp://' . $conn->getRemoteAddress(), PHP_URL_HOST), - '[]' - ); $stream = $conn; if ($request->hasHeader('Transfer-Encoding')) { @@ -155,6 +149,26 @@ public function handleRequest(ConnectionInterface $conn, Request $request) } } + $parsedQuery = array(); + $queryString = $request->getUri()->getQuery(); + if ($queryString) { + parse_str($queryString, $parsedQuery); + } + + $request = new Request( + $request->getMethod(), + $request->getUri()->getPath(), + $parsedQuery, + $request->getProtocolVersion(), + $request->getHeaders() + ); + + // attach remote ip to the request as metadata + $request->remoteAddress = trim( + parse_url('tcp://' . $conn->getRemoteAddress(), PHP_URL_HOST), + '[]' + ); + // forward pause/resume calls to underlying connection $request->on('pause', array($conn, 'pause')); $request->on('resume', array($conn, 'resume')); diff --git a/tests/RequestHeaderParserTest.php b/tests/RequestHeaderParserTest.php index 1193e220..dfc25f4f 100644 --- a/tests/RequestHeaderParserTest.php +++ b/tests/RequestHeaderParserTest.php @@ -45,10 +45,9 @@ public function testHeadersEventShouldReturnRequestAndBodyBuffer() $data .= 'RANDOM DATA'; $parser->feed($data); - $this->assertInstanceOf('React\Http\Request', $request); + $this->assertInstanceOf('Psr\Http\Message\RequestInterface', $request); $this->assertSame('GET', $request->getMethod()); - $this->assertSame('/', $request->getPath()); - $this->assertSame(array(), $request->getQueryParams()); + $this->assertEquals('http://example.com/', $request->getUri()); $this->assertSame('1.1', $request->getProtocolVersion()); $this->assertSame(array('Host' => array('example.com:80'), 'Connection' => array('close')), $request->getHeaders()); @@ -83,10 +82,9 @@ public function testHeadersEventShouldParsePathAndQueryString() $data = $this->createAdvancedPostRequest(); $parser->feed($data); - $this->assertInstanceOf('React\Http\Request', $request); + $this->assertInstanceOf('Psr\Http\Message\RequestInterface', $request); $this->assertSame('POST', $request->getMethod()); - $this->assertSame('/foo', $request->getPath()); - $this->assertSame(array('bar' => 'baz'), $request->getQueryParams()); + $this->assertEquals('http://example.com/foo?bar=baz', $request->getUri()); $this->assertSame('1.1', $request->getProtocolVersion()); $headers = array( 'Host' => array('example.com:80'), diff --git a/tests/ServerTest.php b/tests/ServerTest.php index a7abc7f2..c2518400 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -83,6 +83,7 @@ public function testRequestEvent() $this->assertSame(1, $i); $this->assertInstanceOf('React\Http\Request', $requestAssertion); $this->assertSame('/', $requestAssertion->getPath()); + $this->assertSame(array(), $requestAssertion->getQueryParams()); $this->assertSame('GET', $requestAssertion->getMethod()); $this->assertSame('127.0.0.1', $requestAssertion->remoteAddress); From 57dfe21b5c9386ef222c88bf431aa193871d1ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 21 Feb 2017 10:54:08 +0100 Subject: [PATCH 059/128] Inject PSR-7 request instance into Request class --- src/Request.php | 47 +++++++++++++++---------------------------- src/Server.php | 14 +------------ tests/RequestTest.php | 27 +++++++++++++------------ 3 files changed, 31 insertions(+), 57 deletions(-) diff --git a/src/Request.php b/src/Request.php index 46bc3639..58d7b214 100644 --- a/src/Request.php +++ b/src/Request.php @@ -6,6 +6,7 @@ use React\Stream\ReadableStreamInterface; use React\Stream\WritableStreamInterface; use React\Stream\Util; +use Psr\Http\Message\RequestInterface; /** * The `Request` class is responsible for streaming the incoming request body @@ -24,11 +25,7 @@ class Request extends EventEmitter implements ReadableStreamInterface { private $readable = true; - private $method; - private $path; - private $query; - private $httpVersion; - private $headers; + private $request; // metadata, implicitly added externally public $remoteAddress; @@ -42,13 +39,9 @@ class Request extends EventEmitter implements ReadableStreamInterface * * @internal */ - public function __construct($method, $path, $query = array(), $httpVersion = '1.1', $headers = array()) + public function __construct(RequestInterface $request) { - $this->method = $method; - $this->path = $path; - $this->query = $query; - $this->httpVersion = $httpVersion; - $this->headers = $headers; + $this->request = $request; } /** @@ -58,7 +51,7 @@ public function __construct($method, $path, $query = array(), $httpVersion = '1. */ public function getMethod() { - return $this->method; + return $this->request->getMethod(); } /** @@ -68,7 +61,7 @@ public function getMethod() */ public function getPath() { - return $this->path; + return $this->request->getUri()->getPath(); } /** @@ -78,7 +71,10 @@ public function getPath() */ public function getQueryParams() { - return $this->query; + $params = array(); + parse_str($this->request->getUri()->getQuery(), $params); + + return $params; } /** @@ -88,7 +84,7 @@ public function getQueryParams() */ public function getProtocolVersion() { - return $this->httpVersion; + return $this->request->getProtocolVersion(); } /** @@ -102,7 +98,7 @@ public function getProtocolVersion() */ public function getHeaders() { - return $this->headers; + return $this->request->getHeaders(); } /** @@ -113,18 +109,7 @@ public function getHeaders() */ public function getHeader($name) { - $found = array(); - - $name = strtolower($name); - foreach ($this->headers as $key => $value) { - if (strtolower($key) === $name) { - foreach((array)$value as $one) { - $found[] = $one; - } - } - } - - return $found; + return $this->request->getHeader($name); } /** @@ -135,7 +120,7 @@ public function getHeader($name) */ public function getHeaderLine($name) { - return implode(', ', $this->getHeader($name)); + return $this->request->getHeaderLine($name); } /** @@ -146,7 +131,7 @@ public function getHeaderLine($name) */ public function hasHeader($name) { - return !!$this->getHeader($name); + return $this->request->hasHeader($name); } /** @@ -164,7 +149,7 @@ public function hasHeader($name) */ public function expectsContinue() { - return $this->httpVersion !== '1.0' && '100-continue' === strtolower($this->getHeaderLine('Expect')); + return $this->getProtocolVersion() !== '1.0' && '100-continue' === strtolower($this->getHeaderLine('Expect')); } public function isReadable() diff --git a/src/Server.php b/src/Server.php index 67d46888..35fef695 100644 --- a/src/Server.php +++ b/src/Server.php @@ -149,19 +149,7 @@ public function handleRequest(ConnectionInterface $conn, RequestInterface $reque } } - $parsedQuery = array(); - $queryString = $request->getUri()->getQuery(); - if ($queryString) { - parse_str($queryString, $parsedQuery); - } - - $request = new Request( - $request->getMethod(), - $request->getUri()->getPath(), - $parsedQuery, - $request->getProtocolVersion(), - $request->getHeaders() - ); + $request = new Request($request); // attach remote ip to the request as metadata $request->remoteAddress = trim( diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 940a6a68..9ff13bed 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -3,6 +3,7 @@ namespace React\Tests\Http; use React\Http\Request; +use RingCentral\Psr7\Request as Psr; class RequestTest extends TestCase { @@ -10,7 +11,7 @@ class RequestTest extends TestCase public function expectsContinueShouldBeFalseByDefault() { $headers = array(); - $request = new Request('GET', '/', array(), '1.1', $headers); + $request = new Request(new Psr('GET', '/', $headers, null, '1.1')); $this->assertFalse($request->expectsContinue()); } @@ -19,7 +20,7 @@ public function expectsContinueShouldBeFalseByDefault() public function expectsContinueShouldBeTrueIfContinueExpected() { $headers = array('Expect' => array('100-continue')); - $request = new Request('GET', '/', array(), '1.1', $headers); + $request = new Request(new Psr('GET', '/', $headers, null, '1.1')); $this->assertTrue($request->expectsContinue()); } @@ -28,7 +29,7 @@ public function expectsContinueShouldBeTrueIfContinueExpected() public function expectsContinueShouldBeTrueIfContinueExpectedCaseInsensitive() { $headers = array('EXPECT' => array('100-CONTINUE')); - $request = new Request('GET', '/', array(), '1.1', $headers); + $request = new Request(new Psr('GET', '/', $headers, null, '1.1')); $this->assertTrue($request->expectsContinue()); } @@ -37,14 +38,14 @@ public function expectsContinueShouldBeTrueIfContinueExpectedCaseInsensitive() public function expectsContinueShouldBeFalseForHttp10() { $headers = array('Expect' => array('100-continue')); - $request = new Request('GET', '/', array(), '1.0', $headers); + $request = new Request(new Psr('GET', '/', $headers, null, '1.0')); $this->assertFalse($request->expectsContinue()); } public function testEmptyHeader() { - $request = new Request('GET', '/'); + $request = new Request(new Psr('GET', '/', array())); $this->assertEquals(array(), $request->getHeaders()); $this->assertFalse($request->hasHeader('Test')); @@ -54,9 +55,9 @@ public function testEmptyHeader() public function testHeaderIsCaseInsensitive() { - $request = new Request('GET', '/', array(), '1.1', array( + $request = new Request(new Psr('GET', '/', array( 'TEST' => array('Yes'), - )); + ))); $this->assertEquals(array('TEST' => array('Yes')), $request->getHeaders()); $this->assertTrue($request->hasHeader('Test')); @@ -66,9 +67,9 @@ public function testHeaderIsCaseInsensitive() public function testHeaderWithMultipleValues() { - $request = new Request('GET', '/', array(), '1.1', array( + $request = new Request(new Psr('GET', '/', array( 'Test' => array('a', 'b'), - )); + ))); $this->assertEquals(array('Test' => array('a', 'b')), $request->getHeaders()); $this->assertTrue($request->hasHeader('Test')); @@ -78,7 +79,7 @@ public function testHeaderWithMultipleValues() public function testCloseEmitsCloseEvent() { - $request = new Request('GET', '/'); + $request = new Request(new Psr('GET', '/')); $request->on('close', $this->expectCallableOnce()); @@ -87,7 +88,7 @@ public function testCloseEmitsCloseEvent() public function testCloseMultipleTimesEmitsCloseEventOnce() { - $request = new Request('GET', '/'); + $request = new Request(new Psr('GET', '/')); $request->on('close', $this->expectCallableOnce()); @@ -97,7 +98,7 @@ public function testCloseMultipleTimesEmitsCloseEventOnce() public function testIsNotReadableAfterClose() { - $request = new Request('GET', '/'); + $request = new Request(new Psr('GET', '/')); $request->close(); @@ -108,7 +109,7 @@ public function testPipeReturnsDest() { $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); - $request = new Request('GET', '/'); + $request = new Request(new Psr('GET', '/')); $ret = $request->pipe($dest); From 60f80242d74fffe35336a5e13e936e26be0e950a Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Wed, 15 Feb 2017 13:09:05 +0100 Subject: [PATCH 060/128] Add LenghtLimitedStream --- src/LengthLimitedStream.php | 103 ++++++++++++++++++++++++++++ tests/LengthLimitedStreamTest.php | 108 ++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 src/LengthLimitedStream.php create mode 100644 tests/LengthLimitedStreamTest.php diff --git a/src/LengthLimitedStream.php b/src/LengthLimitedStream.php new file mode 100644 index 00000000..6a2d4033 --- /dev/null +++ b/src/LengthLimitedStream.php @@ -0,0 +1,103 @@ +stream = $stream; + $this->maxLength = $maxLength; + + $this->stream->on('data', array($this, 'handleData')); + $this->stream->on('end', array($this, 'handleEnd')); + $this->stream->on('error', array($this, 'handleError')); + $this->stream->on('close', array($this, 'close')); + } + + public function isReadable() + { + return !$this->closed && $this->stream->isReadable(); + } + + public function pause() + { + $this->stream->pause(); + } + + public function resume() + { + $this->stream->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->stream->close(); + + $this->emit('close'); + $this->removeAllListeners(); + } + + /** @internal */ + public function handleData($data) + { + if (($this->transferredLength + strlen($data)) > $this->maxLength) { + // Only emit data until the value of 'Content-Length' is reached, the rest will be ignored + $data = (string)substr($data, 0, $this->maxLength - $this->transferredLength); + } + + if ($data !== '') { + $this->transferredLength += strlen($data); + $this->emit('data', array($data)); + } + + if ($this->transferredLength === $this->maxLength) { + // 'Content-Length' reached, stream will end + $this->emit('end'); + $this->close(); + $this->stream->removeListener('data', array($this, 'handleData')); + } + } + + /** @internal */ + public function handleError(\Exception $e) + { + $this->emit('error', array($e)); + $this->close(); + } + + /** @internal */ + public function handleEnd() + { + if (!$this->closed) { + $this->emit('end'); + $this->close(); + } + } + +} diff --git a/tests/LengthLimitedStreamTest.php b/tests/LengthLimitedStreamTest.php new file mode 100644 index 00000000..5ba7be0d --- /dev/null +++ b/tests/LengthLimitedStreamTest.php @@ -0,0 +1,108 @@ +input = new ReadableStream(); + } + + public function testSimpleChunk() + { + $stream = new LengthLimitedStream($this->input, 5); + $stream->on('data', $this->expectCallableOnceWith('hello')); + $stream->on('end', $this->expectCallableOnce()); + $this->input->emit('data', array("hello world")); + } + + public function testInputStreamKeepsEmitting() + { + $stream = new LengthLimitedStream($this->input, 5); + $stream->on('data', $this->expectCallableOnceWith('hello')); + $stream->on('end', $this->expectCallableOnce()); + + $this->input->emit('data', array("hello world")); + $this->input->emit('data', array("world")); + $this->input->emit('data', array("world")); + } + + public function testZeroLengthInContentLengthWillIgnoreEmittedDataEvents() + { + $stream = new LengthLimitedStream($this->input, 0); + $stream->on('data', $this->expectCallableNever()); + $stream->on('end', $this->expectCallableOnce()); + $this->input->emit('data', array("hello world")); + } + + public function testHandleError() + { + $stream = new LengthLimitedStream($this->input, 0); + $stream->on('error', $this->expectCallableOnce()); + $stream->on('close', $this->expectCallableOnce()); + + $this->input->emit('error', array(new \RuntimeException())); + + $this->assertFalse($stream->isReadable()); + } + + public function testPauseStream() + { + $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input->expects($this->once())->method('pause'); + + $stream = new LengthLimitedStream($input, 0); + $stream->pause(); + } + + public function testResumeStream() + { + $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input->expects($this->once())->method('pause'); + + $stream = new LengthLimitedStream($input, 0); + $stream->pause(); + $stream->resume(); + } + + public function testPipeStream() + { + $stream = new LengthLimitedStream($this->input, 0); + $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + + $ret = $stream->pipe($dest); + + $this->assertSame($dest, $ret); + } + + public function testHandleClose() + { + $stream = new LengthLimitedStream($this->input, 0); + $stream->on('close', $this->expectCallableOnce()); + + $this->input->close(); + $this->input->emit('end', array()); + + $this->assertFalse($stream->isReadable()); + } + + public function testOutputStreamCanCloseInputStream() + { + $input = new ReadableStream(); + $input->on('close', $this->expectCallableOnce()); + + $stream = new LengthLimitedStream($input, 0); + $stream->on('close', $this->expectCallableOnce()); + + $stream->close(); + + $this->assertFalse($input->isReadable()); + } +} From 99b6faf5a314ccc34894729b7e5e138d36547dc9 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Wed, 15 Feb 2017 13:10:37 +0100 Subject: [PATCH 061/128] Handle Content-Length requests --- tests/ServerTest.php | 151 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 149 insertions(+), 2 deletions(-) diff --git a/tests/ServerTest.php b/tests/ServerTest.php index c2518400..03d82604 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -451,8 +451,8 @@ public function testBodyDataWillBeSendViaRequestEvent() $server = new Server($this->socket); $dataEvent = $this->expectCallableOnceWith('hello'); - $endEvent = $this->expectCallableNever(); - $closeEvent = $this->expectCallableNever(); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { @@ -726,6 +726,153 @@ public function testRequestHttp10WithoutHostEmitsRequestWithNoError() $this->connection->emit('data', array($data)); } + public function testWontEmitFurtherDataWhenContentLengthIsReached() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $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"; + $data .= "world"; + + $this->connection->emit('data', array($data)); + } + + public function testWontEmitFurtherDataWhenContentLengthIsReachedSplitted() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $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)); + + $data = "world"; + + $this->connection->emit('data', array($data)); + } + + public function testContentLengthContainsZeroWillEmitEndEvent() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableNever(); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableNever(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $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: 0\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', array($data)); + } + + public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillBeIgnored() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableNever(); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableNever(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $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: 0\r\n"; + $data .= "\r\n"; + $data .= "hello"; + + $this->connection->emit('data', array($data)); + } + + public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillBeIgnoredSplitted() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableNever(); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableNever(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $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: 0\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', array($data)); + + $data = "hello"; + + $this->connection->emit('data', array($data)); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From a3ce836f9beea5ef9a0a803bb7b5a7e5da22c27b Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Sun, 19 Feb 2017 18:14:05 +0100 Subject: [PATCH 062/128] Ignore Content-Length if Transfer-Encoding isset instead of replacing --- tests/ServerTest.php | 156 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 153 insertions(+), 3 deletions(-) diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 03d82604..32db8ef3 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -793,7 +793,7 @@ public function testContentLengthContainsZeroWillEmitEndEvent() $dataEvent = $this->expectCallableNever(); $endEvent = $this->expectCallableOnce(); - $closeEvent = $this->expectCallableNever(); + $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { @@ -820,7 +820,7 @@ public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillB $dataEvent = $this->expectCallableNever(); $endEvent = $this->expectCallableOnce(); - $closeEvent = $this->expectCallableNever(); + $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { @@ -848,7 +848,7 @@ public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillB $dataEvent = $this->expectCallableNever(); $endEvent = $this->expectCallableOnce(); - $closeEvent = $this->expectCallableNever(); + $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { @@ -873,6 +873,156 @@ public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillB $this->connection->emit('data', array($data)); } + public function testContentLengthWillBeIgnoredIfTransferEncodingIsSet() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + + $requestValidation = null; + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + $requestValidation = $request; + }); + + $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: 4\r\n"; + $data .= "Transfer-Encoding: chunked\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', array($data)); + + $data = "5\r\nhello\r\n"; + $data .= "0\r\n\r\n"; + + $this->connection->emit('data', array($data)); + $this->assertEquals('4', $requestValidation->getHeaderLine('Content-Length')); + $this->assertEquals('chunked', $requestValidation->getHeaderLine('Transfer-Encoding')); + } + + public function testInvalidContentLengthWillBeIgnoreddIfTransferEncodingIsSet() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableOnceWith('hello'); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + + $requestValidation = null; + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + $requestValidation = $request; + }); + + $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: hello world\r\n"; + $data .= "Transfer-Encoding: chunked\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', array($data)); + + $data = "5\r\nhello\r\n"; + $data .= "0\r\n\r\n"; + + $this->connection->emit('data', array($data)); + + // this is valid behavior according to: https://www.ietf.org/rfc/rfc2616.txt chapter 4.4 + $this->assertEquals('hello world', $requestValidation->getHeaderLine('Content-Length')); + $this->assertEquals('chunked', $requestValidation->getHeaderLine('Transfer-Encoding')); + } + + public function testNonIntegerContentLengthValueWillLeadToError() + { + $error = null; + $server = new Server($this->socket); + $server->on('request', $this->expectCallableNever()); + $server->on('error', function ($message) use (&$error) { + $error = $message; + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: bla\r\n"; + $data .= "\r\n"; + $data .= "hello"; + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 400 Bad Request\r\n", $buffer); + $this->assertContains("\r\n\r\nError 400: Bad Request", $buffer); + $this->assertInstanceOf('InvalidArgumentException', $error); + } + + public function testMultipleIntegerInContentLengthWillLeadToError() + { + $error = null; + $server = new Server($this->socket); + $server->on('request', $this->expectCallableNever()); + $server->on('error', function ($message) use (&$error) { + $error = $message; + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: 5, 3, 4\r\n"; + $data .= "\r\n"; + $data .= "hello"; + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 400 Bad Request\r\n", $buffer); + $this->assertContains("\r\n\r\nError 400: Bad Request", $buffer); + $this->assertInstanceOf('InvalidArgumentException', $error); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From ecf8ddf8fd50e157da13121376c678f66a2c3ee6 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Tue, 21 Feb 2017 11:06:39 +0100 Subject: [PATCH 063/128] Handle unexpected end in LengthLimitedStream --- src/LengthLimitedStream.php | 3 +-- tests/LengthLimitedStreamTest.php | 12 ++++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/LengthLimitedStream.php b/src/LengthLimitedStream.php index 6a2d4033..225f9b0b 100644 --- a/src/LengthLimitedStream.php +++ b/src/LengthLimitedStream.php @@ -95,8 +95,7 @@ public function handleError(\Exception $e) public function handleEnd() { if (!$this->closed) { - $this->emit('end'); - $this->close(); + $this->handleError(new \Exception('Unexpected end event')); } } diff --git a/tests/LengthLimitedStreamTest.php b/tests/LengthLimitedStreamTest.php index 5ba7be0d..8e6375d5 100644 --- a/tests/LengthLimitedStreamTest.php +++ b/tests/LengthLimitedStreamTest.php @@ -105,4 +105,16 @@ public function testOutputStreamCanCloseInputStream() $this->assertFalse($input->isReadable()); } + + public function testHandleUnexpectedEnd() + { + $stream = new LengthLimitedStream($this->input, 5); + + $stream->on('data', $this->expectCallableNever()); + $stream->on('close', $this->expectCallableOnce()); + $stream->on('end', $this->expectCallableNever()); + $stream->on('error', $this->expectCallableOnce()); + + $this->input->emit('end'); + } } From eba363b002d99192c0f12c485c305b722a026b79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 21 Feb 2017 11:36:13 +0100 Subject: [PATCH 064/128] Request stream will now be handled internally --- src/Request.php | 24 +++++++++++++++--- src/Server.php | 19 +-------------- tests/RequestTest.php | 57 ++++++++++++++++++++++++++++++++++--------- 3 files changed, 68 insertions(+), 32 deletions(-) diff --git a/src/Request.php b/src/Request.php index 58d7b214..ae8f6303 100644 --- a/src/Request.php +++ b/src/Request.php @@ -26,6 +26,7 @@ class Request extends EventEmitter implements ReadableStreamInterface { private $readable = true; private $request; + private $stream; // metadata, implicitly added externally public $remoteAddress; @@ -39,9 +40,23 @@ class Request extends EventEmitter implements ReadableStreamInterface * * @internal */ - public function __construct(RequestInterface $request) + public function __construct(RequestInterface $request, ReadableStreamInterface $stream) { $this->request = $request; + $this->stream = $stream; + + $that = $this; + // forward data and end events from body stream to request + $stream->on('data', function ($data) use ($that) { + $that->emit('data', array($data)); + }); + $stream->on('end', function () use ($that) { + $that->emit('end'); + }); + $stream->on('error', function ($error) use ($that) { + $that->emit('error', array($error)); + }); + $stream->on('close', array($this, 'close')); } /** @@ -163,7 +178,7 @@ public function pause() return; } - $this->emit('pause'); + $this->stream->pause(); } public function resume() @@ -172,7 +187,7 @@ public function resume() return; } - $this->emit('resume'); + $this->stream->resume(); } public function close() @@ -181,7 +196,10 @@ public function close() return; } + // request closed => stop reading from the stream by pausing it $this->readable = false; + $this->stream->pause(); + $this->emit('close'); $this->removeAllListeners(); } diff --git a/src/Server.php b/src/Server.php index 35fef695..6c16abab 100644 --- a/src/Server.php +++ b/src/Server.php @@ -149,7 +149,7 @@ public function handleRequest(ConnectionInterface $conn, RequestInterface $reque } } - $request = new Request($request); + $request = new Request($request, $stream); // attach remote ip to the request as metadata $request->remoteAddress = trim( @@ -157,23 +157,6 @@ public function handleRequest(ConnectionInterface $conn, RequestInterface $reque '[]' ); - // forward pause/resume calls to underlying connection - $request->on('pause', array($conn, 'pause')); - $request->on('resume', array($conn, 'resume')); - - // request closed => stop reading from the stream by pausing it - // stream closed => close request - $request->on('close', array($stream, 'pause')); - $stream->on('close', array($request, 'close')); - - // forward data and end events from body stream to request - $stream->on('end', function() use ($request) { - $request->emit('end'); - }); - $stream->on('data', function ($data) use ($request) { - $request->emit('data', array($data)); - }); - $this->emit('request', array($request, $response)); } diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 9ff13bed..fa9705d3 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -7,11 +7,18 @@ class RequestTest extends TestCase { + private $stream; + + public function setUp() + { + $this->stream = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + } + /** @test */ public function expectsContinueShouldBeFalseByDefault() { $headers = array(); - $request = new Request(new Psr('GET', '/', $headers, null, '1.1')); + $request = new Request(new Psr('GET', '/', $headers, null, '1.1'), $this->stream); $this->assertFalse($request->expectsContinue()); } @@ -20,7 +27,7 @@ public function expectsContinueShouldBeFalseByDefault() public function expectsContinueShouldBeTrueIfContinueExpected() { $headers = array('Expect' => array('100-continue')); - $request = new Request(new Psr('GET', '/', $headers, null, '1.1')); + $request = new Request(new Psr('GET', '/', $headers, null, '1.1'), $this->stream); $this->assertTrue($request->expectsContinue()); } @@ -29,7 +36,7 @@ public function expectsContinueShouldBeTrueIfContinueExpected() public function expectsContinueShouldBeTrueIfContinueExpectedCaseInsensitive() { $headers = array('EXPECT' => array('100-CONTINUE')); - $request = new Request(new Psr('GET', '/', $headers, null, '1.1')); + $request = new Request(new Psr('GET', '/', $headers, null, '1.1'), $this->stream); $this->assertTrue($request->expectsContinue()); } @@ -38,14 +45,14 @@ public function expectsContinueShouldBeTrueIfContinueExpectedCaseInsensitive() public function expectsContinueShouldBeFalseForHttp10() { $headers = array('Expect' => array('100-continue')); - $request = new Request(new Psr('GET', '/', $headers, null, '1.0')); + $request = new Request(new Psr('GET', '/', $headers, null, '1.0'), $this->stream); $this->assertFalse($request->expectsContinue()); } public function testEmptyHeader() { - $request = new Request(new Psr('GET', '/', array())); + $request = new Request(new Psr('GET', '/', array()), $this->stream); $this->assertEquals(array(), $request->getHeaders()); $this->assertFalse($request->hasHeader('Test')); @@ -57,7 +64,7 @@ public function testHeaderIsCaseInsensitive() { $request = new Request(new Psr('GET', '/', array( 'TEST' => array('Yes'), - ))); + )), $this->stream); $this->assertEquals(array('TEST' => array('Yes')), $request->getHeaders()); $this->assertTrue($request->hasHeader('Test')); @@ -69,7 +76,7 @@ public function testHeaderWithMultipleValues() { $request = new Request(new Psr('GET', '/', array( 'Test' => array('a', 'b'), - ))); + )), $this->stream); $this->assertEquals(array('Test' => array('a', 'b')), $request->getHeaders()); $this->assertTrue($request->hasHeader('Test')); @@ -79,7 +86,7 @@ public function testHeaderWithMultipleValues() public function testCloseEmitsCloseEvent() { - $request = new Request(new Psr('GET', '/')); + $request = new Request(new Psr('GET', '/'), $this->stream); $request->on('close', $this->expectCallableOnce()); @@ -88,7 +95,7 @@ public function testCloseEmitsCloseEvent() public function testCloseMultipleTimesEmitsCloseEventOnce() { - $request = new Request(new Psr('GET', '/')); + $request = new Request(new Psr('GET', '/'), $this->stream); $request->on('close', $this->expectCallableOnce()); @@ -96,20 +103,48 @@ public function testCloseMultipleTimesEmitsCloseEventOnce() $request->close(); } + public function testCloseWillPauseUnderlyingStream() + { + $this->stream->expects($this->once())->method('pause'); + $this->stream->expects($this->never())->method('close'); + + $request = new Request(new Psr('GET', '/'), $this->stream); + + $request->close(); + } + public function testIsNotReadableAfterClose() { - $request = new Request(new Psr('GET', '/')); + $request = new Request(new Psr('GET', '/'), $this->stream); $request->close(); $this->assertFalse($request->isReadable()); } + public function testPauseWillBeForwarded() + { + $this->stream->expects($this->once())->method('pause'); + + $request = new Request(new Psr('GET', '/'), $this->stream); + + $request->pause(); + } + + public function testResumeWillBeForwarded() + { + $this->stream->expects($this->once())->method('resume'); + + $request = new Request(new Psr('GET', '/'), $this->stream); + + $request->resume(); + } + public function testPipeReturnsDest() { $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); - $request = new Request(new Psr('GET', '/')); + $request = new Request(new Psr('GET', '/'), $this->stream); $ret = $request->pipe($dest); From 73e4a1b0ee48bbb89950f8b4415db1b1827033da Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Tue, 28 Feb 2017 23:45:55 +0100 Subject: [PATCH 065/128] Leading zeros are correct chunked encoding --- src/ChunkedDecoder.php | 7 +++++ tests/ChunkedDecoderTest.php | 51 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/ChunkedDecoder.php b/src/ChunkedDecoder.php index 1d3bc16f..4b6ebc4f 100644 --- a/src/ChunkedDecoder.php +++ b/src/ChunkedDecoder.php @@ -108,6 +108,13 @@ public function handleData($data) $hexValue = $array[0]; } + if ($hexValue !== '') { + $hexValue = ltrim($hexValue, "0"); + if ($hexValue === '') { + $hexValue = "0"; + } + } + $this->chunkSize = hexdec($hexValue); if (dechex($this->chunkSize) !== $hexValue) { $this->handleError(new \Exception($hexValue . ' is not a valid hexadecimal number')); diff --git a/tests/ChunkedDecoderTest.php b/tests/ChunkedDecoderTest.php index 6f0c3048..82806a8a 100644 --- a/tests/ChunkedDecoderTest.php +++ b/tests/ChunkedDecoderTest.php @@ -396,4 +396,55 @@ public function testOutputStreamCanCloseInputStream() $this->assertFalse($input->isReadable()); } + + public function testLeadingZerosWillBeIgnored() + { + $this->parser->on('data', $this->expectCallableConsecutive(2, array('hello', 'hello world'))); + $this->parser->on('error', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableNever()); + + $this->input->emit('data', array("00005\r\nhello\r\n")); + $this->input->emit('data', array("0000b\r\nhello world\r\n")); + } + + public function testLeadingZerosInEndChunkWillBeIgnored() + { + $this->parser->on('data', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableOnce()); + $this->parser->on('close', $this->expectCallableOnce()); + + $this->input->emit('data', array("0000\r\n\r\n")); + } + + public function testLeadingZerosInInvalidChunk() + { + $this->parser->on('data', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableOnce()); + + $this->input->emit('data', array("0000hello\r\n\r\n")); + } + + public function testEmptyHeaderLeadsToError() + { + $this->parser->on('data', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableOnce()); + + $this->input->emit('data', array("\r\n\r\n")); + } + + public function testEmptyHeaderAndFilledBodyLeadsToError() + { + $this->parser->on('data', $this->expectCallableNever()); + $this->parser->on('error', $this->expectCallableOnce()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableOnce()); + + $this->input->emit('data', array("\r\nhello\r\n")); + } } From 42747feff1ce329c64c314216f85dc3437d4bdca Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Wed, 1 Mar 2017 12:46:14 +0100 Subject: [PATCH 066/128] Test error events from other streams on request object --- tests/ServerTest.php | 118 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 32db8ef3..700d34bc 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -1023,6 +1023,124 @@ function ($data) use (&$buffer) { $this->assertInstanceOf('InvalidArgumentException', $error); } + public function testInvalidChunkHeaderResultsInErrorOnRequestStream() + { + $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); + $server = new Server($this->socket); + $server->on('request', function ($request, $response) use ($errorEvent){ + $request->on('error', $errorEvent); + }); + + $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 .= "hello\r\hello\r\n"; + + $this->connection->emit('data', array($data)); + } + + public function testTooLongChunkHeaderResultsInErrorOnRequestStream() + { + $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); + $server = new Server($this->socket); + $server->on('request', function ($request, $response) use ($errorEvent){ + $request->on('error', $errorEvent); + }); + + $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"; + for ($i = 0; $i < 1025; $i++) { + $data .= 'a'; + } + + $this->connection->emit('data', array($data)); + } + + public function testTooLongChunkBodyResultsInErrorOnRequestStream() + { + $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); + $server = new Server($this->socket); + $server->on('request', function ($request, $response) use ($errorEvent){ + $request->on('error', $errorEvent); + }); + + $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 world\r\n"; + + $this->connection->emit('data', array($data)); + } + + public function testUnexpectedEndOfConnectionWillResultsInErrorOnRequestStream() + { + $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); + $server = new Server($this->socket); + $server->on('request', function ($request, $response) use ($errorEvent){ + $request->on('error', $errorEvent); + }); + + $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"; + + $this->connection->emit('data', array($data)); + $this->connection->emit('end'); + } + + public function testErrorInChunkedDecoderNeverClosesConnection() + { + $server = new Server($this->socket); + $server->on('request', $this->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 .= "hello\r\nhello\r\n"; + + $this->connection->emit('data', array($data)); + } + + public function testErrorInLengthLimitedStreamNeverClosesConnection() + { + $server = new Server($this->socket); + $server->on('request', $this->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 .= "Content-Length: 5\r\n"; + $data .= "\r\n"; + $data .= "hello"; + + $this->connection->emit('data', array($data)); + $this->connection->emit('end'); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From 5867db40eafa481533e1243e9aabdb142efaa19f Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Tue, 21 Feb 2017 12:04:35 +0100 Subject: [PATCH 067/128] Protect streams against close of other streams on error --- src/CloseProtectionStream.php | 101 +++++++++++++++++++ src/Request.php | 3 +- tests/CloseProtectionStreamTest.php | 146 ++++++++++++++++++++++++++++ tests/RequestTest.php | 5 +- tests/ServerTest.php | 34 +++++++ 5 files changed, 284 insertions(+), 5 deletions(-) create mode 100644 src/CloseProtectionStream.php create mode 100644 tests/CloseProtectionStreamTest.php diff --git a/src/CloseProtectionStream.php b/src/CloseProtectionStream.php new file mode 100644 index 00000000..da4b2625 --- /dev/null +++ b/src/CloseProtectionStream.php @@ -0,0 +1,101 @@ +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() + { + if ($this->closed) { + return; + } + + $this->input->pause(); + } + + public function resume() + { + if ($this->closed) { + return; + } + + $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->emit('close'); + + // 'pause' the stream avoids additional traffic transferred by this stream + $this->input->pause(); + + $this->input->removeListener('data', array($this, 'handleData')); + $this->input->removeListener('error', array($this, 'handleError')); + $this->input->removeListener('end', array($this, 'handleEnd')); + $this->input->removeListener('close', array($this, 'close')); + + $this->removeAllListeners(); + } + + /** @internal */ + public function handleData($data) + { + $this->emit('data', array($data)); + } + + /** @internal */ + public function handleEnd() + { + $this->emit('end'); + $this->close(); + } + + /** @internal */ + public function handleError(\Exception $e) + { + $this->emit('error', array($e)); + } + +} diff --git a/src/Request.php b/src/Request.php index ae8f6303..e3559560 100644 --- a/src/Request.php +++ b/src/Request.php @@ -196,9 +196,8 @@ public function close() return; } - // request closed => stop reading from the stream by pausing it $this->readable = false; - $this->stream->pause(); + $this->stream->close(); $this->emit('close'); $this->removeAllListeners(); diff --git a/tests/CloseProtectionStreamTest.php b/tests/CloseProtectionStreamTest.php new file mode 100644 index 00000000..a85e7c10 --- /dev/null +++ b/tests/CloseProtectionStreamTest.php @@ -0,0 +1,146 @@ +getMockBuilder('React\Stream\ReadableStreamInterface')->disableOriginalConstructor()->getMock(); + $input->expects($this->once())->method('pause'); + $input->expects($this->never())->method('close'); + + $protection = new CloseProtectionStream($input); + $protection->close(); + } + + public function testErrorWontCloseStream() + { + $input = new ReadableStream(); + + $protection = new CloseProtectionStream($input); + $protection->on('error', $this->expectCallableOnce()); + $protection->on('close', $this->expectCallableNever()); + + $input->emit('error', array(new \RuntimeException())); + + $this->assertTrue($protection->isReadable()); + $this->assertTrue($input->isReadable()); + } + + public function testResumeStreamWillResumeInputStream() + { + $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input->expects($this->once())->method('pause'); + $input->expects($this->once())->method('resume'); + + $protection = new CloseProtectionStream($input); + $protection->pause(); + $protection->resume(); + } + + public function testInputStreamIsNotReadableAfterClose() + { + $input = new ReadableStream(); + + $protection = new CloseProtectionStream($input); + $protection->on('close', $this->expectCallableOnce()); + + $input->close(); + + $this->assertFalse($protection->isReadable()); + $this->assertFalse($input->isReadable()); + } + + public function testPipeStream() + { + $input = new ReadableStream(); + + $protection = new CloseProtectionStream($input); + $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + + $ret = $protection->pipe($dest); + + $this->assertSame($dest, $ret); + } + + public function testStopEmittingDataAfterClose() + { + $input = new ReadableStream(); + + $protection = new CloseProtectionStream($input); + $protection->on('data', $this->expectCallableNever()); + + $protection->on('close', $this->expectCallableOnce()); + + $protection->close(); + + $input->emit('data', array('hello')); + + $this->assertFalse($protection->isReadable()); + $this->assertTrue($input->isReadable()); + } + + public function testErrorIsNeverCalledAfterClose() + { + $input = new ReadableStream(); + + $protection = new CloseProtectionStream($input); + $protection->on('data', $this->expectCallableNever()); + $protection->on('error', $this->expectCallableNever()); + $protection->on('close', $this->expectCallableOnce()); + + $protection->close(); + + $input->emit('error', array(new \Exception())); + + $this->assertFalse($protection->isReadable()); + $this->assertTrue($input->isReadable()); + } + + public function testEndWontBeEmittedAfterClose() + { + $input = new ReadableStream(); + + $protection = new CloseProtectionStream($input); + $protection->on('data', $this->expectCallableNever()); + $protection->on('close', $this->expectCallableOnce()); + + $protection->close(); + + $input->emit('end', array()); + + $this->assertFalse($protection->isReadable()); + $this->assertTrue($input->isReadable()); + } + + public function testPauseAfterCloseHasNoEffect() + { + $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input->expects($this->once())->method('pause'); + + $protection = new CloseProtectionStream($input); + $protection->on('data', $this->expectCallableNever()); + $protection->on('close', $this->expectCallableOnce()); + + $protection->close(); + $protection->pause(); + } + + public function testResumeAfterCloseHasNoEffect() + { + $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input->expects($this->once())->method('pause'); + $input->expects($this->never())->method('resume'); + + $protection = new CloseProtectionStream($input); + $protection->on('data', $this->expectCallableNever()); + $protection->on('close', $this->expectCallableOnce()); + + $protection->close(); + $protection->resume(); + } +} diff --git a/tests/RequestTest.php b/tests/RequestTest.php index fa9705d3..3d010b7f 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -103,10 +103,9 @@ public function testCloseMultipleTimesEmitsCloseEventOnce() $request->close(); } - public function testCloseWillPauseUnderlyingStream() + public function testCloseWillCloseUnderlyingStream() { - $this->stream->expects($this->once())->method('pause'); - $this->stream->expects($this->never())->method('close'); + $this->stream->expects($this->once())->method('close'); $request = new Request(new Psr('GET', '/'), $this->stream); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 700d34bc..73f29e8e 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -1031,6 +1031,9 @@ public function testInvalidChunkHeaderResultsInErrorOnRequestStream() $request->on('error', $errorEvent); }); + $this->connection->expects($this->never())->method('close'); + $this->connection->expects($this->once())->method('pause'); + $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1051,6 +1054,9 @@ public function testTooLongChunkHeaderResultsInErrorOnRequestStream() $request->on('error', $errorEvent); }); + $this->connection->expects($this->never())->method('close'); + $this->connection->expects($this->once())->method('pause'); + $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1073,6 +1079,9 @@ public function testTooLongChunkBodyResultsInErrorOnRequestStream() $request->on('error', $errorEvent); }); + $this->connection->expects($this->never())->method('close'); + $this->connection->expects($this->once())->method('pause'); + $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1093,6 +1102,9 @@ public function testUnexpectedEndOfConnectionWillResultsInErrorOnRequestStream() $request->on('error', $errorEvent); }); + $this->connection->expects($this->never())->method('close'); + $this->connection->expects($this->once())->method('pause'); + $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1111,6 +1123,9 @@ public function testErrorInChunkedDecoderNeverClosesConnection() $server = new Server($this->socket); $server->on('request', $this->expectCallableOnce()); + $this->connection->expects($this->never())->method('close'); + $this->connection->expects($this->once())->method('pause'); + $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1128,6 +1143,9 @@ public function testErrorInLengthLimitedStreamNeverClosesConnection() $server = new Server($this->socket); $server->on('request', $this->expectCallableOnce()); + $this->connection->expects($this->never())->method('close'); + $this->connection->expects($this->once())->method('pause'); + $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1141,6 +1159,22 @@ public function testErrorInLengthLimitedStreamNeverClosesConnection() $this->connection->emit('end'); } + public function testCloseRequestWillPauseConnection() + { + $server = new Server($this->socket); + $server->on('request', function ($request, $response) { + $request->close(); + }); + + $this->connection->expects($this->never())->method('close'); + $this->connection->expects($this->once())->method('pause'); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + $this->connection->emit('data', array($data)); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From 86c7130b4c1c1d260294a7496daac5f78bb445d4 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Wed, 1 Mar 2017 13:09:05 +0100 Subject: [PATCH 068/128] Update README --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 39f3b901..cedaee25 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,20 @@ $http->on('error', function (Exception $e) { }); ``` +An `error` event will be emitted for the `Request` if the validation of the body data fails. +This can be e.g. invalid chunked decoded data or an unexpected `end` event. + +```php +$http->on('request', function (Request $request, Response $response) { + $request->on('error', function (\Exception $error) { + echo $error->getMessage(); + }); +}); +``` + +Such an error will `pause` the connection instead of closing it. A response message +can still be sent. + ### Request The `Request` class is responsible for streaming the incoming request body From 0222a6b0e009b5abc8f4ea5d3fbe66ee4dc71363 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Wed, 22 Feb 2017 15:30:39 +0100 Subject: [PATCH 069/128] Handle events of simple requests --- tests/ServerTest.php | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 73f29e8e..4315e890 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -100,7 +100,12 @@ public function testRequestPauseWillbeForwardedToConnection() $this->connection->expects($this->once())->method('pause'); $this->socket->emit('connection', array($this->connection)); - $data = $this->createGetRequest(); + $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"; + $this->connection->emit('data', array($data)); } @@ -1175,6 +1180,31 @@ public function testCloseRequestWillPauseConnection() $this->connection->emit('data', array($data)); } + public function testEndEventWillBeEmittedOnSimpleRequest() + { + $dataEvent = $this->expectCallableNever(); + $closeEvent = $this->expectCallableOnce(); + $endEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + + $server = new Server($this->socket); + $server->on('request', function ($request, $response) use ($dataEvent, $closeEvent, $endEvent, $errorEvent){ + $request->on('data', $dataEvent); + $request->on('close', $closeEvent); + $request->on('end', $endEvent); + $request->on('error', $errorEvent); + }); + + $this->connection->expects($this->once())->method('pause'); + $this->connection->expects($this->never())->method('close'); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From 9dbcfb85eb739aaacfbd6fcc034043055707224e Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Thu, 2 Mar 2017 16:21:55 +0100 Subject: [PATCH 070/128] Use PSR-7 approach to handle bodiless data --- tests/ServerTest.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 4315e890..6eb4fc6f 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -1205,6 +1205,30 @@ public function testEndEventWillBeEmittedOnSimpleRequest() $this->connection->emit('data', array($data)); } + public function testRequestWithoutDefinedLengthWillIgnoreDataEvent() + { + $server = new Server($this->socket); + + $dataEvent = $this->expectCallableNever(); + $endEvent = $this->expectCallableOnce(); + $closeEvent = $this->expectCallableOnce(); + $errorEvent = $this->expectCallableNever(); + + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->on('data', $dataEvent); + $request->on('end', $endEvent); + $request->on('close', $closeEvent); + $request->on('error', $errorEvent); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + $data .= "hello world"; + + $this->connection->emit('data', array($data)); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From 29636f62514e7e261cfc5528d13ed84199e746e1 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Fri, 3 Mar 2017 14:55:06 +0100 Subject: [PATCH 071/128] Remove TE and CL header before emitting the request event --- tests/ServerTest.php | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 6eb4fc6f..b538f2b4 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -487,12 +487,14 @@ public function testChunkedEncodedRequestWillBeParsedForRequestEvent() $endEvent = $this->expectCallableOnce(); $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); + $requestValidation = null; - $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { $request->on('data', $dataEvent); $request->on('end', $endEvent); $request->on('close', $closeEvent); $request->on('error', $errorEvent); + $requestValidation = $request; }); $this->socket->emit('connection', array($this->connection)); @@ -506,6 +508,8 @@ public function testChunkedEncodedRequestWillBeParsedForRequestEvent() $data .= "0\r\n\r\n"; $this->connection->emit('data', array($data)); + + $this->assertFalse($requestValidation->hasHeader('Transfer-Encoding')); } public function testChunkedEncodedRequestAdditionalDataWontBeEmitted() @@ -911,8 +915,9 @@ public function testContentLengthWillBeIgnoredIfTransferEncodingIsSet() $data .= "0\r\n\r\n"; $this->connection->emit('data', array($data)); - $this->assertEquals('4', $requestValidation->getHeaderLine('Content-Length')); - $this->assertEquals('chunked', $requestValidation->getHeaderLine('Transfer-Encoding')); + + $this->assertFalse($requestValidation->hasHeader('Content-Length')); + $this->assertFalse($requestValidation->hasHeader('Transfer-Encoding')); } public function testInvalidContentLengthWillBeIgnoreddIfTransferEncodingIsSet() @@ -938,6 +943,7 @@ public function testInvalidContentLengthWillBeIgnoreddIfTransferEncodingIsSet() $data = "GET / HTTP/1.1\r\n"; $data .= "Host: example.com:80\r\n"; $data .= "Connection: close\r\n"; + // this is valid behavior according to: https://www.ietf.org/rfc/rfc2616.txt chapter 4.4 $data .= "Content-Length: hello world\r\n"; $data .= "Transfer-Encoding: chunked\r\n"; $data .= "\r\n"; @@ -949,9 +955,8 @@ public function testInvalidContentLengthWillBeIgnoreddIfTransferEncodingIsSet() $this->connection->emit('data', array($data)); - // this is valid behavior according to: https://www.ietf.org/rfc/rfc2616.txt chapter 4.4 - $this->assertEquals('hello world', $requestValidation->getHeaderLine('Content-Length')); - $this->assertEquals('chunked', $requestValidation->getHeaderLine('Transfer-Encoding')); + $this->assertFalse($requestValidation->hasHeader('Content-Length')); + $this->assertFalse($requestValidation->hasHeader('Transfer-Encoding')); } public function testNonIntegerContentLengthValueWillLeadToError() From b6524db12efb1547e98a6b88e267b2ccce9f34e8 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Fri, 3 Mar 2017 16:40:57 +0100 Subject: [PATCH 072/128] Update README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index cedaee25..d4db2474 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,8 @@ can still be sent. The `Request` class is responsible for streaming the incoming request body and contains meta data which was parsed from the request headers. +If the request body is chunked-encoded, the data will be decoded and emitted on the data event. +The `Transfer-Encoding` header will be removed. It implements the `ReadableStreamInterface`. From eafa635b542d52718eeb4cefbd0a1a00766f14e5 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Fri, 3 Mar 2017 15:58:37 +0100 Subject: [PATCH 073/128] Handle TE and CL in Response object --- README.md | 5 +- src/Response.php | 13 +++--- tests/ResponseTest.php | 33 ++++++++++++++ tests/ServerTest.php | 101 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d4db2474..9e6def83 100644 --- a/README.md +++ b/README.md @@ -272,8 +272,9 @@ Calling this method after the response has ended/closed is a NOOP. Unless you specify a `Content-Length` header yourself, HTTP/1.1 responses will automatically use chunked transfer encoding and send the respective header -(`Transfer-Encoding: chunked`) automatically. If you know the length of your -body, you MAY specify it like this instead: +(`Transfer-Encoding: chunked`) automatically. The server is responsible for handling +`Transfer-Encoding` so you SHOULD NOT pass it yourself. +If you know the length of your body, you MAY specify it like this instead: ```php $data = 'Hello World!'; diff --git a/src/Response.php b/src/Response.php index 3283d2a7..e1b31297 100644 --- a/src/Response.php +++ b/src/Response.php @@ -204,14 +204,15 @@ public function writeHead($status = 200, array $headers = array()) ); } - // assign chunked transfer-encoding if no 'content-length' is given for HTTP/1.1 responses - if (!isset($lower['content-length']) && $this->protocolVersion === '1.1') { - foreach($headers as $name => $value) { - if (strtolower($name) === 'transfer-encoding') { - unset($headers[$name]); - } + // always remove transfer-encoding + foreach($headers as $name => $value) { + if (strtolower($name) === 'transfer-encoding') { + unset($headers[$name]); } + } + // assign chunked transfer-encoding if no 'content-length' is given for HTTP/1.1 responses + if (!isset($lower['content-length']) && $this->protocolVersion === '1.1') { $headers['Transfer-Encoding'] = 'chunked'; $this->chunkedEncoding = true; } diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 841bcdd9..e8633596 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -596,4 +596,37 @@ public function testDrainEventShouldBeForwarded() $input->emit('drain'); } + + public function testContentLengthWillBeRemovedIfTransferEncodingIsGiven() + { + $expectedHeader = ''; + $expectedHeader .= "HTTP/1.1 200 OK\r\n"; + $expectedHeader .= "X-Powered-By: React/alpha\r\n"; + $expectedHeader .= "Content-Length: 4\r\n"; + $expectedHeader .= "Connection: close\r\n"; + $expectedHeader .= "\r\n"; + + $expectedBody = "hello"; + + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); + $conn + ->expects($this->exactly(2)) + ->method('write') + ->withConsecutive( + array($expectedHeader), + array($expectedBody) + ); + + $response = new Response($conn, '1.1'); + $response->writeHead( + 200, + array( + 'Content-Length' => 4, + 'Transfer-Encoding' => 'chunked' + ) + ); + $response->write('hello'); + } } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index b538f2b4..cdd5e299 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -1234,6 +1234,107 @@ public function testRequestWithoutDefinedLengthWillIgnoreDataEvent() $this->connection->emit('data', array($data)); } + public function testResponseWillBeChunkDecodedByDefault() + { + $server = new Server($this->socket); + + $server->on('request', function (Request $request, Response $response) { + $response->writeHead(); + $response->write('hello'); + }); + + $this->connection + ->expects($this->exactly(2)) + ->method('write') + ->withConsecutive( + array($this->anything()), + array("5\r\nhello\r\n") + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + } + + public function testContentLengthWillBeRemovedForResponseStream() + { + $server = new Server($this->socket); + + $server->on('request', function (Request $request, Response $response) { + $response->writeHead( + 200, + array( + 'Content-Length' => 4, + 'Transfer-Encoding' => 'chunked' + ) + ); + + $response->write('hello'); + }); + + $buffer = ''; + $this->connection + ->expects($this->exactly(2)) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $this->assertNotContains("Transfer-Encoding: chunked", $buffer); + $this->assertContains("Content-Length: 4", $buffer); + $this->assertContains("hello", $buffer); + } + + public function testOnlyAllowChunkedEncoding() + { + $server = new Server($this->socket); + + $server->on('request', function (Request $request, Response $response) { + $response->writeHead( + 200, + array( + 'Transfer-Encoding' => 'custom' + ) + ); + + $response->write('hello'); + }); + + $buffer = ''; + $this->connection + ->expects($this->exactly(2)) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $this->assertContains('Transfer-Encoding: chunked', $buffer); + $this->assertNotContains('Transfer-Encoding: custom', $buffer); + $this->assertContains("5\r\nhello\r\n", $buffer); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From 3d2302b87eb0df04a7890bd37fb1b08ec611a5f4 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Fri, 3 Mar 2017 12:12:08 +0100 Subject: [PATCH 074/128] Add system date 'Date' header if none isset --- README.md | 18 ++++++++ src/Response.php | 6 +++ tests/ResponseTest.php | 94 ++++++++++++++++++++++++++++++++++++------ tests/ServerTest.php | 93 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 198 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 9e6def83..ddc19fbb 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,24 @@ $response->writeHead(200, array( $response->end($data); ``` +A `Date` header will be automatically added with the system date and time if none is given. +You can add a custom `Date` header yourself like this: + +```php +$response->writeHead(200, array( + 'Date' => date('D, d M Y H:i:s T') +)); +``` + +If you don't have a appropriate clock to rely on, you should +unset this header with an empty array: + +```php +$response->writeHead(200, array( + 'Date' => array() +)); +``` + Note that it will automatically assume a `X-Powered-By: react/alpha` header unless your specify a custom `X-Powered-By` header yourself: diff --git a/src/Response.php b/src/Response.php index e1b31297..e7e34182 100644 --- a/src/Response.php +++ b/src/Response.php @@ -211,6 +211,12 @@ public function writeHead($status = 200, array $headers = array()) } } + // assign date header if no 'date' is given, use the current time where this code is running + if (!isset($lower['date'])) { + // IMF-fixdate = day-name "," SP date1 SP time-of-day SP GMT + $headers['Date'] = gmdate('D, d M Y H:i:s') . ' GMT'; + } + // assign chunked transfer-encoding if no 'content-length' is given for HTTP/1.1 responses if (!isset($lower['content-length']) && $this->protocolVersion === '1.1') { $headers['Transfer-Encoding'] = 'chunked'; diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index e8633596..b6367d37 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -25,7 +25,7 @@ public function testResponseShouldBeChunkedByDefault() ->with($expected); $response = new Response($conn); - $response->writeHead(); + $response->writeHead(200, array('Date' => array())); } public function testResponseShouldNotBeChunkedWhenProtocolVersionIsNot11() @@ -44,7 +44,7 @@ public function testResponseShouldNotBeChunkedWhenProtocolVersionIsNot11() ->with($expected); $response = new Response($conn, '1.0'); - $response->writeHead(); + $response->writeHead(200, array('Date' => array())); } public function testResponseShouldBeChunkedEvenWithOtherTransferEncoding() @@ -65,7 +65,7 @@ public function testResponseShouldBeChunkedEvenWithOtherTransferEncoding() ->with($expected); $response = new Response($conn); - $response->writeHead(200, array('transfer-encoding' => 'custom')); + $response->writeHead(200, array('transfer-encoding' => 'custom', 'Date' => array())); } public function testResponseShouldNotBeChunkedWithContentLength() @@ -86,7 +86,7 @@ public function testResponseShouldNotBeChunkedWithContentLength() ->with($expected); $response = new Response($conn); - $response->writeHead(200, array('Content-Length' => 22)); + $response->writeHead(200, array('Content-Length' => 22, 'Date' => array())); } public function testResponseShouldNotBeChunkedWithContentLengthCaseInsensitive() @@ -107,7 +107,7 @@ public function testResponseShouldNotBeChunkedWithContentLengthCaseInsensitive() ->with($expected); $response = new Response($conn); - $response->writeHead(200, array('CONTENT-LENGTH' => 0)); + $response->writeHead(200, array('CONTENT-LENGTH' => 0, 'Date' => array())); } public function testResponseShouldIncludeCustomByPoweredAsFirstHeaderIfGivenExplicitly() @@ -128,7 +128,7 @@ public function testResponseShouldIncludeCustomByPoweredAsFirstHeaderIfGivenExpl ->with($expected); $response = new Response($conn); - $response->writeHead(200, array('Content-Length' => 0, 'X-POWERED-BY' => 'demo')); + $response->writeHead(200, array('Content-Length' => 0, 'X-POWERED-BY' => 'demo', 'Date' => array())); } public function testResponseShouldNotIncludePoweredByIfGivenEmptyArray() @@ -148,7 +148,7 @@ public function testResponseShouldNotIncludePoweredByIfGivenEmptyArray() ->with($expected); $response = new Response($conn); - $response->writeHead(200, array('Content-Length' => 0, 'X-Powered-By' => array())); + $response->writeHead(200, array('Content-Length' => 0, 'X-Powered-By' => array(), 'Date' => array())); } public function testResponseShouldAlwaysIncludeConnectionCloseIrrespectiveOfExplicitValue() @@ -169,7 +169,7 @@ public function testResponseShouldAlwaysIncludeConnectionCloseIrrespectiveOfExpl ->with($expected); $response = new Response($conn); - $response->writeHead(200, array('Content-Length' => 0, 'connection' => 'ignored')); + $response->writeHead(200, array('Content-Length' => 0, 'connection' => 'ignored', 'Date' => array())); } /** @expectedException Exception */ @@ -399,7 +399,7 @@ public function shouldRemoveNewlinesFromHeaders() ->with($expected); $response = new Response($conn); - $response->writeHead(200, array("Foo\nBar" => "Baz\rQux")); + $response->writeHead(200, array("Foo\nBar" => "Baz\rQux", 'Date' => array())); } /** @test */ @@ -421,7 +421,7 @@ public function missingStatusCodeTextShouldResultInNumberOnlyStatus() ->with($expected); $response = new Response($conn); - $response->writeHead(700); + $response->writeHead(700, array('Date' => array())); } /** @test */ @@ -445,7 +445,7 @@ public function shouldAllowArrayHeaderValues() ->with($expected); $response = new Response($conn); - $response->writeHead(200, array("Set-Cookie" => array("foo=bar", "bar=baz"))); + $response->writeHead(200, array("Set-Cookie" => array("foo=bar", "bar=baz"), 'Date' => array())); } /** @test */ @@ -467,7 +467,7 @@ public function shouldIgnoreHeadersWithNullValues() ->with($expected); $response = new Response($conn); - $response->writeHead(200, array("FooBar" => null)); + $response->writeHead(200, array("FooBar" => null, 'Date' => array())); } public function testCloseClosesInputAndEmitsCloseEvent() @@ -624,9 +624,77 @@ public function testContentLengthWillBeRemovedIfTransferEncodingIsGiven() 200, array( 'Content-Length' => 4, - 'Transfer-Encoding' => 'chunked' + 'Transfer-Encoding' => 'chunked', + 'Date' => array() ) ); $response->write('hello'); } + + public function testDateHeaderWillUseServerTime() + { + $buffer = ''; + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); + $conn + ->expects($this->once()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $response = new Response($conn); + $response->writeHead(); + + $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertContains("Date:", $buffer); + } + + public function testDateHeaderWithCustomDate() + { + $expected = ''; + $expected .= "HTTP/1.1 200 OK\r\n"; + $expected .= "X-Powered-By: React/alpha\r\n"; + $expected .= "Date: Tue, 15 Nov 1994 08:12:31 GMT\r\n"; + $expected .= "Transfer-Encoding: chunked\r\n"; + $expected .= "Connection: close\r\n"; + $expected .= "\r\n"; + + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); + $conn + ->expects($this->once()) + ->method('write') + ->with($expected); + + $response = new Response($conn); + $response->writeHead(200, array("Date" => "Tue, 15 Nov 1994 08:12:31 GMT")); + } + + public function testDateHeaderWillBeRemoved() + { + $expected = ''; + $expected .= "HTTP/1.1 200 OK\r\n"; + $expected .= "X-Powered-By: React/alpha\r\n"; + $expected .= "Transfer-Encoding: chunked\r\n"; + $expected .= "Connection: close\r\n"; + $expected .= "\r\n"; + + $conn = $this + ->getMockBuilder('React\Socket\ConnectionInterface') + ->getMock(); + $conn + ->expects($this->once()) + ->method('write') + ->with($expected); + + $response = new Response($conn); + $response->writeHead(200, array("Date" => array())); + } } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index cdd5e299..0741417a 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -1335,6 +1335,99 @@ function ($data) use (&$buffer) { $this->assertContains("5\r\nhello\r\n", $buffer); } + public function testDateHeaderWillBeAddedWhenNoneIsGiven() + { + $server = new Server($this->socket); + + $server->on('request', function (Request $request, Response $response) { + $response->writeHead(200); + }); + + $buffer = ''; + $this->connection + ->expects($this->once()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertContains("Date:", $buffer); + $this->assertContains("\r\n\r\n", $buffer); + } + + public function testAddCustomDateHeader() + { + $server = new Server($this->socket); + + $server->on('request', function (Request $request, Response $response) { + $response->writeHead(200, array("Date" => "Tue, 15 Nov 1994 08:12:31 GMT")); + }); + + $buffer = ''; + $this->connection + ->expects($this->once()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertContains("Date: Tue, 15 Nov 1994 08:12:31 GMT\r\n", $buffer); + $this->assertContains("\r\n\r\n", $buffer); + } + + public function testRemoveDateHeader() + { + $server = new Server($this->socket); + + $server->on('request', function (Request $request, Response $response) { + $response->writeHead(200, array('Date' => array())); + }); + + $buffer = ''; + $this->connection + ->expects($this->once()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertNotContains("Date:", $buffer); + $this->assertContains("\r\n\r\n", $buffer); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From cb52776e7bb7db9c292e9b01c1666d318e07795b Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Sat, 4 Mar 2017 03:17:08 +0100 Subject: [PATCH 075/128] Only allow chunked-encoding for requests --- README.md | 6 +++--- tests/ServerTest.php | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ddc19fbb..78b411af 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,9 @@ Failing to do so will result in the server parsing the incoming request, but never sending a response back to the client. The `Server` supports both HTTP/1.1 and HTTP/1.0 request messages. -If a client sends an invalid request message or uses an invalid HTTP protocol -version, it will emit an `error` event, send an HTTP error response to the -client and close the connection: +If a client sends an invalid request message, uses an invalid HTTP protocol +version or sends an invalid `Transfer-Encoding` in the request header, it will +emit an `error` event, send an HTTP error response to the client and close the connection: ```php $http->on('error', function (Exception $e) { diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 0741417a..dac9297b 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -1428,6 +1428,42 @@ function ($data) use (&$buffer) { $this->assertContains("\r\n\r\n", $buffer); } + public function testOnlyChunkedEncodingIsAllowedForTransferEncoding() + { + $error = null; + + $server = new Server($this->socket); + $server->on('request', $this->expectCallableNever()); + $server->on('error', function ($exception) use (&$error) { + $error = $exception; + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Transfer-Encoding: custom\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 501 Not Implemented\r\n", $buffer); + $this->assertContains("\r\n\r\nError 501: Not Implemented", $buffer); + $this->assertInstanceOf('InvalidArgumentException', $error); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From 284e335c98efea079c397bfbab8d27c3e9d684c8 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Mon, 6 Mar 2017 15:06:43 +0100 Subject: [PATCH 076/128] Add example to handle body data --- README.md | 44 +++++++++++++++++++++--------- examples/03-handling-body-data.php | 34 +++++++++++++++++++++++ 2 files changed, 65 insertions(+), 13 deletions(-) create mode 100644 examples/03-handling-body-data.php diff --git a/README.md b/README.md index 78b411af..5a58c02b 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ See also [`Request`](#request) and [`Response`](#response) for more details. Failing to do so will result in the server parsing the incoming request, but never sending a response back to the client. +Checkout [Request](#request) for details about the request data body. + The `Server` supports both HTTP/1.1 and HTTP/1.0 request messages. If a client sends an invalid request message, uses an invalid HTTP protocol version or sends an invalid `Transfer-Encoding` in the request header, it will @@ -103,19 +105,7 @@ $http->on('error', function (Exception $e) { }); ``` -An `error` event will be emitted for the `Request` if the validation of the body data fails. -This can be e.g. invalid chunked decoded data or an unexpected `end` event. - -```php -$http->on('request', function (Request $request, Response $response) { - $request->on('error', function (\Exception $error) { - echo $error->getMessage(); - }); -}); -``` - -Such an error will `pause` the connection instead of closing it. A response message -can still be sent. +The request object can also emit an error. Checkout [Request](#request) for more details. ### Request @@ -126,6 +116,34 @@ The `Transfer-Encoding` header will be removed. It implements the `ReadableStreamInterface`. +Listen on the `data` event and the `end` event of the [Request](#request) +to evaluate the data of the request body: + +```php +$http->on('request', function (Request $request, Response $response) { + $contentLength = 0; + $request->on('data', function ($data) use (&$contentLength) { + $contentLength += strlen($data); + }); + + $request->on('end', function () use ($response, &$contentLength){ + $response->writeHead(200, array('Content-Type' => 'text/plain')); + $response->end("The length of the submitted request body is: " . $contentLength); + }); + + // an error occures e.g. on invalid chunked encoded data or an unexpected 'end' event + $request->on('error', function (\Exception $exception) use ($response, &$contentLength) { + $response->writeHead(400, array('Content-Type' => 'text/plain')); + $response->end("An error occured while reading at length: " . $contentLength); + }); +}); +``` + +An error will just `pause` the connection instead of closing it. A response message +can still be sent. + +A `close` event will be emitted after an `error` or `end` event. + The constructor is internal, you SHOULD NOT call this yourself. The `Server` is responsible for emitting `Request` and `Response` objects. diff --git a/examples/03-handling-body-data.php b/examples/03-handling-body-data.php new file mode 100644 index 00000000..98b474f7 --- /dev/null +++ b/examples/03-handling-body-data.php @@ -0,0 +1,34 @@ +on('request', function (Request $request, Response $response) { + $contentLength = 0; + $request->on('data', function ($data) use (&$contentLength) { + $contentLength += strlen($data); + }); + + $request->on('end', function () use ($response, &$contentLength){ + $response->writeHead(200, array('Content-Type' => 'text/plain')); + $response->end("The length of the submitted request body is: " . $contentLength); + }); + + // an error occures e.g. on invalid chunked encoded data or an unexpected 'end' event + $request->on('error', function (\Exception $exception) use ($response, &$contentLength) { + $response->writeHead(400, array('Content-Type' => 'text/plain')); + $response->end("An error occured while reading at length: " . $contentLength); + }); +}); + +echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; + +$loop->run(); From 6043b24b008ce746c05a4c8a804ae00216e6728e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Wed, 8 Mar 2017 16:24:41 +0100 Subject: [PATCH 077/128] Forward compatibility with Stream v0.5 and upcoming v0.6 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index a7ca133b..43608b90 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ "php": ">=5.3.0", "ringcentral/psr7": "^1.2", "react/socket": "^0.5", - "react/stream": "^0.4.4", + "react/stream": "^0.6 || ^0.5 || ^0.4.4", "evenement/evenement": "^2.0 || ^1.0" }, "autoload": { From ac74afaf15a29c06a657f5e62639bfe0bd8a7d43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 9 Mar 2017 15:03:48 +0100 Subject: [PATCH 078/128] Prepare v0.6.0 release --- CHANGELOG.md | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 4 +--- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c6a1735..d6a21581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,65 @@ # Changelog +## 0.6.0 (2016-03-09) + +* Feature / BC break: The `Request` and `Response` objects now follow strict + stream semantics and their respective methods and events. + (#116, #129, #133, #135, #136, #137, #138, #140, #141 by @legionth and + #122, #123, #130, #131, #132, #142 by @clue) + + This implies that the `Server` now supports proper detection of the request + message body stream, such as supporting decoding chunked transfer encoding, + delimiting requests with an explicit `Content-Length` header + and those with an empty request message body. + + These streaming semantics are compatible with previous Stream v0.5, future + compatible with v0.5 and upcoming v0.6 versions and can be used like this: + + ```php + $http->on('request', function (Request $request, Response $response) { + $contentLength = 0; + $request->on('data', function ($data) use (&$contentLength) { + $contentLength += strlen($data); + }); + + $request->on('end', function () use ($response, &$contentLength){ + $response->writeHead(200, array('Content-Type' => 'text/plain')); + $response->end("The length of the submitted request body is: " . $contentLength); + }); + + // an error occured + // e.g. on invalid chunked encoded data or an unexpected 'end' event + $request->on('error', function (\Exception $exception) use ($response, &$contentLength) { + $response->writeHead(400, array('Content-Type' => 'text/plain')); + $response->end("An error occured while reading at length: " . $contentLength); + }); + }); + ``` + + Similarly, the `Request` and `Response` now strictly follow the + `close()` method and `close` event semantics. + Closing the `Request` does not interrupt the underlying TCP/IP in + order to allow still sending back a valid response message. + Closing the `Response` does terminate the underlying TCP/IP + connection in order to clean up resources. + + You should make sure to always attach a `request` event listener + like above. The `Server` will not respond to an incoming HTTP + request otherwise and keep the TCP/IP connection pending until the + other side chooses to close the connection. + +* Feature: Support `HTTP/1.1` and `HTTP/1.0` for `Request` and `Response`. + (#124, #125, #126, #127, #128 by @clue and #139 by @legionth) + + The outgoing `Response` will automatically use the same HTTP version as the + incoming `Request` message and will only apply `HTTP/1.1` semantics if + applicable. This includes that the `Response` will automatically attach a + `Date` and `Connection: close` header if applicable. + + This implies that the `Server` now automatically responds with HTTP error + messages for invalid requests (status 400) and those exceeding internal + request header limits (status 431). + ## 0.5.0 (2017-02-16) * Feature / BC break: Change `Request` methods to be in line with PSR-7 diff --git a/README.md b/README.md index 5a58c02b..7301aaa7 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,6 @@ Event-driven, streaming plaintext HTTP and secure HTTPS server for [ReactPHP](ht * [Tests](#tests) * [License](#license) -> Note: This project is in beta stage! Feel free to report any issues you encounter. - ## Quickstart example This is an HTTP server which responds with `Hello World` to every request. @@ -354,7 +352,7 @@ The recommended way to install this library is [through Composer](http://getcomp This will install the latest supported version: ```bash -$ composer require react/http:^0.5 +$ composer require react/http:^0.6 ``` More details about version upgrades can be found in the [CHANGELOG](CHANGELOG.md). From 1680c7d08ffbf736245fc376af34ff01afc54f07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 9 Mar 2017 15:07:22 +0100 Subject: [PATCH 079/128] Update CHANGELOG.md --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6a21581..b3575457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ * Feature / BC break: The `Request` and `Response` objects now follow strict stream semantics and their respective methods and events. - (#116, #129, #133, #135, #136, #137, #138, #140, #141 by @legionth and - #122, #123, #130, #131, #132, #142 by @clue) + (#116, #129, #133, #135, #136, #137, #138, #140, #141 by @legionth + and #122, #123, #130, #131, #132, #142 by @clue) This implies that the `Server` now supports proper detection of the request message body stream, such as supporting decoding chunked transfer encoding, From d8e98fa3d9bf33a1a7a94c6d719d4187a2c08d2e Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Thu, 9 Mar 2017 11:51:53 +0100 Subject: [PATCH 080/128] Send '100 Continue' response automatically via Server --- README.md | 65 +++++-------------------------------- src/Request.php | 18 ---------- src/Response.php | 59 --------------------------------- src/Server.php | 15 +++++++++ tests/RequestTest.php | 36 -------------------- tests/ResponseTest.php | 61 ---------------------------------- tests/ServerTest.php | 74 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 98 insertions(+), 230 deletions(-) diff --git a/README.md b/README.md index 7301aaa7..808389d0 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,7 @@ Event-driven, streaming plaintext HTTP and secure HTTPS server for [ReactPHP](ht * [getHeader()](#getheader) * [getHeaderLine()](#getheaderline) * [hasHeader()](#hasheader) - * [expectsContinue()](#expectscontinue) * [Response](#response) - * [writeContinue()](#writecontinue) * [writeHead()](#writehead) * [Install](#install) * [Tests](#tests) @@ -84,6 +82,15 @@ $http->on('request', function (Request $request, Response $response) { }); ``` +When HTTP/1.1 clients want to send a bigger request body, they MAY send only +the request headers with an additional `Expect: 100-continue` header and +wait before sending the actual (large) message body. +In this case the server will automatically send an intermediary +`HTTP/1.1 100 Continue` response to the client. +This ensures you will receive the request body without a delay as expected. +The [Response](#response) still needs to be created as described in the +examples above. + See also [`Request`](#request) and [`Response`](#response) for more details. > Note that you SHOULD always listen for the `request` event. @@ -195,18 +202,6 @@ Returns a comma-separated list of all values for this header name or an empty st The `hasHeader(string $name): bool` method can be used to check if a header exists by the given case-insensitive name. -#### expectsContinue() - -The `expectsContinue(): bool` method can be used to -check if the request headers contain the `Expect: 100-continue` header. - -This header MAY be included when an HTTP/1.1 client wants to send a bigger -request body. -See [`writeContinue()`](#writecontinue) for more details. - -This will always be `false` for HTTP/1.0 requests, regardless of what -any header values say. - ### Response The `Response` class is responsible for streaming the outgoing response body. @@ -225,48 +220,6 @@ See [`writeHead()`](#writehead) for more details. See the above usage example and the class outline for details. -#### writeContinue() - -The `writeContinue(): void` method can be used to -send an intermediary `HTTP/1.1 100 continue` response. - -This is a feature that is implemented by *many* HTTP/1.1 clients. -When clients want to send a bigger request body, they MAY send only the request -headers with an additional `Expect: 100-continue` header and wait before -sending the actual (large) message body. - -The server side MAY use this header to verify if the request message is -acceptable by checking the request headers (such as `Content-Length` or HTTP -authentication) and then ask the client to continue with sending the message body. -Otherwise, the server can send a normal HTTP response message and save the -client from transfering the whole body at all. - -This method is mostly useful in combination with the -[`expectsContinue()`](#expectscontinue) method like this: - -```php -$http->on('request', function (Request $request, Response $response) { - if ($request->expectsContinue()) { - $response->writeContinue(); - } - - $response->writeHead(200, array('Content-Type' => 'text/plain')); - $response->end("Hello World!\n"); -}); -``` - -Note that calling this method is strictly optional for HTTP/1.1 responses. -If you do not use it, then a HTTP/1.1 client MUST continue sending the -request body after waiting some time. - -This method MUST NOT be invoked after calling [`writeHead()`](#writehead). -This method MUST NOT be invoked if this is not a HTTP/1.1 response -(please check [`expectsContinue()`](#expectscontinue) as above). -Calling this method after sending the headers or if this is not a HTTP/1.1 -response is an error that will result in an `Exception` -(unless the response has ended/closed already). -Calling this method after the response has ended/closed is a NOOP. - #### writeHead() The `writeHead(int $status = 200, array $headers = array(): void` method can be used to diff --git a/src/Request.php b/src/Request.php index e3559560..05df869e 100644 --- a/src/Request.php +++ b/src/Request.php @@ -149,24 +149,6 @@ public function hasHeader($name) return $this->request->hasHeader($name); } - /** - * Checks if the request headers contain the `Expect: 100-continue` header. - * - * This header MAY be included when an HTTP/1.1 client wants to send a bigger - * request body. - * See [`writeContinue()`] for more details. - * - * This will always be `false` for HTTP/1.0 requests, regardless of what - * any header values say. - * - * @return bool - * @see Response::writeContinue() - */ - public function expectsContinue() - { - return $this->getProtocolVersion() !== '1.0' && '100-continue' === strtolower($this->getHeaderLine('Expect')); - } - public function isReadable() { return $this->readable; diff --git a/src/Response.php b/src/Response.php index e7e34182..5442f7a5 100644 --- a/src/Response.php +++ b/src/Response.php @@ -66,65 +66,6 @@ public function isWritable() return $this->writable; } - /** - * Sends an intermediary `HTTP/1.1 100 continue` response. - * - * This is a feature that is implemented by *many* HTTP/1.1 clients. - * When clients want to send a bigger request body, they MAY send only the request - * headers with an additional `Expect: 100-continue` header and wait before - * sending the actual (large) message body. - * - * The server side MAY use this header to verify if the request message is - * acceptable by checking the request headers (such as `Content-Length` or HTTP - * authentication) and then ask the client to continue with sending the message body. - * Otherwise, the server can send a normal HTTP response message and save the - * client from transfering the whole body at all. - * - * This method is mostly useful in combination with the - * [`expectsContinue()`] method like this: - * - * ```php - * $http->on('request', function (Request $request, Response $response) { - * if ($request->expectsContinue()) { - * $response->writeContinue(); - * } - * - * $response->writeHead(200, array('Content-Type' => 'text/plain')); - * $response->end("Hello World!\n"); - * }); - * ``` - * - * Note that calling this method is strictly optional for HTTP/1.1 responses. - * If you do not use it, then a HTTP/1.1 client MUST continue sending the - * request body after waiting some time. - * - * This method MUST NOT be invoked after calling `writeHead()`. - * This method MUST NOT be invoked if this is not a HTTP/1.1 response - * (please check [`expectsContinue()`] as above). - * Calling this method after sending the headers or if this is not a HTTP/1.1 - * response is an error that will result in an `Exception` - * (unless the response has ended/closed already). - * Calling this method after the response has ended/closed is a NOOP. - * - * @return void - * @throws \Exception - * @see Request::expectsContinue() - */ - public function writeContinue() - { - if (!$this->writable) { - return; - } - if ($this->protocolVersion !== '1.1') { - throw new \Exception('Continue requires a HTTP/1.1 message'); - } - if ($this->headWritten) { - throw new \Exception('Response head has already been written.'); - } - - $this->conn->write("HTTP/1.1 100 Continue\r\n\r\n"); - } - /** * Writes the given HTTP message header. * diff --git a/src/Server.php b/src/Server.php index 6c16abab..e4b35e62 100644 --- a/src/Server.php +++ b/src/Server.php @@ -27,6 +27,15 @@ * }); * ``` * + * When HTTP/1.1 clients want to send a bigger request body, they MAY send only + * the request headers with an additional `Expect: 100-continue` header and + * wait before sending the actual (large) message body. + * In this case the server will automatically send an intermediary + * `HTTP/1.1 100 Continue` response to the client. + * This ensures you will receive the request body without a delay as expected. + * The [Response](#response) still needs to be created as described in the + * examples above. + * * See also [`Request`](#request) and [`Response`](#response) for more details. * * > Note that you SHOULD always listen for the `request` event. @@ -43,6 +52,8 @@ * echo 'Error: ' . $e->getMessage() . PHP_EOL; * }); * ``` + * The request object can also emit an error. Checkout [Request](#request) + * for more details. * * @see Request * @see Response @@ -151,6 +162,10 @@ public function handleRequest(ConnectionInterface $conn, RequestInterface $reque $request = new Request($request, $stream); + if ($request->getProtocolVersion() !== '1.0' && '100-continue' === strtolower($request->getHeaderLine('Expect'))) { + $conn->write("HTTP/1.1 100 Continue\r\n\r\n"); + } + // attach remote ip to the request as metadata $request->remoteAddress = trim( parse_url('tcp://' . $conn->getRemoteAddress(), PHP_URL_HOST), diff --git a/tests/RequestTest.php b/tests/RequestTest.php index 3d010b7f..bafb17ac 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -14,42 +14,6 @@ public function setUp() $this->stream = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); } - /** @test */ - public function expectsContinueShouldBeFalseByDefault() - { - $headers = array(); - $request = new Request(new Psr('GET', '/', $headers, null, '1.1'), $this->stream); - - $this->assertFalse($request->expectsContinue()); - } - - /** @test */ - public function expectsContinueShouldBeTrueIfContinueExpected() - { - $headers = array('Expect' => array('100-continue')); - $request = new Request(new Psr('GET', '/', $headers, null, '1.1'), $this->stream); - - $this->assertTrue($request->expectsContinue()); - } - - /** @test */ - public function expectsContinueShouldBeTrueIfContinueExpectedCaseInsensitive() - { - $headers = array('EXPECT' => array('100-CONTINUE')); - $request = new Request(new Psr('GET', '/', $headers, null, '1.1'), $this->stream); - - $this->assertTrue($request->expectsContinue()); - } - - /** @test */ - public function expectsContinueShouldBeFalseForHttp10() - { - $headers = array('Expect' => array('100-continue')); - $request = new Request(new Psr('GET', '/', $headers, null, '1.0'), $this->stream); - - $this->assertFalse($request->expectsContinue()); - } - public function testEmptyHeader() { $request = new Request(new Psr('GET', '/', array()), $this->stream); diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index b6367d37..65fc0598 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -330,55 +330,6 @@ public function testResponseBodyShouldSkipEmptyChunks() $response->end(); } - /** @test */ - public function writeContinueShouldSendContinueLineBeforeRealHeaders() - { - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->at(3)) - ->method('write') - ->with("HTTP/1.1 100 Continue\r\n\r\n"); - $conn - ->expects($this->at(4)) - ->method('write') - ->with($this->stringContains("HTTP/1.1 200 OK\r\n")); - - $response = new Response($conn); - $response->writeContinue(); - $response->writeHead(); - } - - /** - * @test - * @expectedException Exception - */ - public function writeContinueShouldThrowForHttp10() - { - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - - $response = new Response($conn, '1.0'); - $response->writeContinue(); - } - - /** @expectedException Exception */ - public function testWriteContinueAfterWriteHeadShouldThrowException() - { - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write'); - - $response = new Response($conn); - $response->writeHead(); - $response->writeContinue(); - } - /** @test */ public function shouldRemoveNewlinesFromHeaders() { @@ -551,18 +502,6 @@ public function testWriteHeadAfterCloseIsNoOp() $response->writeHead(); } - public function testWriteContinueAfterCloseIsNoOp() - { - $input = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); - $input->expects($this->once())->method('close'); - $input->expects($this->never())->method('write'); - - $response = new Response($input); - $response->close(); - - $response->writeContinue(); - } - public function testEndAfterCloseIsNoOp() { $input = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index dac9297b..a742d015 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -1464,6 +1464,80 @@ function ($data) use (&$buffer) { $this->assertInstanceOf('InvalidArgumentException', $error); } + public function test100ContinueRequestWillBeHandled() + { + $server = new Server($this->socket); + $server->on('request', $this->expectCallableOnce()); + + $this->connection + ->expects($this->once()) + ->method('write') + ->with("HTTP/1.1 100 Continue\r\n\r\n"); + + $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 .= "Expect: 100-continue\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', array($data)); + } + + public function testContinueWontBeSendForHttp10() + { + $server = new Server($this->socket); + $server->on('request', $this->expectCallableOnce()); + + $this->connection + ->expects($this->never()) + ->method('write'); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.0\r\n"; + $data .= "Expect: 100-continue\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', array($data)); + } + + public function testContinueWithLaterResponse() + { + $server = new Server($this->socket); + $server->on('request', function (Request $request, Response $response) { + $response->writeHead(); + $response->end(); + }); + + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Expect: 100-continue\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 100 Continue\r\n\r\n", $buffer); + $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From 0a90127a43145cd214f8107381db567af0b3e2c2 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Sun, 26 Feb 2017 02:09:16 +0100 Subject: [PATCH 081/128] Use callback function instead of request event --- README.md | 46 +++--- examples/01-hello-world.php | 3 +- examples/02-hello-world-https.php | 3 +- examples/03-handling-body-data.php | 3 +- tests/ServerTest.php | 217 ++++++++++------------------- 5 files changed, 94 insertions(+), 178 deletions(-) diff --git a/README.md b/README.md index 808389d0..5c8528fd 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,7 @@ This is an HTTP server which responds with `Hello World` to every request. $loop = React\EventLoop\Factory::create(); $socket = new React\Socket\Server(8080, $loop); -$http = new React\Http\Server($socket); -$http->on('request', function (Request $request, Response $response) { +$http = new Server($socket, function (Request $request, Response $response) { $response->writeHead(200, array('Content-Type' => 'text/plain')); $response->end("Hello World!\n"); }); @@ -47,16 +46,23 @@ See also the [examples](examples). ### Server The `Server` class is responsible for handling incoming connections and then -emit a `request` event for each incoming HTTP request. +processing each incoming HTTP request. It attaches itself to an instance of `React\Socket\ServerInterface` which emits underlying streaming connections in order to then parse incoming data -as HTTP: +as HTTP. + +For each request, it executes the callback function passed to the +constructor with the respective [`Request`](#request) and +[`Response`](#response) objects: ```php $socket = new React\Socket\Server(8080, $loop); -$http = new React\Http\Server($socket); +$http = new Server($socket, function (Request $request, Response $response) { + $response->writeHead(200, array('Content-Type' => 'text/plain')); + $response->end("Hello World!\n"); +}); ``` Similarly, you can also attach this to a @@ -64,19 +70,12 @@ Similarly, you can also attach this to a in order to start a secure HTTPS server like this: ```php -$socket = new Server(8080, $loop); -$socket = new SecureServer($socket, $loop, array( +$socket = new React\Socket\Server(8080, $loop); +$socket = new React\Socket\SecureServer($socket, $loop, array( 'local_cert' => __DIR__ . '/localhost.pem' )); -$http = new React\Http\Server($socket); -``` - -For each incoming connection, it emits a `request` event with the respective -[`Request`](#request) and [`Response`](#response) objects: - -```php -$http->on('request', function (Request $request, Response $response) { +$http = new Server($socket, function (Request $request, Response $response) { $response->writeHead(200, array('Content-Type' => 'text/plain')); $response->end("Hello World!\n"); }); @@ -91,18 +90,14 @@ This ensures you will receive the request body without a delay as expected. The [Response](#response) still needs to be created as described in the examples above. -See also [`Request`](#request) and [`Response`](#response) for more details. - -> Note that you SHOULD always listen for the `request` event. -Failing to do so will result in the server parsing the incoming request, -but never sending a response back to the client. - -Checkout [Request](#request) for details about the request data body. +See also [`Request`](#request) and [`Response`](#response) +for more details(e.g. the request data body). The `Server` supports both HTTP/1.1 and HTTP/1.0 request messages. If a client sends an invalid request message, uses an invalid HTTP protocol version or sends an invalid `Transfer-Encoding` in the request header, it will -emit an `error` event, send an HTTP error response to the client and close the connection: +emit an `error` event, send an HTTP error response to the client and +close the connection: ```php $http->on('error', function (Exception $e) { @@ -110,7 +105,8 @@ $http->on('error', function (Exception $e) { }); ``` -The request object can also emit an error. Checkout [Request](#request) for more details. +The request object can also emit an error. Checkout [Request](#request) +for more details. ### Request @@ -125,7 +121,7 @@ Listen on the `data` event and the `end` event of the [Request](#request) to evaluate the data of the request body: ```php -$http->on('request', function (Request $request, Response $response) { +$http = new Server($socket, function (Request $request, Response $response) { $contentLength = 0; $request->on('data', function ($data) use (&$contentLength) { $contentLength += strlen($data); diff --git a/examples/01-hello-world.php b/examples/01-hello-world.php index 424a9c1e..d44634ed 100644 --- a/examples/01-hello-world.php +++ b/examples/01-hello-world.php @@ -10,8 +10,7 @@ $loop = Factory::create(); $socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); -$server = new \React\Http\Server($socket); -$server->on('request', function (Request $request, Response $response) { +$server = new \React\Http\Server($socket, function (Request $request, Response $response) { $response->writeHead(200, array('Content-Type' => 'text/plain')); $response->end("Hello world!\n"); }); diff --git a/examples/02-hello-world-https.php b/examples/02-hello-world-https.php index c017a196..6ca7e809 100644 --- a/examples/02-hello-world-https.php +++ b/examples/02-hello-world-https.php @@ -14,8 +14,7 @@ 'local_cert' => isset($argv[2]) ? $argv[2] : __DIR__ . '/localhost.pem' )); -$server = new \React\Http\Server($socket); -$server->on('request', function (Request $reques, Response $response) { +$server = new \React\Http\Server($socket, function (Request $request, Response $response) { $response->writeHead(200, array('Content-Type' => 'text/plain')); $response->end("Hello world!\n"); }); diff --git a/examples/03-handling-body-data.php b/examples/03-handling-body-data.php index 98b474f7..a016b66e 100644 --- a/examples/03-handling-body-data.php +++ b/examples/03-handling-body-data.php @@ -10,8 +10,7 @@ $loop = Factory::create(); $socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); -$server = new \React\Http\Server($socket); -$server->on('request', function (Request $request, Response $response) { +$server = new \React\Http\Server($socket, function (Request $request, Response $response) { $contentLength = 0; $request->on('data', function ($data) use (&$contentLength) { $contentLength += strlen($data); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index a742d015..72cdf5bc 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -36,8 +36,7 @@ public function setUp() public function testRequestEventWillNotBeEmittedForIncompleteHeaders() { - $server = new Server($this->socket); - $server->on('request', $this->expectCallableNever()); + $server = new Server($this->socket, $this->expectCallableNever()); $this->socket->emit('connection', array($this->connection)); @@ -48,8 +47,7 @@ public function testRequestEventWillNotBeEmittedForIncompleteHeaders() public function testRequestEventIsEmitted() { - $server = new Server($this->socket); - $server->on('request', $this->expectCallableOnce()); + $server = new Server($this->socket, $this->expectCallableOnce()); $this->socket->emit('connection', array($this->connection)); @@ -63,8 +61,7 @@ public function testRequestEvent() $requestAssertion = null; $responseAssertion = null; - $server = new Server($this->socket); - $server->on('request', function ($request, $response) use (&$i, &$requestAssertion, &$responseAssertion) { + $server = new Server($this->socket, function ($request, $response) use (&$i, &$requestAssertion, &$responseAssertion) { $i++; $requestAssertion = $request; $responseAssertion = $response; @@ -92,8 +89,7 @@ public function testRequestEvent() public function testRequestPauseWillbeForwardedToConnection() { - $server = new Server($this->socket); - $server->on('request', function (Request $request) { + $server = new Server($this->socket, function (Request $request) { $request->pause(); }); @@ -111,8 +107,8 @@ public function testRequestPauseWillbeForwardedToConnection() public function testRequestResumeWillbeForwardedToConnection() { - $server = new Server($this->socket); - $server->on('request', function (Request $request) { + + $server = new Server($this->socket, function (Request $request) { $request->resume(); }); @@ -125,8 +121,7 @@ public function testRequestResumeWillbeForwardedToConnection() public function testRequestCloseWillPauseConnection() { - $server = new Server($this->socket); - $server->on('request', function (Request $request) { + $server = new Server($this->socket, function (Request $request) { $request->close(); }); @@ -139,8 +134,7 @@ public function testRequestCloseWillPauseConnection() public function testRequestPauseAfterCloseWillNotBeForwarded() { - $server = new Server($this->socket); - $server->on('request', function (Request $request) { + $server = new Server($this->socket, function (Request $request) { $request->close(); $request->pause(); }); @@ -154,8 +148,7 @@ public function testRequestPauseAfterCloseWillNotBeForwarded() public function testRequestResumeAfterCloseWillNotBeForwarded() { - $server = new Server($this->socket); - $server->on('request', function (Request $request) { + $server = new Server($this->socket, function (Request $request) { $request->close(); $request->resume(); }); @@ -172,8 +165,7 @@ public function testRequestEventWithoutBodyWillNotEmitData() { $never = $this->expectCallableNever(); - $server = new Server($this->socket); - $server->on('request', function (Request $request) use ($never) { + $server = new Server($this->socket, function (Request $request) use ($never) { $request->on('data', $never); }); @@ -187,8 +179,7 @@ public function testRequestEventWithSecondDataEventWillEmitBodyData() { $once = $this->expectCallableOnceWith('incomplete'); - $server = new Server($this->socket); - $server->on('request', function (Request $request) use ($once) { + $server = new Server($this->socket, function (Request $request) use ($once) { $request->on('data', $once); }); @@ -207,8 +198,7 @@ public function testRequestEventWithPartialBodyWillEmitData() { $once = $this->expectCallableOnceWith('incomplete'); - $server = new Server($this->socket); - $server->on('request', function (Request $request) use ($once) { + $server = new Server($this->socket, function (Request $request) use ($once) { $request->on('data', $once); }); @@ -228,8 +218,7 @@ public function testRequestEventWithPartialBodyWillEmitData() public function testResponseContainsPoweredByHeader() { - $server = new Server($this->socket); - $server->on('request', function (Request $request, Response $response) { + $server = new Server($this->socket, function (Request $request, Response $response) { $response->writeHead(); $response->end(); }); @@ -257,8 +246,7 @@ function ($data) use (&$buffer) { public function testClosingResponseDoesNotSendAnyData() { - $server = new Server($this->socket); - $server->on('request', function (Request $request, Response $response) { + $server = new Server($this->socket, function (Request $request, Response $response) { $response->close(); }); @@ -274,8 +262,7 @@ public function testClosingResponseDoesNotSendAnyData() public function testResponseContainsSameRequestProtocolVersionAndChunkedBodyForHttp11() { - $server = new Server($this->socket); - $server->on('request', function (Request $request, Response $response) { + $server = new Server($this->socket, function (Request $request, Response $response) { $response->writeHead(); $response->end('bye'); }); @@ -304,8 +291,7 @@ function ($data) use (&$buffer) { public function testResponseContainsSameRequestProtocolVersionAndRawBodyForHttp10() { - $server = new Server($this->socket); - $server->on('request', function (Request $request, Response $response) { + $server = new Server($this->socket, function (Request $request, Response $response) { $response->writeHead(); $response->end('bye'); }); @@ -335,7 +321,7 @@ function ($data) use (&$buffer) { public function testRequestInvalidHttpProtocolVersionWillEmitErrorAndSendErrorResponse() { $error = null; - $server = new Server($this->socket); + $server = new Server($this->socket, $this->expectCallableNever()); $server->on('error', function ($message) use (&$error) { $error = $message; }); @@ -364,32 +350,10 @@ function ($data) use (&$buffer) { $this->assertContains("\r\n\r\nError 505: HTTP Version Not Supported", $buffer); } - public function testServerWithNoRequestListenerDoesNotSendAnythingToConnection() - { - $server = new Server($this->socket); - - $this->connection - ->expects($this->never()) - ->method('write'); - - $this->connection - ->expects($this->never()) - ->method('end'); - - $this->connection - ->expects($this->never()) - ->method('close'); - - $this->socket->emit('connection', array($this->connection)); - - $data = $this->createGetRequest(); - $this->connection->emit('data', array($data)); - } - public function testRequestOverflowWillEmitErrorAndSendErrorResponse() { $error = null; - $server = new Server($this->socket); + $server = new Server($this->socket, $this->expectCallableNever()); $server->on('error', function ($message) use (&$error) { $error = $message; }); @@ -422,7 +386,7 @@ function ($data) use (&$buffer) { public function testRequestInvalidWillEmitErrorAndSendErrorResponse() { $error = null; - $server = new Server($this->socket); + $server = new Server($this->socket, $this->expectCallableNever()); $server->on('error', function ($message) use (&$error) { $error = $message; }); @@ -453,14 +417,12 @@ function ($data) use (&$buffer) { public function testBodyDataWillBeSendViaRequestEvent() { - $server = new Server($this->socket); - $dataEvent = $this->expectCallableOnceWith('hello'); $endEvent = $this->expectCallableOnce(); $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->on('data', $dataEvent); $request->on('end', $endEvent); $request->on('close', $closeEvent); @@ -481,15 +443,13 @@ public function testBodyDataWillBeSendViaRequestEvent() public function testChunkedEncodedRequestWillBeParsedForRequestEvent() { - $server = new Server($this->socket); - $dataEvent = $this->expectCallableOnceWith('hello'); $endEvent = $this->expectCallableOnce(); $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); $requestValidation = null; - $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { + $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { $request->on('data', $dataEvent); $request->on('end', $endEvent); $request->on('close', $closeEvent); @@ -514,20 +474,19 @@ public function testChunkedEncodedRequestWillBeParsedForRequestEvent() public function testChunkedEncodedRequestAdditionalDataWontBeEmitted() { - $server = new Server($this->socket); - $dataEvent = $this->expectCallableOnceWith('hello'); $endEvent = $this->expectCallableOnce(); $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->on('data', $dataEvent); $request->on('end', $endEvent); $request->on('close', $closeEvent); $request->on('error', $errorEvent); }); + $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -544,14 +503,12 @@ public function testChunkedEncodedRequestAdditionalDataWontBeEmitted() public function testEmptyChunkedEncodedRequest() { - $server = new Server($this->socket); - $dataEvent = $this->expectCallableNever(); $endEvent = $this->expectCallableOnce(); $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->on('data', $dataEvent); $request->on('end', $endEvent); $request->on('close', $closeEvent); @@ -572,20 +529,19 @@ public function testEmptyChunkedEncodedRequest() public function testChunkedIsUpperCase() { - $server = new Server($this->socket); - $dataEvent = $this->expectCallableOnceWith('hello'); $endEvent = $this->expectCallableOnce(); $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->on('data', $dataEvent); $request->on('end', $endEvent); $request->on('close', $closeEvent); $request->on('error', $errorEvent); }); + $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -601,20 +557,19 @@ public function testChunkedIsUpperCase() public function testChunkedIsMixedUpperAndLowerCase() { - $server = new Server($this->socket); - $dataEvent = $this->expectCallableOnceWith('hello'); $endEvent = $this->expectCallableOnce(); $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->on('data', $dataEvent); $request->on('end', $endEvent); $request->on('close', $closeEvent); $request->on('error', $errorEvent); }); + $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -630,7 +585,7 @@ public function testChunkedIsMixedUpperAndLowerCase() public function testRequestHttp11WithoutHostWillEmitErrorAndSendErrorResponse() { $error = null; - $server = new Server($this->socket); + $server = new Server($this->socket, $this->expectCallableNever()); $server->on('error', function ($message) use (&$error) { $error = $message; }); @@ -662,7 +617,7 @@ function ($data) use (&$buffer) { public function testRequestHttp11WithMalformedHostWillEmitErrorAndSendErrorResponse() { $error = null; - $server = new Server($this->socket); + $server = new Server($this->socket, $this->expectCallableNever()); $server->on('error', function ($message) use (&$error) { $error = $message; }); @@ -694,7 +649,7 @@ function ($data) use (&$buffer) { public function testRequestHttp11WithInvalidHostUriComponentsWillEmitErrorAndSendErrorResponse() { $error = null; - $server = new Server($this->socket); + $server = new Server($this->socket, $this->expectCallableNever()); $server->on('error', function ($message) use (&$error) { $error = $message; }); @@ -725,8 +680,7 @@ function ($data) use (&$buffer) { public function testRequestHttp10WithoutHostEmitsRequestWithNoError() { - $server = new Server($this->socket); - $server->on('request', $this->expectCallableOnce()); + $server = new Server($this->socket, $this->expectCallableOnce()); $server->on('error', $this->expectCallableNever()); $this->socket->emit('connection', array($this->connection)); @@ -737,14 +691,12 @@ public function testRequestHttp10WithoutHostEmitsRequestWithNoError() public function testWontEmitFurtherDataWhenContentLengthIsReached() { - $server = new Server($this->socket); - $dataEvent = $this->expectCallableOnceWith('hello'); $endEvent = $this->expectCallableOnce(); $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->on('data', $dataEvent); $request->on('end', $endEvent); $request->on('close', $closeEvent); @@ -766,20 +718,20 @@ public function testWontEmitFurtherDataWhenContentLengthIsReached() public function testWontEmitFurtherDataWhenContentLengthIsReachedSplitted() { - $server = new Server($this->socket); - $dataEvent = $this->expectCallableOnceWith('hello'); $endEvent = $this->expectCallableOnce(); $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + + $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->on('data', $dataEvent); $request->on('end', $endEvent); $request->on('close', $closeEvent); $request->on('error', $errorEvent); }); + $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -798,14 +750,13 @@ public function testWontEmitFurtherDataWhenContentLengthIsReachedSplitted() public function testContentLengthContainsZeroWillEmitEndEvent() { - $server = new Server($this->socket); $dataEvent = $this->expectCallableNever(); $endEvent = $this->expectCallableOnce(); $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->on('data', $dataEvent); $request->on('end', $endEvent); $request->on('close', $closeEvent); @@ -825,14 +776,12 @@ public function testContentLengthContainsZeroWillEmitEndEvent() public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillBeIgnored() { - $server = new Server($this->socket); - $dataEvent = $this->expectCallableNever(); $endEvent = $this->expectCallableOnce(); $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->on('data', $dataEvent); $request->on('end', $endEvent); $request->on('close', $closeEvent); @@ -853,14 +802,12 @@ public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillB public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillBeIgnoredSplitted() { - $server = new Server($this->socket); - $dataEvent = $this->expectCallableNever(); $endEvent = $this->expectCallableOnce(); $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->on('data', $dataEvent); $request->on('end', $endEvent); $request->on('close', $closeEvent); @@ -884,15 +831,13 @@ public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillB public function testContentLengthWillBeIgnoredIfTransferEncodingIsSet() { - $server = new Server($this->socket); - $dataEvent = $this->expectCallableOnceWith('hello'); $endEvent = $this->expectCallableOnce(); $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); $requestValidation = null; - $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { + $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { $request->on('data', $dataEvent); $request->on('end', $endEvent); $request->on('close', $closeEvent); @@ -922,15 +867,13 @@ public function testContentLengthWillBeIgnoredIfTransferEncodingIsSet() public function testInvalidContentLengthWillBeIgnoreddIfTransferEncodingIsSet() { - $server = new Server($this->socket); - $dataEvent = $this->expectCallableOnceWith('hello'); $endEvent = $this->expectCallableOnce(); $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); $requestValidation = null; - $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { + $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { $request->on('data', $dataEvent); $request->on('end', $endEvent); $request->on('close', $closeEvent); @@ -962,8 +905,7 @@ public function testInvalidContentLengthWillBeIgnoreddIfTransferEncodingIsSet() public function testNonIntegerContentLengthValueWillLeadToError() { $error = null; - $server = new Server($this->socket); - $server->on('request', $this->expectCallableNever()); + $server = new Server($this->socket, $this->expectCallableNever()); $server->on('error', function ($message) use (&$error) { $error = $message; }); @@ -999,8 +941,7 @@ function ($data) use (&$buffer) { public function testMultipleIntegerInContentLengthWillLeadToError() { $error = null; - $server = new Server($this->socket); - $server->on('request', $this->expectCallableNever()); + $server = new Server($this->socket, $this->expectCallableNever()); $server->on('error', function ($message) use (&$error) { $error = $message; }); @@ -1036,8 +977,7 @@ function ($data) use (&$buffer) { public function testInvalidChunkHeaderResultsInErrorOnRequestStream() { $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); - $server = new Server($this->socket); - $server->on('request', function ($request, $response) use ($errorEvent){ + $server = new Server($this->socket, function ($request, $response) use ($errorEvent){ $request->on('error', $errorEvent); }); @@ -1059,8 +999,7 @@ public function testInvalidChunkHeaderResultsInErrorOnRequestStream() public function testTooLongChunkHeaderResultsInErrorOnRequestStream() { $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); - $server = new Server($this->socket); - $server->on('request', function ($request, $response) use ($errorEvent){ + $server = new Server($this->socket, function ($request, $response) use ($errorEvent){ $request->on('error', $errorEvent); }); @@ -1084,8 +1023,7 @@ public function testTooLongChunkHeaderResultsInErrorOnRequestStream() public function testTooLongChunkBodyResultsInErrorOnRequestStream() { $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); - $server = new Server($this->socket); - $server->on('request', function ($request, $response) use ($errorEvent){ + $server = new Server($this->socket, function ($request, $response) use ($errorEvent){ $request->on('error', $errorEvent); }); @@ -1107,8 +1045,7 @@ public function testTooLongChunkBodyResultsInErrorOnRequestStream() public function testUnexpectedEndOfConnectionWillResultsInErrorOnRequestStream() { $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); - $server = new Server($this->socket); - $server->on('request', function ($request, $response) use ($errorEvent){ + $server = new Server($this->socket, function ($request, $response) use ($errorEvent){ $request->on('error', $errorEvent); }); @@ -1130,8 +1067,7 @@ public function testUnexpectedEndOfConnectionWillResultsInErrorOnRequestStream() public function testErrorInChunkedDecoderNeverClosesConnection() { - $server = new Server($this->socket); - $server->on('request', $this->expectCallableOnce()); + $server = new Server($this->socket, $this->expectCallableOnce()); $this->connection->expects($this->never())->method('close'); $this->connection->expects($this->once())->method('pause'); @@ -1150,8 +1086,7 @@ public function testErrorInChunkedDecoderNeverClosesConnection() public function testErrorInLengthLimitedStreamNeverClosesConnection() { - $server = new Server($this->socket); - $server->on('request', $this->expectCallableOnce()); + $server = new Server($this->socket, $this->expectCallableOnce()); $this->connection->expects($this->never())->method('close'); $this->connection->expects($this->once())->method('pause'); @@ -1171,8 +1106,7 @@ public function testErrorInLengthLimitedStreamNeverClosesConnection() public function testCloseRequestWillPauseConnection() { - $server = new Server($this->socket); - $server->on('request', function ($request, $response) { + $server = new Server($this->socket, function ($request, $response) { $request->close(); }); @@ -1192,8 +1126,7 @@ public function testEndEventWillBeEmittedOnSimpleRequest() $endEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket); - $server->on('request', function ($request, $response) use ($dataEvent, $closeEvent, $endEvent, $errorEvent){ + $server = new Server($this->socket, function ($request, $response) use ($dataEvent, $closeEvent, $endEvent, $errorEvent){ $request->on('data', $dataEvent); $request->on('close', $closeEvent); $request->on('end', $endEvent); @@ -1212,14 +1145,12 @@ public function testEndEventWillBeEmittedOnSimpleRequest() public function testRequestWithoutDefinedLengthWillIgnoreDataEvent() { - $server = new Server($this->socket); - $dataEvent = $this->expectCallableNever(); $endEvent = $this->expectCallableOnce(); $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server->on('request', function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->on('data', $dataEvent); $request->on('end', $endEvent); $request->on('close', $closeEvent); @@ -1236,9 +1167,7 @@ public function testRequestWithoutDefinedLengthWillIgnoreDataEvent() public function testResponseWillBeChunkDecodedByDefault() { - $server = new Server($this->socket); - - $server->on('request', function (Request $request, Response $response) { + $server = new Server($this->socket, function (Request $request, Response $response) { $response->writeHead(); $response->write('hello'); }); @@ -1260,9 +1189,7 @@ public function testResponseWillBeChunkDecodedByDefault() public function testContentLengthWillBeRemovedForResponseStream() { - $server = new Server($this->socket); - - $server->on('request', function (Request $request, Response $response) { + $server = new Server($this->socket, function (Request $request, Response $response) { $response->writeHead( 200, array( @@ -1299,9 +1226,7 @@ function ($data) use (&$buffer) { public function testOnlyAllowChunkedEncoding() { - $server = new Server($this->socket); - - $server->on('request', function (Request $request, Response $response) { + $server = new Server($this->socket, function (Request $request, Response $response) { $response->writeHead( 200, array( @@ -1337,9 +1262,7 @@ function ($data) use (&$buffer) { public function testDateHeaderWillBeAddedWhenNoneIsGiven() { - $server = new Server($this->socket); - - $server->on('request', function (Request $request, Response $response) { + $server = new Server($this->socket, function (Request $request, Response $response) { $response->writeHead(200); }); @@ -1368,9 +1291,7 @@ function ($data) use (&$buffer) { public function testAddCustomDateHeader() { - $server = new Server($this->socket); - - $server->on('request', function (Request $request, Response $response) { + $server = new Server($this->socket, function (Request $request, Response $response) { $response->writeHead(200, array("Date" => "Tue, 15 Nov 1994 08:12:31 GMT")); }); @@ -1399,9 +1320,7 @@ function ($data) use (&$buffer) { public function testRemoveDateHeader() { - $server = new Server($this->socket); - - $server->on('request', function (Request $request, Response $response) { + $server = new Server($this->socket, function (Request $request, Response $response) { $response->writeHead(200, array('Date' => array())); }); @@ -1432,8 +1351,7 @@ public function testOnlyChunkedEncodingIsAllowedForTransferEncoding() { $error = null; - $server = new Server($this->socket); - $server->on('request', $this->expectCallableNever()); + $server = new Server($this->socket, $this->expectCallableNever()); $server->on('error', function ($exception) use (&$error) { $error = $exception; }); @@ -1466,8 +1384,7 @@ function ($data) use (&$buffer) { public function test100ContinueRequestWillBeHandled() { - $server = new Server($this->socket); - $server->on('request', $this->expectCallableOnce()); + $server = new Server($this->socket, $this->expectCallableOnce()); $this->connection ->expects($this->once()) @@ -1487,8 +1404,7 @@ public function test100ContinueRequestWillBeHandled() public function testContinueWontBeSendForHttp10() { - $server = new Server($this->socket); - $server->on('request', $this->expectCallableOnce()); + $server = new Server($this->socket, $this->expectCallableOnce()); $this->connection ->expects($this->never()) @@ -1505,8 +1421,7 @@ public function testContinueWontBeSendForHttp10() public function testContinueWithLaterResponse() { - $server = new Server($this->socket); - $server->on('request', function (Request $request, Response $response) { + $server = new Server($this->socket, function (Request $request, Response $response) { $response->writeHead(); $response->end(); }); @@ -1538,6 +1453,14 @@ function ($data) use (&$buffer) { $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); } + /** + * @expectedException InvalidArgumentException + */ + public function testInvalidCallbackFunctionLeadsToException() + { + $server = new Server($this->socket, 'invalid'); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From 88ffd6091c4cac2562b115c169ca188ff7420df1 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Tue, 7 Mar 2017 18:21:53 +0100 Subject: [PATCH 082/128] Replace Request class with PSR-7 Request --- examples/01-hello-world.php | 4 +- examples/02-hello-world-https.php | 4 +- examples/03-handling-body-data.php | 10 +- src/HttpBodyStream.php | 173 ++++++++++++++++++++++ src/Request.php | 194 ------------------------- src/Server.php | 2 +- tests/HttpBodyStreamTest.php | 187 ++++++++++++++++++++++++ tests/RequestTest.php | 116 --------------- tests/ServerTest.php | 224 ++++++++++++++--------------- 9 files changed, 481 insertions(+), 433 deletions(-) create mode 100644 src/HttpBodyStream.php delete mode 100644 src/Request.php create mode 100644 tests/HttpBodyStreamTest.php delete mode 100644 tests/RequestTest.php diff --git a/examples/01-hello-world.php b/examples/01-hello-world.php index d44634ed..49c12664 100644 --- a/examples/01-hello-world.php +++ b/examples/01-hello-world.php @@ -2,15 +2,15 @@ use React\EventLoop\Factory; use React\Socket\Server; -use React\Http\Request; use React\Http\Response; +use Psr\Http\Message\RequestInterface; require __DIR__ . '/../vendor/autoload.php'; $loop = Factory::create(); $socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); -$server = new \React\Http\Server($socket, function (Request $request, Response $response) { +$server = new \React\Http\Server($socket, function (RequestInterface $request, Response $response) { $response->writeHead(200, array('Content-Type' => 'text/plain')); $response->end("Hello world!\n"); }); diff --git a/examples/02-hello-world-https.php b/examples/02-hello-world-https.php index 6ca7e809..29e5aab2 100644 --- a/examples/02-hello-world-https.php +++ b/examples/02-hello-world-https.php @@ -2,9 +2,9 @@ use React\EventLoop\Factory; use React\Socket\Server; -use React\Http\Request; use React\Http\Response; use React\Socket\SecureServer; +use Psr\Http\Message\RequestInterface; require __DIR__ . '/../vendor/autoload.php'; @@ -14,7 +14,7 @@ 'local_cert' => isset($argv[2]) ? $argv[2] : __DIR__ . '/localhost.pem' )); -$server = new \React\Http\Server($socket, function (Request $request, Response $response) { +$server = new \React\Http\Server($socket, function (RequestInterface $request, Response $response) { $response->writeHead(200, array('Content-Type' => 'text/plain')); $response->end("Hello world!\n"); }); diff --git a/examples/03-handling-body-data.php b/examples/03-handling-body-data.php index a016b66e..9bd26c1e 100644 --- a/examples/03-handling-body-data.php +++ b/examples/03-handling-body-data.php @@ -2,27 +2,27 @@ use React\EventLoop\Factory; use React\Socket\Server; -use React\Http\Request; use React\Http\Response; +use Psr\Http\Message\RequestInterface; require __DIR__ . '/../vendor/autoload.php'; $loop = Factory::create(); $socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); -$server = new \React\Http\Server($socket, function (Request $request, Response $response) { +$server = new \React\Http\Server($socket, function (RequestInterface $request, Response $response) { $contentLength = 0; - $request->on('data', function ($data) use (&$contentLength) { + $request->getBody()->on('data', function ($data) use (&$contentLength) { $contentLength += strlen($data); }); - $request->on('end', function () use ($response, &$contentLength){ + $request->getBody()->on('end', function () use ($response, &$contentLength){ $response->writeHead(200, array('Content-Type' => 'text/plain')); $response->end("The length of the submitted request body is: " . $contentLength); }); // an error occures e.g. on invalid chunked encoded data or an unexpected 'end' event - $request->on('error', function (\Exception $exception) use ($response, &$contentLength) { + $request->getBody()->on('error', function (\Exception $exception) use ($response, &$contentLength) { $response->writeHead(400, array('Content-Type' => 'text/plain')); $response->end("An error occured while reading at length: " . $contentLength); }); diff --git a/src/HttpBodyStream.php b/src/HttpBodyStream.php new file mode 100644 index 00000000..a77622ad --- /dev/null +++ b/src/HttpBodyStream.php @@ -0,0 +1,173 @@ +input = $input; + $this->size = $size; + + $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(); + } + + public function getSize() + { + return $this->size; + } + + /** @ignore */ + public function __toString() + { + return ''; + } + + /** @ignore */ + public function detach() + { + return null; + } + + /** @ignore */ + public function tell() + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function eof() + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function isSeekable() + { + return false; + } + + /** @ignore */ + public function seek($offset, $whence = SEEK_SET) + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function rewind() + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function isWritable() + { + return false; + } + + /** @ignore */ + public function write($string) + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function read($length) + { + throw new \BadMethodCallException(); + } + + /** @ignore */ + public function getContents() + { + return ''; + } + + /** @ignore */ + public function getMetadata($key = null) + { + return null; + } + + /** @internal */ + public function handleData($data) + { + $this->emit('data', array($data)); + } + + /** @internal */ + public function handleError(\Exception $e) + { + $this->emit('error', array($e)); + $this->close(); + } + + /** @internal */ + public function handleEnd() + { + if (!$this->closed) { + $this->emit('end'); + $this->close(); + } + } +} diff --git a/src/Request.php b/src/Request.php deleted file mode 100644 index 05df869e..00000000 --- a/src/Request.php +++ /dev/null @@ -1,194 +0,0 @@ -request = $request; - $this->stream = $stream; - - $that = $this; - // forward data and end events from body stream to request - $stream->on('data', function ($data) use ($that) { - $that->emit('data', array($data)); - }); - $stream->on('end', function () use ($that) { - $that->emit('end'); - }); - $stream->on('error', function ($error) use ($that) { - $that->emit('error', array($error)); - }); - $stream->on('close', array($this, 'close')); - } - - /** - * Returns the request method - * - * @return string - */ - public function getMethod() - { - return $this->request->getMethod(); - } - - /** - * Returns the request path - * - * @return string - */ - public function getPath() - { - return $this->request->getUri()->getPath(); - } - - /** - * Returns an array with all query parameters ($_GET) - * - * @return array - */ - public function getQueryParams() - { - $params = array(); - parse_str($this->request->getUri()->getQuery(), $params); - - return $params; - } - - /** - * Returns the HTTP protocol version (such as "1.0" or "1.1") - * - * @return string - */ - public function getProtocolVersion() - { - return $this->request->getProtocolVersion(); - } - - /** - * Returns an array with ALL headers - * - * The keys represent the header name in the exact case in which they were - * originally specified. The values will be an array of strings for each - * value for the respective header name. - * - * @return array - */ - public function getHeaders() - { - return $this->request->getHeaders(); - } - - /** - * Retrieves a message header value by the given case-insensitive name. - * - * @param string $name - * @return string[] a list of all values for this header name or an empty array if header was not found - */ - public function getHeader($name) - { - return $this->request->getHeader($name); - } - - /** - * Retrieves a comma-separated string of the values for a single header. - * - * @param string $name - * @return string a comma-separated list of all values for this header name or an empty string if header was not found - */ - public function getHeaderLine($name) - { - return $this->request->getHeaderLine($name); - } - - /** - * Checks if a header exists by the given case-insensitive name. - * - * @param string $name - * @return bool - */ - public function hasHeader($name) - { - return $this->request->hasHeader($name); - } - - 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 close() - { - if (!$this->readable) { - return; - } - - $this->readable = false; - $this->stream->close(); - - $this->emit('close'); - $this->removeAllListeners(); - } - - public function pipe(WritableStreamInterface $dest, array $options = array()) - { - Util::pipe($this, $dest, $options); - - return $dest; - } -} diff --git a/src/Server.php b/src/Server.php index e4b35e62..e45de1dc 100644 --- a/src/Server.php +++ b/src/Server.php @@ -160,7 +160,7 @@ public function handleRequest(ConnectionInterface $conn, RequestInterface $reque } } - $request = new Request($request, $stream); + $request = $request->withBody(new HttpBodyStream($stream, $contentLength)); if ($request->getProtocolVersion() !== '1.0' && '100-continue' === strtolower($request->getHeaderLine('Expect'))) { $conn->write("HTTP/1.1 100 Continue\r\n\r\n"); diff --git a/tests/HttpBodyStreamTest.php b/tests/HttpBodyStreamTest.php new file mode 100644 index 00000000..9817384a --- /dev/null +++ b/tests/HttpBodyStreamTest.php @@ -0,0 +1,187 @@ +input = new ReadableStream(); + $this->bodyStream = new HttpBodyStream($this->input, null); + } + + public function testDataEmit() + { + $this->bodyStream->on('data', $this->expectCallableOnce(array("hello"))); + $this->input->emit('data', array("hello")); + } + + public function testPauseStream() + { + $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input->expects($this->once())->method('pause'); + + $bodyStream = new HttpBodyStream($input, null); + $bodyStream->pause(); + } + + public function testResumeStream() + { + $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input->expects($this->once())->method('pause'); + + $bodyStream = new HttpBodyStream($input, null); + $bodyStream->pause(); + $bodyStream->resume(); + } + + public function testPipeStream() + { + $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + + $ret = $this->bodyStream->pipe($dest); + + $this->assertSame($dest, $ret); + } + + public function testHandleClose() + { + $this->bodyStream->on('close', $this->expectCallableOnce()); + + $this->input->close(); + $this->input->emit('end', array()); + + $this->assertFalse($this->bodyStream->isReadable()); + } + + public function testStopDataEmittingAfterClose() + { + $bodyStream = new HttpBodyStream($this->input, null); + $bodyStream->on('close', $this->expectCallableOnce()); + $this->bodyStream->on('data', $this->expectCallableOnce(array("hello"))); + + $this->input->emit('data', array("hello")); + $bodyStream->close(); + $this->input->emit('data', array("world")); + } + + public function testHandleError() + { + $this->bodyStream->on('error', $this->expectCallableOnce()); + $this->bodyStream->on('close', $this->expectCallableOnce()); + + $this->input->emit('error', array(new \RuntimeException())); + + $this->assertFalse($this->bodyStream->isReadable()); + } + + public function testToString() + { + $this->assertEquals('', $this->bodyStream->__toString()); + } + + public function testDetach() + { + $this->assertEquals(null, $this->bodyStream->detach()); + } + + public function testGetSizeDefault() + { + $this->assertEquals(null, $this->bodyStream->getSize()); + } + + public function testGetSizeCustom() + { + $stream = new HttpBodyStream($this->input, 5); + $this->assertEquals(5, $stream->getSize()); + } + + /** + * @expectedException BadMethodCallException + */ + public function testTell() + { + $this->bodyStream->tell(); + } + + /** + * @expectedException BadMethodCallException + */ + public function testEof() + { + $this->bodyStream->eof(); + } + + public function testIsSeekable() + { + $this->assertFalse($this->bodyStream->isSeekable()); + } + + /** + * @expectedException BadMethodCallException + */ + public function testWrite() + { + $this->bodyStream->write(''); + } + + /** + * @expectedException BadMethodCallException + */ + public function testRead() + { + $this->bodyStream->read(''); + } + + public function testGetContents() + { + $this->assertEquals('', $this->bodyStream->getContents()); + } + + public function testGetMetaData() + { + $this->assertEquals(null, $this->bodyStream->getMetadata()); + } + + public function testIsReadable() + { + $this->assertTrue($this->bodyStream->isReadable()); + } + + public function testPause() + { + $this->bodyStream->pause(); + } + + public function testResume() + { + $this->bodyStream->resume(); + } + + /** + * @expectedException BadMethodCallException + */ + public function testSeek() + { + $this->bodyStream->seek(''); + } + + /** + * @expectedException BadMethodCallException + */ + public function testRewind() + { + $this->bodyStream->rewind(); + } + + public function testIsWriteable() + { + $this->assertFalse($this->bodyStream->isWritable()); + } +} diff --git a/tests/RequestTest.php b/tests/RequestTest.php deleted file mode 100644 index bafb17ac..00000000 --- a/tests/RequestTest.php +++ /dev/null @@ -1,116 +0,0 @@ -stream = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); - } - - public function testEmptyHeader() - { - $request = new Request(new Psr('GET', '/', array()), $this->stream); - - $this->assertEquals(array(), $request->getHeaders()); - $this->assertFalse($request->hasHeader('Test')); - $this->assertEquals(array(), $request->getHeader('Test')); - $this->assertEquals('', $request->getHeaderLine('Test')); - } - - public function testHeaderIsCaseInsensitive() - { - $request = new Request(new Psr('GET', '/', array( - 'TEST' => array('Yes'), - )), $this->stream); - - $this->assertEquals(array('TEST' => array('Yes')), $request->getHeaders()); - $this->assertTrue($request->hasHeader('Test')); - $this->assertEquals(array('Yes'), $request->getHeader('Test')); - $this->assertEquals('Yes', $request->getHeaderLine('Test')); - } - - public function testHeaderWithMultipleValues() - { - $request = new Request(new Psr('GET', '/', array( - 'Test' => array('a', 'b'), - )), $this->stream); - - $this->assertEquals(array('Test' => array('a', 'b')), $request->getHeaders()); - $this->assertTrue($request->hasHeader('Test')); - $this->assertEquals(array('a', 'b'), $request->getHeader('Test')); - $this->assertEquals('a, b', $request->getHeaderLine('Test')); - } - - public function testCloseEmitsCloseEvent() - { - $request = new Request(new Psr('GET', '/'), $this->stream); - - $request->on('close', $this->expectCallableOnce()); - - $request->close(); - } - - public function testCloseMultipleTimesEmitsCloseEventOnce() - { - $request = new Request(new Psr('GET', '/'), $this->stream); - - $request->on('close', $this->expectCallableOnce()); - - $request->close(); - $request->close(); - } - - public function testCloseWillCloseUnderlyingStream() - { - $this->stream->expects($this->once())->method('close'); - - $request = new Request(new Psr('GET', '/'), $this->stream); - - $request->close(); - } - - public function testIsNotReadableAfterClose() - { - $request = new Request(new Psr('GET', '/'), $this->stream); - - $request->close(); - - $this->assertFalse($request->isReadable()); - } - - public function testPauseWillBeForwarded() - { - $this->stream->expects($this->once())->method('pause'); - - $request = new Request(new Psr('GET', '/'), $this->stream); - - $request->pause(); - } - - public function testResumeWillBeForwarded() - { - $this->stream->expects($this->once())->method('resume'); - - $request = new Request(new Psr('GET', '/'), $this->stream); - - $request->resume(); - } - - public function testPipeReturnsDest() - { - $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); - - $request = new Request(new Psr('GET', '/'), $this->stream); - - $ret = $request->pipe($dest); - - $this->assertSame($dest, $ret); - } -} diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 72cdf5bc..40434f18 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -4,7 +4,7 @@ use React\Http\Server; use React\Http\Response; -use React\Http\Request; +use Psr\Http\Message\RequestInterface; class ServerTest extends TestCase { @@ -78,9 +78,8 @@ public function testRequestEvent() $this->connection->emit('data', array($data)); $this->assertSame(1, $i); - $this->assertInstanceOf('React\Http\Request', $requestAssertion); - $this->assertSame('/', $requestAssertion->getPath()); - $this->assertSame(array(), $requestAssertion->getQueryParams()); + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('/', $requestAssertion->getUri()->getPath()); $this->assertSame('GET', $requestAssertion->getMethod()); $this->assertSame('127.0.0.1', $requestAssertion->remoteAddress); @@ -89,8 +88,8 @@ public function testRequestEvent() public function testRequestPauseWillbeForwardedToConnection() { - $server = new Server($this->socket, function (Request $request) { - $request->pause(); + $server = new Server($this->socket, function (RequestInterface $request) { + $request->getBody()->pause(); }); $this->connection->expects($this->once())->method('pause'); @@ -107,9 +106,8 @@ public function testRequestPauseWillbeForwardedToConnection() public function testRequestResumeWillbeForwardedToConnection() { - - $server = new Server($this->socket, function (Request $request) { - $request->resume(); + $server = new Server($this->socket, function (RequestInterface $request) { + $request->getBody()->resume(); }); $this->connection->expects($this->once())->method('resume'); @@ -121,8 +119,8 @@ public function testRequestResumeWillbeForwardedToConnection() public function testRequestCloseWillPauseConnection() { - $server = new Server($this->socket, function (Request $request) { - $request->close(); + $server = new Server($this->socket, function (RequestInterface $request) { + $request->getBody()->close(); }); $this->connection->expects($this->once())->method('pause'); @@ -134,9 +132,9 @@ public function testRequestCloseWillPauseConnection() public function testRequestPauseAfterCloseWillNotBeForwarded() { - $server = new Server($this->socket, function (Request $request) { - $request->close(); - $request->pause(); + $server = new Server($this->socket, function (RequestInterface $request) { + $request->getBody()->close(); + $request->getBody()->pause(); }); $this->connection->expects($this->once())->method('pause'); @@ -148,9 +146,9 @@ public function testRequestPauseAfterCloseWillNotBeForwarded() public function testRequestResumeAfterCloseWillNotBeForwarded() { - $server = new Server($this->socket, function (Request $request) { - $request->close(); - $request->resume(); + $server = new Server($this->socket, function (RequestInterface $request) { + $request->getBody()->close(); + $request->getBody()->resume(); }); $this->connection->expects($this->once())->method('pause'); @@ -165,8 +163,8 @@ public function testRequestEventWithoutBodyWillNotEmitData() { $never = $this->expectCallableNever(); - $server = new Server($this->socket, function (Request $request) use ($never) { - $request->on('data', $never); + $server = new Server($this->socket, function (RequestInterface $request) use ($never) { + $request->getBody()->on('data', $never); }); $this->socket->emit('connection', array($this->connection)); @@ -179,8 +177,8 @@ public function testRequestEventWithSecondDataEventWillEmitBodyData() { $once = $this->expectCallableOnceWith('incomplete'); - $server = new Server($this->socket, function (Request $request) use ($once) { - $request->on('data', $once); + $server = new Server($this->socket, function (RequestInterface $request) use ($once) { + $request->getBody()->on('data', $once); }); $this->socket->emit('connection', array($this->connection)); @@ -198,8 +196,8 @@ public function testRequestEventWithPartialBodyWillEmitData() { $once = $this->expectCallableOnceWith('incomplete'); - $server = new Server($this->socket, function (Request $request) use ($once) { - $request->on('data', $once); + $server = new Server($this->socket, function (RequestInterface $request) use ($once) { + $request->getBody()->on('data', $once); }); $this->socket->emit('connection', array($this->connection)); @@ -218,7 +216,7 @@ public function testRequestEventWithPartialBodyWillEmitData() public function testResponseContainsPoweredByHeader() { - $server = new Server($this->socket, function (Request $request, Response $response) { + $server = new Server($this->socket, function (RequestInterface $request, Response $response) { $response->writeHead(); $response->end(); }); @@ -246,7 +244,7 @@ function ($data) use (&$buffer) { public function testClosingResponseDoesNotSendAnyData() { - $server = new Server($this->socket, function (Request $request, Response $response) { + $server = new Server($this->socket, function (RequestInterface $request, Response $response) { $response->close(); }); @@ -262,7 +260,7 @@ public function testClosingResponseDoesNotSendAnyData() public function testResponseContainsSameRequestProtocolVersionAndChunkedBodyForHttp11() { - $server = new Server($this->socket, function (Request $request, Response $response) { + $server = new Server($this->socket, function (RequestInterface $request, Response $response) { $response->writeHead(); $response->end('bye'); }); @@ -291,7 +289,7 @@ function ($data) use (&$buffer) { public function testResponseContainsSameRequestProtocolVersionAndRawBodyForHttp10() { - $server = new Server($this->socket, function (Request $request, Response $response) { + $server = new Server($this->socket, function (RequestInterface $request, Response $response) { $response->writeHead(); $response->end('bye'); }); @@ -422,11 +420,11 @@ public function testBodyDataWillBeSendViaRequestEvent() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { - $request->on('data', $dataEvent); - $request->on('end', $endEvent); - $request->on('close', $closeEvent); - $request->on('error', $errorEvent); + $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->getBody()->on('data', $dataEvent); + $request->getBody()->on('end', $endEvent); + $request->getBody()->on('close', $closeEvent); + $request->getBody()->on('error', $errorEvent); }); $this->socket->emit('connection', array($this->connection)); @@ -449,11 +447,11 @@ public function testChunkedEncodedRequestWillBeParsedForRequestEvent() $errorEvent = $this->expectCallableNever(); $requestValidation = null; - $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { - $request->on('data', $dataEvent); - $request->on('end', $endEvent); - $request->on('close', $closeEvent); - $request->on('error', $errorEvent); + $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { + $request->getBody()->on('data', $dataEvent); + $request->getBody()->on('end', $endEvent); + $request->getBody()->on('close', $closeEvent); + $request->getBody()->on('error', $errorEvent); $requestValidation = $request; }); @@ -479,11 +477,11 @@ public function testChunkedEncodedRequestAdditionalDataWontBeEmitted() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { - $request->on('data', $dataEvent); - $request->on('end', $endEvent); - $request->on('close', $closeEvent); - $request->on('error', $errorEvent); + $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->getBody()->on('data', $dataEvent); + $request->getBody()->on('end', $endEvent); + $request->getBody()->on('close', $closeEvent); + $request->getBody()->on('error', $errorEvent); }); @@ -508,11 +506,11 @@ public function testEmptyChunkedEncodedRequest() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { - $request->on('data', $dataEvent); - $request->on('end', $endEvent); - $request->on('close', $closeEvent); - $request->on('error', $errorEvent); + $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->getBody()->on('data', $dataEvent); + $request->getBody()->on('end', $endEvent); + $request->getBody()->on('close', $closeEvent); + $request->getBody()->on('error', $errorEvent); }); $this->socket->emit('connection', array($this->connection)); @@ -534,11 +532,11 @@ public function testChunkedIsUpperCase() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { - $request->on('data', $dataEvent); - $request->on('end', $endEvent); - $request->on('close', $closeEvent); - $request->on('error', $errorEvent); + $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->getBody()->on('data', $dataEvent); + $request->getBody()->on('end', $endEvent); + $request->getBody()->on('close', $closeEvent); + $request->getBody()->on('error', $errorEvent); }); @@ -562,11 +560,11 @@ public function testChunkedIsMixedUpperAndLowerCase() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { - $request->on('data', $dataEvent); - $request->on('end', $endEvent); - $request->on('close', $closeEvent); - $request->on('error', $errorEvent); + $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->getBody()->on('data', $dataEvent); + $request->getBody()->on('end', $endEvent); + $request->getBody()->on('close', $closeEvent); + $request->getBody()->on('error', $errorEvent); }); @@ -696,11 +694,11 @@ public function testWontEmitFurtherDataWhenContentLengthIsReached() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { - $request->on('data', $dataEvent); - $request->on('end', $endEvent); - $request->on('close', $closeEvent); - $request->on('error', $errorEvent); + $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->getBody()->on('data', $dataEvent); + $request->getBody()->on('end', $endEvent); + $request->getBody()->on('close', $closeEvent); + $request->getBody()->on('error', $errorEvent); }); $this->socket->emit('connection', array($this->connection)); @@ -724,11 +722,11 @@ public function testWontEmitFurtherDataWhenContentLengthIsReachedSplitted() $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { - $request->on('data', $dataEvent); - $request->on('end', $endEvent); - $request->on('close', $closeEvent); - $request->on('error', $errorEvent); + $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->getBody()->on('data', $dataEvent); + $request->getBody()->on('end', $endEvent); + $request->getBody()->on('close', $closeEvent); + $request->getBody()->on('error', $errorEvent); }); @@ -756,11 +754,11 @@ public function testContentLengthContainsZeroWillEmitEndEvent() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { - $request->on('data', $dataEvent); - $request->on('end', $endEvent); - $request->on('close', $closeEvent); - $request->on('error', $errorEvent); + $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->getBody()->on('data', $dataEvent); + $request->getBody()->on('end', $endEvent); + $request->getBody()->on('close', $closeEvent); + $request->getBody()->on('error', $errorEvent); }); $this->socket->emit('connection', array($this->connection)); @@ -781,11 +779,11 @@ public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillB $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { - $request->on('data', $dataEvent); - $request->on('end', $endEvent); - $request->on('close', $closeEvent); - $request->on('error', $errorEvent); + $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->getBody()->on('data', $dataEvent); + $request->getBody()->on('end', $endEvent); + $request->getBody()->on('close', $closeEvent); + $request->getBody()->on('error', $errorEvent); }); $this->socket->emit('connection', array($this->connection)); @@ -807,11 +805,11 @@ public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillB $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { - $request->on('data', $dataEvent); - $request->on('end', $endEvent); - $request->on('close', $closeEvent); - $request->on('error', $errorEvent); + $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->getBody()->on('data', $dataEvent); + $request->getBody()->on('end', $endEvent); + $request->getBody()->on('close', $closeEvent); + $request->getBody()->on('error', $errorEvent); }); $this->socket->emit('connection', array($this->connection)); @@ -837,11 +835,11 @@ public function testContentLengthWillBeIgnoredIfTransferEncodingIsSet() $errorEvent = $this->expectCallableNever(); $requestValidation = null; - $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { - $request->on('data', $dataEvent); - $request->on('end', $endEvent); - $request->on('close', $closeEvent); - $request->on('error', $errorEvent); + $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { + $request->getBody()->on('data', $dataEvent); + $request->getBody()->on('end', $endEvent); + $request->getBody()->on('close', $closeEvent); + $request->getBody()->on('error', $errorEvent); $requestValidation = $request; }); @@ -873,11 +871,11 @@ public function testInvalidContentLengthWillBeIgnoreddIfTransferEncodingIsSet() $errorEvent = $this->expectCallableNever(); $requestValidation = null; - $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { - $request->on('data', $dataEvent); - $request->on('end', $endEvent); - $request->on('close', $closeEvent); - $request->on('error', $errorEvent); + $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { + $request->getBody()->on('data', $dataEvent); + $request->getBody()->on('end', $endEvent); + $request->getBody()->on('close', $closeEvent); + $request->getBody()->on('error', $errorEvent); $requestValidation = $request; }); @@ -978,7 +976,7 @@ public function testInvalidChunkHeaderResultsInErrorOnRequestStream() { $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); $server = new Server($this->socket, function ($request, $response) use ($errorEvent){ - $request->on('error', $errorEvent); + $request->getBody()->on('error', $errorEvent); }); $this->connection->expects($this->never())->method('close'); @@ -1000,7 +998,7 @@ public function testTooLongChunkHeaderResultsInErrorOnRequestStream() { $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); $server = new Server($this->socket, function ($request, $response) use ($errorEvent){ - $request->on('error', $errorEvent); + $request->getBody()->on('error', $errorEvent); }); $this->connection->expects($this->never())->method('close'); @@ -1024,7 +1022,7 @@ public function testTooLongChunkBodyResultsInErrorOnRequestStream() { $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); $server = new Server($this->socket, function ($request, $response) use ($errorEvent){ - $request->on('error', $errorEvent); + $request->getBody()->on('error', $errorEvent); }); $this->connection->expects($this->never())->method('close'); @@ -1046,7 +1044,7 @@ public function testUnexpectedEndOfConnectionWillResultsInErrorOnRequestStream() { $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); $server = new Server($this->socket, function ($request, $response) use ($errorEvent){ - $request->on('error', $errorEvent); + $request->getBody()->on('error', $errorEvent); }); $this->connection->expects($this->never())->method('close'); @@ -1107,7 +1105,7 @@ public function testErrorInLengthLimitedStreamNeverClosesConnection() public function testCloseRequestWillPauseConnection() { $server = new Server($this->socket, function ($request, $response) { - $request->close(); + $request->getBody()->close(); }); $this->connection->expects($this->never())->method('close'); @@ -1127,10 +1125,10 @@ public function testEndEventWillBeEmittedOnSimpleRequest() $errorEvent = $this->expectCallableNever(); $server = new Server($this->socket, function ($request, $response) use ($dataEvent, $closeEvent, $endEvent, $errorEvent){ - $request->on('data', $dataEvent); - $request->on('close', $closeEvent); - $request->on('end', $endEvent); - $request->on('error', $errorEvent); + $request->getBody()->on('data', $dataEvent); + $request->getBody()->on('close', $closeEvent); + $request->getBody()->on('end', $endEvent); + $request->getBody()->on('error', $errorEvent); }); $this->connection->expects($this->once())->method('pause'); @@ -1150,11 +1148,11 @@ public function testRequestWithoutDefinedLengthWillIgnoreDataEvent() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (Request $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { - $request->on('data', $dataEvent); - $request->on('end', $endEvent); - $request->on('close', $closeEvent); - $request->on('error', $errorEvent); + $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $request->getBody()->on('data', $dataEvent); + $request->getBody()->on('end', $endEvent); + $request->getBody()->on('close', $closeEvent); + $request->getBody()->on('error', $errorEvent); }); $this->socket->emit('connection', array($this->connection)); @@ -1167,7 +1165,7 @@ public function testRequestWithoutDefinedLengthWillIgnoreDataEvent() public function testResponseWillBeChunkDecodedByDefault() { - $server = new Server($this->socket, function (Request $request, Response $response) { + $server = new Server($this->socket, function (RequestInterface $request, Response $response) { $response->writeHead(); $response->write('hello'); }); @@ -1189,7 +1187,7 @@ public function testResponseWillBeChunkDecodedByDefault() public function testContentLengthWillBeRemovedForResponseStream() { - $server = new Server($this->socket, function (Request $request, Response $response) { + $server = new Server($this->socket, function (RequestInterface $request, Response $response) { $response->writeHead( 200, array( @@ -1226,7 +1224,7 @@ function ($data) use (&$buffer) { public function testOnlyAllowChunkedEncoding() { - $server = new Server($this->socket, function (Request $request, Response $response) { + $server = new Server($this->socket, function (RequestInterface $request, Response $response) { $response->writeHead( 200, array( @@ -1262,7 +1260,7 @@ function ($data) use (&$buffer) { public function testDateHeaderWillBeAddedWhenNoneIsGiven() { - $server = new Server($this->socket, function (Request $request, Response $response) { + $server = new Server($this->socket, function (RequestInterface $request, Response $response) { $response->writeHead(200); }); @@ -1291,7 +1289,7 @@ function ($data) use (&$buffer) { public function testAddCustomDateHeader() { - $server = new Server($this->socket, function (Request $request, Response $response) { + $server = new Server($this->socket, function (RequestInterface $request, Response $response) { $response->writeHead(200, array("Date" => "Tue, 15 Nov 1994 08:12:31 GMT")); }); @@ -1320,7 +1318,7 @@ function ($data) use (&$buffer) { public function testRemoveDateHeader() { - $server = new Server($this->socket, function (Request $request, Response $response) { + $server = new Server($this->socket, function (RequestInterface $request, Response $response) { $response->writeHead(200, array('Date' => array())); }); @@ -1421,7 +1419,7 @@ public function testContinueWontBeSendForHttp10() public function testContinueWithLaterResponse() { - $server = new Server($this->socket, function (Request $request, Response $response) { + $server = new Server($this->socket, function (RequestInterface $request, Response $response) { $response->writeHead(); $response->end(); }); From 30ab51f2ebe5d452a08adf67857834881bd31a3f Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Mon, 20 Mar 2017 14:30:50 +0100 Subject: [PATCH 083/128] Add tests to test case sensitivity --- tests/ChunkedDecoderTest.php | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/ChunkedDecoderTest.php b/tests/ChunkedDecoderTest.php index 82806a8a..7f675f42 100644 --- a/tests/ChunkedDecoderTest.php +++ b/tests/ChunkedDecoderTest.php @@ -447,4 +447,36 @@ public function testEmptyHeaderAndFilledBodyLeadsToError() $this->input->emit('data', array("\r\nhello\r\n")); } + + public function testUpperCaseHexWillBeHandled() + { + $this->parser->on('data', $this->expectCallableOnceWith('0123456790')); + $this->parser->on('error', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableNever()); + + $this->input->emit('data', array("A\r\n0123456790\r\n")); + } + + public function testLowerCaseHexWillBeHandled() + { + $this->parser->on('data', $this->expectCallableOnceWith('0123456790')); + $this->parser->on('error', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableNever()); + + $this->input->emit('data', array("a\r\n0123456790\r\n")); + } + + public function testMixedUpperAndLowerCaseHexValuesInHeaderWillBeHandled() + { + $data = str_repeat('1', (int)hexdec('AA')); + + $this->parser->on('data', $this->expectCallableOnceWith($data)); + $this->parser->on('error', $this->expectCallableNever()); + $this->parser->on('end', $this->expectCallableNever()); + $this->parser->on('close', $this->expectCallableNever()); + + $this->input->emit('data', array("aA\r\n" . $data . "\r\n")); + } } From d1491996e80bc2824fb98c0452b7c46985725922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 23 Mar 2017 21:10:11 +0100 Subject: [PATCH 084/128] Update documentation for RequestInterface --- README.md | 178 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 99 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 5c8528fd..38117f22 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,6 @@ Event-driven, streaming plaintext HTTP and secure HTTPS server for [ReactPHP](ht * [Usage](#usage) * [Server](#server) * [Request](#request) - * [getMethod()](#getmethod) - * [getQueryParams()](#getqueryparams) - * [getProtocolVersion()](#getprotocolversion) - * [getHeaders()](#getheaders) - * [getHeader()](#getheader) - * [getHeaderLine()](#getheaderline) - * [hasHeader()](#hasheader) * [Response](#response) * [writeHead()](#writehead) * [Install](#install) @@ -31,7 +24,7 @@ This is an HTTP server which responds with `Hello World` to every request. $loop = React\EventLoop\Factory::create(); $socket = new React\Socket\Server(8080, $loop); -$http = new Server($socket, function (Request $request, Response $response) { +$http = new Server($socket, function (RequestInterface $request, Response $response) { $response->writeHead(200, array('Content-Type' => 'text/plain')); $response->end("Hello World!\n"); }); @@ -53,13 +46,13 @@ emits underlying streaming connections in order to then parse incoming data as HTTP. For each request, it executes the callback function passed to the -constructor with the respective [`Request`](#request) and -[`Response`](#response) objects: +constructor with the respective [request](#request) and +[response](#response) objects: ```php $socket = new React\Socket\Server(8080, $loop); -$http = new Server($socket, function (Request $request, Response $response) { +$http = new Server($socket, function (RequestInterface $request, Response $response) { $response->writeHead(200, array('Content-Type' => 'text/plain')); $response->end("Hello World!\n"); }); @@ -75,7 +68,7 @@ $socket = new React\Socket\SecureServer($socket, $loop, array( 'local_cert' => __DIR__ . '/localhost.pem' )); -$http = new Server($socket, function (Request $request, Response $response) { +$http = new Server($socket, function (RequestInterface $request, Response $response) { $response->writeHead(200, array('Content-Type' => 'text/plain')); $response->end("Hello World!\n"); }); @@ -90,8 +83,8 @@ This ensures you will receive the request body without a delay as expected. The [Response](#response) still needs to be created as described in the examples above. -See also [`Request`](#request) and [`Response`](#response) -for more details(e.g. the request data body). +See also [request](#request) and [response](#response) +for more details (e.g. the request data body). The `Server` supports both HTTP/1.1 and HTTP/1.0 request messages. If a client sends an invalid request message, uses an invalid HTTP protocol @@ -105,98 +98,125 @@ $http->on('error', function (Exception $e) { }); ``` -The request object can also emit an error. Checkout [Request](#request) -for more details. +Note that the request object can also emit an error. +Check out [request](#request) for more details. ### Request -The `Request` class is responsible for streaming the incoming request body -and contains meta data which was parsed from the request headers. -If the request body is chunked-encoded, the data will be decoded and emitted on the data event. -The `Transfer-Encoding` header will be removed. +An seen above, the `Server` class is responsible for handling incoming +connections and then processing each incoming HTTP request. -It implements the `ReadableStreamInterface`. +The request object will be processed once the request headers have +been received by the client. +This request object implements the +[PSR-7 RequestInterface](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message.md#32-psrhttpmessagerequestinterface) +and will be passed to the callback function like this. -Listen on the `data` event and the `end` event of the [Request](#request) -to evaluate the data of the request body: + ```php +$http = new Server($socket, function (RequestInterface $request, Response $response) { + $response->writeHead(200, array('Content-Type' => 'text/plain')); + $response->write("The method of the request is: " . $request->getMethod()); + $response->end("The requested path is: " . $request->getUri()->getPath()); +}); +``` + +For more details about the request object, check out the documentation of +[PSR-7 RequestInterface](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message.md#32-psrhttpmessagerequestinterface). + +Note that the request object will be processed once the request headers have +been received. +This means that this happens irrespective of (i.e. *before*) receiving the +(potentially much larger) request body. +While this may be uncommon in the PHP ecosystem, this is actually a very powerful +approach that gives you several advantages not otherwise possible: + +* React to requests *before* receiving a large request body, + such as rejecting an unauthenticated request or one that exceeds allowed + message lengths (file uploads). +* Start processing parts of the request body before the remainder of the request + body arrives or if the sender is slowly streaming data. +* Process a large request body without having to buffer anything in memory, + such as accepting a huge file upload or possibly unlimited request body stream. + +The `getBody()` method can be used to access the request body stream. +This method returns a stream instance that implements both the +[PSR-7 StreamInterface](http://www.php-fig.org/psr/psr-7/#psrhttpmessagestreaminterface) +and the [ReactPHP ReadableStreamInterface](https://github.com/reactphp/stream#readablestreaminterface). +However, most of the `PSR-7 StreamInterface` methods have been +designed under the assumption of being in control of the request body. +Given that this does not apply to this server, the following +`PSR-7 StreamInterface` methods are not used and SHOULD NOT be called: +`tell()`, `eof()`, `seek()`, `rewind()`, `write()` and `read()`. +Instead, you should use the `ReactPHP ReadableStreamInterface` which +gives you access to the incoming request body as the individual chunks arrive: ```php -$http = new Server($socket, function (Request $request, Response $response) { +$http = new Server($socket, function (RequestInterface $request, Response $response) { $contentLength = 0; - $request->on('data', function ($data) use (&$contentLength) { + $body = $request->getBody(); + $body->on('data', function ($data) use (&$contentLength) { $contentLength += strlen($data); }); - $request->on('end', function () use ($response, &$contentLength){ + $body->on('end', function () use ($response, &$contentLength){ $response->writeHead(200, array('Content-Type' => 'text/plain')); $response->end("The length of the submitted request body is: " . $contentLength); }); // an error occures e.g. on invalid chunked encoded data or an unexpected 'end' event - $request->on('error', function (\Exception $exception) use ($response, &$contentLength) { + $body->on('error', function (\Exception $exception) use ($response, &$contentLength) { $response->writeHead(400, array('Content-Type' => 'text/plain')); $response->end("An error occured while reading at length: " . $contentLength); }); }); ``` -An error will just `pause` the connection instead of closing it. A response message -can still be sent. - -A `close` event will be emitted after an `error` or `end` event. - -The constructor is internal, you SHOULD NOT call this yourself. -The `Server` is responsible for emitting `Request` and `Response` objects. - -See the above usage example and the class outline for details. - -#### getMethod() - -The `getMethod(): string` method can be used to -return the request method. - -#### getPath() - -The `getPath(): string` method can be used to -return the request path. +The above example simply counts the number of bytes received in the request body. +This can be used as a skeleton for buffering or processing the request body. -#### getQueryParams() +The `data` event will be emitted whenever new data is available on the request +body stream. +The server automatically takes care of decoding chunked transfer encoding +and will only emit the actual payload as data. +In this case, the `Transfer-Encoding` header will be removed. -The `getQueryParams(): array` method can be used to -return an array with all query parameters ($_GET). +The `end` event will be emitted when the request body stream terminates +successfully, i.e. it was read until its expected end. -#### getProtocolVersion() +The `error` event will be emitted in case the request stream contains invalid +chunked data or the connection closes before the complete request stream has +been received. +The server will automatically `pause()` the connection instead of closing it. +A response message can still be sent (unless the connection is already closed). -The `getProtocolVersion(): string` method can be used to -return the HTTP protocol version (such as "1.0" or "1.1"). - -#### getHeaders() - -The `getHeaders(): array` method can be used to -return an array with ALL headers. - -The keys represent the header name in the exact case in which they were -originally specified. The values will be an array of strings for each -value for the respective header name. - -#### getHeader() - -The `getHeader(string $name): string[]` method can be used to -retrieve a message header value by the given case-insensitive name. - -Returns a list of all values for this header name or an empty array if header was not found - -#### getHeaderLine() - -The `getHeaderLine(string $name): string` method can be used to -retrieve a comma-separated string of the values for a single header. - -Returns a comma-separated list of all values for this header name or an empty string if header was not found - -#### hasHeader() +A `close` event will be emitted after an `error` or `end` event. -The `hasHeader(string $name): bool` method can be used to -check if a header exists by the given case-insensitive name. +For more details about the request body stream, check out the documentation of +[ReactPHP ReadableStreamInterface](https://github.com/reactphp/stream#readablestreaminterface). + +The `getSize(): ?int` method can be used if you only want to know the request +body size. +This method returns the complete size of the request body as defined by the +message boundaries. +This value may be `0` if the request message does not contain a request body +(such as a simple `GET` request). +Note that this value may be `null` if the request body size is unknown in +advance because the request message uses chunked transfer encoding. + +```php +$http = new Server($socket, function (RequestInterface $request, Response $response) { + $size = $request->getBody()->getSize(); + if ($size === null) { + $response->writeHead(411, array('Content-Type' => 'text/plain')); + $response->write('The request does not contain an explicit length.'); + $response->write('This server does not accept chunked transfer encoding.'); + $response->end(); + return; + } + $response->writeHead(200, array('Content-Type' => 'text/plain')); + $response->end("Request body size: " . $size . " bytes\n"); +}); +``` ### Response From cf5c8caa7457b37d622d26837a88b58cbdbafd03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 24 Mar 2017 11:58:20 +0100 Subject: [PATCH 085/128] Simplify examples to ease getting started --- README.md | 8 ++++++ examples/02-count-visitors.php | 21 +++++++++++++++ examples/03-stream-response.php | 27 +++++++++++++++++++ ...ng-body-data.php => 04-stream-request.php} | 0 ...rld-https.php => 11-hello-world-https.php} | 0 5 files changed, 56 insertions(+) create mode 100644 examples/02-count-visitors.php create mode 100644 examples/03-stream-response.php rename examples/{03-handling-body-data.php => 04-stream-request.php} (100%) rename examples/{02-hello-world-https.php => 11-hello-world-https.php} (100%) diff --git a/README.md b/README.md index 38117f22..611fc5b9 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ $http = new Server($socket, function (RequestInterface $request, Response $respo }); ``` +See also the [first example](examples) for more details. + Similarly, you can also attach this to a [`React\Socket\SecureServer`](https://github.com/reactphp/socket#secureserver) in order to start a secure HTTPS server like this: @@ -74,6 +76,8 @@ $http = new Server($socket, function (RequestInterface $request, Response $respo }); ``` +See also [example #11](examples) for more details. + When HTTP/1.1 clients want to send a bigger request body, they MAY send only the request headers with an additional `Expect: 100-continue` header and wait before sending the actual (large) message body. @@ -174,6 +178,8 @@ $http = new Server($socket, function (RequestInterface $request, Response $respo The above example simply counts the number of bytes received in the request body. This can be used as a skeleton for buffering or processing the request body. +See also [example #4](examples) for more details. + The `data` event will be emitted whenever new data is available on the request body stream. The server automatically takes care of decoding chunked transfer encoding @@ -224,6 +230,8 @@ The `Response` class is responsible for streaming the outgoing response body. It implements the `WritableStreamInterface`. +See also [example #3](examples) for more details. + The constructor is internal, you SHOULD NOT call this yourself. The `Server` is responsible for emitting `Request` and `Response` objects. diff --git a/examples/02-count-visitors.php b/examples/02-count-visitors.php new file mode 100644 index 00000000..df4bda06 --- /dev/null +++ b/examples/02-count-visitors.php @@ -0,0 +1,21 @@ +writeHead(200, array('Content-Type' => 'text/plain')); + $response->end("Welcome number " . ++$counter . "!\n"); +}); + +echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; + +$loop->run(); diff --git a/examples/03-stream-response.php b/examples/03-stream-response.php new file mode 100644 index 00000000..49617999 --- /dev/null +++ b/examples/03-stream-response.php @@ -0,0 +1,27 @@ +writeHead(200, array('Content-Type' => 'text/plain')); + + $timer = $loop->addPeriodicTimer(0.5, function () use ($response) { + $response->write(microtime(true) . PHP_EOL); + }); + $loop->addTimer(5, function() use ($loop, $timer, $response) { + $loop->cancelTimer($timer); + $response->end(); + }); +}); + +echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; + +$loop->run(); diff --git a/examples/03-handling-body-data.php b/examples/04-stream-request.php similarity index 100% rename from examples/03-handling-body-data.php rename to examples/04-stream-request.php diff --git a/examples/02-hello-world-https.php b/examples/11-hello-world-https.php similarity index 100% rename from examples/02-hello-world-https.php rename to examples/11-hello-world-https.php From 1122c4aa7b74e08c758c0b65d609997ea435cff3 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Fri, 10 Mar 2017 11:12:58 +0100 Subject: [PATCH 086/128] Return Promise with PSR-7 Response resolving --- composer.json | 1 + examples/01-hello-world.php | 12 +- src/ChunkedEncoder.php | 107 ++++++ src/Response.php | 279 ++-------------- tests/ChunkedEncoderTest.php | 83 +++++ tests/ResponseTest.php | 632 +---------------------------------- tests/ServerTest.php | 555 ++++++++++++++++++++++++------ 7 files changed, 684 insertions(+), 985 deletions(-) create mode 100644 src/ChunkedEncoder.php create mode 100644 tests/ChunkedEncoderTest.php diff --git a/composer.json b/composer.json index 43608b90..a08eb15f 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "ringcentral/psr7": "^1.2", "react/socket": "^0.5", "react/stream": "^0.6 || ^0.5 || ^0.4.4", + "react/promise": "^2.0 || ^1.1", "evenement/evenement": "^2.0 || ^1.0" }, "autoload": { diff --git a/examples/01-hello-world.php b/examples/01-hello-world.php index 49c12664..12c36a56 100644 --- a/examples/01-hello-world.php +++ b/examples/01-hello-world.php @@ -10,9 +10,15 @@ $loop = Factory::create(); $socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); -$server = new \React\Http\Server($socket, function (RequestInterface $request, Response $response) { - $response->writeHead(200, array('Content-Type' => 'text/plain')); - $response->end("Hello world!\n"); +$server = new \React\Http\Server($socket, function (RequestInterface $request) { + return new Response( + 200, + array( + 'Content-Length' => strlen("Hello world\n"), + 'Content-Type' => 'text/plain' + ), + "Hello world\n" + ); }); echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; diff --git a/src/ChunkedEncoder.php b/src/ChunkedEncoder.php new file mode 100644 index 00000000..69d88ac7 --- /dev/null +++ b/src/ChunkedEncoder.php @@ -0,0 +1,107 @@ +input = $input; + + $this->input->on('data', array($this, 'handleData')); + $this->input->on('end', array($this, 'handleEnd')); + $this->input->on('error', array($this, 'handleError')); + $this->input->on('close', array($this, 'close')); + } + + public function isReadable() + { + return !$this->closed && $this->input->isReadable(); + } + + public function pause() + { + $this->input->pause(); + } + + public function resume() + { + $this->input->resume(); + } + + public function pipe(WritableStreamInterface $dest, array $options = array()) + { + Util::pipe($this, $dest, $options); + + return $dest; + } + + public function close() + { + if ($this->closed) { + return; + } + + $this->closed = true; + + $this->readable = false; + + $this->emit('close'); + + $this->removeAllListeners(); + } + + /** @internal */ + public function handleData($data) + { + if ($data === '') { + return; + } + + $completeChunk = $this->createChunk($data); + + $this->emit('data', array($completeChunk)); + } + + /** @internal */ + public function handleError(\Exception $e) + { + $this->emit('error', array($e)); + $this->close(); + } + + /** @internal */ + public function handleEnd() + { + $this->emit('data', array("0\r\n\r\n")); + + if (!$this->closed) { + $this->emit('end'); + $this->close(); + } + } + + /** + * @param string $data - string to be transformed in an valid + * HTTP encoded chunk string + * @return string + */ + private function createChunk($data) + { + $byteSize = strlen($data); + $byteSize = dechex($byteSize); + $chunkBeginning = $byteSize . "\r\n"; + + return $chunkBeginning . $data . "\r\n"; + } + +} diff --git a/src/Response.php b/src/Response.php index 5442f7a5..49aadf85 100644 --- a/src/Response.php +++ b/src/Response.php @@ -2,262 +2,35 @@ namespace React\Http; -use Evenement\EventEmitter; -use React\Stream\WritableStreamInterface; +use RingCentral\Psr7\Response as Psr7Response; +use React\Stream\ReadableStreamInterface; +use React\Http\HttpBodyStream; /** - * The `Response` class is responsible for streaming the outgoing response body. - * - * It implements the `WritableStreamInterface`. - * - * The constructor is internal, you SHOULD NOT call this yourself. - * The `Server` is responsible for emitting `Request` and `Response` objects. - * - * The `Response` will automatically use the same HTTP protocol version as the - * corresponding `Request`. - * - * HTTP/1.1 responses will automatically apply chunked transfer encoding if - * no `Content-Length` header has been set. - * See `writeHead()` for more details. - * - * See the usage examples and the class outline for details. - * - * @see WritableStreamInterface - * @see Server + * Implementation of the PSR-7 ResponseInterface + * This class is an extension of RingCentral\Psr7\Response. + * The only difference is that this class will accept implemenations + * of the ReactPHPs ReadableStreamInterface for $body. */ -class Response extends EventEmitter implements WritableStreamInterface +class Response extends Psr7Response { - private $conn; - private $protocolVersion; - - private $closed = false; - private $writable = true; - private $headWritten = false; - private $chunkedEncoding = false; - - /** - * The constructor is internal, you SHOULD NOT call this yourself. - * - * The `Server` is responsible for emitting `Request` and `Response` objects. - * - * Constructor parameters may change at any time. - * - * @internal - */ - public function __construct(WritableStreamInterface $conn, $protocolVersion = '1.1') - { - $this->conn = $conn; - $this->protocolVersion = $protocolVersion; - - $that = $this; - $this->conn->on('close', array($this, 'close')); - - $this->conn->on('error', function ($error) use ($that) { - $that->emit('error', array($error)); - }); - - $this->conn->on('drain', function () use ($that) { - $that->emit('drain'); - }); - } - - public function isWritable() - { - return $this->writable; - } - - /** - * Writes the given HTTP message header. - * - * This method MUST be invoked once before calling `write()` or `end()` to send - * the actual HTTP message body: - * - * ```php - * $response->writeHead(200, array( - * 'Content-Type' => 'text/plain' - * )); - * $response->end('Hello World!'); - * ``` - * - * Calling this method more than once will result in an `Exception` - * (unless the response has ended/closed already). - * Calling this method after the response has ended/closed is a NOOP. - * - * Unless you specify a `Content-Length` header yourself, HTTP/1.1 responses - * will automatically use chunked transfer encoding and send the respective header - * (`Transfer-Encoding: chunked`) automatically. If you know the length of your - * body, you MAY specify it like this instead: - * - * ```php - * $data = 'Hello World!'; - * - * $response->writeHead(200, array( - * 'Content-Type' => 'text/plain', - * 'Content-Length' => strlen($data) - * )); - * $response->end($data); - * ``` - * - * Note that it will automatically assume a `X-Powered-By: react/alpha` header - * unless your specify a custom `X-Powered-By` header yourself: - * - * ```php - * $response->writeHead(200, array( - * 'X-Powered-By' => 'PHP 3' - * )); - * ``` - * - * If you do not want to send this header at all, you can use an empty array as - * value like this: - * - * ```php - * $response->writeHead(200, array( - * 'X-Powered-By' => array() - * )); - * ``` - * - * Note that persistent connections (`Connection: keep-alive`) are currently - * not supported. - * As such, HTTP/1.1 response messages will automatically include a - * `Connection: close` header, irrespective of what header values are - * passed explicitly. - * - * @param int $status - * @param array $headers - * @throws \Exception - */ - public function writeHead($status = 200, array $headers = array()) - { - if (!$this->writable) { - return; - } - if ($this->headWritten) { - throw new \Exception('Response head has already been written.'); - } - - $lower = array_change_key_case($headers); - - // assign default "X-Powered-By" header as first for history reasons - if (!isset($lower['x-powered-by'])) { - $headers = array_merge( - array('X-Powered-By' => 'React/alpha'), - $headers - ); - } - - // always remove transfer-encoding - foreach($headers as $name => $value) { - if (strtolower($name) === 'transfer-encoding') { - unset($headers[$name]); - } - } - - // assign date header if no 'date' is given, use the current time where this code is running - if (!isset($lower['date'])) { - // IMF-fixdate = day-name "," SP date1 SP time-of-day SP GMT - $headers['Date'] = gmdate('D, d M Y H:i:s') . ' GMT'; - } - - // assign chunked transfer-encoding if no 'content-length' is given for HTTP/1.1 responses - if (!isset($lower['content-length']) && $this->protocolVersion === '1.1') { - $headers['Transfer-Encoding'] = 'chunked'; - $this->chunkedEncoding = true; - } - - // HTTP/1.1 assumes persistent connection support by default - // we do not support persistent connections, so let the client know - if ($this->protocolVersion === '1.1') { - foreach($headers as $name => $value) { - if (strtolower($name) === 'connection') { - unset($headers[$name]); - } - } - - $headers['Connection'] = 'close'; - } - - $data = $this->formatHead($status, $headers); - $this->conn->write($data); - - $this->headWritten = true; - } - - private function formatHead($status, array $headers) - { - $status = (int) $status; - $text = isset(ResponseCodes::$statusTexts[$status]) ? ResponseCodes::$statusTexts[$status] : ''; - $data = "HTTP/$this->protocolVersion $status $text\r\n"; - - foreach ($headers as $name => $value) { - $name = str_replace(array("\r", "\n"), '', $name); - - foreach ((array) $value as $val) { - $val = str_replace(array("\r", "\n"), '', $val); - - $data .= "$name: $val\r\n"; - } - } - $data .= "\r\n"; - - return $data; - } - - public function write($data) - { - if (!$this->writable) { - return false; - } - if (!$this->headWritten) { - throw new \Exception('Response head has not yet been written.'); - } - - // prefix with chunk length for chunked transfer encoding - if ($this->chunkedEncoding) { - $len = strlen($data); - - // skip empty chunks - if ($len === 0) { - return true; - } - - $data = dechex($len) . "\r\n" . $data . "\r\n"; - } - - return $this->conn->write($data); - } - - public function end($data = null) - { - if (!$this->writable) { - return; - } - if (!$this->headWritten) { - throw new \Exception('Response head has not yet been written.'); - } - - if (null !== $data) { - $this->write($data); - } - - if ($this->chunkedEncoding) { - $this->conn->write("0\r\n\r\n"); - } - - $this->writable = false; - $this->conn->end(); - } - - public function close() - { - if ($this->closed) { - return; - } - - $this->closed = true; - $this->writable = false; - $this->conn->close(); - - $this->emit('close'); - $this->removeAllListeners(); + public function __construct( + $status = 200, + array $headers = array(), + $body = null, + $version = '1.1', + $reason = null + ) { + if ($body instanceof ReadableStreamInterface) { + $body = new HttpBodyStream($body, null); + } + + parent::__construct( + $status, + $headers, + $body, + $version, + $reason + ); } } diff --git a/tests/ChunkedEncoderTest.php b/tests/ChunkedEncoderTest.php new file mode 100644 index 00000000..ca8dc643 --- /dev/null +++ b/tests/ChunkedEncoderTest.php @@ -0,0 +1,83 @@ +input = new ReadableStream(); + $this->chunkedStream = new ChunkedEncoder($this->input); + } + + public function testChunked() + { + $this->chunkedStream->on('data', $this->expectCallableOnce(array("5\r\nhello\r\n"))); + $this->input->emit('data', array('hello')); + } + + public function testEmptyString() + { + $this->chunkedStream->on('data', $this->expectCallableNever()); + $this->input->emit('data', array('')); + } + + public function testBiggerStringToCheckHexValue() + { + $this->chunkedStream->on('data', $this->expectCallableOnce(array("1a\r\nabcdefghijklmnopqrstuvwxyz\r\n"))); + $this->input->emit('data', array('abcdefghijklmnopqrstuvwxyz')); + } + + public function testHandleClose() + { + $this->chunkedStream->on('close', $this->expectCallableOnce()); + + $this->input->close(); + + $this->assertFalse($this->chunkedStream->isReadable()); + } + + public function testHandleError() + { + $this->chunkedStream->on('error', $this->expectCallableOnce()); + $this->chunkedStream->on('close', $this->expectCallableOnce()); + + $this->input->emit('error', array(new \RuntimeException())); + + $this->assertFalse($this->chunkedStream->isReadable()); + } + + public function testPauseStream() + { + $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input->expects($this->once())->method('pause'); + + $parser = new ChunkedEncoder($input); + $parser->pause(); + } + + public function testResumeStream() + { + $input = $this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock(); + $input->expects($this->once())->method('pause'); + + $parser = new ChunkedEncoder($input); + $parser->pause(); + $parser->resume(); + } + + public function testPipeStream() + { + $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); + + $ret = $this->chunkedStream->pipe($dest); + + $this->assertSame($dest, $ret); + } +} diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 65fc0598..4d024956 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -3,637 +3,19 @@ namespace React\Tests\Http; use React\Http\Response; -use React\Stream\WritableStream; +use React\Stream\ReadableStream; class ResponseTest extends TestCase { - public function testResponseShouldBeChunkedByDefault() + public function testResponseBodyWillBeHttpBodyStream() { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "Transfer-Encoding: chunked\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array('Date' => array())); - } - - public function testResponseShouldNotBeChunkedWhenProtocolVersionIsNot11() - { - $expected = ''; - $expected .= "HTTP/1.0 200 OK\r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn, '1.0'); - $response->writeHead(200, array('Date' => array())); - } - - public function testResponseShouldBeChunkedEvenWithOtherTransferEncoding() - { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "Transfer-Encoding: chunked\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array('transfer-encoding' => 'custom', 'Date' => array())); - } - - public function testResponseShouldNotBeChunkedWithContentLength() - { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "Content-Length: 22\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array('Content-Length' => 22, 'Date' => array())); - } - - public function testResponseShouldNotBeChunkedWithContentLengthCaseInsensitive() - { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "CONTENT-LENGTH: 0\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array('CONTENT-LENGTH' => 0, 'Date' => array())); - } - - public function testResponseShouldIncludeCustomByPoweredAsFirstHeaderIfGivenExplicitly() - { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "Content-Length: 0\r\n"; - $expected .= "X-POWERED-BY: demo\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array('Content-Length' => 0, 'X-POWERED-BY' => 'demo', 'Date' => array())); - } - - public function testResponseShouldNotIncludePoweredByIfGivenEmptyArray() - { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "Content-Length: 0\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array('Content-Length' => 0, 'X-Powered-By' => array(), 'Date' => array())); - } - - public function testResponseShouldAlwaysIncludeConnectionCloseIrrespectiveOfExplicitValue() - { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "Content-Length: 0\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array('Content-Length' => 0, 'connection' => 'ignored', 'Date' => array())); - } - - /** @expectedException Exception */ - public function testWriteHeadTwiceShouldThrowException() - { - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write'); - - $response = new Response($conn); - $response->writeHead(); - $response->writeHead(); - } - - public function testEndWithoutDataWritesEndChunkAndEndsInput() - { - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->at(4)) - ->method('write') - ->with("0\r\n\r\n"); - $conn - ->expects($this->once()) - ->method('end'); - - $response = new Response($conn); - $response->writeHead(); - $response->end(); - } - - public function testEndWithDataWritesToInputAndEndsInputWithoutData() - { - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->at(4)) - ->method('write') - ->with("3\r\nbye\r\n"); - $conn - ->expects($this->at(5)) - ->method('write') - ->with("0\r\n\r\n"); - $conn - ->expects($this->once()) - ->method('end'); - - $response = new Response($conn); - $response->writeHead(); - $response->end('bye'); - } - - public function testEndWithoutDataWithoutChunkedEncodingWritesNoDataAndEndsInput() - { - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write'); - $conn - ->expects($this->once()) - ->method('end'); - - $response = new Response($conn); - $response->writeHead(200, array('Content-Length' => 0)); - $response->end(); - } - - /** @expectedException Exception */ - public function testEndWithoutHeadShouldThrowException() - { - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->never()) - ->method('end'); - - $response = new Response($conn); - $response->end(); - } - - /** @expectedException Exception */ - public function testWriteWithoutHeadShouldThrowException() - { - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->never()) - ->method('write'); - - $response = new Response($conn); - $response->write('test'); - } - - public function testResponseBodyShouldBeChunkedCorrectly() - { - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->at(4)) - ->method('write') - ->with("5\r\nHello\r\n"); - $conn - ->expects($this->at(5)) - ->method('write') - ->with("1\r\n \r\n"); - $conn - ->expects($this->at(6)) - ->method('write') - ->with("6\r\nWorld\n\r\n"); - $conn - ->expects($this->at(7)) - ->method('write') - ->with("0\r\n\r\n"); - - $response = new Response($conn); - $response->writeHead(); - - $response->write('Hello'); - $response->write(' '); - $response->write("World\n"); - $response->end(); - } - - public function testResponseBodyShouldSkipEmptyChunks() - { - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->at(4)) - ->method('write') - ->with("5\r\nHello\r\n"); - $conn - ->expects($this->at(5)) - ->method('write') - ->with("5\r\nWorld\r\n"); - $conn - ->expects($this->at(6)) - ->method('write') - ->with("0\r\n\r\n"); - - $response = new Response($conn); - $response->writeHead(); - - $response->write('Hello'); - $response->write(''); - $response->write('World'); - $response->end(); - } - - /** @test */ - public function shouldRemoveNewlinesFromHeaders() - { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "FooBar: BazQux\r\n"; - $expected .= "Transfer-Encoding: chunked\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array("Foo\nBar" => "Baz\rQux", 'Date' => array())); - } - - /** @test */ - public function missingStatusCodeTextShouldResultInNumberOnlyStatus() - { - $expected = ''; - $expected .= "HTTP/1.1 700 \r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "Transfer-Encoding: chunked\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(700, array('Date' => array())); + $response = new Response(200, array(), new ReadableStream()); + $this->assertInstanceOf('React\Http\HttpBodyStream', $response->getBody()); } - /** @test */ - public function shouldAllowArrayHeaderValues() + public function testStringBodyWillBePsr7Stream() { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "Set-Cookie: foo=bar\r\n"; - $expected .= "Set-Cookie: bar=baz\r\n"; - $expected .= "Transfer-Encoding: chunked\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array("Set-Cookie" => array("foo=bar", "bar=baz"), 'Date' => array())); - } - - /** @test */ - public function shouldIgnoreHeadersWithNullValues() - { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "Transfer-Encoding: chunked\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array("FooBar" => null, 'Date' => array())); - } - - public function testCloseClosesInputAndEmitsCloseEvent() - { - $input = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); - $input->expects($this->once())->method('close'); - - $response = new Response($input); - - $response->on('close', $this->expectCallableOnce()); - - $response->close(); - } - - public function testClosingInputEmitsCloseEvent() - { - $input = new WritableStream(); - $response = new Response($input); - - $response->on('close', $this->expectCallableOnce()); - - $input->close(); - } - - public function testCloseMultipleTimesEmitsCloseEventOnce() - { - $input = new WritableStream(); - $response = new Response($input); - - $response->on('close', $this->expectCallableOnce()); - - $response->close(); - $response->close(); - } - - public function testIsNotWritableAfterClose() - { - $input = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); - - $response = new Response($input); - - $response->close(); - - $this->assertFalse($response->isWritable()); - } - - public function testCloseAfterEndIsPassedThrough() - { - $input = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); - $input->expects($this->once())->method('end'); - $input->expects($this->once())->method('close'); - - $response = new Response($input); - - $response->writeHead(); - $response->end(); - $response->close(); - } - - public function testWriteAfterCloseIsNoOp() - { - $input = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); - $input->expects($this->once())->method('close'); - $input->expects($this->never())->method('write'); - - $response = new Response($input); - $response->close(); - - $this->assertFalse($response->write('noop')); - } - - public function testWriteHeadAfterCloseIsNoOp() - { - $input = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); - $input->expects($this->once())->method('close'); - $input->expects($this->never())->method('write'); - - $response = new Response($input); - $response->close(); - - $response->writeHead(); - } - - public function testEndAfterCloseIsNoOp() - { - $input = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); - $input->expects($this->once())->method('close'); - $input->expects($this->never())->method('write'); - $input->expects($this->never())->method('end'); - - $response = new Response($input); - $response->close(); - - $response->end('noop'); - } - - public function testErrorEventShouldBeForwardedWithoutClosing() - { - $input = new WritableStream(); - $response = new Response($input); - - $response->on('error', $this->expectCallableOnce()); - $response->on('close', $this->expectCallableNever()); - - $input->emit('error', array(new \RuntimeException())); - } - - public function testDrainEventShouldBeForwarded() - { - $input = new WritableStream(); - $response = new Response($input); - - $response->on('drain', $this->expectCallableOnce()); - - $input->emit('drain'); - } - - public function testContentLengthWillBeRemovedIfTransferEncodingIsGiven() - { - $expectedHeader = ''; - $expectedHeader .= "HTTP/1.1 200 OK\r\n"; - $expectedHeader .= "X-Powered-By: React/alpha\r\n"; - $expectedHeader .= "Content-Length: 4\r\n"; - $expectedHeader .= "Connection: close\r\n"; - $expectedHeader .= "\r\n"; - - $expectedBody = "hello"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->exactly(2)) - ->method('write') - ->withConsecutive( - array($expectedHeader), - array($expectedBody) - ); - - $response = new Response($conn, '1.1'); - $response->writeHead( - 200, - array( - 'Content-Length' => 4, - 'Transfer-Encoding' => 'chunked', - 'Date' => array() - ) - ); - $response->write('hello'); - } - - public function testDateHeaderWillUseServerTime() - { - $buffer = ''; - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->will( - $this->returnCallback( - function ($data) use (&$buffer) { - $buffer .= $data; - } - ) - ); - - $response = new Response($conn); - $response->writeHead(); - - $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); - $this->assertContains("Date:", $buffer); - } - - public function testDateHeaderWithCustomDate() - { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "Date: Tue, 15 Nov 1994 08:12:31 GMT\r\n"; - $expected .= "Transfer-Encoding: chunked\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array("Date" => "Tue, 15 Nov 1994 08:12:31 GMT")); - } - - public function testDateHeaderWillBeRemoved() - { - $expected = ''; - $expected .= "HTTP/1.1 200 OK\r\n"; - $expected .= "X-Powered-By: React/alpha\r\n"; - $expected .= "Transfer-Encoding: chunked\r\n"; - $expected .= "Connection: close\r\n"; - $expected .= "\r\n"; - - $conn = $this - ->getMockBuilder('React\Socket\ConnectionInterface') - ->getMock(); - $conn - ->expects($this->once()) - ->method('write') - ->with($expected); - - $response = new Response($conn); - $response->writeHead(200, array("Date" => array())); + $response = new Response(200, array(), 'hello'); + $this->assertInstanceOf('RingCentral\Psr7\Stream', $response->getBody()); } } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 40434f18..d9917375 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -3,8 +3,10 @@ namespace React\Tests\Http; use React\Http\Server; -use React\Http\Response; use Psr\Http\Message\RequestInterface; +use React\Http\Response; +use React\Stream\ReadableStream; +use React\Promise\Promise; class ServerTest extends TestCase { @@ -31,6 +33,9 @@ public function setUp() ) ->getMock(); + $this->connection->method('isWritable')->willReturn(true); + $this->connection->method('isReadable')->willReturn(true); + $this->socket = new SocketServerStub(); } @@ -47,7 +52,9 @@ public function testRequestEventWillNotBeEmittedForIncompleteHeaders() public function testRequestEventIsEmitted() { - $server = new Server($this->socket, $this->expectCallableOnce()); + $server = new Server($this->socket, function (RequestInterface $request) { + return \React\Promise\resolve(new Response()); + }); $this->socket->emit('connection', array($this->connection)); @@ -59,12 +66,24 @@ public function testRequestEvent() { $i = 0; $requestAssertion = null; - $responseAssertion = null; - $server = new Server($this->socket, function ($request, $response) use (&$i, &$requestAssertion, &$responseAssertion) { + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $server = new Server($this->socket, function ($request) use (&$i, &$requestAssertion) { $i++; $requestAssertion = $request; - $responseAssertion = $response; + return \React\Promise\resolve(new Response()); }); $this->connection @@ -83,13 +102,13 @@ public function testRequestEvent() $this->assertSame('GET', $requestAssertion->getMethod()); $this->assertSame('127.0.0.1', $requestAssertion->remoteAddress); - $this->assertInstanceOf('React\Http\Response', $responseAssertion); } public function testRequestPauseWillbeForwardedToConnection() { $server = new Server($this->socket, function (RequestInterface $request) { $request->getBody()->pause(); + return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->once())->method('pause'); @@ -108,6 +127,7 @@ public function testRequestResumeWillbeForwardedToConnection() { $server = new Server($this->socket, function (RequestInterface $request) { $request->getBody()->resume(); + return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->once())->method('resume'); @@ -121,6 +141,7 @@ public function testRequestCloseWillPauseConnection() { $server = new Server($this->socket, function (RequestInterface $request) { $request->getBody()->close(); + return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->once())->method('pause'); @@ -134,7 +155,9 @@ public function testRequestPauseAfterCloseWillNotBeForwarded() { $server = new Server($this->socket, function (RequestInterface $request) { $request->getBody()->close(); - $request->getBody()->pause(); + $request->getBody()->pause();# + + return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->once())->method('pause'); @@ -149,6 +172,8 @@ public function testRequestResumeAfterCloseWillNotBeForwarded() $server = new Server($this->socket, function (RequestInterface $request) { $request->getBody()->close(); $request->getBody()->resume(); + + return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->once())->method('pause'); @@ -165,6 +190,8 @@ public function testRequestEventWithoutBodyWillNotEmitData() $server = new Server($this->socket, function (RequestInterface $request) use ($never) { $request->getBody()->on('data', $never); + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -179,6 +206,8 @@ public function testRequestEventWithSecondDataEventWillEmitBodyData() $server = new Server($this->socket, function (RequestInterface $request) use ($once) { $request->getBody()->on('data', $once); + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -198,6 +227,8 @@ public function testRequestEventWithPartialBodyWillEmitData() $server = new Server($this->socket, function (RequestInterface $request) use ($once) { $request->getBody()->on('data', $once); + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -216,9 +247,8 @@ public function testRequestEventWithPartialBodyWillEmitData() public function testResponseContainsPoweredByHeader() { - $server = new Server($this->socket, function (RequestInterface $request, Response $response) { - $response->writeHead(); - $response->end(); + $server = new Server($this->socket, function (RequestInterface $request) { + return \React\Promise\resolve(new Response()); }); $buffer = ''; @@ -242,27 +272,11 @@ function ($data) use (&$buffer) { $this->assertContains("\r\nX-Powered-By: React/alpha\r\n", $buffer); } - public function testClosingResponseDoesNotSendAnyData() - { - $server = new Server($this->socket, function (RequestInterface $request, Response $response) { - $response->close(); - }); - - $this->connection->expects($this->never())->method('write'); - $this->connection->expects($this->never())->method('end'); - $this->connection->expects($this->once())->method('close'); - - $this->socket->emit('connection', array($this->connection)); - - $data = $this->createGetRequest(); - $this->connection->emit('data', array($data)); - } - public function testResponseContainsSameRequestProtocolVersionAndChunkedBodyForHttp11() { - $server = new Server($this->socket, function (RequestInterface $request, Response $response) { - $response->writeHead(); - $response->end('bye'); + $server = new Server($this->socket, function (RequestInterface $request) { + $response = new Response(200, array(), 'bye'); + return \React\Promise\resolve($response); }); $buffer = ''; @@ -284,14 +298,14 @@ function ($data) use (&$buffer) { $this->connection->emit('data', array($data)); $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); - $this->assertContains("\r\n\r\n3\r\nbye\r\n0\r\n\r\n", $buffer); + $this->assertContains("bye", $buffer); } public function testResponseContainsSameRequestProtocolVersionAndRawBodyForHttp10() { - $server = new Server($this->socket, function (RequestInterface $request, Response $response) { - $response->writeHead(); - $response->end('bye'); + $server = new Server($this->socket, function (RequestInterface $request) { + $response = new Response(200, array(), 'bye'); + return \React\Promise\resolve($response); }); $buffer = ''; @@ -313,7 +327,8 @@ function ($data) use (&$buffer) { $this->connection->emit('data', array($data)); $this->assertContains("HTTP/1.0 200 OK\r\n", $buffer); - $this->assertContains("\r\n\r\nbye", $buffer); + $this->assertContains("\r\n\r\n", $buffer); + $this->assertContains("bye", $buffer); } public function testRequestInvalidHttpProtocolVersionWillEmitErrorAndSendErrorResponse() @@ -344,8 +359,9 @@ function ($data) use (&$buffer) { $this->assertInstanceOf('InvalidArgumentException', $error); - $this->assertContains("HTTP/1.1 505 HTTP Version Not Supported\r\n", $buffer); - $this->assertContains("\r\n\r\nError 505: HTTP Version Not Supported", $buffer); + $this->assertContains("HTTP/1.1 505 HTTP Version not supported\r\n", $buffer); + $this->assertContains("\r\n\r\n", $buffer); + $this->assertContains("Error 505: HTTP Version Not Supported", $buffer); } public function testRequestOverflowWillEmitErrorAndSendErrorResponse() @@ -420,11 +436,13 @@ public function testBodyDataWillBeSendViaRequestEvent() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -447,12 +465,14 @@ public function testChunkedEncodedRequestWillBeParsedForRequestEvent() $errorEvent = $this->expectCallableNever(); $requestValidation = null; - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); $requestValidation = $request; + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -477,11 +497,13 @@ public function testChunkedEncodedRequestAdditionalDataWontBeEmitted() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); @@ -506,11 +528,13 @@ public function testEmptyChunkedEncodedRequest() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -532,11 +556,13 @@ public function testChunkedIsUpperCase() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); @@ -560,11 +586,13 @@ public function testChunkedIsMixedUpperAndLowerCase() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); @@ -678,7 +706,9 @@ function ($data) use (&$buffer) { public function testRequestHttp10WithoutHostEmitsRequestWithNoError() { - $server = new Server($this->socket, $this->expectCallableOnce()); + $server = new Server($this->socket, function (RequestInterface $request) { + return \React\Promise\resolve(new Response()); + }); $server->on('error', $this->expectCallableNever()); $this->socket->emit('connection', array($this->connection)); @@ -694,11 +724,13 @@ public function testWontEmitFurtherDataWhenContentLengthIsReached() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -722,11 +754,13 @@ public function testWontEmitFurtherDataWhenContentLengthIsReachedSplitted() $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); @@ -754,11 +788,13 @@ public function testContentLengthContainsZeroWillEmitEndEvent() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -779,11 +815,13 @@ public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillB $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -805,11 +843,13 @@ public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillB $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -835,12 +875,14 @@ public function testContentLengthWillBeIgnoredIfTransferEncodingIsSet() $errorEvent = $this->expectCallableNever(); $requestValidation = null; - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); $requestValidation = $request; + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -871,12 +913,14 @@ public function testInvalidContentLengthWillBeIgnoreddIfTransferEncodingIsSet() $errorEvent = $this->expectCallableNever(); $requestValidation = null; - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); $requestValidation = $request; + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -975,8 +1019,9 @@ function ($data) use (&$buffer) { public function testInvalidChunkHeaderResultsInErrorOnRequestStream() { $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); - $server = new Server($this->socket, function ($request, $response) use ($errorEvent){ + $server = new Server($this->socket, function ($request) use ($errorEvent){ $request->getBody()->on('error', $errorEvent); + return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->never())->method('close'); @@ -997,8 +1042,9 @@ public function testInvalidChunkHeaderResultsInErrorOnRequestStream() public function testTooLongChunkHeaderResultsInErrorOnRequestStream() { $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); - $server = new Server($this->socket, function ($request, $response) use ($errorEvent){ + $server = new Server($this->socket, function ($request) use ($errorEvent){ $request->getBody()->on('error', $errorEvent); + return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->never())->method('close'); @@ -1021,8 +1067,9 @@ public function testTooLongChunkHeaderResultsInErrorOnRequestStream() public function testTooLongChunkBodyResultsInErrorOnRequestStream() { $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); - $server = new Server($this->socket, function ($request, $response) use ($errorEvent){ + $server = new Server($this->socket, function ($request) use ($errorEvent){ $request->getBody()->on('error', $errorEvent); + return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->never())->method('close'); @@ -1043,8 +1090,9 @@ public function testTooLongChunkBodyResultsInErrorOnRequestStream() public function testUnexpectedEndOfConnectionWillResultsInErrorOnRequestStream() { $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); - $server = new Server($this->socket, function ($request, $response) use ($errorEvent){ + $server = new Server($this->socket, function ($request) use ($errorEvent){ $request->getBody()->on('error', $errorEvent); + return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->never())->method('close'); @@ -1065,7 +1113,9 @@ public function testUnexpectedEndOfConnectionWillResultsInErrorOnRequestStream() public function testErrorInChunkedDecoderNeverClosesConnection() { - $server = new Server($this->socket, $this->expectCallableOnce()); + $server = new Server($this->socket, function (RequestInterface $request) { + return \React\Promise\resolve(new Response()); + }); $this->connection->expects($this->never())->method('close'); $this->connection->expects($this->once())->method('pause'); @@ -1084,7 +1134,9 @@ public function testErrorInChunkedDecoderNeverClosesConnection() public function testErrorInLengthLimitedStreamNeverClosesConnection() { - $server = new Server($this->socket, $this->expectCallableOnce()); + $server = new Server($this->socket, function (RequestInterface $request) { + return \React\Promise\resolve(new Response()); + }); $this->connection->expects($this->never())->method('close'); $this->connection->expects($this->once())->method('pause'); @@ -1104,8 +1156,9 @@ public function testErrorInLengthLimitedStreamNeverClosesConnection() public function testCloseRequestWillPauseConnection() { - $server = new Server($this->socket, function ($request, $response) { + $server = new Server($this->socket, function ($request) { $request->getBody()->close(); + return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->never())->method('close'); @@ -1124,11 +1177,13 @@ public function testEndEventWillBeEmittedOnSimpleRequest() $endEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function ($request, $response) use ($dataEvent, $closeEvent, $endEvent, $errorEvent){ + $server = new Server($this->socket, function ($request) use ($dataEvent, $closeEvent, $endEvent, $errorEvent){ $request->getBody()->on('data', $dataEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->once())->method('pause'); @@ -1148,11 +1203,13 @@ public function testRequestWithoutDefinedLengthWillIgnoreDataEvent() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request, Response $response) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('error', $errorEvent); + + return \React\Promise\resolve(new Response()); }); $this->socket->emit('connection', array($this->connection)); @@ -1165,17 +1222,22 @@ public function testRequestWithoutDefinedLengthWillIgnoreDataEvent() public function testResponseWillBeChunkDecodedByDefault() { - $server = new Server($this->socket, function (RequestInterface $request, Response $response) { - $response->writeHead(); - $response->write('hello'); + $stream = new ReadableStream(); + $server = new Server($this->socket, function (RequestInterface $request) use ($stream) { + $response = new Response(200, array(), $stream); + return \React\Promise\resolve($response); }); + $buffer = ''; $this->connection - ->expects($this->exactly(2)) + ->expects($this->any()) ->method('write') - ->withConsecutive( - array($this->anything()), - array("5\r\nhello\r\n") + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) ); $this->socket->emit('connection', array($this->connection)); @@ -1183,25 +1245,30 @@ public function testResponseWillBeChunkDecodedByDefault() $data = $this->createGetRequest(); $this->connection->emit('data', array($data)); + $stream->emit('data', array('hello')); + + $this->assertContains("Transfer-Encoding: chunked", $buffer); + $this->assertContains("hello", $buffer); } public function testContentLengthWillBeRemovedForResponseStream() { - $server = new Server($this->socket, function (RequestInterface $request, Response $response) { - $response->writeHead( + $server = new Server($this->socket, function (RequestInterface $request) { + $response = new Response( 200, array( - 'Content-Length' => 4, + 'Content-Length' => 5, 'Transfer-Encoding' => 'chunked' - ) + ), + 'hello' ); - $response->write('hello'); + return \React\Promise\resolve($response); }); $buffer = ''; $this->connection - ->expects($this->exactly(2)) + ->expects($this->any()) ->method('write') ->will( $this->returnCallback( @@ -1218,40 +1285,43 @@ function ($data) use (&$buffer) { $this->connection->emit('data', array($data)); $this->assertNotContains("Transfer-Encoding: chunked", $buffer); - $this->assertContains("Content-Length: 4", $buffer); + $this->assertContains("Content-Length: 5", $buffer); $this->assertContains("hello", $buffer); } public function testOnlyAllowChunkedEncoding() { - $server = new Server($this->socket, function (RequestInterface $request, Response $response) { - $response->writeHead( + $stream = new ReadableStream(); + $server = new Server($this->socket, function (RequestInterface $request) use ($stream) { + $response = new Response( 200, array( 'Transfer-Encoding' => 'custom' - ) + ), + $stream ); - $response->write('hello'); + return \React\Promise\resolve($response); }); $buffer = ''; $this->connection - ->expects($this->exactly(2)) - ->method('write') - ->will( - $this->returnCallback( - function ($data) use (&$buffer) { - $buffer .= $data; - } - ) - ); + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); $this->connection->emit('data', array($data)); + $stream->emit('data', array('hello')); $this->assertContains('Transfer-Encoding: chunked', $buffer); $this->assertNotContains('Transfer-Encoding: custom', $buffer); @@ -1260,13 +1330,13 @@ function ($data) use (&$buffer) { public function testDateHeaderWillBeAddedWhenNoneIsGiven() { - $server = new Server($this->socket, function (RequestInterface $request, Response $response) { - $response->writeHead(200); + $server = new Server($this->socket, function (RequestInterface $request) { + return \React\Promise\resolve(new Response()); }); $buffer = ''; $this->connection - ->expects($this->once()) + ->expects($this->any()) ->method('write') ->will( $this->returnCallback( @@ -1289,13 +1359,14 @@ function ($data) use (&$buffer) { public function testAddCustomDateHeader() { - $server = new Server($this->socket, function (RequestInterface $request, Response $response) { - $response->writeHead(200, array("Date" => "Tue, 15 Nov 1994 08:12:31 GMT")); + $server = new Server($this->socket, function (RequestInterface $request) { + $response = new Response(200, array("Date" => "Tue, 15 Nov 1994 08:12:31 GMT")); + return \React\Promise\resolve($response); }); $buffer = ''; $this->connection - ->expects($this->once()) + ->expects($this->any()) ->method('write') ->will( $this->returnCallback( @@ -1318,13 +1389,14 @@ function ($data) use (&$buffer) { public function testRemoveDateHeader() { - $server = new Server($this->socket, function (RequestInterface $request, Response $response) { - $response->writeHead(200, array('Date' => array())); + $server = new Server($this->socket, function (RequestInterface $request) { + $response = new Response(200, array('Date' => '')); + return \React\Promise\resolve($response); }); $buffer = ''; $this->connection - ->expects($this->once()) + ->expects($this->any()) ->method('write') ->will( $this->returnCallback( @@ -1382,12 +1454,21 @@ function ($data) use (&$buffer) { public function test100ContinueRequestWillBeHandled() { - $server = new Server($this->socket, $this->expectCallableOnce()); + $server = new Server($this->socket, function (RequestInterface $request) { + return \React\Promise\resolve(new Response()); + }); + $buffer = ''; $this->connection - ->expects($this->once()) + ->expects($this->any()) ->method('write') - ->with("HTTP/1.1 100 Continue\r\n\r\n"); + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); $this->socket->emit('connection', array($this->connection)); @@ -1398,15 +1479,27 @@ public function test100ContinueRequestWillBeHandled() $data .= "\r\n"; $this->connection->emit('data', array($data)); + $this->assertContains("HTTP/1.1 100 Continue\r\n", $buffer); + $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); } public function testContinueWontBeSendForHttp10() { - $server = new Server($this->socket, $this->expectCallableOnce()); + $server = new Server($this->socket, function (RequestInterface $request) { + return \React\Promise\resolve(new Response()); + }); + $buffer = ''; $this->connection - ->expects($this->never()) - ->method('write'); + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); $this->socket->emit('connection', array($this->connection)); @@ -1415,13 +1508,14 @@ public function testContinueWontBeSendForHttp10() $data .= "\r\n"; $this->connection->emit('data', array($data)); + $this->assertContains("HTTP/1.0 200 OK\r\n", $buffer); + $this->assertNotContains("HTTP/1.1 100 Continue\r\n\r\n", $buffer); } public function testContinueWithLaterResponse() { - $server = new Server($this->socket, function (RequestInterface $request, Response $response) { - $response->writeHead(); - $response->end(); + $server = new Server($this->socket, function (RequestInterface $request) { + return \React\Promise\resolve(new Response()); }); @@ -1459,6 +1553,259 @@ public function testInvalidCallbackFunctionLeadsToException() $server = new Server($this->socket, 'invalid'); } + public function testHttpBodyStreamAsBodyWillStreamData() + { + $input = new ReadableStream(); + + $server = new Server($this->socket, function (RequestInterface $request) use ($input) { + $response = new Response(200, array(), $input); + return \React\Promise\resolve($response); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + $input->emit('data', array('1')); + $input->emit('data', array('23')); + + $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertContains("\r\n\r\n", $buffer); + $this->assertContains("1\r\n1\r\n", $buffer); + $this->assertContains("2\r\n23\r\n", $buffer); + } + + public function testHttpBodyStreamWithContentLengthWillStreamTillLength() + { + $input = new ReadableStream(); + + $server = new Server($this->socket, function (RequestInterface $request) use ($input) { + $response = new Response(200, array('Content-Length' => 5), $input); + return \React\Promise\resolve($response); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + $input->emit('data', array('hel')); + $input->emit('data', array('lo')); + + $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertContains("Content-Length: 5\r\n", $buffer); + $this->assertNotContains("Transfer-Encoding", $buffer); + $this->assertContains("\r\n\r\n", $buffer); + $this->assertContains("hello", $buffer); + } + + public function testCallbackFunctionReturnsPromise() + { + $server = new Server($this->socket, function (RequestInterface $request) { + return \React\Promise\resolve(new Response()); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertContains("\r\n\r\n", $buffer); + } + + public function testReturnInvalidTypeWillResultInError() + { + $server = new Server($this->socket, function (RequestInterface $request) { + return "invalid"; + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.0\r\n\r\n"; + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + } + + public function testResolveWrongTypeInPromiseWillResultInError() + { + $server = new Server($this->socket, function (RequestInterface $request) { + return \React\Promise\resolve("invalid"); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.0\r\n\r\n"; + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + } + + public function testRejectedPromiseWillResultInErrorMessage() + { + $server = new Server($this->socket, function (RequestInterface $request) { + return new Promise(function ($resolve, $reject) { + $reject(new \Exception()); + }); + }); + $server->on('error', $this->expectCallableOnce()); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.0\r\n\r\n"; + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + } + + public function testExcpetionInCallbackWillResultInErrorMessage() + { + $server = new Server($this->socket, function (RequestInterface $request) { + return new Promise(function ($resolve, $reject) { + throw new \Exception('Bad call'); + }); + }); + $server->on('error', $this->expectCallableOnce()); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.0\r\n\r\n"; + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + } + + public function testHeaderWillAlwaysBeContentLengthForStringBody() + { + $server = new Server($this->socket, function (RequestInterface $request) { + return new Response(200, array('Transfer-Encoding' => 'chunked'), 'hello'); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.0\r\n\r\n"; + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertContains("Content-Length: 5\r\n", $buffer); + $this->assertContains("hello", $buffer); + + $this->assertNotContains("Transfer-Encoding", $buffer); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From 324356b8996ab8c038959ea8fcbe48a6340a3c55 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Mon, 27 Mar 2017 12:32:49 +0200 Subject: [PATCH 087/128] Handle Exception in callback function --- tests/ServerTest.php | 108 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/tests/ServerTest.php b/tests/ServerTest.php index d9917375..a7d58e35 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -1657,6 +1657,11 @@ public function testReturnInvalidTypeWillResultInError() return "invalid"; }); + $exception = null; + $server->on('error', function (\Exception $ex) use (&$exception) { + $exception = $ex; + }); + $buffer = ''; $this->connection ->expects($this->any()) @@ -1678,6 +1683,7 @@ function ($data) use (&$buffer) { $this->connection->emit('data', array($data)); $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + $this->assertInstanceOf('RuntimeException', $exception); } public function testResolveWrongTypeInPromiseWillResultInError() @@ -1806,6 +1812,108 @@ function ($data) use (&$buffer) { $this->assertNotContains("Transfer-Encoding", $buffer); } + public function testReturnRequestWillBeHandled() + { + $server = new Server($this->socket, function (RequestInterface $request) { + return new Response(); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.0\r\n\r\n"; + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); + } + + public function testExceptionThrowInCallBackFunctionWillResultInErrorMessage() + { + $server = new Server($this->socket, function (RequestInterface $request) { + throw new \Exception('hello'); + }); + + $exception = null; + $server->on('error', function (\Exception $ex) use (&$exception) { + $exception = $ex; + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.0\r\n\r\n"; + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RuntimeException', $exception); + $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + $this->assertEquals('hello', $exception->getPrevious()->getMessage()); + } + + public function testRejectOfNonExceptionWillResultInErrorMessage() + { + $server = new Server($this->socket, function (RequestInterface $request) { + return new Promise(function ($resolve, $reject) { + $reject('Invalid type'); + }); + }); + + $exception = null; + $server->on('error', function (\Exception $ex) use (&$exception) { + $exception = $ex; + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.0\r\n\r\n"; + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 500 Internal Server Error\r\n", $buffer); + $this->assertInstanceOf('RuntimeException', $exception); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From ed96b938f8ca8ff30264f3c68ae0e69eed93a964 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Mon, 27 Mar 2017 02:41:15 +0200 Subject: [PATCH 088/128] Adapt examples to always returning a promise --- examples/01-hello-world.php | 2 +- examples/02-count-visitors.php | 9 ++++--- examples/03-stream-response.php | 20 ++++++++++----- examples/04-stream-request.php | 41 ++++++++++++++++++++----------- examples/05-error-handling.php | 35 ++++++++++++++++++++++++++ examples/11-hello-world-https.php | 9 ++++--- 6 files changed, 88 insertions(+), 28 deletions(-) create mode 100644 examples/05-error-handling.php diff --git a/examples/01-hello-world.php b/examples/01-hello-world.php index 12c36a56..ed84af11 100644 --- a/examples/01-hello-world.php +++ b/examples/01-hello-world.php @@ -4,6 +4,7 @@ use React\Socket\Server; use React\Http\Response; use Psr\Http\Message\RequestInterface; +use React\Promise\Promise; require __DIR__ . '/../vendor/autoload.php'; @@ -14,7 +15,6 @@ return new Response( 200, array( - 'Content-Length' => strlen("Hello world\n"), 'Content-Type' => 'text/plain' ), "Hello world\n" diff --git a/examples/02-count-visitors.php b/examples/02-count-visitors.php index df4bda06..2c384a3b 100644 --- a/examples/02-count-visitors.php +++ b/examples/02-count-visitors.php @@ -11,9 +11,12 @@ $socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); $counter = 0; -$server = new \React\Http\Server($socket, function (RequestInterface $request, Response $response) use (&$counter) { - $response->writeHead(200, array('Content-Type' => 'text/plain')); - $response->end("Welcome number " . ++$counter . "!\n"); +$server = new \React\Http\Server($socket, function (RequestInterface $request) use (&$counter) { + return new Response( + 200, + array('Content-Type' => 'text/plain'), + "Welcome number " . ++$counter . "!\n" + ); }); echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; diff --git a/examples/03-stream-response.php b/examples/03-stream-response.php index 49617999..5fd990e8 100644 --- a/examples/03-stream-response.php +++ b/examples/03-stream-response.php @@ -4,22 +4,30 @@ use React\Socket\Server; use React\Http\Response; use Psr\Http\Message\RequestInterface; +use React\Stream\ReadableStream; require __DIR__ . '/../vendor/autoload.php'; $loop = Factory::create(); $socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); -$server = new \React\Http\Server($socket, function (RequestInterface $request, Response $response) use ($loop) { - $response->writeHead(200, array('Content-Type' => 'text/plain')); +$server = new \React\Http\Server($socket, function (RequestInterface $request) use ($loop) { + $stream = new ReadableStream(); - $timer = $loop->addPeriodicTimer(0.5, function () use ($response) { - $response->write(microtime(true) . PHP_EOL); + $timer = $loop->addPeriodicTimer(0.5, function () use ($stream) { + $stream->emit('data', array(microtime(true) . PHP_EOL)); }); - $loop->addTimer(5, function() use ($loop, $timer, $response) { + + $loop->addTimer(5, function() use ($loop, $timer, $stream) { $loop->cancelTimer($timer); - $response->end(); + $stream->emit('end'); }); + + return new Response( + 200, + array('Content-Type' => 'text/plain'), + $stream + ); }); echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; diff --git a/examples/04-stream-request.php b/examples/04-stream-request.php index 9bd26c1e..481162ef 100644 --- a/examples/04-stream-request.php +++ b/examples/04-stream-request.php @@ -4,27 +4,38 @@ use React\Socket\Server; use React\Http\Response; use Psr\Http\Message\RequestInterface; +use React\Promise\Promise; require __DIR__ . '/../vendor/autoload.php'; $loop = Factory::create(); $socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); -$server = new \React\Http\Server($socket, function (RequestInterface $request, Response $response) { - $contentLength = 0; - $request->getBody()->on('data', function ($data) use (&$contentLength) { - $contentLength += strlen($data); - }); - - $request->getBody()->on('end', function () use ($response, &$contentLength){ - $response->writeHead(200, array('Content-Type' => 'text/plain')); - $response->end("The length of the submitted request body is: " . $contentLength); - }); - - // an error occures e.g. on invalid chunked encoded data or an unexpected 'end' event - $request->getBody()->on('error', function (\Exception $exception) use ($response, &$contentLength) { - $response->writeHead(400, array('Content-Type' => 'text/plain')); - $response->end("An error occured while reading at length: " . $contentLength); +$server = new \React\Http\Server($socket, function (RequestInterface $request) { + return new Promise(function ($resolve, $reject) use ($request) { + $contentLength = 0; + $request->getBody()->on('data', function ($data) use (&$contentLength) { + $contentLength += strlen($data); + }); + + $request->getBody()->on('end', function () use ($resolve, &$contentLength){ + $response = new Response( + 200, + array('Content-Type' => 'text/plain'), + "The length of the submitted request body is: " . $contentLength + ); + $resolve($response); + }); + + // an error occures e.g. on invalid chunked encoded data or an unexpected 'end' event + $request->getBody()->on('error', function (\Exception $exception) use ($resolve, &$contentLength) { + $response = new Response( + 400, + array('Content-Type' => 'text/plain'), + "An error occured while reading at length: " . $contentLength + ); + $resolve($response); + }); }); }); diff --git a/examples/05-error-handling.php b/examples/05-error-handling.php new file mode 100644 index 00000000..29f54b92 --- /dev/null +++ b/examples/05-error-handling.php @@ -0,0 +1,35 @@ + 'text/plain'), + "Hello World!\n" + ); + + $resolve($response); + }); +}); + +echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; + +$loop->run(); diff --git a/examples/11-hello-world-https.php b/examples/11-hello-world-https.php index 29e5aab2..191e7d62 100644 --- a/examples/11-hello-world-https.php +++ b/examples/11-hello-world-https.php @@ -14,9 +14,12 @@ 'local_cert' => isset($argv[2]) ? $argv[2] : __DIR__ . '/localhost.pem' )); -$server = new \React\Http\Server($socket, function (RequestInterface $request, Response $response) { - $response->writeHead(200, array('Content-Type' => 'text/plain')); - $response->end("Hello world!\n"); +$server = new \React\Http\Server($socket, function (RequestInterface $request) { + return new Response( + 200, + array('Content-Type' => 'text/plain'), + "Hello world!\n" + ); }); //$socket->on('error', 'printf'); From e52ea3887137c7b50a333d94e1bcd75e4f0bed55 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Fri, 24 Mar 2017 13:57:30 +0100 Subject: [PATCH 089/128] Update README --- README.md | 279 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 192 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 611fc5b9..c7e14212 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,6 @@ Event-driven, streaming plaintext HTTP and secure HTTPS server for [ReactPHP](ht * [Server](#server) * [Request](#request) * [Response](#response) - * [writeHead()](#writehead) * [Install](#install) * [Tests](#tests) * [License](#license) @@ -24,9 +23,12 @@ This is an HTTP server which responds with `Hello World` to every request. $loop = React\EventLoop\Factory::create(); $socket = new React\Socket\Server(8080, $loop); -$http = new Server($socket, function (RequestInterface $request, Response $response) { - $response->writeHead(200, array('Content-Type' => 'text/plain')); - $response->end("Hello World!\n"); +$http = new Server($socket, function (RequestInterface $request) { + return new Response( + 200, + array('Content-Type' => 'text/plain'), + "Hello World!\n" + ); }); $loop->run(); @@ -52,9 +54,12 @@ constructor with the respective [request](#request) and ```php $socket = new React\Socket\Server(8080, $loop); -$http = new Server($socket, function (RequestInterface $request, Response $response) { - $response->writeHead(200, array('Content-Type' => 'text/plain')); - $response->end("Hello World!\n"); +$http = new Server($socket, function (RequestInterface $request) { + return new Response( + 200, + array('Content-Type' => 'text/plain'), + "Hello World!\n" + ); }); ``` @@ -70,9 +75,12 @@ $socket = new React\Socket\SecureServer($socket, $loop, array( 'local_cert' => __DIR__ . '/localhost.pem' )); -$http = new Server($socket, function (RequestInterface $request, Response $response) { - $response->writeHead(200, array('Content-Type' => 'text/plain')); - $response->end("Hello World!\n"); +$http = new Server($socket, function (RequestInterface $request) { + return new Response( + 200, + array('Content-Type' => 'text/plain'), + "Hello World!\n" + ); }); ``` @@ -102,6 +110,22 @@ $http->on('error', function (Exception $e) { }); ``` +The server will also emit an `error` event if you return an invalid +type in the callback function or have a unhandled `Exception`. +If your callback function throws an exception, +the `Server` will emit a `RuntimeException` and add the thrown exception +as previous: + +```php +$http->on('error', function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; + if ($e->getPrevious() !== null) { + $previousException = $e->getPrevious(); + echo $previousException->getMessage() . PHP_EOL; + } +}); +``` + Note that the request object can also emit an error. Check out [request](#request) for more details. @@ -117,10 +141,15 @@ This request object implements the and will be passed to the callback function like this. ```php -$http = new Server($socket, function (RequestInterface $request, Response $response) { - $response->writeHead(200, array('Content-Type' => 'text/plain')); - $response->write("The method of the request is: " . $request->getMethod()); - $response->end("The requested path is: " . $request->getUri()->getPath()); +$http = new Server($socket, function (RequestInterface $request) { + $body = "The method of the request is: " . $request->getMethod(); + $body .= "The requested path is: " . $request->getUri()->getPath(); + + return new Response( + 200, + array('Content-Type' => 'text/plain'), + $body + ); }); ``` @@ -155,22 +184,31 @@ Instead, you should use the `ReactPHP ReadableStreamInterface` which gives you access to the incoming request body as the individual chunks arrive: ```php -$http = new Server($socket, function (RequestInterface $request, Response $response) { - $contentLength = 0; - $body = $request->getBody(); - $body->on('data', function ($data) use (&$contentLength) { - $contentLength += strlen($data); - }); - - $body->on('end', function () use ($response, &$contentLength){ - $response->writeHead(200, array('Content-Type' => 'text/plain')); - $response->end("The length of the submitted request body is: " . $contentLength); - }); - - // an error occures e.g. on invalid chunked encoded data or an unexpected 'end' event - $body->on('error', function (\Exception $exception) use ($response, &$contentLength) { - $response->writeHead(400, array('Content-Type' => 'text/plain')); - $response->end("An error occured while reading at length: " . $contentLength); +$http = new Server($socket, function (RequestInterface $request) { + return new Promise(function ($resolve, $reject) use ($request) { + $contentLength = 0; + $request->getBody()->on('data', function ($data) use (&$contentLength) { + $contentLength += strlen($data); + }); + + $request->getBody()->on('end', function () use ($resolve, &$contentLength){ + $response = new Response( + 200, + array('Content-Type' => 'text/plain'), + "The length of the submitted request body is: " . $contentLength + ); + $resolve($response); + }); + + // an error occures e.g. on invalid chunked encoded data or an unexpected 'end' event + $request->getBody()->on('error', function (\Exception $exception) use ($resolve, &$contentLength) { + $response = new Response( + 400, + array('Content-Type' => 'text/plain'), + "An error occured while reading at length: " . $contentLength + ); + $resolve($response); + }); }); }); ``` @@ -210,109 +248,176 @@ Note that this value may be `null` if the request body size is unknown in advance because the request message uses chunked transfer encoding. ```php -$http = new Server($socket, function (RequestInterface $request, Response $response) { +$http = new Server($socket, function (RequestInterface $request) { $size = $request->getBody()->getSize(); if ($size === null) { - $response->writeHead(411, array('Content-Type' => 'text/plain')); - $response->write('The request does not contain an explicit length.'); - $response->write('This server does not accept chunked transfer encoding.'); - $response->end(); - return; + $body = 'The request does not contain an explicit length.'; + $body .= 'This server does not accept chunked transfer encoding.'; + + return new Response( + 411, + array('Content-Type' => 'text/plain'), + $body + ); } - $response->writeHead(200, array('Content-Type' => 'text/plain')); - $response->end("Request body size: " . $size . " bytes\n"); + + return new Response( + 200, + array('Content-Type' => 'text/plain'), + "Request body size: " . $size . " bytes\n" + ); }); ``` ### Response -The `Response` class is responsible for streaming the outgoing response body. +The callback function passed to the constructor of the [Server](#server) +is responsible for processing the request and returning a response, +which will be delivered to the client. +This function MUST return an instance imlementing +[PSR-7 ResponseInterface](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message.md#33-psrhttpmessageresponseinterface) +object or a +[ReactPHP Promise](https://github.com/reactphp/promise#reactpromise) +which will resolve a `PSR-7 ResponseInterface` object. + +You will find a `Response` class +which implements the `PSR-7 ResponseInterface` in this project. +We use instantiation of this class in our projects, +but feel free to use any implemantation of the +`PSR-7 ResponseInterface` you prefer. -It implements the `WritableStreamInterface`. - -See also [example #3](examples) for more details. +```php +$http = new Server($socket, function (RequestInterface $request) { + return new Response( + 200, + array('Content-Type' => 'text/plain'), + "Hello World!\n" + ); +}); +``` -The constructor is internal, you SHOULD NOT call this yourself. -The `Server` is responsible for emitting `Request` and `Response` objects. +The example above returns the response directly, because it needs +no time to be processed. +Using a database, the file system or long calculations +(in fact every action that will take >=1ms) to create your +response, will slow down the server. +To prevent this you SHOULD use a +[ReactPHP Promise](https://github.com/reactphp/promise#reactpromise). +This example shows how such a long-term action could look like: -The `Response` will automatically use the same HTTP protocol version as the -corresponding `Request`. +```php +$server = new \React\Http\Server($socket, function (RequestInterface $request) use ($loop) { + return new Promise(function ($resolve, $reject) use ($request, $loop) { + $loop->addTimer(1.5, function() use ($loop, $resolve) { + $response = new Response( + 200, + array('Content-Type' => 'text/plain'), + "Hello world" + ); + $resolve($response); + }); + }); +}); +``` -HTTP/1.1 responses will automatically apply chunked transfer encoding if -no `Content-Length` header has been set. -See [`writeHead()`](#writehead) for more details. +The above example will create a response after 1.5 second. +This example shows that you need a promise, +if your response needs time to created. +The `ReactPHP Promise` will resolve in a `Response` object when the request +body ends. -See the above usage example and the class outline for details. +The `Response` class in this project supports to add an instance which implements the +[ReactPHP ReadableStreamInterface](https://github.com/reactphp/stream#readablestreaminterface) +for the response body. +So you are able stream data directly into the response body. +Note that other implementations of the `PSR-7 ResponseInterface` likely +only support string. -#### writeHead() +```php +$server = new Server($socket, function (RequestInterface $request) use ($loop) { + $stream = new ReadableStream(); -The `writeHead(int $status = 200, array $headers = array(): void` method can be used to -write the given HTTP message header. + $timer = $loop->addPeriodicTimer(0.5, function () use ($stream) { + $stream->emit('data', array(microtime(true) . PHP_EOL)); + }); -This method MUST be invoked once before calling `write()` or `end()` to send -the actual HTTP message body: + $loop->addTimer(5, function() use ($loop, $timer, $stream) { + $loop->cancelTimer($timer); + $stream->emit('end'); + }); -```php -$response->writeHead(200, array( - 'Content-Type' => 'text/plain' -)); -$response->end('Hello World!'); + return new Response(200, array('Content-Type' => 'text/plain'), $stream); +}); ``` -Calling this method more than once will result in an `Exception` -(unless the response has ended/closed already). -Calling this method after the response has ended/closed is a NOOP. +The above example will emit every 0.5 seconds the current Unix timestamp +with microseconds as float to the client and will end after 5 seconds. +This is just a example you could use of the streaming, +you could also send a big amount of data via little chunks +or use it for body data that needs to calculated. -Unless you specify a `Content-Length` header yourself, HTTP/1.1 responses -will automatically use chunked transfer encoding and send the respective header +If the response body is a `string` a `Content-Length` header will be added automatically. +Unless you specify a `Content-Length` header for a ReactPHP `ReadableStreamInterface` +response body yourself, HTTP/1.1 responses will automatically use chunked transfer encoding +and send the respective header (`Transfer-Encoding: chunked`) automatically. The server is responsible for handling `Transfer-Encoding` so you SHOULD NOT pass it yourself. -If you know the length of your body, you MAY specify it like this instead: +If you know the length of your stream body, you MAY specify it like this instead: ```php -$data = 'Hello World!'; - -$response->writeHead(200, array( - 'Content-Type' => 'text/plain', - 'Content-Length' => strlen($data) -)); -$response->end($data); +$stream = new ReadableStream() +$server = new Server($socket, function (RequestInterface $request) use ($loop, $stream) { + return new Response( + 200, + array( + 'Content-Length' => '5', + 'Content-Type' => 'text/plain', + ), + $stream + ); +}); ``` +An invalid return value or an unhandled `Exception` in the code of the callback +function, will result in an `500 Internal Server Error` message. +Make sure to catch `Exceptions` to create own response messages. + +After the return in the callback function the response will be processed by the `Server`. +The `Server` will add the protocol version of the request, so you don't have to. A `Date` header will be automatically added with the system date and time if none is given. You can add a custom `Date` header yourself like this: ```php -$response->writeHead(200, array( - 'Date' => date('D, d M Y H:i:s T') -)); +$server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array('Date' => date('D, d M Y H:i:s T'))); +}); ``` If you don't have a appropriate clock to rely on, you should -unset this header with an empty array: +unset this header with an empty string: ```php -$response->writeHead(200, array( - 'Date' => array() -)); +$server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array('Date' => '')); +}); ``` Note that it will automatically assume a `X-Powered-By: react/alpha` header unless your specify a custom `X-Powered-By` header yourself: ```php -$response->writeHead(200, array( - 'X-Powered-By' => 'PHP 3' -)); +$server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array('X-Powered-By' => 'PHP 3')); +}); ``` -If you do not want to send this header at all, you can use an empty array as +If you do not want to send this header at all, you can use an empty string as value like this: ```php -$response->writeHead(200, array( - 'X-Powered-By' => array() -)); +$server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array('X-Powered-By' => '')); +}); ``` Note that persistent connections (`Connection: keep-alive`) are currently From e21e94699d3bbc7c630618f2a07b45e06dceb428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 30 Mar 2017 15:41:48 +0200 Subject: [PATCH 090/128] Forward compatibility with upcoming Socket v0.6 and v0.7 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index a08eb15f..ba6ac438 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ "require": { "php": ">=5.3.0", "ringcentral/psr7": "^1.2", - "react/socket": "^0.5", + "react/socket": "^0.7 || ^0.6 || ^0.5", "react/stream": "^0.6 || ^0.5 || ^0.4.4", "react/promise": "^2.0 || ^1.1", "evenement/evenement": "^2.0 || ^1.0" From 9c38e42f427200e21323dc1a758c50f08b658903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 30 Mar 2017 23:12:01 +0200 Subject: [PATCH 091/128] Always use same HTTP protocol version for automatic error responses --- tests/ServerTest.php | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/ServerTest.php b/tests/ServerTest.php index a7d58e35..284a6c9d 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -1452,6 +1452,39 @@ function ($data) use (&$buffer) { $this->assertInstanceOf('InvalidArgumentException', $error); } + public function testOnlyChunkedEncodingIsAllowedForTransferEncodingWithHttp10() + { + $error = null; + + $server = new Server($this->socket, $this->expectCallableNever()); + $server->on('error', function ($exception) use (&$error) { + $error = $exception; + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.0\r\n"; + $data .= "Transfer-Encoding: custom\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.0 501 Not Implemented\r\n", $buffer); + $this->assertContains("\r\n\r\nError 501: Not Implemented", $buffer); + $this->assertInstanceOf('InvalidArgumentException', $error); + } + public function test100ContinueRequestWillBeHandled() { $server = new Server($this->socket, function (RequestInterface $request) { From ca19b0233e8f332e8f1bb509f17a1429c80ec3d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 31 Mar 2017 00:03:40 +0200 Subject: [PATCH 092/128] Responses to HEAD requests and certain status codes never contain a body See https://tools.ietf.org/html/rfc7230#section-3.3 --- README.md | 7 +++ tests/ServerTest.php | 117 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/README.md b/README.md index c7e14212..03b037d8 100644 --- a/README.md +++ b/README.md @@ -377,6 +377,7 @@ $server = new Server($socket, function (RequestInterface $request) use ($loop, $ ); }); ``` + An invalid return value or an unhandled `Exception` in the code of the callback function, will result in an `500 Internal Server Error` message. Make sure to catch `Exceptions` to create own response messages. @@ -384,6 +385,12 @@ Make sure to catch `Exceptions` to create own response messages. After the return in the callback function the response will be processed by the `Server`. The `Server` will add the protocol version of the request, so you don't have to. +Any response to a `HEAD` request and any response with a `1xx` (Informational), +`204` (No Content) or `304` (Not Modified) status code will *not* include a +message body as per the HTTP spec. +This means that your callback does not have to take special care of this and any +response body will simply be ignored. + A `Date` header will be automatically added with the system date and time if none is given. You can add a custom `Date` header yourself like this: diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 284a6c9d..280ad702 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -331,6 +331,87 @@ function ($data) use (&$buffer) { $this->assertContains("bye", $buffer); } + public function testResponseContainsNoResponseBodyForHeadRequest() + { + $server = new Server($this->socket, function (RequestInterface $request) { + return new Response(200, array(), 'bye'); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "HEAD / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertNotContains("bye", $buffer); + } + + public function testResponseContainsNoResponseBodyForNoContentStatus() + { + $server = new Server($this->socket, function (RequestInterface $request) { + return new Response(204, array(), 'bye'); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 204 No Content\r\n", $buffer); + $this->assertNotContains("bye", $buffer); + } + + public function testResponseContainsNoResponseBodyForNotModifiedStatus() + { + $server = new Server($this->socket, function (RequestInterface $request) { + return new Response(304, array(), 'bye'); + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 304 Not Modified\r\n", $buffer); + $this->assertNotContains("bye", $buffer); + } + public function testRequestInvalidHttpProtocolVersionWillEmitErrorAndSendErrorResponse() { $error = null; @@ -980,6 +1061,42 @@ function ($data) use (&$buffer) { $this->assertInstanceOf('InvalidArgumentException', $error); } + public function testNonIntegerContentLengthValueWillLeadToErrorWithNoBodyForHeadRequest() + { + $error = null; + $server = new Server($this->socket, $this->expectCallableNever()); + $server->on('error', function ($message) use (&$error) { + $error = $message; + }); + + $buffer = ''; + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "HEAD / HTTP/1.1\r\n"; + $data .= "Host: example.com:80\r\n"; + $data .= "Connection: close\r\n"; + $data .= "Content-Length: bla\r\n"; + $data .= "\r\n"; + $data .= "hello"; + + $this->connection->emit('data', array($data)); + + $this->assertContains("HTTP/1.1 400 Bad Request\r\n", $buffer); + $this->assertNotContains("\r\n\r\nError 400: Bad Request", $buffer); + $this->assertInstanceOf('InvalidArgumentException', $error); + } + public function testMultipleIntegerInContentLengthWillLeadToError() { $error = null; From 5b9d143ca20f1817d3c2813145d184c234f4216e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 11 Mar 2017 17:52:00 +0100 Subject: [PATCH 093/128] Support asterisk-form request target for OPTIONS method See https://tools.ietf.org/html/rfc7230#section-5.3.4 --- src/RequestHeaderParser.php | 12 ++++++++++++ tests/ServerTest.php | 23 ++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php index d9feda1a..b24325f2 100644 --- a/src/RequestHeaderParser.php +++ b/src/RequestHeaderParser.php @@ -55,8 +55,20 @@ private function parseRequest($data) { list($headers, $bodyBuffer) = explode("\r\n\r\n", $data, 2); + $asterisk = false; + if (strpos($headers, 'OPTIONS * ') === 0) { + $asterisk = true; + $headers = 'OPTIONS / ' . substr($headers, 10); + } + $request = g7\parse_request($headers); + if ($asterisk) { + $request = $request->withUri( + $request->getUri()->withPath('') + )->withRequestTarget('*'); + } + return array($request, $bodyBuffer); } } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 280ad702..26f47173 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -98,10 +98,31 @@ function ($data) use (&$buffer) { $this->assertSame(1, $i); $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); - $this->assertSame('/', $requestAssertion->getUri()->getPath()); $this->assertSame('GET', $requestAssertion->getMethod()); + $this->assertSame('/', $requestAssertion->getRequestTarget()); + $this->assertSame('/', $requestAssertion->getUri()->getPath()); + $this->assertSame('http://example.com/', (string)$requestAssertion->getUri()); $this->assertSame('127.0.0.1', $requestAssertion->remoteAddress); + } + + public function testRequestOptionsAsterisk() + { + $requestAssertion = null; + $server = new Server($this->socket, function ($request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + $data = "OPTIONS * HTTP/1.1\r\nHost: example.com\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('OPTIONS', $requestAssertion->getMethod()); + $this->assertSame('*', $requestAssertion->getRequestTarget()); + $this->assertSame('', $requestAssertion->getUri()->getPath()); + $this->assertSame('http://example.com', (string)$requestAssertion->getUri()); } public function testRequestPauseWillbeForwardedToConnection() From 0f907ab2130afc459bb10ee446ab7827834bd98e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 31 Mar 2017 01:21:36 +0200 Subject: [PATCH 094/128] Certain status codes never contain a body length See https://tools.ietf.org/html/rfc7230#section-3.3.1 and https://tools.ietf.org/html/rfc7230#section-3.3.2 --- README.md | 24 +++++++++++++++++------- tests/ServerTest.php | 4 +++- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 03b037d8..7a5ca69b 100644 --- a/README.md +++ b/README.md @@ -356,12 +356,14 @@ This is just a example you could use of the streaming, you could also send a big amount of data via little chunks or use it for body data that needs to calculated. -If the response body is a `string` a `Content-Length` header will be added automatically. -Unless you specify a `Content-Length` header for a ReactPHP `ReadableStreamInterface` -response body yourself, HTTP/1.1 responses will automatically use chunked transfer encoding -and send the respective header -(`Transfer-Encoding: chunked`) automatically. The server is responsible for handling -`Transfer-Encoding` so you SHOULD NOT pass it yourself. +If the response body is a `string`, a `Content-Length` header will be added +automatically. +If the response body is a ReactPHP `ReadableStreamInterface` and you do not +specify a `Content-Length` header, HTTP/1.1 responses will automatically use +chunked transfer encoding and send the respective header +(`Transfer-Encoding: chunked`) automatically. +The server is responsible for handling `Transfer-Encoding`, so you SHOULD NOT +pass this header yourself. If you know the length of your stream body, you MAY specify it like this instead: ```php @@ -387,10 +389,18 @@ The `Server` will add the protocol version of the request, so you don't have to. Any response to a `HEAD` request and any response with a `1xx` (Informational), `204` (No Content) or `304` (Not Modified) status code will *not* include a -message body as per the HTTP spec. +message body as per the HTTP specs. This means that your callback does not have to take special care of this and any response body will simply be ignored. +Similarly, any response with a `1xx` (Informational) or `204` (No Content) +status code will *not* include a `Content-Length` or `Transfer-Encoding` +header as these do not apply to these messages. +Note that a response to a `HEAD` request and any response with a `304` (Not +Modified) status code MAY include these headers even though +the message does not contain a response body, because these header would apply +to the message if the same request would have used an (unconditional) `GET`. + A `Date` header will be automatically added with the system date and time if none is given. You can add a custom `Date` header yourself like this: diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 26f47173..4534edaa 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -379,7 +379,7 @@ function ($data) use (&$buffer) { $this->assertNotContains("bye", $buffer); } - public function testResponseContainsNoResponseBodyForNoContentStatus() + public function testResponseContainsNoResponseBodyAndNoContentLengthForNoContentStatus() { $server = new Server($this->socket, function (RequestInterface $request) { return new Response(204, array(), 'bye'); @@ -403,6 +403,7 @@ function ($data) use (&$buffer) { $this->connection->emit('data', array($data)); $this->assertContains("HTTP/1.1 204 No Content\r\n", $buffer); + $this->assertNotContains("\r\n\Content-Length: 3\r\n", $buffer); $this->assertNotContains("bye", $buffer); } @@ -430,6 +431,7 @@ function ($data) use (&$buffer) { $this->connection->emit('data', array($data)); $this->assertContains("HTTP/1.1 304 Not Modified\r\n", $buffer); + $this->assertContains("\r\nContent-Length: 3\r\n", $buffer); $this->assertNotContains("bye", $buffer); } From 625c51f9905dbbc10b34e9ac4d6a2e8b12643870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Wed, 29 Mar 2017 20:14:02 +0200 Subject: [PATCH 095/128] Support CONNECT method --- README.md | 38 +++++++- composer.json | 3 +- examples/21-connect-proxy.php | 77 +++++++++++++++ src/RequestHeaderParser.php | 29 +++++- tests/ServerTest.php | 174 ++++++++++++++++++++++++++++++---- 5 files changed, 296 insertions(+), 25 deletions(-) create mode 100644 examples/21-connect-proxy.php diff --git a/README.md b/README.md index 7a5ca69b..1006559f 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,27 @@ $http = new Server($socket, function (RequestInterface $request) { }); ``` +Note that the server supports *any* request method (including custom and non- +standard ones) and all request-target formats defined in the HTTP specs for each +respective method. +You can use `getMethod(): string` and `getRequestTarget(): string` to +check this is an accepted request and may want to reject other requests with +an appropriate error code, such as `400` (Bad Request) or `405` (Method Not +Allowed). + +> The `CONNECT` method is useful in a tunneling setup (HTTPS proxy) and not + something most HTTP servers would want to care about. + Note that if you want to handle this method, the client MAY send a different + request-target than the `Host` header field (such as removing default ports) + and the request-target MUST take precendence when forwarding. + The HTTP specs define an opaque "tunneling mode" for this method and make no + use of the message body. + For consistency reasons, this library uses the message body of the request and + response for tunneled application data. + This implies that that a `2xx` (Successful) response to a `CONNECT` request + can in fact use a streaming response body for the tunneled application data. + See also [example #21](examples) for more details. + ### Response The callback function passed to the constructor of the [Server](#server) @@ -393,14 +414,25 @@ message body as per the HTTP specs. This means that your callback does not have to take special care of this and any response body will simply be ignored. -Similarly, any response with a `1xx` (Informational) or `204` (No Content) -status code will *not* include a `Content-Length` or `Transfer-Encoding` -header as these do not apply to these messages. +Similarly, any `2xx` (Successful) response to a `CONNECT` request, any response +with a `1xx` (Informational) or `204` (No Content) status code will *not* +include a `Content-Length` or `Transfer-Encoding` header as these do not apply +to these messages. Note that a response to a `HEAD` request and any response with a `304` (Not Modified) status code MAY include these headers even though the message does not contain a response body, because these header would apply to the message if the same request would have used an (unconditional) `GET`. +> The `CONNECT` method is useful in a tunneling setup (HTTPS proxy) and not + something most HTTP servers would want to care about. + The HTTP specs define an opaque "tunneling mode" for this method and make no + use of the message body. + For consistency reasons, this library uses the message body of the request and + response for tunneled application data. + This implies that that a `2xx` (Successful) response to a `CONNECT` request + can in fact use a streaming response body for the tunneled application data. + See also [example #21](examples) for more details. + A `Date` header will be automatically added with the system date and time if none is given. You can add a custom `Date` header yourself like this: diff --git a/composer.json b/composer.json index ba6ac438..75d769e2 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ } }, "require-dev": { - "phpunit/phpunit": "^4.8.10||^5.0" + "phpunit/phpunit": "^4.8.10||^5.0", + "react/socket-client": "^0.6" } } diff --git a/examples/21-connect-proxy.php b/examples/21-connect-proxy.php new file mode 100644 index 00000000..6ab54f31 --- /dev/null +++ b/examples/21-connect-proxy.php @@ -0,0 +1,77 @@ +create('8.8.8.8', $loop); +$connector = new DnsConnector(new TcpConnector($loop), $resolver); + +$server = new \React\Http\Server($socket, function (RequestInterface $request) use ($connector) { + if ($request->getMethod() !== 'CONNECT') { + return new Response( + 405, + array('Content-Type' => 'text/plain', 'Allow' => 'CONNECT'), + 'This is a HTTP CONNECT (secure HTTPS) proxy' + ); + } + + // pause consuming request body + $body = $request->getBody(); + $body->pause(); + + $buffer = ''; + $body->on('data', function ($chunk) use (&$buffer) { + $buffer .= $chunk; + }); + + // try to connect to given target host + $promise = $connector->connect($request->getRequestTarget())->then( + function (ConnectionInterface $remote) use ($body, &$buffer) { + // connection established => forward data + $body->pipe($remote); + $body->resume(); + + if ($buffer !== '') { + $remote->write($buffer); + $buffer = ''; + } + + return new Response( + 200, + array(), + $remote + ); + }, + function ($e) { + return new Response( + 502, + array('Content-Type' => 'text/plain'), + 'Unable to connect: ' . $e->getMessage() + ); + } + ); + + // cancel pending connection if request closes prematurely + $body->on('close', function () use ($promise) { + $promise->cancel(); + }); + + return $promise; +}); + +//$server->on('error', 'printf'); + +echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; + +$loop->run(); diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php index b24325f2..db115248 100644 --- a/src/RequestHeaderParser.php +++ b/src/RequestHeaderParser.php @@ -55,18 +55,37 @@ private function parseRequest($data) { list($headers, $bodyBuffer) = explode("\r\n\r\n", $data, 2); - $asterisk = false; + $originalTarget = null; if (strpos($headers, 'OPTIONS * ') === 0) { - $asterisk = true; + $originalTarget = '*'; $headers = 'OPTIONS / ' . substr($headers, 10); + } elseif (strpos($headers, 'CONNECT ') === 0) { + $parts = explode(' ', $headers, 3); + $uri = parse_url('tcp://' . $parts[1]); + + // check this is a valid authority-form request-target (host:port) + if (isset($uri['scheme'], $uri['host'], $uri['port']) && count($uri) === 3) { + $originalTarget = $parts[1]; + $parts[1] = '/'; + $headers = implode(' ', $parts); + } } $request = g7\parse_request($headers); - if ($asterisk) { + // Do not assume this is HTTPS when this happens to be port 443 + // detecting HTTPS is left up to the socket layer (TLS detection) + if ($request->getUri()->getScheme() === 'https') { + $request = $request->withUri( + $request->getUri()->withScheme('http')->withPort(443) + ); + } + + if ($originalTarget !== null) { $request = $request->withUri( - $request->getUri()->withPath('') - )->withRequestTarget('*'); + $request->getUri()->withPath(''), + true + )->withRequestTarget($originalTarget); } return array($request, $bodyBuffer); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 4534edaa..b7ddac2f 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -66,21 +66,7 @@ public function testRequestEvent() { $i = 0; $requestAssertion = null; - - $buffer = ''; - - $this->connection - ->expects($this->any()) - ->method('write') - ->will( - $this->returnCallback( - function ($data) use (&$buffer) { - $buffer .= $data; - } - ) - ); - - $server = new Server($this->socket, function ($request) use (&$i, &$requestAssertion) { + $server = new Server($this->socket, function (RequestInterface $request) use (&$i, &$requestAssertion) { $i++; $requestAssertion = $request; return \React\Promise\resolve(new Response()); @@ -102,13 +88,80 @@ function ($data) use (&$buffer) { $this->assertSame('/', $requestAssertion->getRequestTarget()); $this->assertSame('/', $requestAssertion->getUri()->getPath()); $this->assertSame('http://example.com/', (string)$requestAssertion->getUri()); + $this->assertSame('example.com:80', $requestAssertion->getHeaderLine('Host')); $this->assertSame('127.0.0.1', $requestAssertion->remoteAddress); } + public function testRequestGetWithHostAndCustomPort() + { + $requestAssertion = null; + $server = new Server($this->socket, function (RequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\nHost: example.com:8080\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('GET', $requestAssertion->getMethod()); + $this->assertSame('/', $requestAssertion->getRequestTarget()); + $this->assertSame('/', $requestAssertion->getUri()->getPath()); + $this->assertSame('http://example.com:8080/', (string)$requestAssertion->getUri()); + $this->assertSame(8080, $requestAssertion->getUri()->getPort()); + $this->assertSame('example.com:8080', $requestAssertion->getHeaderLine('Host')); + } + + public function testRequestGetWithHostAndHttpsPort() + { + $requestAssertion = null; + $server = new Server($this->socket, function (RequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\nHost: example.com:443\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('GET', $requestAssertion->getMethod()); + $this->assertSame('/', $requestAssertion->getRequestTarget()); + $this->assertSame('/', $requestAssertion->getUri()->getPath()); + $this->assertSame('http://example.com:443/', (string)$requestAssertion->getUri()); + $this->assertSame(443, $requestAssertion->getUri()->getPort()); + $this->assertSame('example.com:443', $requestAssertion->getHeaderLine('Host')); + } + + public function testRequestGetWithHostAndDefaultPortWillBeIgnored() + { + $requestAssertion = null; + $server = new Server($this->socket, function (RequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\nHost: example.com:80\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('GET', $requestAssertion->getMethod()); + $this->assertSame('/', $requestAssertion->getRequestTarget()); + $this->assertSame('/', $requestAssertion->getUri()->getPath()); + $this->assertSame('http://example.com/', (string)$requestAssertion->getUri()); + $this->assertSame(null, $requestAssertion->getUri()->getPort()); + $this->assertSame('example.com:80', $requestAssertion->getHeaderLine('Host')); + } + public function testRequestOptionsAsterisk() { $requestAssertion = null; - $server = new Server($this->socket, function ($request) use (&$requestAssertion) { + $server = new Server($this->socket, function (RequestInterface $request) use (&$requestAssertion) { $requestAssertion = $request; return new Response(); }); @@ -123,6 +176,95 @@ public function testRequestOptionsAsterisk() $this->assertSame('*', $requestAssertion->getRequestTarget()); $this->assertSame('', $requestAssertion->getUri()->getPath()); $this->assertSame('http://example.com', (string)$requestAssertion->getUri()); + $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host')); + } + + public function testRequestNonOptionsWithAsteriskRequestTargetWillReject() + { + $server = new Server($this->socket, $this->expectCallableNever()); + $server->on('error', $this->expectCallableOnce()); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET * HTTP/1.1\r\nHost: example.com\r\n\r\n"; + $this->connection->emit('data', array($data)); + } + + public function testRequestConnectAuthorityForm() + { + $requestAssertion = null; + $server = new Server($this->socket, function (RequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('CONNECT', $requestAssertion->getMethod()); + $this->assertSame('example.com:443', $requestAssertion->getRequestTarget()); + $this->assertSame('', $requestAssertion->getUri()->getPath()); + $this->assertSame('http://example.com:443', (string)$requestAssertion->getUri()); + $this->assertSame(443, $requestAssertion->getUri()->getPort()); + $this->assertSame('example.com:443', $requestAssertion->getHeaderLine('host')); + } + + public function testRequestConnectAuthorityFormWithDefaultPortWillBeIgnored() + { + $requestAssertion = null; + $server = new Server($this->socket, function (RequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('CONNECT', $requestAssertion->getMethod()); + $this->assertSame('example.com:80', $requestAssertion->getRequestTarget()); + $this->assertSame('', $requestAssertion->getUri()->getPath()); + $this->assertSame('http://example.com', (string)$requestAssertion->getUri()); + $this->assertSame(null, $requestAssertion->getUri()->getPort()); + $this->assertSame('example.com:80', $requestAssertion->getHeaderLine('Host')); + } + + public function testRequestConnectAuthorityFormNonMatchingHostWillBePassedAsIs() + { + $requestAssertion = null; + $server = new Server($this->socket, function (RequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "CONNECT example.com:80 HTTP/1.1\r\nHost: example.com\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('CONNECT', $requestAssertion->getMethod()); + $this->assertSame('example.com:80', $requestAssertion->getRequestTarget()); + $this->assertSame('', $requestAssertion->getUri()->getPath()); + $this->assertSame('http://example.com', (string)$requestAssertion->getUri()); + $this->assertSame(null, $requestAssertion->getUri()->getPort()); + $this->assertSame('example.com', $requestAssertion->getHeaderLine('host')); + } + + public function testRequestNonConnectWithAuthorityRequestTargetWillReject() + { + $server = new Server($this->socket, $this->expectCallableNever()); + $server->on('error', $this->expectCallableOnce()); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET example.com:80 HTTP/1.1\r\nHost: example.com\r\n\r\n"; + $this->connection->emit('data', array($data)); } public function testRequestPauseWillbeForwardedToConnection() From ec43538d3d6fc9683a18b7be56a0dc955925b508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Mon, 3 Apr 2017 17:35:38 +0200 Subject: [PATCH 096/128] Update SocketClient to v0.7 to simplify CONNECT example --- composer.json | 2 +- examples/21-connect-proxy.php | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/composer.json b/composer.json index 75d769e2..9470cd47 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,6 @@ }, "require-dev": { "phpunit/phpunit": "^4.8.10||^5.0", - "react/socket-client": "^0.6" + "react/socket-client": "^0.7" } } diff --git a/examples/21-connect-proxy.php b/examples/21-connect-proxy.php index 6ab54f31..8fcba5b3 100644 --- a/examples/21-connect-proxy.php +++ b/examples/21-connect-proxy.php @@ -4,18 +4,14 @@ use React\Socket\Server; use React\Http\Response; use Psr\Http\Message\RequestInterface; -use React\SocketClient\TcpConnector; +use React\SocketClient\Connector; use React\SocketClient\ConnectionInterface; -use React\SocketClient\DnsConnector; require __DIR__ . '/../vendor/autoload.php'; $loop = Factory::create(); $socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); - -$resolver = new \React\Dns\Resolver\Factory(); -$resolver = $resolver->create('8.8.8.8', $loop); -$connector = new DnsConnector(new TcpConnector($loop), $resolver); +$connector = new Connector($loop); $server = new \React\Http\Server($socket, function (RequestInterface $request) use ($connector) { if ($request->getMethod() !== 'CONNECT') { From 8f595b29db36e0f9e6170d2764887d1a44c022e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Wed, 5 Apr 2017 00:45:21 +0200 Subject: [PATCH 097/128] Replace deprecated SocketClient with new Socket component --- composer.json | 2 +- examples/21-connect-proxy.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 9470cd47..4c74c704 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,6 @@ }, "require-dev": { "phpunit/phpunit": "^4.8.10||^5.0", - "react/socket-client": "^0.7" + "react/socket": "^0.7" } } diff --git a/examples/21-connect-proxy.php b/examples/21-connect-proxy.php index 8fcba5b3..24d383fc 100644 --- a/examples/21-connect-proxy.php +++ b/examples/21-connect-proxy.php @@ -4,8 +4,8 @@ use React\Socket\Server; use React\Http\Response; use Psr\Http\Message\RequestInterface; -use React\SocketClient\Connector; -use React\SocketClient\ConnectionInterface; +use React\Socket\Connector; +use React\Socket\ConnectionInterface; require __DIR__ . '/../vendor/autoload.php'; From fca4a6edcc998ea79e68c1dcfec4b6ff958b8596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 6 Apr 2017 22:57:14 +0200 Subject: [PATCH 098/128] Fix tests to support new Socket v0.6 and up --- tests/SocketServerStub.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/SocketServerStub.php b/tests/SocketServerStub.php index bdbb7ac2..a6610243 100644 --- a/tests/SocketServerStub.php +++ b/tests/SocketServerStub.php @@ -16,4 +16,14 @@ public function close() { // NO-OP } + + public function pause() + { + // NO-OP + } + + public function resume() + { + // NO-OP + } } From 1bb100eb96f05dd5acee63642a1c5f766694f155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Wed, 5 Apr 2017 00:48:44 +0200 Subject: [PATCH 099/128] Add benchmarking example --- examples/99-benchmark-download.php | 94 ++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 examples/99-benchmark-download.php diff --git a/examples/99-benchmark-download.php b/examples/99-benchmark-download.php new file mode 100644 index 00000000..0a0e25cd --- /dev/null +++ b/examples/99-benchmark-download.php @@ -0,0 +1,94 @@ + /dev/null +// $ wget http://localhost:8080/10g.bin -O /dev/null +// $ ab -n10 -c10 http://localhost:8080/1g.bin +// $ docker run -it --rm --net=host jordi/ab ab -n10 -c10 http://localhost:8080/1g.bin + +use React\EventLoop\Factory; +use React\Socket\Server; +use React\Http\Response; +use Psr\Http\Message\RequestInterface; +use React\Stream\ReadableStream; + +require __DIR__ . '/../vendor/autoload.php'; + +$loop = Factory::create(); +$socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); + +/** A readable stream that can emit a lot of data */ +class ChunkRepeater extends ReadableStream +{ + private $chunk; + private $count; + private $position = 0; + private $paused = true; + + public function __construct($chunk, $count) + { + $this->chunk = $chunk; + $this->count = $count; + } + + public function pause() + { + $this->paused = true; + } + + public function resume() + { + if (!$this->paused) { + return; + } + + // keep emitting until stream is paused + $this->paused = false; + while ($this->position < $this->count && !$this->paused) { + ++$this->position; + $this->emit('data', array($this->chunk)); + } + + // end once the last chunk has been written + if ($this->position >= $this->count) { + $this->emit('end'); + $this->close(); + } + } + + public function getSize() + { + return strlen($this->chunk) * $this->count; + } +} + +$server = new \React\Http\Server($socket, function (RequestInterface $request) use ($loop) { + switch ($request->getUri()->getPath()) { + case '/': + return new Response( + 200, + array('Content-Type' => 'text/html'), + '1g.bin
10g.bin' + ); + case '/1g.bin': + $stream = new ChunkRepeater(str_repeat('.', 1000000), 1000); + break; + case '/10g.bin': + $stream = new ChunkRepeater(str_repeat('.', 1000000), 10000); + break; + default: + return new Response(404); + } + + $loop->addTimer(0, array($stream, 'resume')); + + return new Response( + 200, + array('Content-Type' => 'application/octet-data', 'Content-Length' => $stream->getSize()), + $stream + ); +}); + +echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; + +$loop->run(); From 83e75bcc181da1df14ef60cafc25ada01d7d68fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Mon, 3 Apr 2017 18:34:44 +0200 Subject: [PATCH 100/128] Use https-scheme for request URIs if secure TLS is used --- composer.json | 3 +- src/RequestHeaderParser.php | 3 +- tests/FunctionalServerTest.php | 204 +++++++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 tests/FunctionalServerTest.php diff --git a/composer.json b/composer.json index 4c74c704..fccbc1bd 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ }, "require-dev": { "phpunit/phpunit": "^4.8.10||^5.0", - "react/socket": "^0.7" + "react/socket": "^0.7", + "clue/block-react": "^1.1" } } diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php index db115248..792a0604 100644 --- a/src/RequestHeaderParser.php +++ b/src/RequestHeaderParser.php @@ -77,7 +77,8 @@ private function parseRequest($data) // detecting HTTPS is left up to the socket layer (TLS detection) if ($request->getUri()->getScheme() === 'https') { $request = $request->withUri( - $request->getUri()->withScheme('http')->withPort(443) + $request->getUri()->withScheme('http')->withPort(443), + true ); } diff --git a/tests/FunctionalServerTest.php b/tests/FunctionalServerTest.php new file mode 100644 index 00000000..4c7ed6ff --- /dev/null +++ b/tests/FunctionalServerTest.php @@ -0,0 +1,204 @@ +getUri()); + }); + + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\nHost: " . $conn->getRemoteAddress() . "\r\n\r\n"); + + return BufferedSink::createPromise($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertContains("HTTP/1.0 200 OK", $response); + $this->assertContains('http://' . $socket->getAddress() . '/', $response); + + $socket->close(); + } + + public function testSecureHttpsOnRandomPort() + { + if (!function_exists('stream_socket_enable_crypto')) { + $this->markTestSkipped('Not supported on your platform (outdated HHVM?)'); + } + + $loop = Factory::create(); + $socket = new Socket(0, $loop); + $socket = new SecureServer($socket, $loop, array( + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + )); + $connector = new Connector($loop, array( + 'tls' => array('verify_peer' => false) + )); + + $server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array(), (string)$request->getUri()); + }); + + $result = $connector->connect('tls://' . $socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\nHost: " . $conn->getRemoteAddress() . "\r\n\r\n"); + + return BufferedSink::createPromise($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertContains("HTTP/1.0 200 OK", $response); + $this->assertContains('https://' . $socket->getAddress() . '/', $response); + + $socket->close(); + } + + public function testPlainHttpOnStandardPortReturnsUriWithNoPort() + { + $loop = Factory::create(); + try { + $socket = new Socket(80, $loop); + } catch (\RuntimeException $e) { + $this->markTestSkipped('Listening on port 80 failed (root and unused?)'); + } + $connector = new Connector($loop); + + $server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array(), (string)$request->getUri()); + }); + + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n"); + + return BufferedSink::createPromise($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertContains("HTTP/1.0 200 OK", $response); + $this->assertContains('http://127.0.0.1/', $response); + + $socket->close(); + } + + public function testSecureHttpsOnStandardPortReturnsUriWithNoPort() + { + if (!function_exists('stream_socket_enable_crypto')) { + $this->markTestSkipped('Not supported on your platform (outdated HHVM?)'); + } + + $loop = Factory::create(); + try { + $socket = new Socket(443, $loop); + } catch (\RuntimeException $e) { + $this->markTestSkipped('Listening on port 443 failed (root and unused?)'); + } + $socket = new SecureServer($socket, $loop, array( + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + )); + $connector = new Connector($loop, array( + 'tls' => array('verify_peer' => false) + )); + + $server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array(), (string)$request->getUri()); + }); + + $result = $connector->connect('tls://' . $socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n"); + + return BufferedSink::createPromise($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertContains("HTTP/1.0 200 OK", $response); + $this->assertContains('https://127.0.0.1/', $response); + + $socket->close(); + } + + public function testPlainHttpOnHttpsStandardPortReturnsUriWithPort() + { + $loop = Factory::create(); + try { + $socket = new Socket(443, $loop); + } catch (\RuntimeException $e) { + $this->markTestSkipped('Listening on port 443 failed (root and unused?)'); + } + $connector = new Connector($loop); + + $server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array(), (string)$request->getUri()); + }); + + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\nHost: " . $conn->getRemoteAddress() . "\r\n\r\n"); + + return BufferedSink::createPromise($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertContains("HTTP/1.0 200 OK", $response); + $this->assertContains('http://127.0.0.1:443/', $response); + + $socket->close(); + } + + public function testSecureHttpsOnHttpStandardPortReturnsUriWithPort() + { + if (!function_exists('stream_socket_enable_crypto')) { + $this->markTestSkipped('Not supported on your platform (outdated HHVM?)'); + } + + $loop = Factory::create(); + try { + $socket = new Socket(80, $loop); + } catch (\RuntimeException $e) { + $this->markTestSkipped('Listening on port 80 failed (root and unused?)'); + } + $socket = new SecureServer($socket, $loop, array( + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + )); + $connector = new Connector($loop, array( + 'tls' => array('verify_peer' => false) + )); + + $server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array(), (string)$request->getUri() . 'x' . $request->getHeaderLine('Host')); + }); + + $result = $connector->connect('tls://' . $socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\nHost: " . $conn->getRemoteAddress() . "\r\n\r\n"); + + return BufferedSink::createPromise($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertContains("HTTP/1.0 200 OK", $response); + $this->assertContains('https://127.0.0.1:80/', $response); + + $socket->close(); + } +} From e668e04ece8d8d7e271a0dde045dc19f63d43800 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Thu, 20 Apr 2017 01:32:49 +0200 Subject: [PATCH 101/128] Replace Request with ServerRequest --- examples/01-hello-world.php | 5 +- examples/02-count-visitors.php | 4 +- examples/03-stream-response.php | 4 +- examples/04-stream-request.php | 4 +- examples/05-error-handling.php | 4 +- examples/11-hello-world-https.php | 4 +- examples/21-connect-proxy.php | 4 +- examples/99-benchmark-download.php | 4 +- src/RequestHeaderParser.php | 7 ++ src/ServerRequest.php | 98 +++++++++++++++++++++++ tests/ServerRequestTest.php | 87 ++++++++++++++++++++ tests/ServerTest.php | 122 ++++++++++++++--------------- 12 files changed, 269 insertions(+), 78 deletions(-) create mode 100644 src/ServerRequest.php create mode 100644 tests/ServerRequestTest.php diff --git a/examples/01-hello-world.php b/examples/01-hello-world.php index ed84af11..cf047944 100644 --- a/examples/01-hello-world.php +++ b/examples/01-hello-world.php @@ -3,15 +3,14 @@ use React\EventLoop\Factory; use React\Socket\Server; use React\Http\Response; -use Psr\Http\Message\RequestInterface; -use React\Promise\Promise; +use Psr\Http\Message\ServerRequestInterface; require __DIR__ . '/../vendor/autoload.php'; $loop = Factory::create(); $socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); -$server = new \React\Http\Server($socket, function (RequestInterface $request) { +$server = new \React\Http\Server($socket, function (ServerRequestInterface $request) { return new Response( 200, array( diff --git a/examples/02-count-visitors.php b/examples/02-count-visitors.php index 2c384a3b..9f69f797 100644 --- a/examples/02-count-visitors.php +++ b/examples/02-count-visitors.php @@ -3,7 +3,7 @@ use React\EventLoop\Factory; use React\Socket\Server; use React\Http\Response; -use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ServerRequestInterface; require __DIR__ . '/../vendor/autoload.php'; @@ -11,7 +11,7 @@ $socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); $counter = 0; -$server = new \React\Http\Server($socket, function (RequestInterface $request) use (&$counter) { +$server = new \React\Http\Server($socket, function (ServerRequestInterface $request) use (&$counter) { return new Response( 200, array('Content-Type' => 'text/plain'), diff --git a/examples/03-stream-response.php b/examples/03-stream-response.php index 5fd990e8..8edf7d40 100644 --- a/examples/03-stream-response.php +++ b/examples/03-stream-response.php @@ -3,7 +3,7 @@ use React\EventLoop\Factory; use React\Socket\Server; use React\Http\Response; -use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ServerRequestInterface; use React\Stream\ReadableStream; require __DIR__ . '/../vendor/autoload.php'; @@ -11,7 +11,7 @@ $loop = Factory::create(); $socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); -$server = new \React\Http\Server($socket, function (RequestInterface $request) use ($loop) { +$server = new \React\Http\Server($socket, function (ServerRequestInterface $request) use ($loop) { $stream = new ReadableStream(); $timer = $loop->addPeriodicTimer(0.5, function () use ($stream) { diff --git a/examples/04-stream-request.php b/examples/04-stream-request.php index 481162ef..fabb5bb7 100644 --- a/examples/04-stream-request.php +++ b/examples/04-stream-request.php @@ -3,7 +3,7 @@ use React\EventLoop\Factory; use React\Socket\Server; use React\Http\Response; -use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ServerRequestInterface; use React\Promise\Promise; require __DIR__ . '/../vendor/autoload.php'; @@ -11,7 +11,7 @@ $loop = Factory::create(); $socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); -$server = new \React\Http\Server($socket, function (RequestInterface $request) { +$server = new \React\Http\Server($socket, function (ServerRequestInterface $request) { return new Promise(function ($resolve, $reject) use ($request) { $contentLength = 0; $request->getBody()->on('data', function ($data) use (&$contentLength) { diff --git a/examples/05-error-handling.php b/examples/05-error-handling.php index 29f54b92..00ed0cfa 100644 --- a/examples/05-error-handling.php +++ b/examples/05-error-handling.php @@ -3,7 +3,7 @@ use React\EventLoop\Factory; use React\Socket\Server; use React\Http\Response; -use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ServerRequestInterface; use React\Promise\Promise; require __DIR__ . '/../vendor/autoload.php'; @@ -12,7 +12,7 @@ $socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); $count = 0; -$server = new \React\Http\Server($socket, function (RequestInterface $request) use (&$count) { +$server = new \React\Http\Server($socket, function (ServerRequestInterface $request) use (&$count) { return new Promise(function ($resolve, $reject) use (&$count) { $count++; diff --git a/examples/11-hello-world-https.php b/examples/11-hello-world-https.php index 191e7d62..de958007 100644 --- a/examples/11-hello-world-https.php +++ b/examples/11-hello-world-https.php @@ -4,7 +4,7 @@ use React\Socket\Server; use React\Http\Response; use React\Socket\SecureServer; -use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ServerRequestInterface; require __DIR__ . '/../vendor/autoload.php'; @@ -14,7 +14,7 @@ 'local_cert' => isset($argv[2]) ? $argv[2] : __DIR__ . '/localhost.pem' )); -$server = new \React\Http\Server($socket, function (RequestInterface $request) { +$server = new \React\Http\Server($socket, function (ServerRequestInterface $request) { return new Response( 200, array('Content-Type' => 'text/plain'), diff --git a/examples/21-connect-proxy.php b/examples/21-connect-proxy.php index 24d383fc..03939d29 100644 --- a/examples/21-connect-proxy.php +++ b/examples/21-connect-proxy.php @@ -3,7 +3,7 @@ use React\EventLoop\Factory; use React\Socket\Server; use React\Http\Response; -use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ServerRequestInterface; use React\Socket\Connector; use React\Socket\ConnectionInterface; @@ -13,7 +13,7 @@ $socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); $connector = new Connector($loop); -$server = new \React\Http\Server($socket, function (RequestInterface $request) use ($connector) { +$server = new \React\Http\Server($socket, function (ServerRequestInterface $request) use ($connector) { if ($request->getMethod() !== 'CONNECT') { return new Response( 405, diff --git a/examples/99-benchmark-download.php b/examples/99-benchmark-download.php index 0a0e25cd..47460b5f 100644 --- a/examples/99-benchmark-download.php +++ b/examples/99-benchmark-download.php @@ -9,7 +9,7 @@ use React\EventLoop\Factory; use React\Socket\Server; use React\Http\Response; -use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ServerRequestInterface; use React\Stream\ReadableStream; require __DIR__ . '/../vendor/autoload.php'; @@ -62,7 +62,7 @@ public function getSize() } } -$server = new \React\Http\Server($socket, function (RequestInterface $request) use ($loop) { +$server = new \React\Http\Server($socket, function (ServerRequestInterface $request) use ($loop) { switch ($request->getUri()->getPath()) { case '/': return new Response( diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php index 792a0604..510de700 100644 --- a/src/RequestHeaderParser.php +++ b/src/RequestHeaderParser.php @@ -72,6 +72,13 @@ private function parseRequest($data) } $request = g7\parse_request($headers); + $request = new ServerRequest( + $request->getMethod(), + $request->getUri(), + $request->getHeaders(), + $request->getBody(), + $request->getProtocolVersion() + ); // Do not assume this is HTTPS when this happens to be port 443 // detecting HTTPS is left up to the socket layer (TLS detection) diff --git a/src/ServerRequest.php b/src/ServerRequest.php new file mode 100644 index 00000000..44826f6c --- /dev/null +++ b/src/ServerRequest.php @@ -0,0 +1,98 @@ +serverParams; + } + + public function getCookieParams() + { + return $this->cookies; + } + + public function withCookieParams(array $cookies) + { + $new = clone $this; + $new->cookies = $cookies; + return $new; + } + + public function getQueryParams() + { + return $this->queryParams; + } + + public function withQueryParams(array $query) + { + $new = clone $this; + $new->queryParams = $query; + return $new; + } + + public function getUploadedFiles() + { + return $this->fileParams; + } + + public function withUploadedFiles(array $uploadedFiles) + { + $new = clone $this; + $new->fileParams = $uploadedFiles; + return $new; + } + + public function getParsedBody() + { + return $this->parsedBody; + } + + public function withParsedBody($data) + { + $new = clone $this; + $new->parsedBody = $data; + return $new; + } + + public function getAttributes() + { + return $this->attributes; + } + + public function getAttribute($name, $default = null) + { + if (!array_key_exists($name, $this->attributes)) { + return $default; + } + return $this->attributes[$name]; + } + + public function withAttribute($name, $value) + { + $new = clone $this; + $new->attributes[$name] = $value; + return $new; + } + + public function withoutAttribute($name) + { + $new = clone $this; + unset($new->attributes[$name]); + return $new; + } +} diff --git a/tests/ServerRequestTest.php b/tests/ServerRequestTest.php new file mode 100644 index 00000000..fc873439 --- /dev/null +++ b/tests/ServerRequestTest.php @@ -0,0 +1,87 @@ +request = new ServerRequest('GET', 'http://localhost'); + } + + public function testGetNoAttributes() + { + $this->assertEquals(array(), $this->request->getAttributes()); + } + + public function testWithAttribute() + { + $request = $this->request->withAttribute('hello', 'world'); + + $this->assertNotSame($request, $this->request); + $this->assertEquals(array('hello' => 'world'), $request->getAttributes()); + } + + public function testGetAttribute() + { + $request = $this->request->withAttribute('hello', 'world'); + + $this->assertNotSame($request, $this->request); + $this->assertEquals('world', $request->getAttribute('hello')); + } + + public function testGetDefaultAttribute() + { + $request = $this->request->withAttribute('hello', 'world'); + + $this->assertNotSame($request, $this->request); + $this->assertEquals(null, $request->getAttribute('hi', null)); + } + + public function testWithoutAttribute() + { + $request = $this->request->withAttribute('hello', 'world'); + $request = $request->withAttribute('test', 'nice'); + + $request = $request->withoutAttribute('hello'); + + $this->assertNotSame($request, $this->request); + $this->assertEquals(array('test' => 'nice'), $request->getAttributes()); + } + + public function testWithCookieParams() + { + $request = $this->request->withCookieParams(array('test' => 'world')); + + $this->assertNotSame($request, $this->request); + $this->assertEquals(array('test' => 'world'), $request->getCookieParams()); + } + + public function testWithQueryParams() + { + $request = $this->request->withQueryParams(array('test' => 'world')); + + $this->assertNotSame($request, $this->request); + $this->assertEquals(array('test' => 'world'), $request->getQueryParams()); + } + + public function testWithUploadedFiles() + { + $request = $this->request->withUploadedFiles(array('test' => 'world')); + + $this->assertNotSame($request, $this->request); + $this->assertEquals(array('test' => 'world'), $request->getUploadedFiles()); + } + + public function testWithParsedBody() + { + $request = $this->request->withParsedBody(array('test' => 'world')); + + $this->assertNotSame($request, $this->request); + $this->assertEquals(array('test' => 'world'), $request->getParsedBody()); + } +} diff --git a/tests/ServerTest.php b/tests/ServerTest.php index b7ddac2f..d831aa86 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -3,7 +3,7 @@ namespace React\Tests\Http; use React\Http\Server; -use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ServerRequestInterface; use React\Http\Response; use React\Stream\ReadableStream; use React\Promise\Promise; @@ -52,7 +52,7 @@ public function testRequestEventWillNotBeEmittedForIncompleteHeaders() public function testRequestEventIsEmitted() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { return \React\Promise\resolve(new Response()); }); @@ -66,7 +66,7 @@ public function testRequestEvent() { $i = 0; $requestAssertion = null; - $server = new Server($this->socket, function (RequestInterface $request) use (&$i, &$requestAssertion) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$i, &$requestAssertion) { $i++; $requestAssertion = $request; return \React\Promise\resolve(new Response()); @@ -95,7 +95,7 @@ public function testRequestEvent() public function testRequestGetWithHostAndCustomPort() { $requestAssertion = null; - $server = new Server($this->socket, function (RequestInterface $request) use (&$requestAssertion) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { $requestAssertion = $request; return new Response(); }); @@ -117,7 +117,7 @@ public function testRequestGetWithHostAndCustomPort() public function testRequestGetWithHostAndHttpsPort() { $requestAssertion = null; - $server = new Server($this->socket, function (RequestInterface $request) use (&$requestAssertion) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { $requestAssertion = $request; return new Response(); }); @@ -139,7 +139,7 @@ public function testRequestGetWithHostAndHttpsPort() public function testRequestGetWithHostAndDefaultPortWillBeIgnored() { $requestAssertion = null; - $server = new Server($this->socket, function (RequestInterface $request) use (&$requestAssertion) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { $requestAssertion = $request; return new Response(); }); @@ -161,7 +161,7 @@ public function testRequestGetWithHostAndDefaultPortWillBeIgnored() public function testRequestOptionsAsterisk() { $requestAssertion = null; - $server = new Server($this->socket, function (RequestInterface $request) use (&$requestAssertion) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { $requestAssertion = $request; return new Response(); }); @@ -193,7 +193,7 @@ public function testRequestNonOptionsWithAsteriskRequestTargetWillReject() public function testRequestConnectAuthorityForm() { $requestAssertion = null; - $server = new Server($this->socket, function (RequestInterface $request) use (&$requestAssertion) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { $requestAssertion = $request; return new Response(); }); @@ -215,7 +215,7 @@ public function testRequestConnectAuthorityForm() public function testRequestConnectAuthorityFormWithDefaultPortWillBeIgnored() { $requestAssertion = null; - $server = new Server($this->socket, function (RequestInterface $request) use (&$requestAssertion) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { $requestAssertion = $request; return new Response(); }); @@ -237,7 +237,7 @@ public function testRequestConnectAuthorityFormWithDefaultPortWillBeIgnored() public function testRequestConnectAuthorityFormNonMatchingHostWillBePassedAsIs() { $requestAssertion = null; - $server = new Server($this->socket, function (RequestInterface $request) use (&$requestAssertion) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { $requestAssertion = $request; return new Response(); }); @@ -269,7 +269,7 @@ public function testRequestNonConnectWithAuthorityRequestTargetWillReject() public function testRequestPauseWillbeForwardedToConnection() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { $request->getBody()->pause(); return \React\Promise\resolve(new Response()); }); @@ -288,7 +288,7 @@ public function testRequestPauseWillbeForwardedToConnection() public function testRequestResumeWillbeForwardedToConnection() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { $request->getBody()->resume(); return \React\Promise\resolve(new Response()); }); @@ -302,7 +302,7 @@ public function testRequestResumeWillbeForwardedToConnection() public function testRequestCloseWillPauseConnection() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { $request->getBody()->close(); return \React\Promise\resolve(new Response()); }); @@ -316,7 +316,7 @@ public function testRequestCloseWillPauseConnection() public function testRequestPauseAfterCloseWillNotBeForwarded() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { $request->getBody()->close(); $request->getBody()->pause();# @@ -332,7 +332,7 @@ public function testRequestPauseAfterCloseWillNotBeForwarded() public function testRequestResumeAfterCloseWillNotBeForwarded() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { $request->getBody()->close(); $request->getBody()->resume(); @@ -351,7 +351,7 @@ public function testRequestEventWithoutBodyWillNotEmitData() { $never = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request) use ($never) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($never) { $request->getBody()->on('data', $never); return \React\Promise\resolve(new Response()); @@ -367,7 +367,7 @@ public function testRequestEventWithSecondDataEventWillEmitBodyData() { $once = $this->expectCallableOnceWith('incomplete'); - $server = new Server($this->socket, function (RequestInterface $request) use ($once) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($once) { $request->getBody()->on('data', $once); return \React\Promise\resolve(new Response()); @@ -388,7 +388,7 @@ public function testRequestEventWithPartialBodyWillEmitData() { $once = $this->expectCallableOnceWith('incomplete'); - $server = new Server($this->socket, function (RequestInterface $request) use ($once) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($once) { $request->getBody()->on('data', $once); return \React\Promise\resolve(new Response()); @@ -410,7 +410,7 @@ public function testRequestEventWithPartialBodyWillEmitData() public function testResponseContainsPoweredByHeader() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { return \React\Promise\resolve(new Response()); }); @@ -437,7 +437,7 @@ function ($data) use (&$buffer) { public function testResponseContainsSameRequestProtocolVersionAndChunkedBodyForHttp11() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { $response = new Response(200, array(), 'bye'); return \React\Promise\resolve($response); }); @@ -466,7 +466,7 @@ function ($data) use (&$buffer) { public function testResponseContainsSameRequestProtocolVersionAndRawBodyForHttp10() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { $response = new Response(200, array(), 'bye'); return \React\Promise\resolve($response); }); @@ -496,7 +496,7 @@ function ($data) use (&$buffer) { public function testResponseContainsNoResponseBodyForHeadRequest() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { return new Response(200, array(), 'bye'); }); @@ -523,7 +523,7 @@ function ($data) use (&$buffer) { public function testResponseContainsNoResponseBodyAndNoContentLengthForNoContentStatus() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { return new Response(204, array(), 'bye'); }); @@ -551,7 +551,7 @@ function ($data) use (&$buffer) { public function testResponseContainsNoResponseBodyForNotModifiedStatus() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { return new Response(304, array(), 'bye'); }); @@ -682,7 +682,7 @@ public function testBodyDataWillBeSendViaRequestEvent() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -711,7 +711,7 @@ public function testChunkedEncodedRequestWillBeParsedForRequestEvent() $errorEvent = $this->expectCallableNever(); $requestValidation = null; - $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -743,7 +743,7 @@ public function testChunkedEncodedRequestAdditionalDataWontBeEmitted() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -774,7 +774,7 @@ public function testEmptyChunkedEncodedRequest() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -802,7 +802,7 @@ public function testChunkedIsUpperCase() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -832,7 +832,7 @@ public function testChunkedIsMixedUpperAndLowerCase() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -952,7 +952,7 @@ function ($data) use (&$buffer) { public function testRequestHttp10WithoutHostEmitsRequestWithNoError() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { return \React\Promise\resolve(new Response()); }); $server->on('error', $this->expectCallableNever()); @@ -970,7 +970,7 @@ public function testWontEmitFurtherDataWhenContentLengthIsReached() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -1000,7 +1000,7 @@ public function testWontEmitFurtherDataWhenContentLengthIsReachedSplitted() $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -1034,7 +1034,7 @@ public function testContentLengthContainsZeroWillEmitEndEvent() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -1061,7 +1061,7 @@ public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillB $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -1089,7 +1089,7 @@ public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillB $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -1121,7 +1121,7 @@ public function testContentLengthWillBeIgnoredIfTransferEncodingIsSet() $errorEvent = $this->expectCallableNever(); $requestValidation = null; - $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -1159,7 +1159,7 @@ public function testInvalidContentLengthWillBeIgnoreddIfTransferEncodingIsSet() $errorEvent = $this->expectCallableNever(); $requestValidation = null; - $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -1395,7 +1395,7 @@ public function testUnexpectedEndOfConnectionWillResultsInErrorOnRequestStream() public function testErrorInChunkedDecoderNeverClosesConnection() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { return \React\Promise\resolve(new Response()); }); @@ -1416,7 +1416,7 @@ public function testErrorInChunkedDecoderNeverClosesConnection() public function testErrorInLengthLimitedStreamNeverClosesConnection() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { return \React\Promise\resolve(new Response()); }); @@ -1485,7 +1485,7 @@ public function testRequestWithoutDefinedLengthWillIgnoreDataEvent() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (RequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -1505,7 +1505,7 @@ public function testRequestWithoutDefinedLengthWillIgnoreDataEvent() public function testResponseWillBeChunkDecodedByDefault() { $stream = new ReadableStream(); - $server = new Server($this->socket, function (RequestInterface $request) use ($stream) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($stream) { $response = new Response(200, array(), $stream); return \React\Promise\resolve($response); }); @@ -1535,7 +1535,7 @@ function ($data) use (&$buffer) { public function testContentLengthWillBeRemovedForResponseStream() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { $response = new Response( 200, array( @@ -1574,7 +1574,7 @@ function ($data) use (&$buffer) { public function testOnlyAllowChunkedEncoding() { $stream = new ReadableStream(); - $server = new Server($this->socket, function (RequestInterface $request) use ($stream) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($stream) { $response = new Response( 200, array( @@ -1612,7 +1612,7 @@ function ($data) use (&$buffer) { public function testDateHeaderWillBeAddedWhenNoneIsGiven() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { return \React\Promise\resolve(new Response()); }); @@ -1641,7 +1641,7 @@ function ($data) use (&$buffer) { public function testAddCustomDateHeader() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { $response = new Response(200, array("Date" => "Tue, 15 Nov 1994 08:12:31 GMT")); return \React\Promise\resolve($response); }); @@ -1671,7 +1671,7 @@ function ($data) use (&$buffer) { public function testRemoveDateHeader() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { $response = new Response(200, array('Date' => '')); return \React\Promise\resolve($response); }); @@ -1769,7 +1769,7 @@ function ($data) use (&$buffer) { public function test100ContinueRequestWillBeHandled() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { return \React\Promise\resolve(new Response()); }); @@ -1800,7 +1800,7 @@ function ($data) use (&$buffer) { public function testContinueWontBeSendForHttp10() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { return \React\Promise\resolve(new Response()); }); @@ -1829,7 +1829,7 @@ function ($data) use (&$buffer) { public function testContinueWithLaterResponse() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { return \React\Promise\resolve(new Response()); }); @@ -1872,7 +1872,7 @@ public function testHttpBodyStreamAsBodyWillStreamData() { $input = new ReadableStream(); - $server = new Server($this->socket, function (RequestInterface $request) use ($input) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($input) { $response = new Response(200, array(), $input); return \React\Promise\resolve($response); }); @@ -1907,7 +1907,7 @@ public function testHttpBodyStreamWithContentLengthWillStreamTillLength() { $input = new ReadableStream(); - $server = new Server($this->socket, function (RequestInterface $request) use ($input) { + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($input) { $response = new Response(200, array('Content-Length' => 5), $input); return \React\Promise\resolve($response); }); @@ -1941,7 +1941,7 @@ function ($data) use (&$buffer) { public function testCallbackFunctionReturnsPromise() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { return \React\Promise\resolve(new Response()); }); @@ -1968,7 +1968,7 @@ function ($data) use (&$buffer) { public function testReturnInvalidTypeWillResultInError() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { return "invalid"; }); @@ -2003,7 +2003,7 @@ function ($data) use (&$buffer) { public function testResolveWrongTypeInPromiseWillResultInError() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { return \React\Promise\resolve("invalid"); }); @@ -2032,7 +2032,7 @@ function ($data) use (&$buffer) { public function testRejectedPromiseWillResultInErrorMessage() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { return new Promise(function ($resolve, $reject) { $reject(new \Exception()); }); @@ -2064,7 +2064,7 @@ function ($data) use (&$buffer) { public function testExcpetionInCallbackWillResultInErrorMessage() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { return new Promise(function ($resolve, $reject) { throw new \Exception('Bad call'); }); @@ -2096,7 +2096,7 @@ function ($data) use (&$buffer) { public function testHeaderWillAlwaysBeContentLengthForStringBody() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { return new Response(200, array('Transfer-Encoding' => 'chunked'), 'hello'); }); @@ -2129,7 +2129,7 @@ function ($data) use (&$buffer) { public function testReturnRequestWillBeHandled() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { return new Response(); }); @@ -2158,7 +2158,7 @@ function ($data) use (&$buffer) { public function testExceptionThrowInCallBackFunctionWillResultInErrorMessage() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { throw new \Exception('hello'); }); @@ -2194,7 +2194,7 @@ function ($data) use (&$buffer) { public function testRejectOfNonExceptionWillResultInErrorMessage() { - $server = new Server($this->socket, function (RequestInterface $request) { + $server = new Server($this->socket, function (ServerRequestInterface $request) { return new Promise(function ($resolve, $reject) { $reject('Invalid type'); }); From 9dc6ab1da00d14ac032ad603e9f41005927aebf7 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Thu, 20 Apr 2017 01:32:59 +0200 Subject: [PATCH 102/128] Update documentation --- README.md | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 1006559f..685791cd 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ This is an HTTP server which responds with `Hello World` to every request. $loop = React\EventLoop\Factory::create(); $socket = new React\Socket\Server(8080, $loop); -$http = new Server($socket, function (RequestInterface $request) { +$http = new Server($socket, function (ServerRequestInterface $request) { return new Response( 200, array('Content-Type' => 'text/plain'), @@ -54,7 +54,7 @@ constructor with the respective [request](#request) and ```php $socket = new React\Socket\Server(8080, $loop); -$http = new Server($socket, function (RequestInterface $request) { +$http = new Server($socket, function (ServerRequestInterface $request) { return new Response( 200, array('Content-Type' => 'text/plain'), @@ -75,7 +75,7 @@ $socket = new React\Socket\SecureServer($socket, $loop, array( 'local_cert' => __DIR__ . '/localhost.pem' )); -$http = new Server($socket, function (RequestInterface $request) { +$http = new Server($socket, function (ServerRequestInterface $request) { return new Response( 200, array('Content-Type' => 'text/plain'), @@ -137,11 +137,13 @@ connections and then processing each incoming HTTP request. The request object will be processed once the request headers have been received by the client. This request object implements the +[PSR-7 ServerRequestInterface](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message.md#321-psrhttpmessageserverrequestinterface) +which in turn extends the [PSR-7 RequestInterface](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message.md#32-psrhttpmessagerequestinterface) and will be passed to the callback function like this. ```php -$http = new Server($socket, function (RequestInterface $request) { +$http = new Server($socket, function (ServerRequestInterface $request) { $body = "The method of the request is: " . $request->getMethod(); $body .= "The requested path is: " . $request->getUri()->getPath(); @@ -154,8 +156,14 @@ $http = new Server($socket, function (RequestInterface $request) { ``` For more details about the request object, check out the documentation of +[PSR-7 ServerRequestInterface](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message.md#321-psrhttpmessageserverrequestinterface) +and [PSR-7 RequestInterface](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message.md#32-psrhttpmessagerequestinterface). +> Currently the the server params, cookies and uploaded files are not added by the + `Server`, but you can add these parameters by yourself using the given methods. + The next versions of this project will cover these features. + Note that the request object will be processed once the request headers have been received. This means that this happens irrespective of (i.e. *before*) receiving the @@ -184,7 +192,7 @@ Instead, you should use the `ReactPHP ReadableStreamInterface` which gives you access to the incoming request body as the individual chunks arrive: ```php -$http = new Server($socket, function (RequestInterface $request) { +$http = new Server($socket, function (ServerRequestInterface $request) { return new Promise(function ($resolve, $reject) use ($request) { $contentLength = 0; $request->getBody()->on('data', function ($data) use (&$contentLength) { @@ -248,7 +256,7 @@ Note that this value may be `null` if the request body size is unknown in advance because the request message uses chunked transfer encoding. ```php -$http = new Server($socket, function (RequestInterface $request) { +$http = new Server($socket, function (ServerRequestInterface $request) { $size = $request->getBody()->getSize(); if ($size === null) { $body = 'The request does not contain an explicit length.'; @@ -308,7 +316,7 @@ but feel free to use any implemantation of the `PSR-7 ResponseInterface` you prefer. ```php -$http = new Server($socket, function (RequestInterface $request) { +$http = new Server($socket, function (ServerRequestInterface $request) { return new Response( 200, array('Content-Type' => 'text/plain'), @@ -327,7 +335,7 @@ To prevent this you SHOULD use a This example shows how such a long-term action could look like: ```php -$server = new \React\Http\Server($socket, function (RequestInterface $request) use ($loop) { +$server = new \React\Http\Server($socket, function (ServerRequestInterface $request) use ($loop) { return new Promise(function ($resolve, $reject) use ($request, $loop) { $loop->addTimer(1.5, function() use ($loop, $resolve) { $response = new Response( @@ -355,7 +363,7 @@ Note that other implementations of the `PSR-7 ResponseInterface` likely only support string. ```php -$server = new Server($socket, function (RequestInterface $request) use ($loop) { +$server = new Server($socket, function (ServerRequestInterface $request) use ($loop) { $stream = new ReadableStream(); $timer = $loop->addPeriodicTimer(0.5, function () use ($stream) { @@ -389,7 +397,7 @@ If you know the length of your stream body, you MAY specify it like this instead ```php $stream = new ReadableStream() -$server = new Server($socket, function (RequestInterface $request) use ($loop, $stream) { +$server = new Server($socket, function (ServerRequestInterface $request) use ($loop, $stream) { return new Response( 200, array( @@ -437,7 +445,7 @@ A `Date` header will be automatically added with the system date and time if non You can add a custom `Date` header yourself like this: ```php -$server = new Server($socket, function (RequestInterface $request) { +$server = new Server($socket, function (ServerRequestInterface $request) { return new Response(200, array('Date' => date('D, d M Y H:i:s T'))); }); ``` @@ -446,7 +454,7 @@ If you don't have a appropriate clock to rely on, you should unset this header with an empty string: ```php -$server = new Server($socket, function (RequestInterface $request) { +$server = new Server($socket, function (ServerRequestInterface $request) { return new Response(200, array('Date' => '')); }); ``` @@ -455,7 +463,7 @@ Note that it will automatically assume a `X-Powered-By: react/alpha` header unless your specify a custom `X-Powered-By` header yourself: ```php -$server = new Server($socket, function (RequestInterface $request) { +$server = new Server($socket, function (ServerRequestInterface $request) { return new Response(200, array('X-Powered-By' => 'PHP 3')); }); ``` @@ -464,7 +472,7 @@ If you do not want to send this header at all, you can use an empty string as value like this: ```php -$server = new Server($socket, function (RequestInterface $request) { +$server = new Server($socket, function (ServerRequestInterface $request) { return new Response(200, array('X-Powered-By' => '')); }); ``` From c4463f527dd77249f7895d0ba107c71eec40e238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 3 Mar 2017 10:05:46 +0100 Subject: [PATCH 103/128] Validate proxy requests in absolute-form --- README.md | 10 +- examples/21-http-proxy.php | 45 +++++++ ...connect-proxy.php => 22-connect-proxy.php} | 0 src/RequestHeaderParser.php | 16 +++ tests/RequestHeaderParserTest.php | 32 +++++ tests/ServerTest.php | 111 ++++++++++++++++++ 6 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 examples/21-http-proxy.php rename examples/{21-connect-proxy.php => 22-connect-proxy.php} (100%) diff --git a/README.md b/README.md index 685791cd..74cf2254 100644 --- a/README.md +++ b/README.md @@ -279,7 +279,13 @@ $http = new Server($socket, function (ServerRequestInterface $request) { Note that the server supports *any* request method (including custom and non- standard ones) and all request-target formats defined in the HTTP specs for each -respective method. +respective method, including *normal* `origin-form` requests as well as +proxy requests in `absolute-form` and `authority-form`. +The `getUri(): UriInterface` method can be used to get the effective request +URI which provides you access to individiual URI components. +Note that (depending on the given `request-target`) certain URI components may +or may not be present, for example the `getPath(): string` method will return +an empty string for requests in `asterisk-form` or `authority-form`. You can use `getMethod(): string` and `getRequestTarget(): string` to check this is an accepted request and may want to reject other requests with an appropriate error code, such as `400` (Bad Request) or `405` (Method Not @@ -439,7 +445,7 @@ to the message if the same request would have used an (unconditional) `GET`. response for tunneled application data. This implies that that a `2xx` (Successful) response to a `CONNECT` request can in fact use a streaming response body for the tunneled application data. - See also [example #21](examples) for more details. + See also [example #22](examples) for more details. A `Date` header will be automatically added with the system date and time if none is given. You can add a custom `Date` header yourself like this: diff --git a/examples/21-http-proxy.php b/examples/21-http-proxy.php new file mode 100644 index 00000000..720f51fe --- /dev/null +++ b/examples/21-http-proxy.php @@ -0,0 +1,45 @@ +getRequestTarget(), '://') === false) { + return new Response( + 400, + array('Content-Type' => 'text/plain'), + 'This is a plain HTTP proxy' + ); + } + + // prepare outgoing client request by updating request-target and Host header + $host = (string)$request->getUri()->withScheme('')->withPath('')->withQuery(''); + $target = (string)$request->getUri()->withScheme('')->withHost('')->withPort(null); + if ($target === '') { + $target = $request->getMethod() === 'OPTIONS' ? '*' : '/'; + } + $outgoing = $request->withRequestTarget($target)->withHeader('Host', $host); + + // pseudo code only: simply dump the outgoing request as a string + // left up as an exercise: use an HTTP client to send the outgoing request + // and forward the incoming response to the original client request + return new Response( + 200, + array('Content-Type' => 'text/plain'), + Psr7\str($outgoing) + ); +}); + +//$server->on('error', 'printf'); + +echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; + +$loop->run(); diff --git a/examples/21-connect-proxy.php b/examples/22-connect-proxy.php similarity index 100% rename from examples/21-connect-proxy.php rename to examples/22-connect-proxy.php diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php index 510de700..97fa7a00 100644 --- a/src/RequestHeaderParser.php +++ b/src/RequestHeaderParser.php @@ -71,7 +71,12 @@ private function parseRequest($data) } } + // parse request headers into obj implementing RequestInterface $request = g7\parse_request($headers); + + // create new obj implementing ServerRequestInterface by preserving all + // previous properties and restoring original request target-target + $target = $request->getRequestTarget(); $request = new ServerRequest( $request->getMethod(), $request->getUri(), @@ -79,6 +84,7 @@ private function parseRequest($data) $request->getBody(), $request->getProtocolVersion() ); + $request = $request->withRequestTarget($target); // Do not assume this is HTTPS when this happens to be port 443 // detecting HTTPS is left up to the socket layer (TLS detection) @@ -96,6 +102,16 @@ private function parseRequest($data) )->withRequestTarget($originalTarget); } + // ensure absolute-form request-target contains a valid URI + if (strpos($request->getRequestTarget(), '://') !== false) { + $parts = parse_url($request->getRequestTarget()); + + // make sure value contains valid host component (IP or hostname), but no fragment + if (!isset($parts['scheme'], $parts['host']) || $parts['scheme'] !== 'http' || isset($parts['fragment'])) { + throw new \InvalidArgumentException('Invalid absolute-form request-target'); + } + } + return array($request, $bodyBuffer); } } diff --git a/tests/RequestHeaderParserTest.php b/tests/RequestHeaderParserTest.php index dfc25f4f..f0422361 100644 --- a/tests/RequestHeaderParserTest.php +++ b/tests/RequestHeaderParserTest.php @@ -167,6 +167,38 @@ public function testGuzzleRequestParseException() $this->assertSame(0, count($parser->listeners('error'))); } + public function testInvalidAbsoluteFormSchemeEmitsError() + { + $error = null; + + $parser = new RequestHeaderParser(); + $parser->on('headers', $this->expectCallableNever()); + $parser->on('error', function ($message) use (&$error) { + $error = $message; + }); + + $parser->feed("GET tcp://example.com:80/ HTTP/1.0\r\n\r\n"); + + $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertSame('Invalid absolute-form request-target', $error->getMessage()); + } + + public function testInvalidAbsoluteFormWithFragmentEmitsError() + { + $error = null; + + $parser = new RequestHeaderParser(); + $parser->on('headers', $this->expectCallableNever()); + $parser->on('error', function ($message) use (&$error) { + $error = $message; + }); + + $parser->feed("GET http://example.com:80/#home HTTP/1.0\r\n\r\n"); + + $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertSame('Invalid absolute-form request-target', $error->getMessage()); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; diff --git a/tests/ServerTest.php b/tests/ServerTest.php index d831aa86..aa4b8f89 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -267,6 +267,117 @@ public function testRequestNonConnectWithAuthorityRequestTargetWillReject() $this->connection->emit('data', array($data)); } + public function testRequestAbsoluteEvent() + { + $requestAssertion = null; + + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET http://example.com/test HTTP/1.1\r\nHost: example.com\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('GET', $requestAssertion->getMethod()); + $this->assertSame('http://example.com/test', $requestAssertion->getRequestTarget()); + $this->assertEquals('http://example.com/test', $requestAssertion->getUri()); + $this->assertSame('/test', $requestAssertion->getUri()->getPath()); + $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host')); + } + + public function testRequestAbsoluteAddsMissingHostEvent() + { + $requestAssertion = null; + + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + $server->on('error', 'printf'); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET http://example.com:8080/test HTTP/1.0\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('GET', $requestAssertion->getMethod()); + $this->assertSame('http://example.com:8080/test', $requestAssertion->getRequestTarget()); + $this->assertEquals('http://example.com:8080/test', $requestAssertion->getUri()); + $this->assertSame('/test', $requestAssertion->getUri()->getPath()); + $this->assertSame('example.com:8080', $requestAssertion->getHeaderLine('Host')); + } + + public function testRequestAbsoluteNonMatchingHostWillBePassedAsIs() + { + $requestAssertion = null; + + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET http://example.com/test HTTP/1.1\r\nHost: other.example.org\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('GET', $requestAssertion->getMethod()); + $this->assertSame('http://example.com/test', $requestAssertion->getRequestTarget()); + $this->assertEquals('http://example.com/test', $requestAssertion->getUri()); + $this->assertSame('/test', $requestAssertion->getUri()->getPath()); + $this->assertSame('other.example.org', $requestAssertion->getHeaderLine('Host')); + } + + public function testRequestOptionsAsteriskEvent() + { + $requestAssertion = null; + + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "OPTIONS * HTTP/1.1\r\nHost: example.com\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('OPTIONS', $requestAssertion->getMethod()); + $this->assertSame('*', $requestAssertion->getRequestTarget()); + $this->assertEquals('http://example.com', $requestAssertion->getUri()); + $this->assertSame('', $requestAssertion->getUri()->getPath()); + $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host')); + } + + public function testRequestOptionsAbsoluteEvent() + { + $requestAssertion = null; + + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "OPTIONS http://example.com HTTP/1.1\r\nHost: example.com\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('OPTIONS', $requestAssertion->getMethod()); + $this->assertSame('http://example.com', $requestAssertion->getRequestTarget()); + $this->assertEquals('http://example.com', $requestAssertion->getUri()); + $this->assertSame('', $requestAssertion->getUri()->getPath()); + $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host')); + } + public function testRequestPauseWillbeForwardedToConnection() { $server = new Server($this->socket, function (ServerRequestInterface $request) { From c77accb7ed6fead005c7151fe91b289de2cd01c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 18 Apr 2017 15:27:42 +0200 Subject: [PATCH 104/128] Use socket address for URI if Host header is missing --- README.md | 6 ++ tests/FunctionalServerTest.php | 146 +++++++++++++++++++++++++++++++++ tests/ServerTest.php | 26 ++++++ 3 files changed, 178 insertions(+) diff --git a/README.md b/README.md index 74cf2254..988baec7 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,12 @@ URI which provides you access to individiual URI components. Note that (depending on the given `request-target`) certain URI components may or may not be present, for example the `getPath(): string` method will return an empty string for requests in `asterisk-form` or `authority-form`. +Its `getHost(): string` method will return the host as determined by the +effective request URI, which defaults to the local socket address if a HTTP/1.0 +client did not specify one (i.e. no `Host` header). +Its `getScheme(): string` method will return `http` or `https` depending +on whether the request was made over a secure TLS connection to the target host. + You can use `getMethod(): string` and `getRequestTarget(): string` to check this is an accepted request and may want to reject other requests with an appropriate error code, such as `400` (Bad Request) or `405` (Method Not diff --git a/tests/FunctionalServerTest.php b/tests/FunctionalServerTest.php index 4c7ed6ff..f34f2bc3 100644 --- a/tests/FunctionalServerTest.php +++ b/tests/FunctionalServerTest.php @@ -39,6 +39,54 @@ public function testPlainHttpOnRandomPort() $socket->close(); } + public function testPlainHttpOnRandomPortWithoutHostHeaderUsesSocketUri() + { + $loop = Factory::create(); + $socket = new Socket(0, $loop); + $connector = new Connector($loop); + + $server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array(), (string)$request->getUri()); + }); + + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\n\r\n"); + + return BufferedSink::createPromise($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertContains("HTTP/1.0 200 OK", $response); + $this->assertContains('http://' . $socket->getAddress() . '/', $response); + + $socket->close(); + } + + public function testPlainHttpOnRandomPortWithOtherHostHeaderTakesPrecedence() + { + $loop = Factory::create(); + $socket = new Socket(0, $loop); + $connector = new Connector($loop); + + $server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array(), (string)$request->getUri()); + }); + + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\nHost: localhost:1000\r\n\r\n"); + + return BufferedSink::createPromise($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertContains("HTTP/1.0 200 OK", $response); + $this->assertContains('http://localhost:1000/', $response); + + $socket->close(); + } + public function testSecureHttpsOnRandomPort() { if (!function_exists('stream_socket_enable_crypto')) { @@ -72,6 +120,39 @@ public function testSecureHttpsOnRandomPort() $socket->close(); } + public function testSecureHttpsOnRandomPortWithoutHostHeaderUsesSocketUri() + { + if (!function_exists('stream_socket_enable_crypto')) { + $this->markTestSkipped('Not supported on your platform (outdated HHVM?)'); + } + + $loop = Factory::create(); + $socket = new Socket(0, $loop); + $socket = new SecureServer($socket, $loop, array( + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + )); + $connector = new Connector($loop, array( + 'tls' => array('verify_peer' => false) + )); + + $server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array(), (string)$request->getUri()); + }); + + $result = $connector->connect('tls://' . $socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\n\r\n"); + + return BufferedSink::createPromise($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertContains("HTTP/1.0 200 OK", $response); + $this->assertContains('https://' . $socket->getAddress() . '/', $response); + + $socket->close(); + } + public function testPlainHttpOnStandardPortReturnsUriWithNoPort() { $loop = Factory::create(); @@ -100,6 +181,34 @@ public function testPlainHttpOnStandardPortReturnsUriWithNoPort() $socket->close(); } + public function testPlainHttpOnStandardPortWithoutHostHeaderReturnsUriWithNoPort() + { + $loop = Factory::create(); + try { + $socket = new Socket(80, $loop); + } catch (\RuntimeException $e) { + $this->markTestSkipped('Listening on port 80 failed (root and unused?)'); + } + $connector = new Connector($loop); + + $server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array(), (string)$request->getUri()); + }); + + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\n\r\n"); + + return BufferedSink::createPromise($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertContains("HTTP/1.0 200 OK", $response); + $this->assertContains('http://127.0.0.1/', $response); + + $socket->close(); + } + public function testSecureHttpsOnStandardPortReturnsUriWithNoPort() { if (!function_exists('stream_socket_enable_crypto')) { @@ -137,6 +246,43 @@ public function testSecureHttpsOnStandardPortReturnsUriWithNoPort() $socket->close(); } + public function testSecureHttpsOnStandardPortWithoutHostHeaderUsesSocketUri() + { + if (!function_exists('stream_socket_enable_crypto')) { + $this->markTestSkipped('Not supported on your platform (outdated HHVM?)'); + } + + $loop = Factory::create(); + try { + $socket = new Socket(443, $loop); + } catch (\RuntimeException $e) { + $this->markTestSkipped('Listening on port 443 failed (root and unused?)'); + } + $socket = new SecureServer($socket, $loop, array( + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + )); + $connector = new Connector($loop, array( + 'tls' => array('verify_peer' => false) + )); + + $server = new Server($socket, function (RequestInterface $request) { + return new Response(200, array(), (string)$request->getUri()); + }); + + $result = $connector->connect('tls://' . $socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\n\r\n"); + + return BufferedSink::createPromise($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertContains("HTTP/1.0 200 OK", $response); + $this->assertContains('https://127.0.0.1/', $response); + + $socket->close(); + } + public function testPlainHttpOnHttpsStandardPortReturnsUriWithPort() { $loop = Factory::create(); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index aa4b8f89..bd4f3a7b 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -267,6 +267,32 @@ public function testRequestNonConnectWithAuthorityRequestTargetWillReject() $this->connection->emit('data', array($data)); } + public function testRequestWithoutHostEventUsesSocketAddress() + { + $requestAssertion = null; + + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $requestAssertion = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + + $this->connection + ->expects($this->once()) + ->method('getLocalAddress') + ->willReturn('127.0.0.1:80'); + + $data = "GET /test HTTP/1.0\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); + $this->assertSame('GET', $requestAssertion->getMethod()); + $this->assertSame('/test', $requestAssertion->getRequestTarget()); + $this->assertEquals('http://127.0.0.1/test', $requestAssertion->getUri()); + $this->assertSame('/test', $requestAssertion->getUri()->getPath()); + } + public function testRequestAbsoluteEvent() { $requestAssertion = null; From 440ab689d92335b68afbd02a774d95f89ef06e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 21 Apr 2017 09:19:42 +0200 Subject: [PATCH 105/128] Simplify request validation by moving logic to RequestHeaderParser --- src/RequestHeaderParser.php | 24 +++++++++++++++++++++- tests/RequestHeaderParserTest.php | 33 +++++++++++++++++++++++++++++++ tests/ServerTest.php | 11 +++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php index 97fa7a00..1566476a 100644 --- a/src/RequestHeaderParser.php +++ b/src/RequestHeaderParser.php @@ -30,7 +30,7 @@ public function feed($data) } if ($currentHeaderSize > $this->maxSize) { - $this->emit('error', array(new \OverflowException("Maximum header size of {$this->maxSize} exceeded."), $this)); + $this->emit('error', array(new \OverflowException("Maximum header size of {$this->maxSize} exceeded.", 431), $this)); $this->removeAllListeners(); return; } @@ -112,6 +112,28 @@ private function parseRequest($data) } } + // only support HTTP/1.1 and HTTP/1.0 requests + if ($request->getProtocolVersion() !== '1.1' && $request->getProtocolVersion() !== '1.0') { + throw new \InvalidArgumentException('Received request with invalid protocol version', 505); + } + + // HTTP/1.1 requests MUST include a valid host header (host and optional port) + // https://tools.ietf.org/html/rfc7230#section-5.4 + if ($request->getProtocolVersion() === '1.1') { + $parts = parse_url('http://' . $request->getHeaderLine('Host')); + + // make sure value contains valid host component (IP or hostname) + if (!$parts || !isset($parts['scheme'], $parts['host'])) { + $parts = false; + } + + // make sure value does not contain any other URI component + unset($parts['scheme'], $parts['host'], $parts['port']); + if ($parts === false || $parts) { + throw new \InvalidArgumentException('Invalid Host header for HTTP/1.1 request'); + } + } + return array($request, $bodyBuffer); } } diff --git a/tests/RequestHeaderParserTest.php b/tests/RequestHeaderParserTest.php index f0422361..9f1f7d75 100644 --- a/tests/RequestHeaderParserTest.php +++ b/tests/RequestHeaderParserTest.php @@ -199,6 +199,39 @@ public function testInvalidAbsoluteFormWithFragmentEmitsError() $this->assertSame('Invalid absolute-form request-target', $error->getMessage()); } + public function testInvalidHostHeaderForHttp11() + { + $error = null; + + $parser = new RequestHeaderParser(); + $parser->on('headers', $this->expectCallableNever()); + $parser->on('error', function ($message) use (&$error) { + $error = $message; + }); + + $parser->feed("GET / HTTP/1.1\r\nHost: a/b/c\r\n\r\n"); + + $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertSame('Invalid Host header for HTTP/1.1 request', $error->getMessage()); + } + + public function testInvalidHttpVersion() + { + $error = null; + + $parser = new RequestHeaderParser(); + $parser->on('headers', $this->expectCallableNever()); + $parser->on('error', function ($message) use (&$error) { + $error = $message; + }); + + $parser->feed("GET / HTTP/1.2\r\n\r\n"); + + $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertSame(505, $error->getCode()); + $this->assertSame('Received request with invalid protocol version', $error->getMessage()); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; diff --git a/tests/ServerTest.php b/tests/ServerTest.php index bd4f3a7b..af53cc02 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -256,6 +256,17 @@ public function testRequestConnectAuthorityFormNonMatchingHostWillBePassedAsIs() $this->assertSame('example.com', $requestAssertion->getHeaderLine('host')); } + public function testRequestConnectOriginFormRequestTargetWillReject() + { + $server = new Server($this->socket, $this->expectCallableNever()); + $server->on('error', $this->expectCallableOnce()); + + $this->socket->emit('connection', array($this->connection)); + + $data = "CONNECT / HTTP/1.1\r\nHost: example.com\r\n\r\n"; + $this->connection->emit('data', array($data)); + } + public function testRequestNonConnectWithAuthorityRequestTargetWillReject() { $server = new Server($this->socket, $this->expectCallableNever()); From d973e400c2481f8b5e5361369c23cdf0500546ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 21 Apr 2017 12:57:46 +0200 Subject: [PATCH 106/128] Sanitize Host header value across all requests --- README.md | 5 ++- src/RequestHeaderParser.php | 22 +++++++--- tests/RequestHeaderParserTest.php | 22 ++++++++-- tests/ServerTest.php | 67 +++++-------------------------- 4 files changed, 50 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 988baec7..d8155817 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,9 @@ client did not specify one (i.e. no `Host` header). Its `getScheme(): string` method will return `http` or `https` depending on whether the request was made over a secure TLS connection to the target host. +The `Host` header value will be sanitized to match this host component plus the +port component only if it is non-standard for this URI scheme. + You can use `getMethod(): string` and `getRequestTarget(): string` to check this is an accepted request and may want to reject other requests with an appropriate error code, such as `400` (Bad Request) or `405` (Method Not @@ -300,7 +303,7 @@ Allowed). > The `CONNECT` method is useful in a tunneling setup (HTTPS proxy) and not something most HTTP servers would want to care about. Note that if you want to handle this method, the client MAY send a different - request-target than the `Host` header field (such as removing default ports) + request-target than the `Host` header value (such as removing default ports) and the request-target MUST take precendence when forwarding. The HTTP specs define an opaque "tunneling mode" for this method and make no use of the message body. diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php index 1566476a..91c8d2cd 100644 --- a/src/RequestHeaderParser.php +++ b/src/RequestHeaderParser.php @@ -55,6 +55,8 @@ private function parseRequest($data) { list($headers, $bodyBuffer) = explode("\r\n\r\n", $data, 2); + // parser does not support asterisk-form and authority-form + // remember original target and temporarily replace and re-apply below $originalTarget = null; if (strpos($headers, 'OPTIONS * ') === 0) { $originalTarget = '*'; @@ -75,7 +77,7 @@ private function parseRequest($data) $request = g7\parse_request($headers); // create new obj implementing ServerRequestInterface by preserving all - // previous properties and restoring original request target-target + // previous properties and restoring original request-target $target = $request->getRequestTarget(); $request = new ServerRequest( $request->getMethod(), @@ -95,9 +97,18 @@ private function parseRequest($data) ); } + // re-apply actual request target from above if ($originalTarget !== null) { + $uri = $request->getUri()->withPath(''); + + // re-apply host and port from request-target if given + $parts = parse_url('tcp://' . $originalTarget); + if (isset($parts['host'], $parts['port'])) { + $uri = $uri->withHost($parts['host'])->withPort($parts['port']); + } + $request = $request->withUri( - $request->getUri()->withPath(''), + $uri, true )->withRequestTarget($originalTarget); } @@ -117,9 +128,8 @@ private function parseRequest($data) throw new \InvalidArgumentException('Received request with invalid protocol version', 505); } - // HTTP/1.1 requests MUST include a valid host header (host and optional port) - // https://tools.ietf.org/html/rfc7230#section-5.4 - if ($request->getProtocolVersion() === '1.1') { + // Optional Host header value MUST be valid (host and optional port) + if ($request->hasHeader('Host')) { $parts = parse_url('http://' . $request->getHeaderLine('Host')); // make sure value contains valid host component (IP or hostname) @@ -130,7 +140,7 @@ private function parseRequest($data) // make sure value does not contain any other URI component unset($parts['scheme'], $parts['host'], $parts['port']); if ($parts === false || $parts) { - throw new \InvalidArgumentException('Invalid Host header for HTTP/1.1 request'); + throw new \InvalidArgumentException('Invalid Host header value'); } } diff --git a/tests/RequestHeaderParserTest.php b/tests/RequestHeaderParserTest.php index 9f1f7d75..9ecefddf 100644 --- a/tests/RequestHeaderParserTest.php +++ b/tests/RequestHeaderParserTest.php @@ -199,7 +199,7 @@ public function testInvalidAbsoluteFormWithFragmentEmitsError() $this->assertSame('Invalid absolute-form request-target', $error->getMessage()); } - public function testInvalidHostHeaderForHttp11() + public function testInvalidHeaderContainsFullUri() { $error = null; @@ -209,10 +209,26 @@ public function testInvalidHostHeaderForHttp11() $error = $message; }); - $parser->feed("GET / HTTP/1.1\r\nHost: a/b/c\r\n\r\n"); + $parser->feed("GET / HTTP/1.1\r\nHost: http://user:pass@host/\r\n\r\n"); $this->assertInstanceOf('InvalidArgumentException', $error); - $this->assertSame('Invalid Host header for HTTP/1.1 request', $error->getMessage()); + $this->assertSame('Invalid Host header value', $error->getMessage()); + } + + public function testInvalidAbsoluteFormWithHostHeaderEmpty() + { + $error = null; + + $parser = new RequestHeaderParser(); + $parser->on('headers', $this->expectCallableNever()); + $parser->on('error', function ($message) use (&$error) { + $error = $message; + }); + + $parser->feed("GET http://example.com/ HTTP/1.1\r\nHost: \r\n\r\n"); + + $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertSame('Invalid Host header value', $error->getMessage()); } public function testInvalidHttpVersion() diff --git a/tests/ServerTest.php b/tests/ServerTest.php index af53cc02..ee435dc7 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -88,7 +88,7 @@ public function testRequestEvent() $this->assertSame('/', $requestAssertion->getRequestTarget()); $this->assertSame('/', $requestAssertion->getUri()->getPath()); $this->assertSame('http://example.com/', (string)$requestAssertion->getUri()); - $this->assertSame('example.com:80', $requestAssertion->getHeaderLine('Host')); + $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host')); $this->assertSame('127.0.0.1', $requestAssertion->remoteAddress); } @@ -155,7 +155,7 @@ public function testRequestGetWithHostAndDefaultPortWillBeIgnored() $this->assertSame('/', $requestAssertion->getUri()->getPath()); $this->assertSame('http://example.com/', (string)$requestAssertion->getUri()); $this->assertSame(null, $requestAssertion->getUri()->getPort()); - $this->assertSame('example.com:80', $requestAssertion->getHeaderLine('Host')); + $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host')); } public function testRequestOptionsAsterisk() @@ -209,7 +209,7 @@ public function testRequestConnectAuthorityForm() $this->assertSame('', $requestAssertion->getUri()->getPath()); $this->assertSame('http://example.com:443', (string)$requestAssertion->getUri()); $this->assertSame(443, $requestAssertion->getUri()->getPort()); - $this->assertSame('example.com:443', $requestAssertion->getHeaderLine('host')); + $this->assertSame('example.com:443', $requestAssertion->getHeaderLine('Host')); } public function testRequestConnectAuthorityFormWithDefaultPortWillBeIgnored() @@ -231,10 +231,10 @@ public function testRequestConnectAuthorityFormWithDefaultPortWillBeIgnored() $this->assertSame('', $requestAssertion->getUri()->getPath()); $this->assertSame('http://example.com', (string)$requestAssertion->getUri()); $this->assertSame(null, $requestAssertion->getUri()->getPort()); - $this->assertSame('example.com:80', $requestAssertion->getHeaderLine('Host')); + $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host')); } - public function testRequestConnectAuthorityFormNonMatchingHostWillBePassedAsIs() + public function testRequestConnectAuthorityFormNonMatchingHostWillBeOverwritten() { $requestAssertion = null; $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { @@ -244,7 +244,7 @@ public function testRequestConnectAuthorityFormNonMatchingHostWillBePassedAsIs() $this->socket->emit('connection', array($this->connection)); - $data = "CONNECT example.com:80 HTTP/1.1\r\nHost: example.com\r\n\r\n"; + $data = "CONNECT example.com:80 HTTP/1.1\r\nHost: other.example.org\r\n\r\n"; $this->connection->emit('data', array($data)); $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); @@ -253,7 +253,7 @@ public function testRequestConnectAuthorityFormNonMatchingHostWillBePassedAsIs() $this->assertSame('', $requestAssertion->getUri()->getPath()); $this->assertSame('http://example.com', (string)$requestAssertion->getUri()); $this->assertSame(null, $requestAssertion->getUri()->getPort()); - $this->assertSame('example.com', $requestAssertion->getHeaderLine('host')); + $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host')); } public function testRequestConnectOriginFormRequestTargetWillReject() @@ -349,7 +349,7 @@ public function testRequestAbsoluteAddsMissingHostEvent() $this->assertSame('example.com:8080', $requestAssertion->getHeaderLine('Host')); } - public function testRequestAbsoluteNonMatchingHostWillBePassedAsIs() + public function testRequestAbsoluteNonMatchingHostWillBeOverwritten() { $requestAssertion = null; @@ -368,7 +368,7 @@ public function testRequestAbsoluteNonMatchingHostWillBePassedAsIs() $this->assertSame('http://example.com/test', $requestAssertion->getRequestTarget()); $this->assertEquals('http://example.com/test', $requestAssertion->getUri()); $this->assertSame('/test', $requestAssertion->getUri()->getPath()); - $this->assertSame('other.example.org', $requestAssertion->getHeaderLine('Host')); + $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host')); } public function testRequestOptionsAsteriskEvent() @@ -1002,39 +1002,7 @@ public function testChunkedIsMixedUpperAndLowerCase() $this->connection->emit('data', array($data)); } - public function testRequestHttp11WithoutHostWillEmitErrorAndSendErrorResponse() - { - $error = null; - $server = new Server($this->socket, $this->expectCallableNever()); - $server->on('error', function ($message) use (&$error) { - $error = $message; - }); - - $buffer = ''; - - $this->connection - ->expects($this->any()) - ->method('write') - ->will( - $this->returnCallback( - function ($data) use (&$buffer) { - $buffer .= $data; - } - ) - ); - - $this->socket->emit('connection', array($this->connection)); - - $data = "GET / HTTP/1.1\r\n\r\n"; - $this->connection->emit('data', array($data)); - - $this->assertInstanceOf('InvalidArgumentException', $error); - - $this->assertContains("HTTP/1.1 400 Bad Request\r\n", $buffer); - $this->assertContains("\r\n\r\nError 400: Bad Request", $buffer); - } - - public function testRequestHttp11WithMalformedHostWillEmitErrorAndSendErrorResponse() + public function testRequestWithMalformedHostWillEmitErrorAndSendErrorResponse() { $error = null; $server = new Server($this->socket, $this->expectCallableNever()); @@ -1066,7 +1034,7 @@ function ($data) use (&$buffer) { $this->assertContains("\r\n\r\nError 400: Bad Request", $buffer); } - public function testRequestHttp11WithInvalidHostUriComponentsWillEmitErrorAndSendErrorResponse() + public function testRequestWithInvalidHostUriComponentsWillEmitErrorAndSendErrorResponse() { $error = null; $server = new Server($this->socket, $this->expectCallableNever()); @@ -1098,19 +1066,6 @@ function ($data) use (&$buffer) { $this->assertContains("\r\n\r\nError 400: Bad Request", $buffer); } - public function testRequestHttp10WithoutHostEmitsRequestWithNoError() - { - $server = new Server($this->socket, function (ServerRequestInterface $request) { - return \React\Promise\resolve(new Response()); - }); - $server->on('error', $this->expectCallableNever()); - - $this->socket->emit('connection', array($this->connection)); - - $data = "GET / HTTP/1.0\r\n\r\n"; - $this->connection->emit('data', array($data)); - } - public function testWontEmitFurtherDataWhenContentLengthIsReached() { $dataEvent = $this->expectCallableOnceWith('hello'); From eac22d287b676789e75489f3ed8ea6fe82a88b43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 22 Apr 2017 00:41:16 +0200 Subject: [PATCH 107/128] Move complete URI handling to RequestHeaderParser --- src/RequestHeaderParser.php | 64 ++++++++++++++++++++++++------- tests/RequestHeaderParserTest.php | 36 +++++++++++++++-- tests/ServerTest.php | 4 +- 3 files changed, 85 insertions(+), 19 deletions(-) diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php index 91c8d2cd..cb25dcab 100644 --- a/src/RequestHeaderParser.php +++ b/src/RequestHeaderParser.php @@ -17,6 +17,13 @@ class RequestHeaderParser extends EventEmitter private $buffer = ''; private $maxSize = 4096; + private $uri; + + public function __construct($localSocketUri = '') + { + $this->uri = $localSocketUri; + } + public function feed($data) { $this->buffer .= $data; @@ -88,15 +95,6 @@ private function parseRequest($data) ); $request = $request->withRequestTarget($target); - // Do not assume this is HTTPS when this happens to be port 443 - // detecting HTTPS is left up to the socket layer (TLS detection) - if ($request->getUri()->getScheme() === 'https') { - $request = $request->withUri( - $request->getUri()->withScheme('http')->withPort(443), - true - ); - } - // re-apply actual request target from above if ($originalTarget !== null) { $uri = $request->getUri()->withPath(''); @@ -113,6 +111,11 @@ private function parseRequest($data) )->withRequestTarget($originalTarget); } + // only support HTTP/1.1 and HTTP/1.0 requests + if ($request->getProtocolVersion() !== '1.1' && $request->getProtocolVersion() !== '1.0') { + throw new \InvalidArgumentException('Received request with invalid protocol version', 505); + } + // ensure absolute-form request-target contains a valid URI if (strpos($request->getRequestTarget(), '://') !== false) { $parts = parse_url($request->getRequestTarget()); @@ -123,11 +126,6 @@ private function parseRequest($data) } } - // only support HTTP/1.1 and HTTP/1.0 requests - if ($request->getProtocolVersion() !== '1.1' && $request->getProtocolVersion() !== '1.0') { - throw new \InvalidArgumentException('Received request with invalid protocol version', 505); - } - // Optional Host header value MUST be valid (host and optional port) if ($request->hasHeader('Host')) { $parts = parse_url('http://' . $request->getHeaderLine('Host')); @@ -144,6 +142,44 @@ private function parseRequest($data) } } + // set URI components from socket address if not already filled via Host header + if ($request->getUri()->getHost() === '') { + $parts = parse_url($this->uri); + + $request = $request->withUri( + $request->getUri()->withScheme('http')->withHost($parts['host'])->withPort($parts['port']), + true + ); + } + + // Do not assume this is HTTPS when this happens to be port 443 + // detecting HTTPS is left up to the socket layer (TLS detection) + if ($request->getUri()->getScheme() === 'https') { + $request = $request->withUri( + $request->getUri()->withScheme('http')->withPort(443), + true + ); + } + + // Update request URI to "https" scheme if the connection is encrypted + $parts = parse_url($this->uri); + if (isset($parts['scheme']) && $parts['scheme'] === 'https') { + // The request URI may omit default ports here, so try to parse port + // from Host header field (if possible) + $port = $request->getUri()->getPort(); + if ($port === null) { + $port = parse_url('tcp://' . $request->getHeaderLine('Host'), PHP_URL_PORT); // @codeCoverageIgnore + } + + $request = $request->withUri( + $request->getUri()->withScheme('https')->withPort($port), + true + ); + } + + // always sanitize Host header because it contains critical routing information + $request = $request->withUri($request->getUri()->withUserInfo('u')->withUserInfo('')); + return array($request, $bodyBuffer); } } diff --git a/tests/RequestHeaderParserTest.php b/tests/RequestHeaderParserTest.php index 9ecefddf..be725294 100644 --- a/tests/RequestHeaderParserTest.php +++ b/tests/RequestHeaderParserTest.php @@ -49,7 +49,7 @@ public function testHeadersEventShouldReturnRequestAndBodyBuffer() $this->assertSame('GET', $request->getMethod()); $this->assertEquals('http://example.com/', $request->getUri()); $this->assertSame('1.1', $request->getProtocolVersion()); - $this->assertSame(array('Host' => array('example.com:80'), 'Connection' => array('close')), $request->getHeaders()); + $this->assertSame(array('Host' => array('example.com'), 'Connection' => array('close')), $request->getHeaders()); $this->assertSame('RANDOM DATA', $bodyBuffer); } @@ -87,13 +87,43 @@ public function testHeadersEventShouldParsePathAndQueryString() $this->assertEquals('http://example.com/foo?bar=baz', $request->getUri()); $this->assertSame('1.1', $request->getProtocolVersion()); $headers = array( - 'Host' => array('example.com:80'), + 'Host' => array('example.com'), 'User-Agent' => array('react/alpha'), 'Connection' => array('close'), ); $this->assertSame($headers, $request->getHeaders()); } + public function testHeaderEventWithShouldApplyDefaultAddressFromConstructor() + { + $request = null; + + $parser = new RequestHeaderParser('http://127.1.1.1:8000'); + $parser->on('headers', function ($parsedRequest) use (&$request) { + $request = $parsedRequest; + }); + + $parser->feed("GET /foo HTTP/1.0\r\n\r\n"); + + $this->assertEquals('http://127.1.1.1:8000/foo', $request->getUri()); + $this->assertEquals('127.1.1.1:8000', $request->getHeaderLine('Host')); + } + + public function testHeaderEventViaHttpsShouldApplySchemeFromConstructor() + { + $request = null; + + $parser = new RequestHeaderParser('https://127.1.1.1:8000'); + $parser->on('headers', function ($parsedRequest) use (&$request) { + $request = $parsedRequest; + }); + + $parser->feed("GET /foo HTTP/1.0\r\nHost: example.com\r\n\r\n"); + + $this->assertEquals('https://example.com/foo', $request->getUri()); + $this->assertEquals('example.com', $request->getHeaderLine('Host')); + } + public function testHeaderOverflowShouldEmitError() { $error = null; @@ -137,7 +167,7 @@ public function testHeaderOverflowShouldNotEmitErrorWhenDataExceedsMaxHeaderSize $parser->feed($data); $headers = array( - 'Host' => array('example.com:80'), + 'Host' => array('example.com'), 'User-Agent' => array('react/alpha'), 'Connection' => array('close'), ); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index ee435dc7..25c01810 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -287,13 +287,13 @@ public function testRequestWithoutHostEventUsesSocketAddress() return new Response(); }); - $this->socket->emit('connection', array($this->connection)); - $this->connection ->expects($this->once()) ->method('getLocalAddress') ->willReturn('127.0.0.1:80'); + $this->socket->emit('connection', array($this->connection)); + $data = "GET /test HTTP/1.0\r\n\r\n"; $this->connection->emit('data', array($data)); From 75b7deea30286ad85a65f76f4bffab249f7e2c5b Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Fri, 21 Apr 2017 14:34:39 +0200 Subject: [PATCH 108/128] Add server-side parameters to implementation --- src/RequestHeaderParser.php | 35 +++++++++++--- src/ServerRequest.php | 22 +++++++++ tests/RequestHeaderParserTest.php | 77 +++++++++++++++++++++++++++++++ tests/ServerRequestTest.php | 21 +++++++++ tests/ServerTest.php | 45 ++++++++++++++++-- 5 files changed, 190 insertions(+), 10 deletions(-) diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php index cb25dcab..cb6537e2 100644 --- a/src/RequestHeaderParser.php +++ b/src/RequestHeaderParser.php @@ -17,11 +17,13 @@ class RequestHeaderParser extends EventEmitter private $buffer = ''; private $maxSize = 4096; - private $uri; + private $localSocketUri; + private $remoteSocketUri; - public function __construct($localSocketUri = '') + public function __construct($localSocketUri = null, $remoteSocketUri = null) { - $this->uri = $localSocketUri; + $this->localSocketUri = $localSocketUri; + $this->remoteSocketUri = $remoteSocketUri; } public function feed($data) @@ -85,13 +87,34 @@ private function parseRequest($data) // create new obj implementing ServerRequestInterface by preserving all // previous properties and restoring original request-target + $serverParams = array( + 'REQUEST_TIME' => time(), + 'REQUEST_TIME_FLOAT' => microtime(true) + ); + + if ($this->remoteSocketUri !== null) { + $remoteAddress = parse_url($this->remoteSocketUri); + $serverParams['REMOTE_ADDR'] = $remoteAddress['host']; + $serverParams['REMOTE_PORT'] = $remoteAddress['port']; + } + + if ($this->localSocketUri !== null) { + $localAddress = parse_url($this->localSocketUri); + $serverParams['SERVER_ADDR'] = $localAddress['host']; + $serverParams['SERVER_PORT'] = $localAddress['port']; + if (isset($localAddress['scheme']) && $localAddress['scheme'] === 'https') { + $serverParams['HTTPS'] = 'on'; + } + } + $target = $request->getRequestTarget(); $request = new ServerRequest( $request->getMethod(), $request->getUri(), $request->getHeaders(), $request->getBody(), - $request->getProtocolVersion() + $request->getProtocolVersion(), + $serverParams ); $request = $request->withRequestTarget($target); @@ -144,7 +167,7 @@ private function parseRequest($data) // set URI components from socket address if not already filled via Host header if ($request->getUri()->getHost() === '') { - $parts = parse_url($this->uri); + $parts = parse_url($this->localSocketUri); $request = $request->withUri( $request->getUri()->withScheme('http')->withHost($parts['host'])->withPort($parts['port']), @@ -162,7 +185,7 @@ private function parseRequest($data) } // Update request URI to "https" scheme if the connection is encrypted - $parts = parse_url($this->uri); + $parts = parse_url($this->localSocketUri); if (isset($parts['scheme']) && $parts['scheme'] === 'https') { // The request URI may omit default ports here, so try to parse port // from Host header field (if possible) diff --git a/src/ServerRequest.php b/src/ServerRequest.php index 44826f6c..ea36a5a9 100644 --- a/src/ServerRequest.php +++ b/src/ServerRequest.php @@ -16,6 +16,28 @@ class ServerRequest extends Request implements ServerRequestInterface private $queryParams = array(); private $parsedBody = null; + /** + * @param null|string $method HTTP method for the request. + * @param null|string|UriInterface $uri URI for the request. + * @param array $headers Headers for the message. + * @param string|resource|StreamInterface $body Message body. + * @param string $protocolVersion HTTP protocol version. + * @param array server-side parameters + * + * @throws InvalidArgumentException for an invalid URI + */ + public function __construct( + $method, + $uri, + array $headers = array(), + $body = null, + $protocolVersion = '1.1', + $serverParams = array() + ) { + $this->serverParams = $serverParams; + parent::__construct($method, $uri, $headers, $body, $protocolVersion); + } + public function getServerParams() { return $this->serverParams; diff --git a/tests/RequestHeaderParserTest.php b/tests/RequestHeaderParserTest.php index be725294..957bb6ac 100644 --- a/tests/RequestHeaderParserTest.php +++ b/tests/RequestHeaderParserTest.php @@ -278,6 +278,83 @@ public function testInvalidHttpVersion() $this->assertSame('Received request with invalid protocol version', $error->getMessage()); } + public function testServerParamsWillBeSetOnHttpsRequest() + { + $request = null; + + $parser = new RequestHeaderParser( + 'https://127.1.1.1:8000', + 'https://192.168.1.1:8001' + ); + + $parser->on('headers', function ($parsedRequest) use (&$request) { + $request = $parsedRequest; + }); + + $parser->feed("GET /foo HTTP/1.0\r\nHost: example.com\r\n\r\n"); + $serverParams = $request->getServerParams(); + + $this->assertEquals('on', $serverParams['HTTPS']); + $this->assertNotEmpty($serverParams['REQUEST_TIME']); + $this->assertNotEmpty($serverParams['REQUEST_TIME_FLOAT']); + + $this->assertEquals('127.1.1.1', $serverParams['SERVER_ADDR']); + $this->assertEquals('8000', $serverParams['SERVER_PORT']); + + $this->assertEquals('192.168.1.1', $serverParams['REMOTE_ADDR']); + $this->assertEquals('8001', $serverParams['REMOTE_PORT']); + } + + public function testServerParamsWillBeSetOnHttpRequest() + { + $request = null; + + $parser = new RequestHeaderParser( + 'http://127.1.1.1:8000', + 'http://192.168.1.1:8001' + ); + + $parser->on('headers', function ($parsedRequest) use (&$request) { + $request = $parsedRequest; + }); + + $parser->feed("GET /foo HTTP/1.0\r\nHost: example.com\r\n\r\n"); + $serverParams = $request->getServerParams(); + + $this->assertArrayNotHasKey('HTTPS', $serverParams); + $this->assertNotEmpty($serverParams['REQUEST_TIME']); + $this->assertNotEmpty($serverParams['REQUEST_TIME_FLOAT']); + + $this->assertEquals('127.1.1.1', $serverParams['SERVER_ADDR']); + $this->assertEquals('8000', $serverParams['SERVER_PORT']); + + $this->assertEquals('192.168.1.1', $serverParams['REMOTE_ADDR']); + $this->assertEquals('8001', $serverParams['REMOTE_PORT']); + } + + public function testServerParamsWontBeSetOnMissingUrls() + { + $request = null; + + $parser = new RequestHeaderParser(); + + $parser->on('headers', function ($parsedRequest) use (&$request) { + $request = $parsedRequest; + }); + + $parser->feed("GET /foo HTTP/1.0\r\nHost: example.com\r\n\r\n"); + $serverParams = $request->getServerParams(); + + $this->assertNotEmpty($serverParams['REQUEST_TIME']); + $this->assertNotEmpty($serverParams['REQUEST_TIME_FLOAT']); + + $this->assertArrayNotHasKey('SERVER_ADDR', $serverParams); + $this->assertArrayNotHasKey('SERVER_PORT', $serverParams); + + $this->assertArrayNotHasKey('REMOTE_ADDR', $serverParams); + $this->assertArrayNotHasKey('REMOTE_PORT', $serverParams); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; diff --git a/tests/ServerRequestTest.php b/tests/ServerRequestTest.php index fc873439..14edf3a8 100644 --- a/tests/ServerRequestTest.php +++ b/tests/ServerRequestTest.php @@ -84,4 +84,25 @@ public function testWithParsedBody() $this->assertNotSame($request, $this->request); $this->assertEquals(array('test' => 'world'), $request->getParsedBody()); } + + public function testServerRequestParameter() + { + $body = 'hello=world'; + $request = new ServerRequest( + 'POST', + 'http://127.0.0.1', + array('Content-Length' => strlen($body)), + $body, + '1.0', + array('SERVER_ADDR' => '127.0.0.1') + ); + + $serverParams = $request->getServerParams(); + $this->assertEquals('POST', $request->getMethod()); + $this->assertEquals('http://127.0.0.1', $request->getUri()); + $this->assertEquals('11', $request->getHeaderLine('Content-Length')); + $this->assertEquals('hello=world', $request->getBody()); + $this->assertEquals('1.0', $request->getProtocolVersion()); + $this->assertEquals('127.0.0.1', $serverParams['SERVER_ADDR']); + } } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 25c01810..3cd88bf8 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -69,19 +69,22 @@ public function testRequestEvent() $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$i, &$requestAssertion) { $i++; $requestAssertion = $request; + return \React\Promise\resolve(new Response()); }); $this->connection - ->expects($this->once()) + ->expects($this->any()) ->method('getRemoteAddress') - ->willReturn('127.0.0.1'); + ->willReturn('127.0.0.1:8080'); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); $this->connection->emit('data', array($data)); + $serverParams = $requestAssertion->getServerParams(); + $this->assertSame(1, $i); $this->assertInstanceOf('RingCentral\Psr7\Request', $requestAssertion); $this->assertSame('GET', $requestAssertion->getMethod()); @@ -89,7 +92,7 @@ public function testRequestEvent() $this->assertSame('/', $requestAssertion->getUri()->getPath()); $this->assertSame('http://example.com/', (string)$requestAssertion->getUri()); $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host')); - $this->assertSame('127.0.0.1', $requestAssertion->remoteAddress); + $this->assertSame('127.0.0.1', $serverParams['REMOTE_ADDR']); } public function testRequestGetWithHostAndCustomPort() @@ -288,7 +291,7 @@ public function testRequestWithoutHostEventUsesSocketAddress() }); $this->connection - ->expects($this->once()) + ->expects($this->any()) ->method('getLocalAddress') ->willReturn('127.0.0.1:80'); @@ -2332,6 +2335,40 @@ function ($data) use (&$buffer) { $this->assertInstanceOf('RuntimeException', $exception); } + public function testServerRequestParams() + { + $requestValidation = null; + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestValidation) { + $requestValidation = $request; + return new Response(); + }); + + $this->connection + ->expects($this->any()) + ->method('getRemoteAddress') + ->willReturn('192.168.1.2:80'); + + $this->connection + ->expects($this->any()) + ->method('getLocalAddress') + ->willReturn('127.0.0.1:8080'); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + + $this->connection->emit('data', array($data)); + + $serverParams = $requestValidation->getServerParams(); + + $this->assertEquals('127.0.0.1', $serverParams['SERVER_ADDR']); + $this->assertEquals('8080', $serverParams['SERVER_PORT']); + $this->assertEquals('192.168.1.2', $serverParams['REMOTE_ADDR']); + $this->assertEquals('80', $serverParams['REMOTE_PORT']); + $this->assertNotNull($serverParams['REQUEST_TIME']); + $this->assertNotNull($serverParams['REQUEST_TIME_FLOAT']); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From be143ddff84d853222a0d211ac588285515f4a3b Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Sun, 23 Apr 2017 02:19:49 +0200 Subject: [PATCH 109/128] Update docs and add example --- README.md | 37 ++++++++++++++++++- examples/02-client-ip.php | 25 +++++++++++++ ...unt-visitors.php => 03-count-visitors.php} | 0 ...am-response.php => 04-stream-response.php} | 0 ...ream-request.php => 05-stream-request.php} | 0 ...ror-handling.php => 06-error-handling.php} | 0 6 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 examples/02-client-ip.php rename examples/{02-count-visitors.php => 03-count-visitors.php} (100%) rename examples/{03-stream-response.php => 04-stream-response.php} (100%) rename examples/{04-stream-request.php => 05-stream-request.php} (100%) rename examples/{05-error-handling.php => 06-error-handling.php} (100%) diff --git a/README.md b/README.md index d8155817..d4d35e39 100644 --- a/README.md +++ b/README.md @@ -155,12 +155,47 @@ $http = new Server($socket, function (ServerRequestInterface $request) { }); ``` +The `getServerParams(): mixed[]` method can be used to +get server-side parameters similar to the `$_SERVER` variable. +The following parameters are currently available: + +* `REMOTE_ADDR` + The IP address of the request sender +* `REMOTE_PORT` + Port of the request sender +* `SERVER_ADDR` + The IP address of the server +* `SERVER_PORT` + The port of the server +* `REQUEST_TIME` + Unix timestamp when the complete request header has been received, + as integer similar to `time()` +* `REQUEST_TIME_FLOAT` + Unix timestamp when the complete request header has been received, + as float similar to `microtime(true)` +* `HTTPS` + Set to 'on' if the request used HTTPS, otherwise it won't be set + +```php +$http = new Server($socket, function (ServerRequestInterface $request) { + $body = "Your IP is: " . $request->getServerParams()['REMOTE_ADDR']; + + return new Response( + 200, + array('Content-Type' => 'text/plain'), + $body + ); +}); +``` + +See also [example #2](examples). + For more details about the request object, check out the documentation of [PSR-7 ServerRequestInterface](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message.md#321-psrhttpmessageserverrequestinterface) and [PSR-7 RequestInterface](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message.md#32-psrhttpmessagerequestinterface). -> Currently the the server params, cookies and uploaded files are not added by the +> Currently the cookies and uploaded files are not added by the `Server`, but you can add these parameters by yourself using the given methods. The next versions of this project will cover these features. diff --git a/examples/02-client-ip.php b/examples/02-client-ip.php new file mode 100644 index 00000000..31b7ad32 --- /dev/null +++ b/examples/02-client-ip.php @@ -0,0 +1,25 @@ +getServerParams()['REMOTE_ADDR']; + + return new Response( + 200, + array('Content-Type' => 'text/plain'), + $body + ); +}); + +echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; + +$loop->run(); diff --git a/examples/02-count-visitors.php b/examples/03-count-visitors.php similarity index 100% rename from examples/02-count-visitors.php rename to examples/03-count-visitors.php diff --git a/examples/03-stream-response.php b/examples/04-stream-response.php similarity index 100% rename from examples/03-stream-response.php rename to examples/04-stream-response.php diff --git a/examples/04-stream-request.php b/examples/05-stream-request.php similarity index 100% rename from examples/04-stream-request.php rename to examples/05-stream-request.php diff --git a/examples/05-error-handling.php b/examples/06-error-handling.php similarity index 100% rename from examples/05-error-handling.php rename to examples/06-error-handling.php From e570bb603e213a1eb5628e915e7b727395acf9c8 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Wed, 26 Apr 2017 14:46:41 +0200 Subject: [PATCH 110/128] Add query parameters to ServerRequest --- src/RequestHeaderParser.php | 8 ++++++++ tests/RequestHeaderParserTest.php | 17 +++++++++++++++++ tests/ServerTest.php | 21 +++++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php index cb6537e2..bb438b37 100644 --- a/src/RequestHeaderParser.php +++ b/src/RequestHeaderParser.php @@ -118,6 +118,14 @@ private function parseRequest($data) ); $request = $request->withRequestTarget($target); + // Add query params + $queryString = $request->getUri()->getQuery(); + if ($queryString !== '') { + $queryParams = array(); + parse_str($queryString, $queryParams); + $request = $request->withQueryParams($queryParams); + } + // re-apply actual request target from above if ($originalTarget !== null) { $uri = $request->getUri()->withPath(''); diff --git a/tests/RequestHeaderParserTest.php b/tests/RequestHeaderParserTest.php index 957bb6ac..956bf4ff 100644 --- a/tests/RequestHeaderParserTest.php +++ b/tests/RequestHeaderParserTest.php @@ -355,6 +355,23 @@ public function testServerParamsWontBeSetOnMissingUrls() $this->assertArrayNotHasKey('REMOTE_PORT', $serverParams); } + public function testQueryParmetersWillBeSet() + { + $request = null; + + $parser = new RequestHeaderParser(); + + $parser->on('headers', function ($parsedRequest) use (&$request) { + $request = $parsedRequest; + }); + + $parser->feed("GET /foo.php?hello=world&test=this HTTP/1.0\r\nHost: example.com\r\n\r\n"); + $queryParams = $request->getQueryParams(); + + $this->assertEquals('world', $queryParams['hello']); + $this->assertEquals('this', $queryParams['test']); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 3cd88bf8..a0dd0f5b 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -90,6 +90,7 @@ public function testRequestEvent() $this->assertSame('GET', $requestAssertion->getMethod()); $this->assertSame('/', $requestAssertion->getRequestTarget()); $this->assertSame('/', $requestAssertion->getUri()->getPath()); + $this->assertSame(array(), $requestAssertion->getQueryParams()); $this->assertSame('http://example.com/', (string)$requestAssertion->getUri()); $this->assertSame('example.com', $requestAssertion->getHeaderLine('Host')); $this->assertSame('127.0.0.1', $serverParams['REMOTE_ADDR']); @@ -2369,6 +2370,26 @@ public function testServerRequestParams() $this->assertNotNull($serverParams['REQUEST_TIME_FLOAT']); } + public function testQueryParametersWillBeAddedToRequest() + { + $requestValidation = null; + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestValidation) { + $requestValidation = $request; + return new Response(); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET /foo.php?hello=world&test=bar HTTP/1.0\r\n\r\n"; + + $this->connection->emit('data', array($data)); + + $queryParams = $requestValidation->getQueryParams(); + + $this->assertEquals('world', $queryParams['hello']); + $this->assertEquals('bar', $queryParams['test']); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From fa17cf71bcd359601a12f0f6edcf16a53c15a5d8 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Wed, 26 Apr 2017 14:46:48 +0200 Subject: [PATCH 111/128] Update README --- README.md | 30 +++++++++++++++++ examples/03-query-parameter.php | 32 +++++++++++++++++++ ...unt-visitors.php => 04-count-visitors.php} | 0 ...am-response.php => 05-stream-response.php} | 0 ...ream-request.php => 06-stream-request.php} | 0 ...ror-handling.php => 07-error-handling.php} | 0 6 files changed, 62 insertions(+) create mode 100644 examples/03-query-parameter.php rename examples/{03-count-visitors.php => 04-count-visitors.php} (100%) rename examples/{04-stream-response.php => 05-stream-response.php} (100%) rename examples/{05-stream-request.php => 06-stream-request.php} (100%) rename examples/{06-error-handling.php => 07-error-handling.php} (100%) diff --git a/README.md b/README.md index d4d35e39..7551cda7 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,36 @@ $http = new Server($socket, function (ServerRequestInterface $request) { See also [example #2](examples). +The `getQueryParams(): array` method can be used to get the query parameters +similiar to the `$_GET` variable. + +```php +$http = new Server($socket, function (ServerRequestInterface $request) { + $queryParams = $request->getQueryParams(); + + $body = 'The query parameter "foo" is not set. Click the following link '; + $body .= 'to use query parameter in your request'; + + if (isset($queryParams['foo'])) { + $body = 'The value of "foo" is: ' . htmlspecialchars($queryParams['foo']); + } + + return new Response( + 200, + array('Content-Type' => 'text/html'), + $body + ); +}); +``` + +The response in the above example will return a response body with a link. +The URL contains the query parameter `foo` with the value `bar`. +Use [`htmlentities`](http://php.net/manual/en/function.htmlentities.php) +like in this example to prevent +[Cross-Site Scripting (abbreviated as XSS)](https://en.wikipedia.org/wiki/Cross-site_scripting). + +See also [example #3](examples). + For more details about the request object, check out the documentation of [PSR-7 ServerRequestInterface](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message.md#321-psrhttpmessageserverrequestinterface) and diff --git a/examples/03-query-parameter.php b/examples/03-query-parameter.php new file mode 100644 index 00000000..15f6c49a --- /dev/null +++ b/examples/03-query-parameter.php @@ -0,0 +1,32 @@ +getQueryParams(); + + $body = 'The query parameter "foo" is not set. Click the following link '; + $body .= 'to use query parameter in your request'; + + if (isset($queryParams['foo'])) { + $body = 'The value of "foo" is: ' . htmlspecialchars($queryParams['foo']); + } + + return new Response( + 200, + array('Content-Type' => 'text/html'), + $body + ); +}); + +echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; + +$loop->run(); diff --git a/examples/03-count-visitors.php b/examples/04-count-visitors.php similarity index 100% rename from examples/03-count-visitors.php rename to examples/04-count-visitors.php diff --git a/examples/04-stream-response.php b/examples/05-stream-response.php similarity index 100% rename from examples/04-stream-response.php rename to examples/05-stream-response.php diff --git a/examples/05-stream-request.php b/examples/06-stream-request.php similarity index 100% rename from examples/05-stream-request.php rename to examples/06-stream-request.php diff --git a/examples/06-error-handling.php b/examples/07-error-handling.php similarity index 100% rename from examples/06-error-handling.php rename to examples/07-error-handling.php From ee49ccb0ec7b129da71d14bd6a47013ef676ffa4 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Fri, 21 Apr 2017 14:47:33 +0200 Subject: [PATCH 112/128] Add function to parse cookies --- src/ServerRequest.php | 29 ++++++++++++++ tests/ServerRequestTest.php | 77 +++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/src/ServerRequest.php b/src/ServerRequest.php index ea36a5a9..c3fee8f2 100644 --- a/src/ServerRequest.php +++ b/src/ServerRequest.php @@ -117,4 +117,33 @@ public function withoutAttribute($name) unset($new->attributes[$name]); return $new; } + + /** + * @internal + * @param string $cookie + * @return boolean|mixed[] + */ + public static function parseCookie($cookie) + { + // PSR-7 `getHeadline('Cookies')` will return multiple + // cookie header coma-seperated. Multiple cookie headers + // are not allowed according to https://tools.ietf.org/html/rfc6265#section-5.4 + if (strpos($cookie, ',') !== false) { + return false; + } + + $cookieArray = explode(';', $cookie); + $result = array(); + + foreach ($cookieArray as $pair) { + $nameValuePair = explode('=', $pair, 2); + if (count($nameValuePair) === 2) { + $key = urldecode($nameValuePair[0]); + $value = urldecode($nameValuePair[1]); + $result[$key] = $value; + } + } + + return $result; + } } diff --git a/tests/ServerRequestTest.php b/tests/ServerRequestTest.php index 14edf3a8..dfc44dd9 100644 --- a/tests/ServerRequestTest.php +++ b/tests/ServerRequestTest.php @@ -105,4 +105,81 @@ public function testServerRequestParameter() $this->assertEquals('1.0', $request->getProtocolVersion()); $this->assertEquals('127.0.0.1', $serverParams['SERVER_ADDR']); } + + public function testParseSingleCookieNameValuePairWillReturnValidArray() + { + $cookieString = 'hello=world'; + $cookies = ServerRequest::parseCookie($cookieString); + $this->assertEquals(array('hello' => 'world'), $cookies); + }# + + public function testParseMultipleCookieNameValuePaiWillReturnValidArray() + { + $cookieString = 'hello=world;test=abc'; + $cookies = ServerRequest::parseCookie($cookieString); + $this->assertEquals(array('hello' => 'world', 'test' => 'abc'), $cookies); + } + + public function testParseMultipleCookieNameValuePairWillReturnFalse() + { + // Could be done through multiple 'Cookie' headers + // getHeaderLine('Cookie') will return a value seperated by coma + // e.g. + // GET / HTTP/1.1\r\n + // Host: test.org\r\n + // Cookie: hello=world\r\n + // Cookie: test=abc\r\n\r\n + $cookieString = 'hello=world,test=abc'; + $cookies = ServerRequest::parseCookie($cookieString); + $this->assertEquals(false, $cookies); + } + + public function testOnlyFirstSetWillBeAddedToCookiesArray() + { + $cookieString = 'hello=world;hello=abc'; + $cookies = ServerRequest::parseCookie($cookieString); + $this->assertEquals(array('hello' => 'abc'), $cookies); + } + + public function testOtherEqualSignsWillBeAddedToValueAndWillReturnValidArray() + { + $cookieString = 'hello=world=test=php'; + $cookies = ServerRequest::parseCookie($cookieString); + $this->assertEquals(array('hello' => 'world=test=php'), $cookies); + } + + public function testSingleCookieValueInCookiesReturnsEmptyArray() + { + $cookieString = 'world'; + $cookies = ServerRequest::parseCookie($cookieString); + $this->assertEquals(array(), $cookies); + } + + public function testSingleMutlipleCookieValuesReturnsEmptyArray() + { + $cookieString = 'world;test'; + $cookies = ServerRequest::parseCookie($cookieString); + $this->assertEquals(array(), $cookies); + } + + public function testSingleValueIsValidInMultipleValueCookieWillReturnValidArray() + { + $cookieString = 'world;test=php'; + $cookies = ServerRequest::parseCookie($cookieString); + $this->assertEquals(array('test' => 'php'), $cookies); + } + + public function testUrlEncodingForValueWillReturnValidArray() + { + $cookieString = 'hello=world%21;test=100%25%20coverage'; + $cookies = ServerRequest::parseCookie($cookieString); + $this->assertEquals(array('hello' => 'world!', 'test' => '100% coverage'), $cookies); + } + + public function testUrlEncodingForKeyWillReturnValidArray() + { + $cookieString = 'react%3Bphp=is%20great'; + $cookies = ServerRequest::parseCookie($cookieString); + $this->assertEquals(array('react;php' => 'is great'), $cookies); + } } From a1808317a4eda3c5ec470e0addc58dcda4c1fe2c Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Fri, 21 Apr 2017 14:48:16 +0200 Subject: [PATCH 113/128] Parse cookie for request object --- src/RequestHeaderParser.php | 5 +++ src/ServerRequest.php | 6 ++-- tests/ServerRequestTest.php | 21 ++++++++----- tests/ServerTest.php | 62 +++++++++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 9 deletions(-) diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php index bb438b37..d7fdc1d2 100644 --- a/src/RequestHeaderParser.php +++ b/src/RequestHeaderParser.php @@ -126,6 +126,11 @@ private function parseRequest($data) $request = $request->withQueryParams($queryParams); } + $cookies = ServerRequest::parseCookie($request->getHeaderLine('Cookie')); + if ($cookies !== false) { + $request = $request->withCookieParams($cookies); + } + // re-apply actual request target from above if ($originalTarget !== null) { $uri = $request->getUri()->withPath(''); diff --git a/src/ServerRequest.php b/src/ServerRequest.php index c3fee8f2..0f31628f 100644 --- a/src/ServerRequest.php +++ b/src/ServerRequest.php @@ -125,8 +125,8 @@ public function withoutAttribute($name) */ public static function parseCookie($cookie) { - // PSR-7 `getHeadline('Cookies')` will return multiple - // cookie header coma-seperated. Multiple cookie headers + // PSR-7 `getHeaderLine('Cookies')` will return multiple + // cookie header comma-seperated. Multiple cookie headers // are not allowed according to https://tools.ietf.org/html/rfc6265#section-5.4 if (strpos($cookie, ',') !== false) { return false; @@ -136,7 +136,9 @@ public static function parseCookie($cookie) $result = array(); foreach ($cookieArray as $pair) { + $pair = trim($pair); $nameValuePair = explode('=', $pair, 2); + if (count($nameValuePair) === 2) { $key = urldecode($nameValuePair[0]); $value = urldecode($nameValuePair[1]); diff --git a/tests/ServerRequestTest.php b/tests/ServerRequestTest.php index dfc44dd9..a2c68a4a 100644 --- a/tests/ServerRequestTest.php +++ b/tests/ServerRequestTest.php @@ -111,11 +111,11 @@ public function testParseSingleCookieNameValuePairWillReturnValidArray() $cookieString = 'hello=world'; $cookies = ServerRequest::parseCookie($cookieString); $this->assertEquals(array('hello' => 'world'), $cookies); - }# + } public function testParseMultipleCookieNameValuePaiWillReturnValidArray() { - $cookieString = 'hello=world;test=abc'; + $cookieString = 'hello=world; test=abc'; $cookies = ServerRequest::parseCookie($cookieString); $this->assertEquals(array('hello' => 'world', 'test' => 'abc'), $cookies); } @@ -123,7 +123,7 @@ public function testParseMultipleCookieNameValuePaiWillReturnValidArray() public function testParseMultipleCookieNameValuePairWillReturnFalse() { // Could be done through multiple 'Cookie' headers - // getHeaderLine('Cookie') will return a value seperated by coma + // getHeaderLine('Cookie') will return a value seperated by comma // e.g. // GET / HTTP/1.1\r\n // Host: test.org\r\n @@ -136,7 +136,7 @@ public function testParseMultipleCookieNameValuePairWillReturnFalse() public function testOnlyFirstSetWillBeAddedToCookiesArray() { - $cookieString = 'hello=world;hello=abc'; + $cookieString = 'hello=world; hello=abc'; $cookies = ServerRequest::parseCookie($cookieString); $this->assertEquals(array('hello' => 'abc'), $cookies); } @@ -157,21 +157,21 @@ public function testSingleCookieValueInCookiesReturnsEmptyArray() public function testSingleMutlipleCookieValuesReturnsEmptyArray() { - $cookieString = 'world;test'; + $cookieString = 'world; test'; $cookies = ServerRequest::parseCookie($cookieString); $this->assertEquals(array(), $cookies); } public function testSingleValueIsValidInMultipleValueCookieWillReturnValidArray() { - $cookieString = 'world;test=php'; + $cookieString = 'world; test=php'; $cookies = ServerRequest::parseCookie($cookieString); $this->assertEquals(array('test' => 'php'), $cookies); } public function testUrlEncodingForValueWillReturnValidArray() { - $cookieString = 'hello=world%21;test=100%25%20coverage'; + $cookieString = 'hello=world%21; test=100%25%20coverage'; $cookies = ServerRequest::parseCookie($cookieString); $this->assertEquals(array('hello' => 'world!', 'test' => '100% coverage'), $cookies); } @@ -182,4 +182,11 @@ public function testUrlEncodingForKeyWillReturnValidArray() $cookies = ServerRequest::parseCookie($cookieString); $this->assertEquals(array('react;php' => 'is great'), $cookies); } + + public function testCookieWithoutSpaceAfterSeparatorWillBeAccepted() + { + $cookieString = 'hello=world;react=php'; + $cookies = ServerRequest::parseCookie($cookieString); + $this->assertEquals(array('hello' => 'world', 'react' => 'php'), $cookies); + } } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index a0dd0f5b..450b90cc 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -2390,6 +2390,68 @@ public function testQueryParametersWillBeAddedToRequest() $this->assertEquals('bar', $queryParams['test']); } + public function testCookieWillBeAddedToServerRequest() + { + $requestValidation = null; + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestValidation) { + $requestValidation = $request; + return new Response(); + }); + + $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 .= "Cookie: hello=world\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', array($data)); + + $this->assertEquals(array('hello' => 'world'), $requestValidation->getCookieParams()); + } + + public function testMultipleCookiesWontBeAddedToServerRequest() + { + $requestValidation = null; + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestValidation) { + $requestValidation = $request; + return new Response(); + }); + + $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 .= "Cookie: hello=world\r\n"; + $data .= "Cookie: test=failed\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', array($data)); + $this->assertEquals(array(), $requestValidation->getCookieParams()); + } + + public function testCookieWithSeparatorWillBeAddedToServerRequest() + { + $requestValidation = null; + $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestValidation) { + $requestValidation = $request; + return new Response(); + }); + + $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 .= "Cookie: hello=world; test=abc\r\n"; + $data .= "\r\n"; + + $this->connection->emit('data', array($data)); + $this->assertEquals(array('hello' => 'world', 'test' => 'abc'), $requestValidation->getCookieParams()); + } + private function createGetRequest() { $data = "GET / HTTP/1.1\r\n"; From 190ebd4b78442c703f4312d4c599ecb585e33878 Mon Sep 17 00:00:00 2001 From: Niels Theen Date: Sun, 23 Apr 2017 02:00:45 +0200 Subject: [PATCH 114/128] Add description and example --- README.md | 39 ++++++++++++++++++++++++++++++++- examples/06-cookie-handling.php | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 examples/06-cookie-handling.php diff --git a/README.md b/README.md index 7551cda7..331082b3 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ For more details about the request object, check out the documentation of and [PSR-7 RequestInterface](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message.md#32-psrhttpmessagerequestinterface). -> Currently the cookies and uploaded files are not added by the +> Currently the uploaded files are not added by the `Server`, but you can add these parameters by yourself using the given methods. The next versions of this project will cover these features. @@ -378,6 +378,43 @@ Allowed). can in fact use a streaming response body for the tunneled application data. See also [example #21](examples) for more details. +The `getCookieParams(): string[]` method can be used to +get all cookies sent with the current request. + +```php +$http = new Server($socket, function (ServerRequestInterface $request) { + $key = 'react\php'; + + if (isset($request->getCookieParams()[$key])) { + $body = "Your cookie value is: " . $request->getCookieParams()[$key]; + + return new Response( + 200, + array('Content-Type' => 'text/plain'), + $body + ); + } + + return new Response( + 200, + array( + 'Content-Type' => 'text/plain', + 'Set-Cookie' => urlencode($key) . '=' . urlencode('test;more') + ), + "Your cookie has been set." + ); +}); +``` + +The above example will try to set a cookie on first access and +will try to print the cookie value on all subsequent tries. +Note how the example uses the `urlencode()` function to encode +non-alphanumeric characters. +This encoding is also used internally when decoding the name and value of cookies +(which is in line with other implementations, such as PHP's cookie functions). + +See also [example #6](examples) for more details. + ### Response The callback function passed to the constructor of the [Server](#server) diff --git a/examples/06-cookie-handling.php b/examples/06-cookie-handling.php new file mode 100644 index 00000000..67e008bb --- /dev/null +++ b/examples/06-cookie-handling.php @@ -0,0 +1,38 @@ +getCookieParams()[$key])) { + $body = "Your cookie value is: " . $request->getCookieParams()[$key]; + + return new Response( + 200, + array('Content-Type' => 'text/plain'), + $body + ); + } + + return new Response( + 200, + array( + 'Content-Type' => 'text/plain', + 'Set-Cookie' => urlencode($key) . '=' . urlencode('test;more') + ), + "Your cookie has been set." + ); +}); + +echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; + +$loop->run(); From 6a03ff429212049fffc4b7be30428155dbc81a66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 6 May 2017 10:44:46 +0200 Subject: [PATCH 115/128] Forward compatibility with Socket v1.0 and v0.8 --- composer.json | 4 ++-- src/RequestHeaderParser.php | 3 +++ tests/FunctionalServerTest.php | 37 +++++++++++++++++++++------------- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/composer.json b/composer.json index fccbc1bd..50e11f88 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ "require": { "php": ">=5.3.0", "ringcentral/psr7": "^1.2", - "react/socket": "^0.7 || ^0.6 || ^0.5", + "react/socket": "^1.0 || ^0.8 || ^0.7 || ^0.6 || ^0.5", "react/stream": "^0.6 || ^0.5 || ^0.4.4", "react/promise": "^2.0 || ^1.1", "evenement/evenement": "^2.0 || ^1.0" @@ -18,7 +18,7 @@ }, "require-dev": { "phpunit/phpunit": "^4.8.10||^5.0", - "react/socket": "^0.7", + "react/socket": "^1.0 || ^0.8 || ^0.7", "clue/block-react": "^1.1" } } diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php index d7fdc1d2..0b75ac01 100644 --- a/src/RequestHeaderParser.php +++ b/src/RequestHeaderParser.php @@ -181,6 +181,9 @@ private function parseRequest($data) // set URI components from socket address if not already filled via Host header if ($request->getUri()->getHost() === '') { $parts = parse_url($this->localSocketUri); + if (!isset($parts['host'], $parts['port'])) { + $parts = array('host' => '127.0.0.1', 'port' => 80); + } $request = $request->withUri( $request->getUri()->withScheme('http')->withHost($parts['host'])->withPort($parts['port']), diff --git a/tests/FunctionalServerTest.php b/tests/FunctionalServerTest.php index f34f2bc3..4b807617 100644 --- a/tests/FunctionalServerTest.php +++ b/tests/FunctionalServerTest.php @@ -13,7 +13,7 @@ use React\Http\Response; use React\Socket\SecureServer; -class FunctionServerTest extends TestCase +class FunctionalServerTest extends TestCase { public function testPlainHttpOnRandomPort() { @@ -26,7 +26,7 @@ public function testPlainHttpOnRandomPort() }); $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { - $conn->write("GET / HTTP/1.0\r\nHost: " . $conn->getRemoteAddress() . "\r\n\r\n"); + $conn->write("GET / HTTP/1.0\r\nHost: " . noScheme($conn->getRemoteAddress()) . "\r\n\r\n"); return BufferedSink::createPromise($conn); }); @@ -34,7 +34,7 @@ public function testPlainHttpOnRandomPort() $response = Block\await($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); - $this->assertContains('http://' . $socket->getAddress() . '/', $response); + $this->assertContains('http://' . noScheme($socket->getAddress()) . '/', $response); $socket->close(); } @@ -58,7 +58,7 @@ public function testPlainHttpOnRandomPortWithoutHostHeaderUsesSocketUri() $response = Block\await($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); - $this->assertContains('http://' . $socket->getAddress() . '/', $response); + $this->assertContains('http://' . noScheme($socket->getAddress()) . '/', $response); $socket->close(); } @@ -106,8 +106,8 @@ public function testSecureHttpsOnRandomPort() return new Response(200, array(), (string)$request->getUri()); }); - $result = $connector->connect('tls://' . $socket->getAddress())->then(function (ConnectionInterface $conn) { - $conn->write("GET / HTTP/1.0\r\nHost: " . $conn->getRemoteAddress() . "\r\n\r\n"); + $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\nHost: " . noScheme($conn->getRemoteAddress()) . "\r\n\r\n"); return BufferedSink::createPromise($conn); }); @@ -115,7 +115,7 @@ public function testSecureHttpsOnRandomPort() $response = Block\await($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); - $this->assertContains('https://' . $socket->getAddress() . '/', $response); + $this->assertContains('https://' . noScheme($socket->getAddress()) . '/', $response); $socket->close(); } @@ -139,7 +139,7 @@ public function testSecureHttpsOnRandomPortWithoutHostHeaderUsesSocketUri() return new Response(200, array(), (string)$request->getUri()); }); - $result = $connector->connect('tls://' . $socket->getAddress())->then(function (ConnectionInterface $conn) { + $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\n\r\n"); return BufferedSink::createPromise($conn); @@ -148,7 +148,7 @@ public function testSecureHttpsOnRandomPortWithoutHostHeaderUsesSocketUri() $response = Block\await($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); - $this->assertContains('https://' . $socket->getAddress() . '/', $response); + $this->assertContains('https://' . noScheme($socket->getAddress()) . '/', $response); $socket->close(); } @@ -232,7 +232,7 @@ public function testSecureHttpsOnStandardPortReturnsUriWithNoPort() return new Response(200, array(), (string)$request->getUri()); }); - $result = $connector->connect('tls://' . $socket->getAddress())->then(function (ConnectionInterface $conn) { + $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n"); return BufferedSink::createPromise($conn); @@ -269,7 +269,7 @@ public function testSecureHttpsOnStandardPortWithoutHostHeaderUsesSocketUri() return new Response(200, array(), (string)$request->getUri()); }); - $result = $connector->connect('tls://' . $socket->getAddress())->then(function (ConnectionInterface $conn) { + $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\n\r\n"); return BufferedSink::createPromise($conn); @@ -298,7 +298,7 @@ public function testPlainHttpOnHttpsStandardPortReturnsUriWithPort() }); $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { - $conn->write("GET / HTTP/1.0\r\nHost: " . $conn->getRemoteAddress() . "\r\n\r\n"); + $conn->write("GET / HTTP/1.0\r\nHost: " . noScheme($conn->getRemoteAddress()) . "\r\n\r\n"); return BufferedSink::createPromise($conn); }); @@ -334,8 +334,8 @@ public function testSecureHttpsOnHttpStandardPortReturnsUriWithPort() return new Response(200, array(), (string)$request->getUri() . 'x' . $request->getHeaderLine('Host')); }); - $result = $connector->connect('tls://' . $socket->getAddress())->then(function (ConnectionInterface $conn) { - $conn->write("GET / HTTP/1.0\r\nHost: " . $conn->getRemoteAddress() . "\r\n\r\n"); + $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.0\r\nHost: " . noScheme($conn->getRemoteAddress()) . "\r\n\r\n"); return BufferedSink::createPromise($conn); }); @@ -348,3 +348,12 @@ public function testSecureHttpsOnHttpStandardPortReturnsUriWithPort() $socket->close(); } } + +function noScheme($uri) +{ + $pos = strpos($uri, '://'); + if ($pos !== false) { + $uri = substr($uri, $pos + 3); + } + return $uri; +} From 8969bbf70a855412df5ab64e7ee7b3162bacadfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 12 May 2017 16:22:07 +0200 Subject: [PATCH 116/128] Forward compatibility with Stream v1.0 and v0.7 --- README.md | 6 +-- composer.json | 2 +- examples/05-stream-response.php | 4 +- examples/99-benchmark-download.php | 31 ++++++++++-- tests/ChunkedDecoderTest.php | 6 +-- tests/ChunkedEncoderTest.php | 4 +- tests/CloseProtectionStreamTest.php | 14 +++--- tests/FunctionalServerTest.php | 73 ++++++++++++++++++++--------- tests/HttpBodyStreamTest.php | 4 +- tests/LengthLimitedStreamTest.php | 6 +-- tests/ResponseTest.php | 4 +- tests/ServerTest.php | 10 ++-- 12 files changed, 108 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 331082b3..1b6c137e 100644 --- a/README.md +++ b/README.md @@ -477,11 +477,11 @@ The `Response` class in this project supports to add an instance which implement for the response body. So you are able stream data directly into the response body. Note that other implementations of the `PSR-7 ResponseInterface` likely -only support string. +only support strings. ```php $server = new Server($socket, function (ServerRequestInterface $request) use ($loop) { - $stream = new ReadableStream(); + $stream = new ThroughStream(); $timer = $loop->addPeriodicTimer(0.5, function () use ($stream) { $stream->emit('data', array(microtime(true) . PHP_EOL)); @@ -513,7 +513,7 @@ pass this header yourself. If you know the length of your stream body, you MAY specify it like this instead: ```php -$stream = new ReadableStream() +$stream = new ThroughStream() $server = new Server($socket, function (ServerRequestInterface $request) use ($loop, $stream) { return new Response( 200, diff --git a/composer.json b/composer.json index 50e11f88..6dd1f48e 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ "php": ">=5.3.0", "ringcentral/psr7": "^1.2", "react/socket": "^1.0 || ^0.8 || ^0.7 || ^0.6 || ^0.5", - "react/stream": "^0.6 || ^0.5 || ^0.4.4", + "react/stream": "^1.0 || ^0.7 || ^0.6 || ^0.5 || ^0.4.6", "react/promise": "^2.0 || ^1.1", "evenement/evenement": "^2.0 || ^1.0" }, diff --git a/examples/05-stream-response.php b/examples/05-stream-response.php index 8edf7d40..b4ef6962 100644 --- a/examples/05-stream-response.php +++ b/examples/05-stream-response.php @@ -4,7 +4,7 @@ use React\Socket\Server; use React\Http\Response; use Psr\Http\Message\ServerRequestInterface; -use React\Stream\ReadableStream; +use React\Stream\ThroughStream; require __DIR__ . '/../vendor/autoload.php'; @@ -12,7 +12,7 @@ $socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); $server = new \React\Http\Server($socket, function (ServerRequestInterface $request) use ($loop) { - $stream = new ReadableStream(); + $stream = new ThroughStream(); $timer = $loop->addPeriodicTimer(0.5, function () use ($stream) { $stream->emit('data', array(microtime(true) . PHP_EOL)); diff --git a/examples/99-benchmark-download.php b/examples/99-benchmark-download.php index 47460b5f..0be294b1 100644 --- a/examples/99-benchmark-download.php +++ b/examples/99-benchmark-download.php @@ -10,7 +10,9 @@ use React\Socket\Server; use React\Http\Response; use Psr\Http\Message\ServerRequestInterface; -use React\Stream\ReadableStream; +use Evenement\EventEmitter; +use React\Stream\ReadableStreamInterface; +use React\Stream\WritableStreamInterface; require __DIR__ . '/../vendor/autoload.php'; @@ -18,12 +20,13 @@ $socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); /** A readable stream that can emit a lot of data */ -class ChunkRepeater extends ReadableStream +class ChunkRepeater extends EventEmitter implements ReadableStreamInterface { private $chunk; private $count; private $position = 0; private $paused = true; + private $closed = false; public function __construct($chunk, $count) { @@ -38,7 +41,7 @@ public function pause() public function resume() { - if (!$this->paused) { + if (!$this->paused || $this->closed) { return; } @@ -56,6 +59,28 @@ public function resume() } } + public function pipe(WritableStreamInterface $dest, array $options = array()) + { + return; + } + + public function isReadable() + { + return !$this->closed; + } + + public function close() + { + if ($this->closed) { + return; + } + + $this->closed = true; + $this->count = 0; + $this->paused = true; + $this->emit('close'); + } + public function getSize() { return strlen($this->chunk) * $this->count; diff --git a/tests/ChunkedDecoderTest.php b/tests/ChunkedDecoderTest.php index 7f675f42..87548f79 100644 --- a/tests/ChunkedDecoderTest.php +++ b/tests/ChunkedDecoderTest.php @@ -2,14 +2,14 @@ namespace React\Tests\Http; -use React\Stream\ReadableStream; +use React\Stream\ThroughStream; use React\Http\ChunkedDecoder; class ChunkedDecoderTest extends TestCase { public function setUp() { - $this->input = new ReadableStream(); + $this->input = new ThroughStream(); $this->parser = new ChunkedDecoder($this->input); } @@ -386,7 +386,7 @@ public function testHandleClose() public function testOutputStreamCanCloseInputStream() { - $input = new ReadableStream(); + $input = new ThroughStream(); $input->on('close', $this->expectCallableOnce()); $stream = new ChunkedDecoder($input); diff --git a/tests/ChunkedEncoderTest.php b/tests/ChunkedEncoderTest.php index ca8dc643..8dcdbdbc 100644 --- a/tests/ChunkedEncoderTest.php +++ b/tests/ChunkedEncoderTest.php @@ -2,7 +2,7 @@ namespace React\Tests\Http; -use React\Stream\ReadableStream; +use React\Stream\ThroughStream; use React\Http\ChunkedEncoder; class ChunkedEncoderTest extends TestCase @@ -12,7 +12,7 @@ class ChunkedEncoderTest extends TestCase public function setUp() { - $this->input = new ReadableStream(); + $this->input = new ThroughStream(); $this->chunkedStream = new ChunkedEncoder($this->input); } diff --git a/tests/CloseProtectionStreamTest.php b/tests/CloseProtectionStreamTest.php index a85e7c10..e0c82596 100644 --- a/tests/CloseProtectionStreamTest.php +++ b/tests/CloseProtectionStreamTest.php @@ -3,7 +3,7 @@ namespace React\Tests\Http; use React\Http\CloseProtectionStream; -use React\Stream\ReadableStream; +use React\Stream\ThroughStream; class CloseProtectionStreamTest extends TestCase { @@ -19,7 +19,7 @@ public function testClosePausesTheInputStreamInsteadOfClosing() public function testErrorWontCloseStream() { - $input = new ReadableStream(); + $input = new ThroughStream(); $protection = new CloseProtectionStream($input); $protection->on('error', $this->expectCallableOnce()); @@ -44,7 +44,7 @@ public function testResumeStreamWillResumeInputStream() public function testInputStreamIsNotReadableAfterClose() { - $input = new ReadableStream(); + $input = new ThroughStream(); $protection = new CloseProtectionStream($input); $protection->on('close', $this->expectCallableOnce()); @@ -57,7 +57,7 @@ public function testInputStreamIsNotReadableAfterClose() public function testPipeStream() { - $input = new ReadableStream(); + $input = new ThroughStream(); $protection = new CloseProtectionStream($input); $dest = $this->getMockBuilder('React\Stream\WritableStreamInterface')->getMock(); @@ -69,7 +69,7 @@ public function testPipeStream() public function testStopEmittingDataAfterClose() { - $input = new ReadableStream(); + $input = new ThroughStream(); $protection = new CloseProtectionStream($input); $protection->on('data', $this->expectCallableNever()); @@ -86,7 +86,7 @@ public function testStopEmittingDataAfterClose() public function testErrorIsNeverCalledAfterClose() { - $input = new ReadableStream(); + $input = new ThroughStream(); $protection = new CloseProtectionStream($input); $protection->on('data', $this->expectCallableNever()); @@ -103,7 +103,7 @@ public function testErrorIsNeverCalledAfterClose() public function testEndWontBeEmittedAfterClose() { - $input = new ReadableStream(); + $input = new ThroughStream(); $protection = new CloseProtectionStream($input); $protection->on('data', $this->expectCallableNever()); diff --git a/tests/FunctionalServerTest.php b/tests/FunctionalServerTest.php index 4b807617..042c4585 100644 --- a/tests/FunctionalServerTest.php +++ b/tests/FunctionalServerTest.php @@ -8,10 +8,13 @@ use Psr\Http\Message\RequestInterface; use React\Socket\Connector; use React\Socket\ConnectionInterface; -use React\Stream\BufferedSink; use Clue\React\Block; use React\Http\Response; use React\Socket\SecureServer; +use React\Stream\ReadableStreamInterface; +use React\EventLoop\LoopInterface; +use React\Promise\Promise; +use React\Promise\PromiseInterface; class FunctionalServerTest extends TestCase { @@ -28,10 +31,10 @@ public function testPlainHttpOnRandomPort() $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\nHost: " . noScheme($conn->getRemoteAddress()) . "\r\n\r\n"); - return BufferedSink::createPromise($conn); + return $conn; }); - $response = Block\await($result, $loop, 1.0); + $response = $this->buffer($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); $this->assertContains('http://' . noScheme($socket->getAddress()) . '/', $response); @@ -52,10 +55,10 @@ public function testPlainHttpOnRandomPortWithoutHostHeaderUsesSocketUri() $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\n\r\n"); - return BufferedSink::createPromise($conn); + return $conn; }); - $response = Block\await($result, $loop, 1.0); + $response = $this->buffer($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); $this->assertContains('http://' . noScheme($socket->getAddress()) . '/', $response); @@ -76,10 +79,10 @@ public function testPlainHttpOnRandomPortWithOtherHostHeaderTakesPrecedence() $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\nHost: localhost:1000\r\n\r\n"); - return BufferedSink::createPromise($conn); + return $conn; }); - $response = Block\await($result, $loop, 1.0); + $response = $this->buffer($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); $this->assertContains('http://localhost:1000/', $response); @@ -109,10 +112,10 @@ public function testSecureHttpsOnRandomPort() $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\nHost: " . noScheme($conn->getRemoteAddress()) . "\r\n\r\n"); - return BufferedSink::createPromise($conn); + return $conn; }); - $response = Block\await($result, $loop, 1.0); + $response = $this->buffer($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); $this->assertContains('https://' . noScheme($socket->getAddress()) . '/', $response); @@ -142,10 +145,10 @@ public function testSecureHttpsOnRandomPortWithoutHostHeaderUsesSocketUri() $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\n\r\n"); - return BufferedSink::createPromise($conn); + return $conn; }); - $response = Block\await($result, $loop, 1.0); + $response = $this->buffer($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); $this->assertContains('https://' . noScheme($socket->getAddress()) . '/', $response); @@ -170,10 +173,10 @@ public function testPlainHttpOnStandardPortReturnsUriWithNoPort() $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n"); - return BufferedSink::createPromise($conn); + return $conn; }); - $response = Block\await($result, $loop, 1.0); + $response = $this->buffer($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); $this->assertContains('http://127.0.0.1/', $response); @@ -198,10 +201,10 @@ public function testPlainHttpOnStandardPortWithoutHostHeaderReturnsUriWithNoPort $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\n\r\n"); - return BufferedSink::createPromise($conn); + return $conn; }); - $response = Block\await($result, $loop, 1.0); + $response = $this->buffer($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); $this->assertContains('http://127.0.0.1/', $response); @@ -235,10 +238,10 @@ public function testSecureHttpsOnStandardPortReturnsUriWithNoPort() $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n"); - return BufferedSink::createPromise($conn); + return $conn; }); - $response = Block\await($result, $loop, 1.0); + $response = $this->buffer($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); $this->assertContains('https://127.0.0.1/', $response); @@ -272,10 +275,10 @@ public function testSecureHttpsOnStandardPortWithoutHostHeaderUsesSocketUri() $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\n\r\n"); - return BufferedSink::createPromise($conn); + return $conn; }); - $response = Block\await($result, $loop, 1.0); + $response = $this->buffer($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); $this->assertContains('https://127.0.0.1/', $response); @@ -300,10 +303,10 @@ public function testPlainHttpOnHttpsStandardPortReturnsUriWithPort() $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\nHost: " . noScheme($conn->getRemoteAddress()) . "\r\n\r\n"); - return BufferedSink::createPromise($conn); + return $conn; }); - $response = Block\await($result, $loop, 1.0); + $response = $this->buffer($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); $this->assertContains('http://127.0.0.1:443/', $response); @@ -337,16 +340,40 @@ public function testSecureHttpsOnHttpStandardPortReturnsUriWithPort() $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\nHost: " . noScheme($conn->getRemoteAddress()) . "\r\n\r\n"); - return BufferedSink::createPromise($conn); + return $conn; }); - $response = Block\await($result, $loop, 1.0); + $response = $this->buffer($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); $this->assertContains('https://127.0.0.1:80/', $response); $socket->close(); } + + protected function buffer(PromiseInterface $promise, LoopInterface $loop, $timeout) + { + return Block\await($promise->then(function (ReadableStreamInterface $stream) { + return new Promise( + function ($resolve, $reject) use ($stream) { + $buffer = ''; + $stream->on('data', function ($chunk) use (&$buffer) { + $buffer .= $chunk; + }); + + $stream->on('error', $reject); + + $stream->on('close', function () use (&$buffer, $resolve) { + $resolve($buffer); + }); + }, + function () use ($stream) { + $stream->close(); + throw new \RuntimeException(); + } + ); + }), $loop, $timeout); + } } function noScheme($uri) diff --git a/tests/HttpBodyStreamTest.php b/tests/HttpBodyStreamTest.php index 9817384a..31e168e0 100644 --- a/tests/HttpBodyStreamTest.php +++ b/tests/HttpBodyStreamTest.php @@ -3,7 +3,7 @@ namespace React\Tests\Http; use React\Http\HttpBodyStream; -use React\Stream\ReadableStream; +use React\Stream\ThroughStream; class HttpBodyStreamTest extends TestCase { @@ -12,7 +12,7 @@ class HttpBodyStreamTest extends TestCase public function setUp() { - $this->input = new ReadableStream(); + $this->input = new ThroughStream(); $this->bodyStream = new HttpBodyStream($this->input, null); } diff --git a/tests/LengthLimitedStreamTest.php b/tests/LengthLimitedStreamTest.php index 8e6375d5..61ecdef6 100644 --- a/tests/LengthLimitedStreamTest.php +++ b/tests/LengthLimitedStreamTest.php @@ -3,7 +3,7 @@ namespace React\Tests\Http; use React\Http\LengthLimitedStream; -use React\Stream\ReadableStream; +use React\Stream\ThroughStream; class LengthLimitedStreamTest extends TestCase { @@ -12,7 +12,7 @@ class LengthLimitedStreamTest extends TestCase public function setUp() { - $this->input = new ReadableStream(); + $this->input = new ThroughStream(); } public function testSimpleChunk() @@ -95,7 +95,7 @@ public function testHandleClose() public function testOutputStreamCanCloseInputStream() { - $input = new ReadableStream(); + $input = new ThroughStream(); $input->on('close', $this->expectCallableOnce()); $stream = new LengthLimitedStream($input, 0); diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 4d024956..68627626 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -3,13 +3,13 @@ namespace React\Tests\Http; use React\Http\Response; -use React\Stream\ReadableStream; +use React\Stream\ThroughStream; class ResponseTest extends TestCase { public function testResponseBodyWillBeHttpBodyStream() { - $response = new Response(200, array(), new ReadableStream()); + $response = new Response(200, array(), new ThroughStream()); $this->assertInstanceOf('React\Http\HttpBodyStream', $response->getBody()); } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 450b90cc..e8f6bc19 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -5,7 +5,7 @@ use React\Http\Server; use Psr\Http\Message\ServerRequestInterface; use React\Http\Response; -use React\Stream\ReadableStream; +use React\Stream\ThroughStream; use React\Promise\Promise; class ServerTest extends TestCase @@ -1611,7 +1611,7 @@ public function testRequestWithoutDefinedLengthWillIgnoreDataEvent() public function testResponseWillBeChunkDecodedByDefault() { - $stream = new ReadableStream(); + $stream = new ThroughStream(); $server = new Server($this->socket, function (ServerRequestInterface $request) use ($stream) { $response = new Response(200, array(), $stream); return \React\Promise\resolve($response); @@ -1680,7 +1680,7 @@ function ($data) use (&$buffer) { public function testOnlyAllowChunkedEncoding() { - $stream = new ReadableStream(); + $stream = new ThroughStream(); $server = new Server($this->socket, function (ServerRequestInterface $request) use ($stream) { $response = new Response( 200, @@ -1977,7 +1977,7 @@ public function testInvalidCallbackFunctionLeadsToException() public function testHttpBodyStreamAsBodyWillStreamData() { - $input = new ReadableStream(); + $input = new ThroughStream(); $server = new Server($this->socket, function (ServerRequestInterface $request) use ($input) { $response = new Response(200, array(), $input); @@ -2012,7 +2012,7 @@ function ($data) use (&$buffer) { public function testHttpBodyStreamWithContentLengthWillStreamTillLength() { - $input = new ReadableStream(); + $input = new ThroughStream(); $server = new Server($this->socket, function (ServerRequestInterface $request) use ($input) { $response = new Response(200, array('Content-Length' => 5), $input); From 3dcc5ece89108a5936cc70cbebb69ad1a5a09e3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Mon, 15 May 2017 23:51:15 +0200 Subject: [PATCH 117/128] Simplify buffering tests by using new react/promise-stream release --- composer.json | 1 + tests/FunctionalServerTest.php | 69 ++++++++++++---------------------- 2 files changed, 24 insertions(+), 46 deletions(-) diff --git a/composer.json b/composer.json index 6dd1f48e..fd8f5cb8 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ }, "require-dev": { "phpunit/phpunit": "^4.8.10||^5.0", + "react/promise-stream": "^0.1.1", "react/socket": "^1.0 || ^0.8 || ^0.7", "clue/block-react": "^1.1" } diff --git a/tests/FunctionalServerTest.php b/tests/FunctionalServerTest.php index 042c4585..cc1344e1 100644 --- a/tests/FunctionalServerTest.php +++ b/tests/FunctionalServerTest.php @@ -15,6 +15,7 @@ use React\EventLoop\LoopInterface; use React\Promise\Promise; use React\Promise\PromiseInterface; +use React\Promise\Stream; class FunctionalServerTest extends TestCase { @@ -31,10 +32,10 @@ public function testPlainHttpOnRandomPort() $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\nHost: " . noScheme($conn->getRemoteAddress()) . "\r\n\r\n"); - return $conn; + return Stream\buffer($conn); }); - $response = $this->buffer($result, $loop, 1.0); + $response = Block\await($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); $this->assertContains('http://' . noScheme($socket->getAddress()) . '/', $response); @@ -55,10 +56,10 @@ public function testPlainHttpOnRandomPortWithoutHostHeaderUsesSocketUri() $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\n\r\n"); - return $conn; + return Stream\buffer($conn); }); - $response = $this->buffer($result, $loop, 1.0); + $response = Block\await($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); $this->assertContains('http://' . noScheme($socket->getAddress()) . '/', $response); @@ -79,10 +80,10 @@ public function testPlainHttpOnRandomPortWithOtherHostHeaderTakesPrecedence() $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\nHost: localhost:1000\r\n\r\n"); - return $conn; + return Stream\buffer($conn); }); - $response = $this->buffer($result, $loop, 1.0); + $response = Block\await($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); $this->assertContains('http://localhost:1000/', $response); @@ -112,10 +113,10 @@ public function testSecureHttpsOnRandomPort() $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\nHost: " . noScheme($conn->getRemoteAddress()) . "\r\n\r\n"); - return $conn; + return Stream\buffer($conn); }); - $response = $this->buffer($result, $loop, 1.0); + $response = Block\await($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); $this->assertContains('https://' . noScheme($socket->getAddress()) . '/', $response); @@ -145,10 +146,10 @@ public function testSecureHttpsOnRandomPortWithoutHostHeaderUsesSocketUri() $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\n\r\n"); - return $conn; + return Stream\buffer($conn); }); - $response = $this->buffer($result, $loop, 1.0); + $response = Block\await($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); $this->assertContains('https://' . noScheme($socket->getAddress()) . '/', $response); @@ -173,10 +174,10 @@ public function testPlainHttpOnStandardPortReturnsUriWithNoPort() $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n"); - return $conn; + return Stream\buffer($conn); }); - $response = $this->buffer($result, $loop, 1.0); + $response = Block\await($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); $this->assertContains('http://127.0.0.1/', $response); @@ -201,10 +202,10 @@ public function testPlainHttpOnStandardPortWithoutHostHeaderReturnsUriWithNoPort $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\n\r\n"); - return $conn; + return Stream\buffer($conn); }); - $response = $this->buffer($result, $loop, 1.0); + $response = Block\await($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); $this->assertContains('http://127.0.0.1/', $response); @@ -238,10 +239,10 @@ public function testSecureHttpsOnStandardPortReturnsUriWithNoPort() $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n"); - return $conn; + return Stream\buffer($conn); }); - $response = $this->buffer($result, $loop, 1.0); + $response = Block\await($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); $this->assertContains('https://127.0.0.1/', $response); @@ -275,10 +276,10 @@ public function testSecureHttpsOnStandardPortWithoutHostHeaderUsesSocketUri() $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\n\r\n"); - return $conn; + return Stream\buffer($conn); }); - $response = $this->buffer($result, $loop, 1.0); + $response = Block\await($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); $this->assertContains('https://127.0.0.1/', $response); @@ -303,10 +304,10 @@ public function testPlainHttpOnHttpsStandardPortReturnsUriWithPort() $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\nHost: " . noScheme($conn->getRemoteAddress()) . "\r\n\r\n"); - return $conn; + return Stream\buffer($conn); }); - $response = $this->buffer($result, $loop, 1.0); + $response = Block\await($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); $this->assertContains('http://127.0.0.1:443/', $response); @@ -340,40 +341,16 @@ public function testSecureHttpsOnHttpStandardPortReturnsUriWithPort() $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\nHost: " . noScheme($conn->getRemoteAddress()) . "\r\n\r\n"); - return $conn; + return Stream\buffer($conn); }); - $response = $this->buffer($result, $loop, 1.0); + $response = Block\await($result, $loop, 1.0); $this->assertContains("HTTP/1.0 200 OK", $response); $this->assertContains('https://127.0.0.1:80/', $response); $socket->close(); } - - protected function buffer(PromiseInterface $promise, LoopInterface $loop, $timeout) - { - return Block\await($promise->then(function (ReadableStreamInterface $stream) { - return new Promise( - function ($resolve, $reject) use ($stream) { - $buffer = ''; - $stream->on('data', function ($chunk) use (&$buffer) { - $buffer .= $chunk; - }); - - $stream->on('error', $reject); - - $stream->on('close', function () use (&$buffer, $resolve) { - $resolve($buffer); - }); - }, - function () use ($stream) { - $stream->close(); - throw new \RuntimeException(); - } - ); - }), $loop, $timeout); - } } function noScheme($uri) From 9a9971fbcde7ee1bd83f3a30fac5afba206403af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 16 May 2017 00:49:54 +0200 Subject: [PATCH 118/128] Ignore HHVM test failures for now until Travis tests work again --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index f67b7d54..fcd3a2d6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,6 +16,8 @@ matrix: - php: 7.0 env: - DEPENDENCIES=lowest + allow_failures: + - php: hhvm install: - composer install --no-interaction From aaa70b0f70c26293b1c2c12d64e57f1255c87421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sun, 21 May 2017 20:50:43 +0200 Subject: [PATCH 119/128] Automatically cancel pending promises once client connection closes --- README.md | 5 ++ composer.json | 2 +- ...unt-visitors.php => 02-count-visitors.php} | 0 .../{02-client-ip.php => 03-client-ip.php} | 0 ...y-parameter.php => 04-query-parameter.php} | 0 ...ie-handling.php => 05-cookie-handling.php} | 0 examples/06-sleep.php | 29 +++++++ ...am-response.php => 08-stream-response.php} | 0 ...ream-request.php => 09-stream-request.php} | 0 examples/22-connect-proxy.php | 9 +-- tests/ServerTest.php | 77 ++++++++++++++++--- 11 files changed, 104 insertions(+), 18 deletions(-) rename examples/{04-count-visitors.php => 02-count-visitors.php} (100%) rename examples/{02-client-ip.php => 03-client-ip.php} (100%) rename examples/{03-query-parameter.php => 04-query-parameter.php} (100%) rename examples/{06-cookie-handling.php => 05-cookie-handling.php} (100%) create mode 100644 examples/06-sleep.php rename examples/{05-stream-response.php => 08-stream-response.php} (100%) rename examples/{06-stream-request.php => 09-stream-request.php} (100%) diff --git a/README.md b/README.md index 1b6c137e..3a897f20 100644 --- a/README.md +++ b/README.md @@ -471,6 +471,11 @@ This example shows that you need a promise, if your response needs time to created. The `ReactPHP Promise` will resolve in a `Response` object when the request body ends. +If the client closes the connection while the promise is still pending, the +promise will automatically be cancelled. +The promise cancellation handler can be used to clean up any pending resources +allocated in this case (if applicable). +If a promise is resolved after the client closes, it will simply be ignored. The `Response` class in this project supports to add an instance which implements the [ReactPHP ReadableStreamInterface](https://github.com/reactphp/stream#readablestreaminterface) diff --git a/composer.json b/composer.json index fd8f5cb8..e73e29c7 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "ringcentral/psr7": "^1.2", "react/socket": "^1.0 || ^0.8 || ^0.7 || ^0.6 || ^0.5", "react/stream": "^1.0 || ^0.7 || ^0.6 || ^0.5 || ^0.4.6", - "react/promise": "^2.0 || ^1.1", + "react/promise": "^2.1 || ^1.2.1", "evenement/evenement": "^2.0 || ^1.0" }, "autoload": { diff --git a/examples/04-count-visitors.php b/examples/02-count-visitors.php similarity index 100% rename from examples/04-count-visitors.php rename to examples/02-count-visitors.php diff --git a/examples/02-client-ip.php b/examples/03-client-ip.php similarity index 100% rename from examples/02-client-ip.php rename to examples/03-client-ip.php diff --git a/examples/03-query-parameter.php b/examples/04-query-parameter.php similarity index 100% rename from examples/03-query-parameter.php rename to examples/04-query-parameter.php diff --git a/examples/06-cookie-handling.php b/examples/05-cookie-handling.php similarity index 100% rename from examples/06-cookie-handling.php rename to examples/05-cookie-handling.php diff --git a/examples/06-sleep.php b/examples/06-sleep.php new file mode 100644 index 00000000..9fb75542 --- /dev/null +++ b/examples/06-sleep.php @@ -0,0 +1,29 @@ +addTimer(1.5, function() use ($loop, $resolve) { + $response = new Response( + 200, + array('Content-Type' => 'text/plain'), + "Hello world" + ); + $resolve($response); + }); + }); +}); + +echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; + +$loop->run(); diff --git a/examples/05-stream-response.php b/examples/08-stream-response.php similarity index 100% rename from examples/05-stream-response.php rename to examples/08-stream-response.php diff --git a/examples/06-stream-request.php b/examples/09-stream-request.php similarity index 100% rename from examples/06-stream-request.php rename to examples/09-stream-request.php diff --git a/examples/22-connect-proxy.php b/examples/22-connect-proxy.php index 03939d29..c9e59c0e 100644 --- a/examples/22-connect-proxy.php +++ b/examples/22-connect-proxy.php @@ -32,7 +32,7 @@ }); // try to connect to given target host - $promise = $connector->connect($request->getRequestTarget())->then( + return $connector->connect($request->getRequestTarget())->then( function (ConnectionInterface $remote) use ($body, &$buffer) { // connection established => forward data $body->pipe($remote); @@ -57,13 +57,6 @@ function ($e) { ); } ); - - // cancel pending connection if request closes prematurely - $body->on('close', function () use ($promise) { - $promise->cancel(); - }); - - return $promise; }); //$server->on('error', 'printf'); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index e8f6bc19..f485b256 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -423,7 +423,7 @@ public function testRequestPauseWillbeForwardedToConnection() { $server = new Server($this->socket, function (ServerRequestInterface $request) { $request->getBody()->pause(); - return \React\Promise\resolve(new Response()); + return new Response(); }); $this->connection->expects($this->once())->method('pause'); @@ -442,7 +442,7 @@ public function testRequestResumeWillbeForwardedToConnection() { $server = new Server($this->socket, function (ServerRequestInterface $request) { $request->getBody()->resume(); - return \React\Promise\resolve(new Response()); + return new Response(); }); $this->connection->expects($this->once())->method('resume'); @@ -456,7 +456,7 @@ public function testRequestCloseWillPauseConnection() { $server = new Server($this->socket, function (ServerRequestInterface $request) { $request->getBody()->close(); - return \React\Promise\resolve(new Response()); + return new Response(); }); $this->connection->expects($this->once())->method('pause'); @@ -472,7 +472,7 @@ public function testRequestPauseAfterCloseWillNotBeForwarded() $request->getBody()->close(); $request->getBody()->pause();# - return \React\Promise\resolve(new Response()); + return new Response(); }); $this->connection->expects($this->once())->method('pause'); @@ -488,7 +488,7 @@ public function testRequestResumeAfterCloseWillNotBeForwarded() $request->getBody()->close(); $request->getBody()->resume(); - return \React\Promise\resolve(new Response()); + return new Response(); }); $this->connection->expects($this->once())->method('pause'); @@ -506,7 +506,7 @@ public function testRequestEventWithoutBodyWillNotEmitData() $server = new Server($this->socket, function (ServerRequestInterface $request) use ($never) { $request->getBody()->on('data', $never); - return \React\Promise\resolve(new Response()); + return new Response(); }); $this->socket->emit('connection', array($this->connection)); @@ -522,7 +522,7 @@ public function testRequestEventWithSecondDataEventWillEmitBodyData() $server = new Server($this->socket, function (ServerRequestInterface $request) use ($once) { $request->getBody()->on('data', $once); - return \React\Promise\resolve(new Response()); + return new Response(); }); $this->socket->emit('connection', array($this->connection)); @@ -543,7 +543,7 @@ public function testRequestEventWithPartialBodyWillEmitData() $server = new Server($this->socket, function (ServerRequestInterface $request) use ($once) { $request->getBody()->on('data', $once); - return \React\Promise\resolve(new Response()); + return new Response(); }); $this->socket->emit('connection', array($this->connection)); @@ -563,7 +563,7 @@ public function testRequestEventWithPartialBodyWillEmitData() public function testResponseContainsPoweredByHeader() { $server = new Server($this->socket, function (ServerRequestInterface $request) { - return \React\Promise\resolve(new Response()); + return new Response(); }); $buffer = ''; @@ -587,6 +587,65 @@ function ($data) use (&$buffer) { $this->assertContains("\r\nX-Powered-By: React/alpha\r\n", $buffer); } + public function testPendingPromiseWillNotSendAnything() + { + $never = $this->expectCallableNever(); + + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($never) { + return new Promise(function () { }, $never); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + $this->connection->emit('data', array($data)); + + $this->assertEquals('', $buffer); + } + + public function testPendingPromiseWillBeCancelledIfConnectionCloses() + { + $once = $this->expectCallableOnce(); + + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($once) { + return new Promise(function () { }, $once); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + $this->connection->emit('data', array($data)); + $this->connection->emit('close'); + + $this->assertEquals('', $buffer); + } + public function testResponseContainsSameRequestProtocolVersionAndChunkedBodyForHttp11() { $server = new Server($this->socket, function (ServerRequestInterface $request) { From 93a963e92fb479cad66f1454c049fb4ab1a27652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Mon, 22 May 2017 21:11:44 +0200 Subject: [PATCH 120/128] Send empty response body for closed response body stream --- README.md | 3 ++ tests/FunctionalServerTest.php | 28 +++++++++++++++ tests/ServerTest.php | 62 ++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/README.md b/README.md index 3a897f20..ea27c075 100644 --- a/README.md +++ b/README.md @@ -507,6 +507,9 @@ This is just a example you could use of the streaming, you could also send a big amount of data via little chunks or use it for body data that needs to calculated. +If the request handler resolves with a response stream that is already closed, +it will simply send an empty response body. + If the response body is a `string`, a `Content-Length` header will be added automatically. If the response body is a ReactPHP `ReadableStreamInterface` and you do not diff --git a/tests/FunctionalServerTest.php b/tests/FunctionalServerTest.php index cc1344e1..716cf513 100644 --- a/tests/FunctionalServerTest.php +++ b/tests/FunctionalServerTest.php @@ -16,6 +16,7 @@ use React\Promise\Promise; use React\Promise\PromiseInterface; use React\Promise\Stream; +use React\Stream\ThroughStream; class FunctionalServerTest extends TestCase { @@ -351,6 +352,33 @@ public function testSecureHttpsOnHttpStandardPortReturnsUriWithPort() $socket->close(); } + + public function testClosedStreamFromRequestHandlerWillBeSendEmptyBody() + { + $loop = Factory::create(); + $socket = new Socket(0, $loop); + $connector = new Connector($loop); + + $stream = new ThroughStream(); + $stream->close(); + + $server = new Server($socket, function (RequestInterface $request) use ($stream) { + return new Response(200, array(), $stream); + }); + + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) use ($loop) { + $conn->write("GET / HTTP/1.0\r\n\r\n"); + + return Stream\buffer($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertStringStartsWith("HTTP/1.0 200 OK", $response); + $this->assertStringEndsWith("\r\n\r\n", $response); + + $socket->close(); + } } function noScheme($uri) diff --git a/tests/ServerTest.php b/tests/ServerTest.php index f485b256..567ebfec 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -646,6 +646,68 @@ function ($data) use (&$buffer) { $this->assertEquals('', $buffer); } + public function testStreamAlreadyClosedWillSendEmptyBodyChunkedEncoded() + { + $stream = new ThroughStream(); + $stream->close(); + + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($stream) { + return new Response(200, array(), $stream); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertStringStartsWith("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertStringEndsWith("\r\n\r\n0\r\n\r\n", $buffer); + } + + public function testStreamAlreadyClosedWillSendEmptyBodyPlainHttp10() + { + $stream = new ThroughStream(); + $stream->close(); + + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($stream) { + return new Response(200, array(), $stream); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.0\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertStringStartsWith("HTTP/1.0 200 OK\r\n", $buffer); + $this->assertStringEndsWith("\r\n\r\n", $buffer); +} + public function testResponseContainsSameRequestProtocolVersionAndChunkedBodyForHttp11() { $server = new Server($this->socket, function (ServerRequestInterface $request) { From 1573e896c85d42c7e4bbdcf1161fb67c09ec43d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 23 May 2017 13:11:56 +0200 Subject: [PATCH 121/128] Automatically close response stream if connection closes --- README.md | 6 ++ src/ChunkedEncoder.php | 4 +- tests/FunctionalServerTest.php | 69 +++++++++++++++++++++- tests/ServerTest.php | 102 ++++++++++++++++++++++++++++++++- 4 files changed, 175 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ea27c075..2d524c0d 100644 --- a/README.md +++ b/README.md @@ -509,6 +509,12 @@ or use it for body data that needs to calculated. If the request handler resolves with a response stream that is already closed, it will simply send an empty response body. +If the client closes the connection while the stream is still open, the +response stream will automatically be closed. +If a promise is resolved with a streaming body after the client closes, the +response stream will automatically be closed. +The `close` event can be used to clean up any pending resources allocated +in this case (if applicable). If the response body is a `string`, a `Content-Length` header will be added automatically. diff --git a/src/ChunkedEncoder.php b/src/ChunkedEncoder.php index 69d88ac7..eaa453c8 100644 --- a/src/ChunkedEncoder.php +++ b/src/ChunkedEncoder.php @@ -52,11 +52,9 @@ public function close() } $this->closed = true; - - $this->readable = false; + $this->input->close(); $this->emit('close'); - $this->removeAllListeners(); } diff --git a/tests/FunctionalServerTest.php b/tests/FunctionalServerTest.php index 716cf513..38716c75 100644 --- a/tests/FunctionalServerTest.php +++ b/tests/FunctionalServerTest.php @@ -353,7 +353,7 @@ public function testSecureHttpsOnHttpStandardPortReturnsUriWithPort() $socket->close(); } - public function testClosedStreamFromRequestHandlerWillBeSendEmptyBody() + public function testClosedStreamFromRequestHandlerWillSendEmptyBody() { $loop = Factory::create(); $socket = new Socket(0, $loop); @@ -379,6 +379,73 @@ public function testClosedStreamFromRequestHandlerWillBeSendEmptyBody() $socket->close(); } + + public function testStreamFromRequestHandlerWillBeClosedIfConnectionClosesWhileSendingBody() + { + $loop = Factory::create(); + $socket = new Socket(0, $loop); + $connector = new Connector($loop); + + $stream = new ThroughStream(); + $stream->on('close', $this->expectCallableOnce()); + + $server = new Server($socket, function (RequestInterface $request) use ($stream) { + return new Response(200, array(), $stream); + }); + + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) use ($loop) { + $conn->write("GET / HTTP/1.0\r\nContent-Length: 100\r\n\r\n"); + + $loop->addTimer(0.1, function() use ($conn) { + $conn->end(); + }); + + return Stream\buffer($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertStringStartsWith("HTTP/1.0 200 OK", $response); + $this->assertStringEndsWith("\r\n\r\n", $response); + + $socket->close(); + } + + public function testStreamFromRequestHandlerWillBeClosedIfConnectionClosesButWillOnlyBeDetectedOnNextWrite() + { + $loop = Factory::create(); + $socket = new Socket(0, $loop); + $connector = new Connector($loop); + + $stream = new ThroughStream(); + $stream->on('close', $this->expectCallableOnce()); + + $server = new Server($socket, function (RequestInterface $request) use ($stream) { + return new Response(200, array(), $stream); + }); + + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) use ($loop) { + $conn->write("GET / HTTP/1.0\r\n\r\n"); + + $loop->addTimer(0.1, function() use ($conn) { + $conn->end(); + }); + + return Stream\buffer($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $stream->write('nope'); + Block\sleep(0.1, $loop); + $stream->write('nope'); + Block\sleep(0.1, $loop); + + $this->assertStringStartsWith("HTTP/1.0 200 OK", $response); + $this->assertStringEndsWith("\r\n\r\n", $response); + + $socket->close(); + } } function noScheme($uri) diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 567ebfec..7c2e98fa 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -677,7 +677,39 @@ function ($data) use (&$buffer) { $this->assertStringEndsWith("\r\n\r\n0\r\n\r\n", $buffer); } - public function testStreamAlreadyClosedWillSendEmptyBodyPlainHttp10() + public function testResponseStreamEndingWillSendEmptyBodyChunkedEncoded() + { + $stream = new ThroughStream(); + + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($stream) { + return new Response(200, array(), $stream); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $stream->end(); + + $this->assertStringStartsWith("HTTP/1.1 200 OK\r\n", $buffer); + $this->assertStringEndsWith("\r\n\r\n0\r\n\r\n", $buffer); + } + + public function testResponseStreamAlreadyClosedWillSendEmptyBodyPlainHttp10() { $stream = new ThroughStream(); $stream->close(); @@ -706,7 +738,73 @@ function ($data) use (&$buffer) { $this->assertStringStartsWith("HTTP/1.0 200 OK\r\n", $buffer); $this->assertStringEndsWith("\r\n\r\n", $buffer); -} + } + + public function testResponseStreamWillBeClosedIfConnectionIsAlreadyClosed() + { + $stream = new ThroughStream(); + $stream->on('close', $this->expectCallableOnce()); + + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($stream) { + return new Response(200, array(), $stream); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->connection = $this->getMockBuilder('React\Socket\Connection') + ->disableOriginalConstructor() + ->setMethods( + array( + 'write', + 'end', + 'close', + 'pause', + 'resume', + 'isReadable', + 'isWritable', + 'getRemoteAddress', + 'getLocalAddress', + 'pipe' + ) + ) + ->getMock(); + + $this->connection->expects($this->once())->method('isWritable')->willReturn(false); + $this->connection->expects($this->never())->method('write'); + $this->connection->expects($this->never())->method('write'); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + $this->connection->emit('data', array($data)); + } + + public function testResponseStreamWillBeClosedIfConnectionEmitsCloseEvent() + { + $stream = new ThroughStream(); + $stream->on('close', $this->expectCallableOnce()); + + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($stream) { + return new Response(200, array(), $stream); + }); + + $this->socket->emit('connection', array($this->connection)); + + $data = $this->createGetRequest(); + $this->connection->emit('data', array($data)); + $this->connection->emit('close'); + } public function testResponseContainsSameRequestProtocolVersionAndChunkedBodyForHttp11() { From 4908d32198f9d1032029a207312a6def7035ef58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Mon, 22 May 2017 08:49:28 +0200 Subject: [PATCH 122/128] Validate request-target of CONNECT in RequestHeaderParser --- src/RequestHeaderParser.php | 2 ++ tests/RequestHeaderParserTest.php | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/RequestHeaderParser.php b/src/RequestHeaderParser.php index 0b75ac01..02d67e32 100644 --- a/src/RequestHeaderParser.php +++ b/src/RequestHeaderParser.php @@ -79,6 +79,8 @@ private function parseRequest($data) $originalTarget = $parts[1]; $parts[1] = '/'; $headers = implode(' ', $parts); + } else { + throw new \InvalidArgumentException('CONNECT method MUST use authority-form request target'); } } diff --git a/tests/RequestHeaderParserTest.php b/tests/RequestHeaderParserTest.php index 956bf4ff..f4b20a7f 100644 --- a/tests/RequestHeaderParserTest.php +++ b/tests/RequestHeaderParserTest.php @@ -261,6 +261,22 @@ public function testInvalidAbsoluteFormWithHostHeaderEmpty() $this->assertSame('Invalid Host header value', $error->getMessage()); } + public function testInvalidConnectRequestWithNonAuthorityForm() + { + $error = null; + + $parser = new RequestHeaderParser(); + $parser->on('headers', $this->expectCallableNever()); + $parser->on('error', function ($message) use (&$error) { + $error = $message; + }); + + $parser->feed("CONNECT http://example.com:8080/ HTTP/1.1\r\nHost: example.com:8080\r\n\r\n"); + + $this->assertInstanceOf('InvalidArgumentException', $error); + $this->assertSame('CONNECT method MUST use authority-form request target', $error->getMessage()); + } + public function testInvalidHttpVersion() { $error = null; From 961f8f04e4199697210c682bbcaa7d97f7a98465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Mon, 22 May 2017 15:02:02 +0200 Subject: [PATCH 123/128] Use duplex stream response body for CONNECT requests --- README.md | 36 +++++++---- examples/08-stream-response.php | 4 ++ examples/22-connect-proxy.php | 19 +----- src/HttpBodyStream.php | 2 +- tests/FunctionalServerTest.php | 106 ++++++++++++++++++++++++++++++++ tests/ServerTest.php | 48 +++++++++++++++ 6 files changed, 185 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 2d524c0d..c1353347 100644 --- a/README.md +++ b/README.md @@ -370,13 +370,6 @@ Allowed). Note that if you want to handle this method, the client MAY send a different request-target than the `Host` header value (such as removing default ports) and the request-target MUST take precendence when forwarding. - The HTTP specs define an opaque "tunneling mode" for this method and make no - use of the message body. - For consistency reasons, this library uses the message body of the request and - response for tunneled application data. - This implies that that a `2xx` (Successful) response to a `CONNECT` request - can in fact use a streaming response body for the tunneled application data. - See also [example #21](examples) for more details. The `getCookieParams(): string[]` method can be used to get all cookies sent with the current request. @@ -562,14 +555,35 @@ Modified) status code MAY include these headers even though the message does not contain a response body, because these header would apply to the message if the same request would have used an (unconditional) `GET`. +> Note that special care has to be taken if you use a body stream instance that + implements ReactPHP's + [`DuplexStreamInterface`](https://github.com/reactphp/stream#duplexstreaminterface) + (such as the `ThroughStream` in the above example). + +> For *most* cases, this will simply only consume its readable side and forward + (send) any data that is emitted by the stream, thus entirely ignoring the + writable side of the stream. + If however this is a `2xx` (Successful) response to a `CONNECT` method, it + will also *write* data to the writable side of the stream. + This can be avoided by either rejecting all requests with the `CONNECT` + method (which is what most *normal* origin HTTP servers would likely do) or + or ensuring that only ever an instance of `ReadableStreamInterface` is + used. + > The `CONNECT` method is useful in a tunneling setup (HTTPS proxy) and not - something most HTTP servers would want to care about. + something most origin HTTP servers would want to care about. The HTTP specs define an opaque "tunneling mode" for this method and make no use of the message body. - For consistency reasons, this library uses the message body of the request and - response for tunneled application data. + For consistency reasons, this library uses a `DuplexStreamInterface` in the + response body for tunneled application data. This implies that that a `2xx` (Successful) response to a `CONNECT` request - can in fact use a streaming response body for the tunneled application data. + can in fact use a streaming response body for the tunneled application data, + so that any raw data the client sends over the connection will be piped + through the writable stream for consumption. + Note that while the HTTP specs make no use of the request body for `CONNECT` + requests, one may still be present. Normal request body processing applies + here and the connection will only turn to "tunneling mode" after the request + body has been processed (which should be empty in most cases). See also [example #22](examples) for more details. A `Date` header will be automatically added with the system date and time if none is given. diff --git a/examples/08-stream-response.php b/examples/08-stream-response.php index b4ef6962..e563be38 100644 --- a/examples/08-stream-response.php +++ b/examples/08-stream-response.php @@ -12,6 +12,10 @@ $socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); $server = new \React\Http\Server($socket, function (ServerRequestInterface $request) use ($loop) { + if ($request->getMethod() !== 'GET' || $request->getUri()->getPath() !== '/') { + return new Response(404); + } + $stream = new ThroughStream(); $timer = $loop->addPeriodicTimer(0.5, function () use ($stream) { diff --git a/examples/22-connect-proxy.php b/examples/22-connect-proxy.php index c9e59c0e..7e7acd2c 100644 --- a/examples/22-connect-proxy.php +++ b/examples/22-connect-proxy.php @@ -22,27 +22,10 @@ ); } - // pause consuming request body - $body = $request->getBody(); - $body->pause(); - - $buffer = ''; - $body->on('data', function ($chunk) use (&$buffer) { - $buffer .= $chunk; - }); - // try to connect to given target host return $connector->connect($request->getRequestTarget())->then( - function (ConnectionInterface $remote) use ($body, &$buffer) { + function (ConnectionInterface $remote) { // connection established => forward data - $body->pipe($remote); - $body->resume(); - - if ($buffer !== '') { - $remote->write($buffer); - $buffer = ''; - } - return new Response( 200, array(), diff --git a/src/HttpBodyStream.php b/src/HttpBodyStream.php index a77622ad..8f44f4fc 100644 --- a/src/HttpBodyStream.php +++ b/src/HttpBodyStream.php @@ -17,7 +17,7 @@ */ class HttpBodyStream extends EventEmitter implements StreamInterface, ReadableStreamInterface { - private $input; + public $input; private $closed = false; private $size; diff --git a/tests/FunctionalServerTest.php b/tests/FunctionalServerTest.php index 38716c75..3579db09 100644 --- a/tests/FunctionalServerTest.php +++ b/tests/FunctionalServerTest.php @@ -446,6 +446,112 @@ public function testStreamFromRequestHandlerWillBeClosedIfConnectionClosesButWil $socket->close(); } + + public function testConnectWithThroughStreamReturnsDataAsGiven() + { + $loop = Factory::create(); + $socket = new Socket(0, $loop); + $connector = new Connector($loop); + + $server = new Server($socket, function (RequestInterface $request) use ($loop) { + $stream = new ThroughStream(); + + $loop->addTimer(0.1, function () use ($stream) { + $stream->end(); + }); + + return new Response(200, array(), $stream); + }); + + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n"); + + $conn->once('data', function () use ($conn) { + $conn->write('hello'); + $conn->write('world'); + }); + + return Stream\buffer($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertStringStartsWith("HTTP/1.1 200 OK\r\n", $response); + $this->assertStringEndsWith("\r\n\r\nhelloworld", $response); + + $socket->close(); + } + + public function testConnectWithThroughStreamReturnedFromPromiseReturnsDataAsGiven() + { + $loop = Factory::create(); + $socket = new Socket(0, $loop); + $connector = new Connector($loop); + + $server = new Server($socket, function (RequestInterface $request) use ($loop) { + $stream = new ThroughStream(); + + $loop->addTimer(0.1, function () use ($stream) { + $stream->end(); + }); + + return new Promise(function ($resolve) use ($loop, $stream) { + $loop->addTimer(0.001, function () use ($resolve, $stream) { + $resolve(new Response(200, array(), $stream)); + }); + }); + }); + + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n"); + + $conn->once('data', function () use ($conn) { + $conn->write('hello'); + $conn->write('world'); + }); + + return Stream\buffer($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertStringStartsWith("HTTP/1.1 200 OK\r\n", $response); + $this->assertStringEndsWith("\r\n\r\nhelloworld", $response); + + $socket->close(); + } + + public function testConnectWithClosedThroughStreamReturnsNoData() + { + $loop = Factory::create(); + $socket = new Socket(0, $loop); + $connector = new Connector($loop); + + $server = new Server($socket, function (RequestInterface $request) { + $stream = new ThroughStream(); + $stream->close(); + + return new Response(200, array(), $stream); + }); + + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n"); + + $conn->once('data', function () use ($conn) { + $conn->write('hello'); + $conn->write('world'); + }); + + return Stream\buffer($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertStringStartsWith("HTTP/1.1 200 OK\r\n", $response); + $this->assertStringEndsWith("\r\n\r\n", $response); + + $socket->close(); + } } function noScheme($uri) diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 7c2e98fa..e7483cab 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -806,6 +806,54 @@ public function testResponseStreamWillBeClosedIfConnectionEmitsCloseEvent() $this->connection->emit('close'); } + public function testConnectResponseStreamWillPipeDataToConnection() + { + $stream = new ThroughStream(); + + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($stream) { + return new Response(200, array(), $stream); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $stream->write('hello'); + $stream->write('world'); + + $this->assertStringEndsWith("\r\n\r\nhelloworld", $buffer); + } + + public function testConnectResponseStreamWillPipeDataFromConnection() + { + $stream = new ThroughStream(); + + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($stream) { + return new Response(200, array(), $stream); + }); + + $this->socket->emit('connection', array($this->connection)); + + $this->connection->expects($this->once())->method('pipe')->with($stream); + + $data = "CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n"; + $this->connection->emit('data', array($data)); + } + public function testResponseContainsSameRequestProtocolVersionAndChunkedBodyForHttp11() { $server = new Server($this->socket, function (ServerRequestInterface $request) { From b9ed029d6c94e738c39af0e96e43f2dfaf79cf2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 12 May 2017 23:02:12 +0200 Subject: [PATCH 124/128] Support Upgrade header such as `Upgrade: WebSocket` or custom protocols --- README.md | 24 +++++-- examples/31-upgrade-echo.php | 58 +++++++++++++++++ examples/32-upgrade-chat.php | 85 ++++++++++++++++++++++++ tests/FunctionalServerTest.php | 35 ++++++++++ tests/ServerTest.php | 116 +++++++++++++++++++++++++++++++++ 5 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 examples/31-upgrade-echo.php create mode 100644 examples/32-upgrade-chat.php diff --git a/README.md b/README.md index c1353347..dd51ada0 100644 --- a/README.md +++ b/README.md @@ -559,17 +559,33 @@ to the message if the same request would have used an (unconditional) `GET`. implements ReactPHP's [`DuplexStreamInterface`](https://github.com/reactphp/stream#duplexstreaminterface) (such as the `ThroughStream` in the above example). - +> > For *most* cases, this will simply only consume its readable side and forward (send) any data that is emitted by the stream, thus entirely ignoring the writable side of the stream. - If however this is a `2xx` (Successful) response to a `CONNECT` method, it - will also *write* data to the writable side of the stream. + If however this is either a `101` (Switching Protocols) response or a `2xx` + (Successful) response to a `CONNECT` method, it will also *write* data to the + writable side of the stream. This can be avoided by either rejecting all requests with the `CONNECT` method (which is what most *normal* origin HTTP servers would likely do) or or ensuring that only ever an instance of `ReadableStreamInterface` is used. - +> +> The `101` (Switching Protocols) response code is useful for the more advanced + `Upgrade` requests, such as upgrading to the WebSocket protocol or + implementing custom protocol logic that is out of scope of the HTTP specs and + this HTTP library. + If you want to handle the `Upgrade: WebSocket` header, you will likely want + to look into using [Ratchet](http://socketo.me/) instead. + If you want to handle a custom protocol, you will likely want to look into the + [HTTP specs](https://tools.ietf.org/html/rfc7230#section-6.7) and also see + [examples #31 and #32](examples) for more details. + In particular, the `101` (Switching Protocols) response code MUST NOT be used + unless you send an `Upgrade` response header value that is also present in + the corresponding HTTP/1.1 `Upgrade` request header value. + The server automatically takes care of sending a `Connection: upgrade` + header value in this case, so you don't have to. +> > The `CONNECT` method is useful in a tunneling setup (HTTPS proxy) and not something most origin HTTP servers would want to care about. The HTTP specs define an opaque "tunneling mode" for this method and make no diff --git a/examples/31-upgrade-echo.php b/examples/31-upgrade-echo.php new file mode 100644 index 00000000..b28e8344 --- /dev/null +++ b/examples/31-upgrade-echo.php @@ -0,0 +1,58 @@ + GET / HTTP/1.1 +> Upgrade: echo +> +< HTTP/1.1 101 Switching Protocols +< Upgrade: echo +< Connection: upgrade +< +> hello +< hello +> world +< world +*/ + +use React\EventLoop\Factory; +use React\Http\Server; +use React\Http\Response; +use Psr\Http\Message\ServerRequestInterface; +use React\Stream\ReadableStream; +use React\Stream\ThroughStream; +use React\Stream\CompositeStream; + +require __DIR__ . '/../vendor/autoload.php'; + +$loop = Factory::create(); +$socket = new React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); + +$server = new Server($socket, function (ServerRequestInterface $request) use ($loop) { + if ($request->getHeaderLine('Upgrade') !== 'echo' || $request->getProtocolVersion() === '1.0') { + return new Response(426, array('Upgrade' => 'echo'), '"Upgrade: echo" required'); + } + + // simply return a duplex ThroughStream here + // it will simply emit any data that is sent to it + // this means that any Upgraded data will simply be sent back to the client + $stream = new ThroughStream(); + + $loop->addTimer(0, function () use ($stream) { + $stream->write("Hello! Anything you send will be piped back." . PHP_EOL); + }); + + return new Response( + 101, + array( + 'Upgrade' => 'echo' + ), + $stream + ); +}); + +echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; + +$loop->run(); diff --git a/examples/32-upgrade-chat.php b/examples/32-upgrade-chat.php new file mode 100644 index 00000000..eb74b674 --- /dev/null +++ b/examples/32-upgrade-chat.php @@ -0,0 +1,85 @@ + GET / HTTP/1.1 +> Upgrade: chat +> +< HTTP/1.1 101 Switching Protocols +< Upgrade: chat +< Connection: upgrade +< +> hello +< user123: hello +> world +< user123: world + +Hint: try this with multiple connections :) +*/ + +use React\EventLoop\Factory; +use React\Http\Server; +use React\Http\Response; +use Psr\Http\Message\ServerRequestInterface; +use React\Stream\ReadableStream; +use React\Stream\ThroughStream; +use React\Stream\CompositeStream; + +require __DIR__ . '/../vendor/autoload.php'; + +$loop = Factory::create(); +$socket = new React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); + +// simply use a shared duplex ThroughStream for all clients +// it will simply emit any data that is sent to it +// this means that any Upgraded data will simply be sent back to the client +$chat = new ThroughStream(); + +$server = new Server($socket, function (ServerRequestInterface $request) use ($loop, $chat) { + if ($request->getHeaderLine('Upgrade') !== 'chat' || $request->getProtocolVersion() === '1.0') { + return new Response(426, array('Upgrade' => 'chat'), '"Upgrade: chat" required'); + } + + // user stream forwards chat data and accepts incoming data + $out = $chat->pipe(new ThroughStream()); + $in = new ThroughStream(); + $stream = new CompositeStream( + $out, + $in + ); + + // assign some name for this new connection + $username = 'user' . mt_rand(); + + // send anything that is received to the whole channel + $in->on('data', function ($data) use ($username, $chat) { + $data = trim(preg_replace('/[^\w\d \.\,\-\!\?]/u', '', $data)); + + $chat->write($username . ': ' . $data . PHP_EOL); + }); + + // say hello to new user + $loop->addTimer(0, function () use ($chat, $username, $out) { + $out->write('Welcome to this chat example, ' . $username . '!' . PHP_EOL); + $chat->write($username . ' joined' . PHP_EOL); + }); + + // send goodbye to channel once connection closes + $stream->on('close', function () use ($username, $chat) { + $chat->write($username . ' left' . PHP_EOL); + }); + + return new Response( + 101, + array( + 'Upgrade' => 'chat' + ), + $stream + ); +}); + +echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; + +$loop->run(); diff --git a/tests/FunctionalServerTest.php b/tests/FunctionalServerTest.php index 3579db09..e3f365ea 100644 --- a/tests/FunctionalServerTest.php +++ b/tests/FunctionalServerTest.php @@ -447,6 +447,41 @@ public function testStreamFromRequestHandlerWillBeClosedIfConnectionClosesButWil $socket->close(); } + public function testUpgradeWithThroughStreamReturnsDataAsGiven() + { + $loop = Factory::create(); + $socket = new Socket(0, $loop); + $connector = new Connector($loop); + + $server = new Server($socket, function (RequestInterface $request) use ($loop) { + $stream = new ThroughStream(); + + $loop->addTimer(0.1, function () use ($stream) { + $stream->end(); + }); + + return new Response(101, array('Upgrade' => 'echo'), $stream); + }); + + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { + $conn->write("GET / HTTP/1.1\r\nHost: example.com:80\r\nUpgrade: echo\r\n\r\n"); + + $conn->once('data', function () use ($conn) { + $conn->write('hello'); + $conn->write('world'); + }); + + return Stream\buffer($conn); + }); + + $response = Block\await($result, $loop, 1.0); + + $this->assertStringStartsWith("HTTP/1.1 101 Switching Protocols\r\n", $response); + $this->assertStringEndsWith("\r\n\r\nhelloworld", $response); + + $socket->close(); + } + public function testConnectWithThroughStreamReturnsDataAsGiven() { $loop = Factory::create(); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index e7483cab..60163dd5 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -806,6 +806,121 @@ public function testResponseStreamWillBeClosedIfConnectionEmitsCloseEvent() $this->connection->emit('close'); } + public function testUpgradeInResponseCanBeUsedToAdvertisePossibleUpgrade() + { + $server = new Server($this->socket, function (ServerRequestInterface $request) { + return new Response(200, array('date' => '', 'x-powered-by' => '', 'Upgrade' => 'demo'), 'foo'); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertEquals("HTTP/1.1 200 OK\r\nUpgrade: demo\r\nContent-Length: 3\r\nConnection: close\r\n\r\nfoo", $buffer); + } + + public function testUpgradeWishInRequestCanBeIgnoredByReturningNormalResponse() + { + $server = new Server($this->socket, function (ServerRequestInterface $request) { + return new Response(200, array('date' => '', 'x-powered-by' => ''), 'foo'); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\nUpgrade: demo\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertEquals("HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nfoo", $buffer); + } + + public function testUpgradeSwitchingProtocolIncludesConnectionUpgradeHeaderWithoutContentLength() + { + $server = new Server($this->socket, function (ServerRequestInterface $request) { + return new Response(101, array('date' => '', 'x-powered-by' => '', 'Upgrade' => 'demo'), 'foo'); + }); + + $server->on('error', 'printf'); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\nUpgrade: demo\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $this->assertEquals("HTTP/1.1 101 Switching Protocols\r\nUpgrade: demo\r\nConnection: upgrade\r\n\r\nfoo", $buffer); + } + + public function testUpgradeSwitchingProtocolWithStreamWillPipeDataToConnection() + { + $stream = new ThroughStream(); + + $server = new Server($this->socket, function (ServerRequestInterface $request) use ($stream) { + return new Response(101, array('date' => '', 'x-powered-by' => '', 'Upgrade' => 'demo'), $stream); + }); + + $buffer = ''; + + $this->connection + ->expects($this->any()) + ->method('write') + ->will( + $this->returnCallback( + function ($data) use (&$buffer) { + $buffer .= $data; + } + ) + ); + + $this->socket->emit('connection', array($this->connection)); + + $data = "GET / HTTP/1.1\r\nUpgrade: demo\r\n\r\n"; + $this->connection->emit('data', array($data)); + + $stream->write('hello'); + $stream->write('world'); + + $this->assertEquals("HTTP/1.1 101 Switching Protocols\r\nUpgrade: demo\r\nConnection: upgrade\r\n\r\nhelloworld", $buffer); + } + public function testConnectResponseStreamWillPipeDataToConnection() { $stream = new ThroughStream(); @@ -838,6 +953,7 @@ function ($data) use (&$buffer) { $this->assertStringEndsWith("\r\n\r\nhelloworld", $buffer); } + public function testConnectResponseStreamWillPipeDataFromConnection() { $stream = new ThroughStream(); From 9a8a536094f6a6e9ee2269add537121d1e0f46a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 26 May 2017 15:00:56 +0200 Subject: [PATCH 125/128] Fix reporting listening addresses in examples --- examples/01-hello-world.php | 2 +- examples/02-count-visitors.php | 2 +- examples/03-client-ip.php | 2 +- examples/04-query-parameter.php | 2 +- examples/05-cookie-handling.php | 2 +- examples/06-sleep.php | 2 +- examples/07-error-handling.php | 2 +- examples/08-stream-response.php | 2 +- examples/09-stream-request.php | 2 +- examples/11-hello-world-https.php | 2 +- examples/21-http-proxy.php | 2 +- examples/22-connect-proxy.php | 2 +- examples/31-upgrade-echo.php | 2 +- examples/32-upgrade-chat.php | 2 +- examples/99-benchmark-download.php | 2 +- 15 files changed, 15 insertions(+), 15 deletions(-) diff --git a/examples/01-hello-world.php b/examples/01-hello-world.php index cf047944..4b34f5af 100644 --- a/examples/01-hello-world.php +++ b/examples/01-hello-world.php @@ -20,6 +20,6 @@ ); }); -echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/02-count-visitors.php b/examples/02-count-visitors.php index 9f69f797..a2788510 100644 --- a/examples/02-count-visitors.php +++ b/examples/02-count-visitors.php @@ -19,6 +19,6 @@ ); }); -echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/03-client-ip.php b/examples/03-client-ip.php index 31b7ad32..ee6d4f90 100644 --- a/examples/03-client-ip.php +++ b/examples/03-client-ip.php @@ -20,6 +20,6 @@ ); }); -echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/04-query-parameter.php b/examples/04-query-parameter.php index 15f6c49a..ab64aef0 100644 --- a/examples/04-query-parameter.php +++ b/examples/04-query-parameter.php @@ -27,6 +27,6 @@ ); }); -echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/05-cookie-handling.php b/examples/05-cookie-handling.php index 67e008bb..8b453a6f 100644 --- a/examples/05-cookie-handling.php +++ b/examples/05-cookie-handling.php @@ -33,6 +33,6 @@ ); }); -echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/06-sleep.php b/examples/06-sleep.php index 9fb75542..f04b1758 100644 --- a/examples/06-sleep.php +++ b/examples/06-sleep.php @@ -24,6 +24,6 @@ }); }); -echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/07-error-handling.php b/examples/07-error-handling.php index 00ed0cfa..265a1e27 100644 --- a/examples/07-error-handling.php +++ b/examples/07-error-handling.php @@ -30,6 +30,6 @@ }); }); -echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/08-stream-response.php b/examples/08-stream-response.php index e563be38..a3d578d4 100644 --- a/examples/08-stream-response.php +++ b/examples/08-stream-response.php @@ -34,6 +34,6 @@ ); }); -echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/09-stream-request.php b/examples/09-stream-request.php index fabb5bb7..356a6f22 100644 --- a/examples/09-stream-request.php +++ b/examples/09-stream-request.php @@ -39,6 +39,6 @@ }); }); -echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/11-hello-world-https.php b/examples/11-hello-world-https.php index de958007..bf1b6d34 100644 --- a/examples/11-hello-world-https.php +++ b/examples/11-hello-world-https.php @@ -24,6 +24,6 @@ //$socket->on('error', 'printf'); -echo 'Listening on https://' . $socket->getAddress() . PHP_EOL; +echo 'Listening on ' . str_replace('tls:', 'https:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/21-http-proxy.php b/examples/21-http-proxy.php index 720f51fe..1285f5ed 100644 --- a/examples/21-http-proxy.php +++ b/examples/21-http-proxy.php @@ -40,6 +40,6 @@ //$server->on('error', 'printf'); -echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/22-connect-proxy.php b/examples/22-connect-proxy.php index 7e7acd2c..5b5af3ed 100644 --- a/examples/22-connect-proxy.php +++ b/examples/22-connect-proxy.php @@ -44,6 +44,6 @@ function ($e) { //$server->on('error', 'printf'); -echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/31-upgrade-echo.php b/examples/31-upgrade-echo.php index b28e8344..9e7d61d9 100644 --- a/examples/31-upgrade-echo.php +++ b/examples/31-upgrade-echo.php @@ -53,6 +53,6 @@ ); }); -echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/32-upgrade-chat.php b/examples/32-upgrade-chat.php index eb74b674..d3cae956 100644 --- a/examples/32-upgrade-chat.php +++ b/examples/32-upgrade-chat.php @@ -80,6 +80,6 @@ ); }); -echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/99-benchmark-download.php b/examples/99-benchmark-download.php index 0be294b1..b34a6b89 100644 --- a/examples/99-benchmark-download.php +++ b/examples/99-benchmark-download.php @@ -114,6 +114,6 @@ public function getSize() ); }); -echo 'Listening on http://' . $socket->getAddress() . PHP_EOL; +echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); From 748e456df45df0a055135d0d488fb62ba775afce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 27 May 2017 11:51:03 +0200 Subject: [PATCH 126/128] Add listen() method to support multiple listening sockets --- README.md | 77 +++---- examples/01-hello-world.php | 10 +- examples/02-count-visitors.php | 10 +- examples/03-client-ip.php | 10 +- examples/04-query-parameter.php | 10 +- examples/05-cookie-handling.php | 10 +- examples/06-sleep.php | 8 +- examples/07-error-handling.php | 10 +- examples/08-stream-response.php | 10 +- examples/09-stream-request.php | 10 +- examples/11-hello-world-https.php | 17 +- examples/21-http-proxy.php | 10 +- examples/22-connect-proxy.php | 10 +- examples/31-upgrade-echo.php | 12 +- examples/32-upgrade-chat.php | 13 +- examples/99-benchmark-download.php | 12 +- tests/FunctionalServerTest.php | 109 ++++++---- tests/ServerTest.php | 322 +++++++++++++++++++---------- 18 files changed, 420 insertions(+), 250 deletions(-) diff --git a/README.md b/README.md index dd51ada0..26cb2998 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,8 @@ This is an HTTP server which responds with `Hello World` to every request. ```php $loop = React\EventLoop\Factory::create(); -$socket = new React\Socket\Server(8080, $loop); -$http = new Server($socket, function (ServerRequestInterface $request) { +$server = new Server(function (ServerRequestInterface $request) { return new Response( 200, array('Content-Type' => 'text/plain'), @@ -31,6 +30,9 @@ $http = new Server($socket, function (ServerRequestInterface $request) { ); }); +$socket = new React\Socket\Server(8080, $loop); +$server->listen($socket); + $loop->run(); ``` @@ -43,18 +45,12 @@ See also the [examples](examples). The `Server` class is responsible for handling incoming connections and then processing each incoming HTTP request. -It attaches itself to an instance of `React\Socket\ServerInterface` which -emits underlying streaming connections in order to then parse incoming data -as HTTP. - For each request, it executes the callback function passed to the -constructor with the respective [request](#request) and -[response](#response) objects: +constructor with the respective [request](#request) object and expects +a respective [response](#response) object in return. ```php -$socket = new React\Socket\Server(8080, $loop); - -$http = new Server($socket, function (ServerRequestInterface $request) { +$server = new Server(function (ServerRequestInterface $request) { return new Response( 200, array('Content-Type' => 'text/plain'), @@ -63,25 +59,36 @@ $http = new Server($socket, function (ServerRequestInterface $request) { }); ``` -See also the [first example](examples) for more details. +In order to process any connections, the server needs to be attached to an +instance of `React\Socket\ServerInterface` which emits underlying streaming +connections in order to then parse incoming data as HTTP. + +You can attach this to a +[`React\Socket\Server`](https://github.com/reactphp/socket#server) +in order to start a plaintext HTTP server like this: + +```php +$server = new Server($handler); + +$socket = new React\Socket\Server(8080, $loop); +$server->listen($socket); +``` + +See also the `listen()` method and the [first example](examples) for more details. Similarly, you can also attach this to a [`React\Socket\SecureServer`](https://github.com/reactphp/socket#secureserver) in order to start a secure HTTPS server like this: ```php +$server = new Server($handler); + $socket = new React\Socket\Server(8080, $loop); $socket = new React\Socket\SecureServer($socket, $loop, array( 'local_cert' => __DIR__ . '/localhost.pem' )); -$http = new Server($socket, function (ServerRequestInterface $request) { - return new Response( - 200, - array('Content-Type' => 'text/plain'), - "Hello World!\n" - ); -}); +$server->listen($socket); ``` See also [example #11](examples) for more details. @@ -105,7 +112,7 @@ emit an `error` event, send an HTTP error response to the client and close the connection: ```php -$http->on('error', function (Exception $e) { +$server->on('error', function (Exception $e) { echo 'Error: ' . $e->getMessage() . PHP_EOL; }); ``` @@ -117,7 +124,7 @@ the `Server` will emit a `RuntimeException` and add the thrown exception as previous: ```php -$http->on('error', function (Exception $e) { +$server->on('error', function (Exception $e) { echo 'Error: ' . $e->getMessage() . PHP_EOL; if ($e->getPrevious() !== null) { $previousException = $e->getPrevious(); @@ -143,7 +150,7 @@ which in turn extends the and will be passed to the callback function like this. ```php -$http = new Server($socket, function (ServerRequestInterface $request) { +$server = new Server(function (ServerRequestInterface $request) { $body = "The method of the request is: " . $request->getMethod(); $body .= "The requested path is: " . $request->getUri()->getPath(); @@ -177,7 +184,7 @@ The following parameters are currently available: Set to 'on' if the request used HTTPS, otherwise it won't be set ```php -$http = new Server($socket, function (ServerRequestInterface $request) { +$server = new Server(function (ServerRequestInterface $request) { $body = "Your IP is: " . $request->getServerParams()['REMOTE_ADDR']; return new Response( @@ -194,7 +201,7 @@ The `getQueryParams(): array` method can be used to get the query parameters similiar to the `$_GET` variable. ```php -$http = new Server($socket, function (ServerRequestInterface $request) { +$server = new Server(function (ServerRequestInterface $request) { $queryParams = $request->getQueryParams(); $body = 'The query parameter "foo" is not set. Click the following link '; @@ -257,7 +264,7 @@ Instead, you should use the `ReactPHP ReadableStreamInterface` which gives you access to the incoming request body as the individual chunks arrive: ```php -$http = new Server($socket, function (ServerRequestInterface $request) { +$server = new Server(function (ServerRequestInterface $request) { return new Promise(function ($resolve, $reject) use ($request) { $contentLength = 0; $request->getBody()->on('data', function ($data) use (&$contentLength) { @@ -321,7 +328,7 @@ Note that this value may be `null` if the request body size is unknown in advance because the request message uses chunked transfer encoding. ```php -$http = new Server($socket, function (ServerRequestInterface $request) { +$server = new Server(function (ServerRequestInterface $request) { $size = $request->getBody()->getSize(); if ($size === null) { $body = 'The request does not contain an explicit length.'; @@ -375,7 +382,7 @@ The `getCookieParams(): string[]` method can be used to get all cookies sent with the current request. ```php -$http = new Server($socket, function (ServerRequestInterface $request) { +$server = new Server(function (ServerRequestInterface $request) { $key = 'react\php'; if (isset($request->getCookieParams()[$key])) { @@ -426,7 +433,7 @@ but feel free to use any implemantation of the `PSR-7 ResponseInterface` you prefer. ```php -$http = new Server($socket, function (ServerRequestInterface $request) { +$server = new Server(function (ServerRequestInterface $request) { return new Response( 200, array('Content-Type' => 'text/plain'), @@ -445,7 +452,7 @@ To prevent this you SHOULD use a This example shows how such a long-term action could look like: ```php -$server = new \React\Http\Server($socket, function (ServerRequestInterface $request) use ($loop) { +$server = new Server(function (ServerRequestInterface $request) use ($loop) { return new Promise(function ($resolve, $reject) use ($request, $loop) { $loop->addTimer(1.5, function() use ($loop, $resolve) { $response = new Response( @@ -478,7 +485,7 @@ Note that other implementations of the `PSR-7 ResponseInterface` likely only support strings. ```php -$server = new Server($socket, function (ServerRequestInterface $request) use ($loop) { +$server = new Server(function (ServerRequestInterface $request) use ($loop) { $stream = new ThroughStream(); $timer = $loop->addPeriodicTimer(0.5, function () use ($stream) { @@ -521,7 +528,7 @@ If you know the length of your stream body, you MAY specify it like this instead ```php $stream = new ThroughStream() -$server = new Server($socket, function (ServerRequestInterface $request) use ($loop, $stream) { +$server = new Server(function (ServerRequestInterface $request) use ($stream) { return new Response( 200, array( @@ -606,7 +613,7 @@ A `Date` header will be automatically added with the system date and time if non You can add a custom `Date` header yourself like this: ```php -$server = new Server($socket, function (ServerRequestInterface $request) { +$server = new Server(function (ServerRequestInterface $request) { return new Response(200, array('Date' => date('D, d M Y H:i:s T'))); }); ``` @@ -615,7 +622,7 @@ If you don't have a appropriate clock to rely on, you should unset this header with an empty string: ```php -$server = new Server($socket, function (ServerRequestInterface $request) { +$server = new Server(function (ServerRequestInterface $request) { return new Response(200, array('Date' => '')); }); ``` @@ -624,7 +631,7 @@ Note that it will automatically assume a `X-Powered-By: react/alpha` header unless your specify a custom `X-Powered-By` header yourself: ```php -$server = new Server($socket, function (ServerRequestInterface $request) { +$server = new Server(function (ServerRequestInterface $request) { return new Response(200, array('X-Powered-By' => 'PHP 3')); }); ``` @@ -633,7 +640,7 @@ If you do not want to send this header at all, you can use an empty string as value like this: ```php -$server = new Server($socket, function (ServerRequestInterface $request) { +$server = new Server(function (ServerRequestInterface $request) { return new Response(200, array('X-Powered-By' => '')); }); ``` diff --git a/examples/01-hello-world.php b/examples/01-hello-world.php index 4b34f5af..f703a5d7 100644 --- a/examples/01-hello-world.php +++ b/examples/01-hello-world.php @@ -1,16 +1,15 @@ listen($socket); + echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/02-count-visitors.php b/examples/02-count-visitors.php index a2788510..5a225110 100644 --- a/examples/02-count-visitors.php +++ b/examples/02-count-visitors.php @@ -1,17 +1,16 @@ 'text/plain'), @@ -19,6 +18,9 @@ ); }); +$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); +$server->listen($socket); + echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/03-client-ip.php b/examples/03-client-ip.php index ee6d4f90..3fbcabfd 100644 --- a/examples/03-client-ip.php +++ b/examples/03-client-ip.php @@ -1,16 +1,15 @@ getServerParams()['REMOTE_ADDR']; return new Response( @@ -20,6 +19,9 @@ ); }); +$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); +$server->listen($socket); + echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/04-query-parameter.php b/examples/04-query-parameter.php index ab64aef0..3a60aae8 100644 --- a/examples/04-query-parameter.php +++ b/examples/04-query-parameter.php @@ -1,16 +1,15 @@ getQueryParams(); $body = 'The query parameter "foo" is not set. Click the following link '; @@ -27,6 +26,9 @@ ); }); +$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); +$server->listen($socket); + echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/05-cookie-handling.php b/examples/05-cookie-handling.php index 8b453a6f..5441adbe 100644 --- a/examples/05-cookie-handling.php +++ b/examples/05-cookie-handling.php @@ -1,16 +1,15 @@ getCookieParams()[$key])) { @@ -33,6 +32,9 @@ ); }); +$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); +$server->listen($socket); + echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/06-sleep.php b/examples/06-sleep.php index f04b1758..926aac10 100644 --- a/examples/06-sleep.php +++ b/examples/06-sleep.php @@ -3,15 +3,14 @@ use Psr\Http\Message\ServerRequestInterface; use React\EventLoop\Factory; use React\Http\Response; +use React\Http\Server; use React\Promise\Promise; -use React\Socket\Server; require __DIR__ . '/../vendor/autoload.php'; $loop = Factory::create(); -$socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); -$server = new \React\Http\Server($socket, function (ServerRequestInterface $request) use ($loop) { +$server = new Server(function (ServerRequestInterface $request) use ($loop) { return new Promise(function ($resolve, $reject) use ($request, $loop) { $loop->addTimer(1.5, function() use ($loop, $resolve) { $response = new Response( @@ -24,6 +23,9 @@ }); }); +$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); +$server->listen($socket); + echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/07-error-handling.php b/examples/07-error-handling.php index 265a1e27..5dbc6955 100644 --- a/examples/07-error-handling.php +++ b/examples/07-error-handling.php @@ -1,18 +1,17 @@ listen($socket); + echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/08-stream-response.php b/examples/08-stream-response.php index a3d578d4..399e3a77 100644 --- a/examples/08-stream-response.php +++ b/examples/08-stream-response.php @@ -1,17 +1,16 @@ getMethod() !== 'GET' || $request->getUri()->getPath() !== '/') { return new Response(404); } @@ -34,6 +33,9 @@ ); }); +$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); +$server->listen($socket); + echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/09-stream-request.php b/examples/09-stream-request.php index 356a6f22..bcf5456b 100644 --- a/examples/09-stream-request.php +++ b/examples/09-stream-request.php @@ -1,17 +1,16 @@ getBody()->on('data', function ($data) use (&$contentLength) { @@ -39,6 +38,9 @@ }); }); +$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); +$server->listen($socket); + echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/11-hello-world-https.php b/examples/11-hello-world-https.php index bf1b6d34..6610c3e0 100644 --- a/examples/11-hello-world-https.php +++ b/examples/11-hello-world-https.php @@ -1,20 +1,15 @@ isset($argv[2]) ? $argv[2] : __DIR__ . '/localhost.pem' -)); -$server = new \React\Http\Server($socket, function (ServerRequestInterface $request) { +$server = new Server(function (ServerRequestInterface $request) { return new Response( 200, array('Content-Type' => 'text/plain'), @@ -22,6 +17,12 @@ ); }); +$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); +$socket = new \React\Socket\SecureServer($socket, $loop, array( + 'local_cert' => isset($argv[2]) ? $argv[2] : __DIR__ . '/localhost.pem' +)); +$server->listen($socket); + //$socket->on('error', 'printf'); echo 'Listening on ' . str_replace('tls:', 'https:', $socket->getAddress()) . PHP_EOL; diff --git a/examples/21-http-proxy.php b/examples/21-http-proxy.php index 1285f5ed..250cbf7a 100644 --- a/examples/21-http-proxy.php +++ b/examples/21-http-proxy.php @@ -1,17 +1,16 @@ getRequestTarget(), '://') === false) { return new Response( 400, @@ -38,7 +37,8 @@ ); }); -//$server->on('error', 'printf'); +$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); +$server->listen($socket); echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; diff --git a/examples/22-connect-proxy.php b/examples/22-connect-proxy.php index 5b5af3ed..ed8e80b0 100644 --- a/examples/22-connect-proxy.php +++ b/examples/22-connect-proxy.php @@ -1,19 +1,18 @@ getMethod() !== 'CONNECT') { return new Response( 405, @@ -42,7 +41,8 @@ function ($e) { ); }); -//$server->on('error', 'printf'); +$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); +$server->listen($socket); echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; diff --git a/examples/31-upgrade-echo.php b/examples/31-upgrade-echo.php index 9e7d61d9..b098ef03 100644 --- a/examples/31-upgrade-echo.php +++ b/examples/31-upgrade-echo.php @@ -17,20 +17,17 @@ < world */ +use Psr\Http\Message\ServerRequestInterface; use React\EventLoop\Factory; -use React\Http\Server; use React\Http\Response; -use Psr\Http\Message\ServerRequestInterface; -use React\Stream\ReadableStream; +use React\Http\Server; use React\Stream\ThroughStream; -use React\Stream\CompositeStream; require __DIR__ . '/../vendor/autoload.php'; $loop = Factory::create(); -$socket = new React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); -$server = new Server($socket, function (ServerRequestInterface $request) use ($loop) { +$server = new Server(function (ServerRequestInterface $request) use ($loop) { if ($request->getHeaderLine('Upgrade') !== 'echo' || $request->getProtocolVersion() === '1.0') { return new Response(426, array('Upgrade' => 'echo'), '"Upgrade: echo" required'); } @@ -53,6 +50,9 @@ ); }); +$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); +$server->listen($socket); + echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/32-upgrade-chat.php b/examples/32-upgrade-chat.php index d3cae956..49cb0305 100644 --- a/examples/32-upgrade-chat.php +++ b/examples/32-upgrade-chat.php @@ -19,25 +19,23 @@ Hint: try this with multiple connections :) */ +use Psr\Http\Message\ServerRequestInterface; use React\EventLoop\Factory; -use React\Http\Server; use React\Http\Response; -use Psr\Http\Message\ServerRequestInterface; -use React\Stream\ReadableStream; -use React\Stream\ThroughStream; +use React\Http\Server; use React\Stream\CompositeStream; +use React\Stream\ThroughStream; require __DIR__ . '/../vendor/autoload.php'; $loop = Factory::create(); -$socket = new React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); // simply use a shared duplex ThroughStream for all clients // it will simply emit any data that is sent to it // this means that any Upgraded data will simply be sent back to the client $chat = new ThroughStream(); -$server = new Server($socket, function (ServerRequestInterface $request) use ($loop, $chat) { +$server = new Server(function (ServerRequestInterface $request) use ($loop, $chat) { if ($request->getHeaderLine('Upgrade') !== 'chat' || $request->getProtocolVersion() === '1.0') { return new Response(426, array('Upgrade' => 'chat'), '"Upgrade: chat" required'); } @@ -80,6 +78,9 @@ ); }); +$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); +$server->listen($socket); + echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/examples/99-benchmark-download.php b/examples/99-benchmark-download.php index b34a6b89..a8a6e03a 100644 --- a/examples/99-benchmark-download.php +++ b/examples/99-benchmark-download.php @@ -6,18 +6,17 @@ // $ ab -n10 -c10 http://localhost:8080/1g.bin // $ docker run -it --rm --net=host jordi/ab ab -n10 -c10 http://localhost:8080/1g.bin +use Evenement\EventEmitter; +use Psr\Http\Message\ServerRequestInterface; use React\EventLoop\Factory; -use React\Socket\Server; use React\Http\Response; -use Psr\Http\Message\ServerRequestInterface; -use Evenement\EventEmitter; +use React\Http\Server; use React\Stream\ReadableStreamInterface; use React\Stream\WritableStreamInterface; require __DIR__ . '/../vendor/autoload.php'; $loop = Factory::create(); -$socket = new Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); /** A readable stream that can emit a lot of data */ class ChunkRepeater extends EventEmitter implements ReadableStreamInterface @@ -87,7 +86,7 @@ public function getSize() } } -$server = new \React\Http\Server($socket, function (ServerRequestInterface $request) use ($loop) { +$server = new Server(function (ServerRequestInterface $request) use ($loop) { switch ($request->getUri()->getPath()) { case '/': return new Response( @@ -114,6 +113,9 @@ public function getSize() ); }); +$socket = new \React\Socket\Server(isset($argv[1]) ? $argv[1] : '0.0.0.0:0', $loop); +$server->listen($socket); + echo 'Listening on ' . str_replace('tcp:', 'http:', $socket->getAddress()) . PHP_EOL; $loop->run(); diff --git a/tests/FunctionalServerTest.php b/tests/FunctionalServerTest.php index e3f365ea..06f06db9 100644 --- a/tests/FunctionalServerTest.php +++ b/tests/FunctionalServerTest.php @@ -12,7 +12,6 @@ use React\Http\Response; use React\Socket\SecureServer; use React\Stream\ReadableStreamInterface; -use React\EventLoop\LoopInterface; use React\Promise\Promise; use React\Promise\PromiseInterface; use React\Promise\Stream; @@ -23,13 +22,15 @@ class FunctionalServerTest extends TestCase public function testPlainHttpOnRandomPort() { $loop = Factory::create(); - $socket = new Socket(0, $loop); $connector = new Connector($loop); - $server = new Server($socket, function (RequestInterface $request) { + $server = new Server(function (RequestInterface $request) { return new Response(200, array(), (string)$request->getUri()); }); + $socket = new Socket(0, $loop); + $server->listen($socket); + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\nHost: " . noScheme($conn->getRemoteAddress()) . "\r\n\r\n"); @@ -47,13 +48,15 @@ public function testPlainHttpOnRandomPort() public function testPlainHttpOnRandomPortWithoutHostHeaderUsesSocketUri() { $loop = Factory::create(); - $socket = new Socket(0, $loop); $connector = new Connector($loop); - $server = new Server($socket, function (RequestInterface $request) { + $server = new Server(function (RequestInterface $request) { return new Response(200, array(), (string)$request->getUri()); }); + $socket = new Socket(0, $loop); + $server->listen($socket); + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\n\r\n"); @@ -71,13 +74,15 @@ public function testPlainHttpOnRandomPortWithoutHostHeaderUsesSocketUri() public function testPlainHttpOnRandomPortWithOtherHostHeaderTakesPrecedence() { $loop = Factory::create(); - $socket = new Socket(0, $loop); $connector = new Connector($loop); - $server = new Server($socket, function (RequestInterface $request) { + $server = new Server(function (RequestInterface $request) { return new Response(200, array(), (string)$request->getUri()); }); + $socket = new Socket(0, $loop); + $server->listen($socket); + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\nHost: localhost:1000\r\n\r\n"); @@ -99,18 +104,20 @@ public function testSecureHttpsOnRandomPort() } $loop = Factory::create(); - $socket = new Socket(0, $loop); - $socket = new SecureServer($socket, $loop, array( - 'local_cert' => __DIR__ . '/../examples/localhost.pem' - )); $connector = new Connector($loop, array( 'tls' => array('verify_peer' => false) )); - $server = new Server($socket, function (RequestInterface $request) { + $server = new Server(function (RequestInterface $request) { return new Response(200, array(), (string)$request->getUri()); }); + $socket = new Socket(0, $loop); + $socket = new SecureServer($socket, $loop, array( + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + )); + $server->listen($socket); + $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\nHost: " . noScheme($conn->getRemoteAddress()) . "\r\n\r\n"); @@ -132,18 +139,20 @@ public function testSecureHttpsOnRandomPortWithoutHostHeaderUsesSocketUri() } $loop = Factory::create(); - $socket = new Socket(0, $loop); - $socket = new SecureServer($socket, $loop, array( - 'local_cert' => __DIR__ . '/../examples/localhost.pem' - )); $connector = new Connector($loop, array( 'tls' => array('verify_peer' => false) )); - $server = new Server($socket, function (RequestInterface $request) { + $server = new Server(function (RequestInterface $request) { return new Response(200, array(), (string)$request->getUri()); }); + $socket = new Socket(0, $loop); + $socket = new SecureServer($socket, $loop, array( + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + )); + $server->listen($socket); + $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\n\r\n"); @@ -168,10 +177,12 @@ public function testPlainHttpOnStandardPortReturnsUriWithNoPort() } $connector = new Connector($loop); - $server = new Server($socket, function (RequestInterface $request) { + $server = new Server(function (RequestInterface $request) { return new Response(200, array(), (string)$request->getUri()); }); + $server->listen($socket); + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n"); @@ -196,10 +207,12 @@ public function testPlainHttpOnStandardPortWithoutHostHeaderReturnsUriWithNoPort } $connector = new Connector($loop); - $server = new Server($socket, function (RequestInterface $request) { + $server = new Server(function (RequestInterface $request) { return new Response(200, array(), (string)$request->getUri()); }); + $server->listen($socket); + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\n\r\n"); @@ -233,10 +246,12 @@ public function testSecureHttpsOnStandardPortReturnsUriWithNoPort() 'tls' => array('verify_peer' => false) )); - $server = new Server($socket, function (RequestInterface $request) { + $server = new Server(function (RequestInterface $request) { return new Response(200, array(), (string)$request->getUri()); }); + $server->listen($socket); + $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n"); @@ -270,10 +285,12 @@ public function testSecureHttpsOnStandardPortWithoutHostHeaderUsesSocketUri() 'tls' => array('verify_peer' => false) )); - $server = new Server($socket, function (RequestInterface $request) { + $server = new Server(function (RequestInterface $request) { return new Response(200, array(), (string)$request->getUri()); }); + $server->listen($socket); + $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\n\r\n"); @@ -298,10 +315,12 @@ public function testPlainHttpOnHttpsStandardPortReturnsUriWithPort() } $connector = new Connector($loop); - $server = new Server($socket, function (RequestInterface $request) { + $server = new Server(function (RequestInterface $request) { return new Response(200, array(), (string)$request->getUri()); }); + $server->listen($socket); + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\nHost: " . noScheme($conn->getRemoteAddress()) . "\r\n\r\n"); @@ -335,10 +354,12 @@ public function testSecureHttpsOnHttpStandardPortReturnsUriWithPort() 'tls' => array('verify_peer' => false) )); - $server = new Server($socket, function (RequestInterface $request) { + $server = new Server(function (RequestInterface $request) { return new Response(200, array(), (string)$request->getUri() . 'x' . $request->getHeaderLine('Host')); }); + $server->listen($socket); + $result = $connector->connect('tls://' . noScheme($socket->getAddress()))->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.0\r\nHost: " . noScheme($conn->getRemoteAddress()) . "\r\n\r\n"); @@ -356,16 +377,18 @@ public function testSecureHttpsOnHttpStandardPortReturnsUriWithPort() public function testClosedStreamFromRequestHandlerWillSendEmptyBody() { $loop = Factory::create(); - $socket = new Socket(0, $loop); $connector = new Connector($loop); $stream = new ThroughStream(); $stream->close(); - $server = new Server($socket, function (RequestInterface $request) use ($stream) { + $server = new Server(function (RequestInterface $request) use ($stream) { return new Response(200, array(), $stream); }); + $socket = new Socket(0, $loop); + $server->listen($socket); + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) use ($loop) { $conn->write("GET / HTTP/1.0\r\n\r\n"); @@ -383,16 +406,18 @@ public function testClosedStreamFromRequestHandlerWillSendEmptyBody() public function testStreamFromRequestHandlerWillBeClosedIfConnectionClosesWhileSendingBody() { $loop = Factory::create(); - $socket = new Socket(0, $loop); $connector = new Connector($loop); $stream = new ThroughStream(); $stream->on('close', $this->expectCallableOnce()); - $server = new Server($socket, function (RequestInterface $request) use ($stream) { + $server = new Server(function (RequestInterface $request) use ($stream) { return new Response(200, array(), $stream); }); + $socket = new Socket(0, $loop); + $server->listen($socket); + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) use ($loop) { $conn->write("GET / HTTP/1.0\r\nContent-Length: 100\r\n\r\n"); @@ -414,16 +439,18 @@ public function testStreamFromRequestHandlerWillBeClosedIfConnectionClosesWhileS public function testStreamFromRequestHandlerWillBeClosedIfConnectionClosesButWillOnlyBeDetectedOnNextWrite() { $loop = Factory::create(); - $socket = new Socket(0, $loop); $connector = new Connector($loop); $stream = new ThroughStream(); $stream->on('close', $this->expectCallableOnce()); - $server = new Server($socket, function (RequestInterface $request) use ($stream) { + $server = new Server(function (RequestInterface $request) use ($stream) { return new Response(200, array(), $stream); }); + $socket = new Socket(0, $loop); + $server->listen($socket); + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) use ($loop) { $conn->write("GET / HTTP/1.0\r\n\r\n"); @@ -450,10 +477,9 @@ public function testStreamFromRequestHandlerWillBeClosedIfConnectionClosesButWil public function testUpgradeWithThroughStreamReturnsDataAsGiven() { $loop = Factory::create(); - $socket = new Socket(0, $loop); $connector = new Connector($loop); - $server = new Server($socket, function (RequestInterface $request) use ($loop) { + $server = new Server(function (RequestInterface $request) use ($loop) { $stream = new ThroughStream(); $loop->addTimer(0.1, function () use ($stream) { @@ -463,6 +489,9 @@ public function testUpgradeWithThroughStreamReturnsDataAsGiven() return new Response(101, array('Upgrade' => 'echo'), $stream); }); + $socket = new Socket(0, $loop); + $server->listen($socket); + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { $conn->write("GET / HTTP/1.1\r\nHost: example.com:80\r\nUpgrade: echo\r\n\r\n"); @@ -485,10 +514,9 @@ public function testUpgradeWithThroughStreamReturnsDataAsGiven() public function testConnectWithThroughStreamReturnsDataAsGiven() { $loop = Factory::create(); - $socket = new Socket(0, $loop); $connector = new Connector($loop); - $server = new Server($socket, function (RequestInterface $request) use ($loop) { + $server = new Server(function (RequestInterface $request) use ($loop) { $stream = new ThroughStream(); $loop->addTimer(0.1, function () use ($stream) { @@ -498,6 +526,9 @@ public function testConnectWithThroughStreamReturnsDataAsGiven() return new Response(200, array(), $stream); }); + $socket = new Socket(0, $loop); + $server->listen($socket); + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { $conn->write("CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n"); @@ -520,10 +551,9 @@ public function testConnectWithThroughStreamReturnsDataAsGiven() public function testConnectWithThroughStreamReturnedFromPromiseReturnsDataAsGiven() { $loop = Factory::create(); - $socket = new Socket(0, $loop); $connector = new Connector($loop); - $server = new Server($socket, function (RequestInterface $request) use ($loop) { + $server = new Server(function (RequestInterface $request) use ($loop) { $stream = new ThroughStream(); $loop->addTimer(0.1, function () use ($stream) { @@ -537,6 +567,9 @@ public function testConnectWithThroughStreamReturnedFromPromiseReturnsDataAsGive }); }); + $socket = new Socket(0, $loop); + $server->listen($socket); + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { $conn->write("CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n"); @@ -559,16 +592,18 @@ public function testConnectWithThroughStreamReturnedFromPromiseReturnsDataAsGive public function testConnectWithClosedThroughStreamReturnsNoData() { $loop = Factory::create(); - $socket = new Socket(0, $loop); $connector = new Connector($loop); - $server = new Server($socket, function (RequestInterface $request) { + $server = new Server(function (RequestInterface $request) { $stream = new ThroughStream(); $stream->close(); return new Response(200, array(), $stream); }); + $socket = new Socket(0, $loop); + $server->listen($socket); + $result = $connector->connect($socket->getAddress())->then(function (ConnectionInterface $conn) { $conn->write("CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n"); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 60163dd5..76e2ba13 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -41,8 +41,9 @@ public function setUp() public function testRequestEventWillNotBeEmittedForIncompleteHeaders() { - $server = new Server($this->socket, $this->expectCallableNever()); + $server = new Server($this->expectCallableNever()); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = ''; @@ -52,10 +53,11 @@ public function testRequestEventWillNotBeEmittedForIncompleteHeaders() public function testRequestEventIsEmitted() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { return \React\Promise\resolve(new Response()); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -66,7 +68,7 @@ public function testRequestEvent() { $i = 0; $requestAssertion = null; - $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$i, &$requestAssertion) { + $server = new Server(function (ServerRequestInterface $request) use (&$i, &$requestAssertion) { $i++; $requestAssertion = $request; @@ -78,6 +80,7 @@ public function testRequestEvent() ->method('getRemoteAddress') ->willReturn('127.0.0.1:8080'); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -99,11 +102,12 @@ public function testRequestEvent() public function testRequestGetWithHostAndCustomPort() { $requestAssertion = null; - $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) { $requestAssertion = $request; return new Response(); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\nHost: example.com:8080\r\n\r\n"; @@ -121,11 +125,12 @@ public function testRequestGetWithHostAndCustomPort() public function testRequestGetWithHostAndHttpsPort() { $requestAssertion = null; - $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) { $requestAssertion = $request; return new Response(); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\nHost: example.com:443\r\n\r\n"; @@ -143,11 +148,12 @@ public function testRequestGetWithHostAndHttpsPort() public function testRequestGetWithHostAndDefaultPortWillBeIgnored() { $requestAssertion = null; - $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) { $requestAssertion = $request; return new Response(); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\nHost: example.com:80\r\n\r\n"; @@ -165,11 +171,12 @@ public function testRequestGetWithHostAndDefaultPortWillBeIgnored() public function testRequestOptionsAsterisk() { $requestAssertion = null; - $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) { $requestAssertion = $request; return new Response(); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "OPTIONS * HTTP/1.1\r\nHost: example.com\r\n\r\n"; @@ -185,9 +192,10 @@ public function testRequestOptionsAsterisk() public function testRequestNonOptionsWithAsteriskRequestTargetWillReject() { - $server = new Server($this->socket, $this->expectCallableNever()); + $server = new Server($this->expectCallableNever()); $server->on('error', $this->expectCallableOnce()); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET * HTTP/1.1\r\nHost: example.com\r\n\r\n"; @@ -197,11 +205,12 @@ public function testRequestNonOptionsWithAsteriskRequestTargetWillReject() public function testRequestConnectAuthorityForm() { $requestAssertion = null; - $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) { $requestAssertion = $request; return new Response(); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n"; @@ -219,11 +228,12 @@ public function testRequestConnectAuthorityForm() public function testRequestConnectAuthorityFormWithDefaultPortWillBeIgnored() { $requestAssertion = null; - $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) { $requestAssertion = $request; return new Response(); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n"; @@ -241,11 +251,12 @@ public function testRequestConnectAuthorityFormWithDefaultPortWillBeIgnored() public function testRequestConnectAuthorityFormNonMatchingHostWillBeOverwritten() { $requestAssertion = null; - $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) { $requestAssertion = $request; return new Response(); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "CONNECT example.com:80 HTTP/1.1\r\nHost: other.example.org\r\n\r\n"; @@ -262,9 +273,10 @@ public function testRequestConnectAuthorityFormNonMatchingHostWillBeOverwritten( public function testRequestConnectOriginFormRequestTargetWillReject() { - $server = new Server($this->socket, $this->expectCallableNever()); + $server = new Server($this->expectCallableNever()); $server->on('error', $this->expectCallableOnce()); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "CONNECT / HTTP/1.1\r\nHost: example.com\r\n\r\n"; @@ -273,9 +285,10 @@ public function testRequestConnectOriginFormRequestTargetWillReject() public function testRequestNonConnectWithAuthorityRequestTargetWillReject() { - $server = new Server($this->socket, $this->expectCallableNever()); + $server = new Server($this->expectCallableNever()); $server->on('error', $this->expectCallableOnce()); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET example.com:80 HTTP/1.1\r\nHost: example.com\r\n\r\n"; @@ -286,7 +299,7 @@ public function testRequestWithoutHostEventUsesSocketAddress() { $requestAssertion = null; - $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) { $requestAssertion = $request; return new Response(); }); @@ -296,6 +309,7 @@ public function testRequestWithoutHostEventUsesSocketAddress() ->method('getLocalAddress') ->willReturn('127.0.0.1:80'); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET /test HTTP/1.0\r\n\r\n"; @@ -312,11 +326,12 @@ public function testRequestAbsoluteEvent() { $requestAssertion = null; - $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) { $requestAssertion = $request; return new Response(); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET http://example.com/test HTTP/1.1\r\nHost: example.com\r\n\r\n"; @@ -334,12 +349,13 @@ public function testRequestAbsoluteAddsMissingHostEvent() { $requestAssertion = null; - $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) { $requestAssertion = $request; return new Response(); }); $server->on('error', 'printf'); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET http://example.com:8080/test HTTP/1.0\r\n\r\n"; @@ -357,11 +373,12 @@ public function testRequestAbsoluteNonMatchingHostWillBeOverwritten() { $requestAssertion = null; - $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) { $requestAssertion = $request; return new Response(); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET http://example.com/test HTTP/1.1\r\nHost: other.example.org\r\n\r\n"; @@ -379,11 +396,12 @@ public function testRequestOptionsAsteriskEvent() { $requestAssertion = null; - $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) { $requestAssertion = $request; return new Response(); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "OPTIONS * HTTP/1.1\r\nHost: example.com\r\n\r\n"; @@ -401,11 +419,12 @@ public function testRequestOptionsAbsoluteEvent() { $requestAssertion = null; - $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestAssertion) { + $server = new Server(function (ServerRequestInterface $request) use (&$requestAssertion) { $requestAssertion = $request; return new Response(); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "OPTIONS http://example.com HTTP/1.1\r\nHost: example.com\r\n\r\n"; @@ -421,12 +440,14 @@ public function testRequestOptionsAbsoluteEvent() public function testRequestPauseWillbeForwardedToConnection() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { $request->getBody()->pause(); return new Response(); }); $this->connection->expects($this->once())->method('pause'); + + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -440,12 +461,14 @@ public function testRequestPauseWillbeForwardedToConnection() public function testRequestResumeWillbeForwardedToConnection() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { $request->getBody()->resume(); return new Response(); }); $this->connection->expects($this->once())->method('resume'); + + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -454,12 +477,14 @@ public function testRequestResumeWillbeForwardedToConnection() public function testRequestCloseWillPauseConnection() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { $request->getBody()->close(); return new Response(); }); $this->connection->expects($this->once())->method('pause'); + + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -468,7 +493,7 @@ public function testRequestCloseWillPauseConnection() public function testRequestPauseAfterCloseWillNotBeForwarded() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { $request->getBody()->close(); $request->getBody()->pause();# @@ -476,6 +501,8 @@ public function testRequestPauseAfterCloseWillNotBeForwarded() }); $this->connection->expects($this->once())->method('pause'); + + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -484,7 +511,7 @@ public function testRequestPauseAfterCloseWillNotBeForwarded() public function testRequestResumeAfterCloseWillNotBeForwarded() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { $request->getBody()->close(); $request->getBody()->resume(); @@ -493,6 +520,8 @@ public function testRequestResumeAfterCloseWillNotBeForwarded() $this->connection->expects($this->once())->method('pause'); $this->connection->expects($this->never())->method('resume'); + + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -503,12 +532,13 @@ public function testRequestEventWithoutBodyWillNotEmitData() { $never = $this->expectCallableNever(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($never) { + $server = new Server(function (ServerRequestInterface $request) use ($never) { $request->getBody()->on('data', $never); return new Response(); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -519,12 +549,13 @@ public function testRequestEventWithSecondDataEventWillEmitBodyData() { $once = $this->expectCallableOnceWith('incomplete'); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($once) { + $server = new Server(function (ServerRequestInterface $request) use ($once) { $request->getBody()->on('data', $once); return new Response(); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = ''; @@ -540,12 +571,13 @@ public function testRequestEventWithPartialBodyWillEmitData() { $once = $this->expectCallableOnceWith('incomplete'); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($once) { + $server = new Server(function (ServerRequestInterface $request) use ($once) { $request->getBody()->on('data', $once); return new Response(); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = ''; @@ -562,7 +594,7 @@ public function testRequestEventWithPartialBodyWillEmitData() public function testResponseContainsPoweredByHeader() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { return new Response(); }); @@ -579,6 +611,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -591,7 +624,7 @@ public function testPendingPromiseWillNotSendAnything() { $never = $this->expectCallableNever(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($never) { + $server = new Server(function (ServerRequestInterface $request) use ($never) { return new Promise(function () { }, $never); }); @@ -608,6 +641,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -620,7 +654,7 @@ public function testPendingPromiseWillBeCancelledIfConnectionCloses() { $once = $this->expectCallableOnce(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($once) { + $server = new Server(function (ServerRequestInterface $request) use ($once) { return new Promise(function () { }, $once); }); @@ -637,6 +671,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -651,7 +686,7 @@ public function testStreamAlreadyClosedWillSendEmptyBodyChunkedEncoded() $stream = new ThroughStream(); $stream->close(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($stream) { + $server = new Server(function (ServerRequestInterface $request) use ($stream) { return new Response(200, array(), $stream); }); @@ -668,6 +703,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; @@ -681,7 +717,7 @@ public function testResponseStreamEndingWillSendEmptyBodyChunkedEncoded() { $stream = new ThroughStream(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($stream) { + $server = new Server(function (ServerRequestInterface $request) use ($stream) { return new Response(200, array(), $stream); }); @@ -698,6 +734,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; @@ -714,7 +751,7 @@ public function testResponseStreamAlreadyClosedWillSendEmptyBodyPlainHttp10() $stream = new ThroughStream(); $stream->close(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($stream) { + $server = new Server(function (ServerRequestInterface $request) use ($stream) { return new Response(200, array(), $stream); }); @@ -731,6 +768,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.0\r\nHost: localhost\r\n\r\n"; @@ -745,7 +783,7 @@ public function testResponseStreamWillBeClosedIfConnectionIsAlreadyClosed() $stream = new ThroughStream(); $stream->on('close', $this->expectCallableOnce()); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($stream) { + $server = new Server(function (ServerRequestInterface $request) use ($stream) { return new Response(200, array(), $stream); }); @@ -784,6 +822,7 @@ function ($data) use (&$buffer) { $this->connection->expects($this->never())->method('write'); $this->connection->expects($this->never())->method('write'); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -795,10 +834,11 @@ public function testResponseStreamWillBeClosedIfConnectionEmitsCloseEvent() $stream = new ThroughStream(); $stream->on('close', $this->expectCallableOnce()); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($stream) { + $server = new Server(function (ServerRequestInterface $request) use ($stream) { return new Response(200, array(), $stream); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -808,7 +848,7 @@ public function testResponseStreamWillBeClosedIfConnectionEmitsCloseEvent() public function testUpgradeInResponseCanBeUsedToAdvertisePossibleUpgrade() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { return new Response(200, array('date' => '', 'x-powered-by' => '', 'Upgrade' => 'demo'), 'foo'); }); @@ -825,6 +865,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n\r\n"; @@ -835,7 +876,7 @@ function ($data) use (&$buffer) { public function testUpgradeWishInRequestCanBeIgnoredByReturningNormalResponse() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { return new Response(200, array('date' => '', 'x-powered-by' => ''), 'foo'); }); @@ -852,6 +893,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\nUpgrade: demo\r\n\r\n"; @@ -862,7 +904,7 @@ function ($data) use (&$buffer) { public function testUpgradeSwitchingProtocolIncludesConnectionUpgradeHeaderWithoutContentLength() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { return new Response(101, array('date' => '', 'x-powered-by' => '', 'Upgrade' => 'demo'), 'foo'); }); @@ -881,6 +923,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\nUpgrade: demo\r\n\r\n"; @@ -893,7 +936,7 @@ public function testUpgradeSwitchingProtocolWithStreamWillPipeDataToConnection() { $stream = new ThroughStream(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($stream) { + $server = new Server(function (ServerRequestInterface $request) use ($stream) { return new Response(101, array('date' => '', 'x-powered-by' => '', 'Upgrade' => 'demo'), $stream); }); @@ -910,6 +953,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\nUpgrade: demo\r\n\r\n"; @@ -925,7 +969,7 @@ public function testConnectResponseStreamWillPipeDataToConnection() { $stream = new ThroughStream(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($stream) { + $server = new Server(function (ServerRequestInterface $request) use ($stream) { return new Response(200, array(), $stream); }); @@ -942,6 +986,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n"; @@ -958,10 +1003,11 @@ public function testConnectResponseStreamWillPipeDataFromConnection() { $stream = new ThroughStream(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($stream) { + $server = new Server(function (ServerRequestInterface $request) use ($stream) { return new Response(200, array(), $stream); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $this->connection->expects($this->once())->method('pipe')->with($stream); @@ -972,7 +1018,7 @@ public function testConnectResponseStreamWillPipeDataFromConnection() public function testResponseContainsSameRequestProtocolVersionAndChunkedBodyForHttp11() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { $response = new Response(200, array(), 'bye'); return \React\Promise\resolve($response); }); @@ -990,6 +1036,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; @@ -1001,7 +1048,7 @@ function ($data) use (&$buffer) { public function testResponseContainsSameRequestProtocolVersionAndRawBodyForHttp10() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { $response = new Response(200, array(), 'bye'); return \React\Promise\resolve($response); }); @@ -1019,6 +1066,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.0\r\n\r\n"; @@ -1031,7 +1079,7 @@ function ($data) use (&$buffer) { public function testResponseContainsNoResponseBodyForHeadRequest() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { return new Response(200, array(), 'bye'); }); @@ -1047,6 +1095,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "HEAD / HTTP/1.1\r\nHost: localhost\r\n\r\n"; @@ -1058,7 +1107,7 @@ function ($data) use (&$buffer) { public function testResponseContainsNoResponseBodyAndNoContentLengthForNoContentStatus() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { return new Response(204, array(), 'bye'); }); @@ -1074,6 +1123,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; @@ -1086,7 +1136,7 @@ function ($data) use (&$buffer) { public function testResponseContainsNoResponseBodyForNotModifiedStatus() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { return new Response(304, array(), 'bye'); }); @@ -1102,6 +1152,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; @@ -1115,7 +1166,7 @@ function ($data) use (&$buffer) { public function testRequestInvalidHttpProtocolVersionWillEmitErrorAndSendErrorResponse() { $error = null; - $server = new Server($this->socket, $this->expectCallableNever()); + $server = new Server($this->expectCallableNever()); $server->on('error', function ($message) use (&$error) { $error = $message; }); @@ -1133,6 +1184,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.2\r\nHost: localhost\r\n\r\n"; @@ -1148,7 +1200,7 @@ function ($data) use (&$buffer) { public function testRequestOverflowWillEmitErrorAndSendErrorResponse() { $error = null; - $server = new Server($this->socket, $this->expectCallableNever()); + $server = new Server($this->expectCallableNever()); $server->on('error', function ($message) use (&$error) { $error = $message; }); @@ -1166,6 +1218,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nX-DATA: "; @@ -1181,7 +1234,7 @@ function ($data) use (&$buffer) { public function testRequestInvalidWillEmitErrorAndSendErrorResponse() { $error = null; - $server = new Server($this->socket, $this->expectCallableNever()); + $server = new Server($this->expectCallableNever()); $server->on('error', function ($message) use (&$error) { $error = $message; }); @@ -1199,6 +1252,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "bad request\r\n\r\n"; @@ -1217,7 +1271,7 @@ public function testBodyDataWillBeSendViaRequestEvent() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -1226,6 +1280,7 @@ public function testBodyDataWillBeSendViaRequestEvent() return \React\Promise\resolve(new Response()); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1246,7 +1301,7 @@ public function testChunkedEncodedRequestWillBeParsedForRequestEvent() $errorEvent = $this->expectCallableNever(); $requestValidation = null; - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { + $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -1256,6 +1311,7 @@ public function testChunkedEncodedRequestWillBeParsedForRequestEvent() return \React\Promise\resolve(new Response()); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1278,7 +1334,7 @@ public function testChunkedEncodedRequestAdditionalDataWontBeEmitted() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -1287,7 +1343,7 @@ public function testChunkedEncodedRequestAdditionalDataWontBeEmitted() return \React\Promise\resolve(new Response()); }); - + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1309,7 +1365,7 @@ public function testEmptyChunkedEncodedRequest() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -1318,6 +1374,7 @@ public function testEmptyChunkedEncodedRequest() return \React\Promise\resolve(new Response()); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1337,7 +1394,7 @@ public function testChunkedIsUpperCase() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -1346,7 +1403,7 @@ public function testChunkedIsUpperCase() return \React\Promise\resolve(new Response()); }); - + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1367,7 +1424,7 @@ public function testChunkedIsMixedUpperAndLowerCase() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -1376,7 +1433,7 @@ public function testChunkedIsMixedUpperAndLowerCase() return \React\Promise\resolve(new Response()); }); - + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1392,7 +1449,7 @@ public function testChunkedIsMixedUpperAndLowerCase() public function testRequestWithMalformedHostWillEmitErrorAndSendErrorResponse() { $error = null; - $server = new Server($this->socket, $this->expectCallableNever()); + $server = new Server($this->expectCallableNever()); $server->on('error', function ($message) use (&$error) { $error = $message; }); @@ -1410,6 +1467,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\nHost: ///\r\n\r\n"; @@ -1424,7 +1482,7 @@ function ($data) use (&$buffer) { public function testRequestWithInvalidHostUriComponentsWillEmitErrorAndSendErrorResponse() { $error = null; - $server = new Server($this->socket, $this->expectCallableNever()); + $server = new Server($this->expectCallableNever()); $server->on('error', function ($message) use (&$error) { $error = $message; }); @@ -1442,6 +1500,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\nHost: localhost:80/test\r\n\r\n"; @@ -1460,7 +1519,7 @@ public function testWontEmitFurtherDataWhenContentLengthIsReached() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -1469,6 +1528,7 @@ public function testWontEmitFurtherDataWhenContentLengthIsReached() return \React\Promise\resolve(new Response()); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1490,7 +1550,7 @@ public function testWontEmitFurtherDataWhenContentLengthIsReachedSplitted() $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -1499,7 +1559,7 @@ public function testWontEmitFurtherDataWhenContentLengthIsReachedSplitted() return \React\Promise\resolve(new Response()); }); - + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1524,7 +1584,7 @@ public function testContentLengthContainsZeroWillEmitEndEvent() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -1533,6 +1593,7 @@ public function testContentLengthContainsZeroWillEmitEndEvent() return \React\Promise\resolve(new Response()); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1551,7 +1612,7 @@ public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillB $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -1560,6 +1621,7 @@ public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillB return \React\Promise\resolve(new Response()); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1579,7 +1641,7 @@ public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillB $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -1588,6 +1650,7 @@ public function testContentLengthContainsZeroWillEmitEndEventAdditionalDataWillB return \React\Promise\resolve(new Response()); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1611,7 +1674,7 @@ public function testContentLengthWillBeIgnoredIfTransferEncodingIsSet() $errorEvent = $this->expectCallableNever(); $requestValidation = null; - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { + $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -1621,6 +1684,7 @@ public function testContentLengthWillBeIgnoredIfTransferEncodingIsSet() return \React\Promise\resolve(new Response()); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1649,7 +1713,7 @@ public function testInvalidContentLengthWillBeIgnoreddIfTransferEncodingIsSet() $errorEvent = $this->expectCallableNever(); $requestValidation = null; - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { + $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent, &$requestValidation) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -1659,6 +1723,7 @@ public function testInvalidContentLengthWillBeIgnoreddIfTransferEncodingIsSet() return \React\Promise\resolve(new Response()); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1683,7 +1748,7 @@ public function testInvalidContentLengthWillBeIgnoreddIfTransferEncodingIsSet() public function testNonIntegerContentLengthValueWillLeadToError() { $error = null; - $server = new Server($this->socket, $this->expectCallableNever()); + $server = new Server($this->expectCallableNever()); $server->on('error', function ($message) use (&$error) { $error = $message; }); @@ -1700,6 +1765,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1719,7 +1785,7 @@ function ($data) use (&$buffer) { public function testNonIntegerContentLengthValueWillLeadToErrorWithNoBodyForHeadRequest() { $error = null; - $server = new Server($this->socket, $this->expectCallableNever()); + $server = new Server($this->expectCallableNever()); $server->on('error', function ($message) use (&$error) { $error = $message; }); @@ -1736,6 +1802,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "HEAD / HTTP/1.1\r\n"; @@ -1755,7 +1822,7 @@ function ($data) use (&$buffer) { public function testMultipleIntegerInContentLengthWillLeadToError() { $error = null; - $server = new Server($this->socket, $this->expectCallableNever()); + $server = new Server($this->expectCallableNever()); $server->on('error', function ($message) use (&$error) { $error = $message; }); @@ -1772,6 +1839,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1791,7 +1859,7 @@ function ($data) use (&$buffer) { public function testInvalidChunkHeaderResultsInErrorOnRequestStream() { $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); - $server = new Server($this->socket, function ($request) use ($errorEvent){ + $server = new Server(function ($request) use ($errorEvent){ $request->getBody()->on('error', $errorEvent); return \React\Promise\resolve(new Response()); }); @@ -1799,6 +1867,7 @@ public function testInvalidChunkHeaderResultsInErrorOnRequestStream() $this->connection->expects($this->never())->method('close'); $this->connection->expects($this->once())->method('pause'); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1814,7 +1883,7 @@ public function testInvalidChunkHeaderResultsInErrorOnRequestStream() public function testTooLongChunkHeaderResultsInErrorOnRequestStream() { $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); - $server = new Server($this->socket, function ($request) use ($errorEvent){ + $server = new Server(function ($request) use ($errorEvent){ $request->getBody()->on('error', $errorEvent); return \React\Promise\resolve(new Response()); }); @@ -1822,6 +1891,7 @@ public function testTooLongChunkHeaderResultsInErrorOnRequestStream() $this->connection->expects($this->never())->method('close'); $this->connection->expects($this->once())->method('pause'); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1839,7 +1909,7 @@ public function testTooLongChunkHeaderResultsInErrorOnRequestStream() public function testTooLongChunkBodyResultsInErrorOnRequestStream() { $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); - $server = new Server($this->socket, function ($request) use ($errorEvent){ + $server = new Server(function ($request) use ($errorEvent){ $request->getBody()->on('error', $errorEvent); return \React\Promise\resolve(new Response()); }); @@ -1847,6 +1917,7 @@ public function testTooLongChunkBodyResultsInErrorOnRequestStream() $this->connection->expects($this->never())->method('close'); $this->connection->expects($this->once())->method('pause'); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1862,7 +1933,7 @@ public function testTooLongChunkBodyResultsInErrorOnRequestStream() public function testUnexpectedEndOfConnectionWillResultsInErrorOnRequestStream() { $errorEvent = $this->expectCallableOnceWith($this->isInstanceOf('Exception')); - $server = new Server($this->socket, function ($request) use ($errorEvent){ + $server = new Server(function ($request) use ($errorEvent){ $request->getBody()->on('error', $errorEvent); return \React\Promise\resolve(new Response()); }); @@ -1870,6 +1941,7 @@ public function testUnexpectedEndOfConnectionWillResultsInErrorOnRequestStream() $this->connection->expects($this->never())->method('close'); $this->connection->expects($this->once())->method('pause'); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1885,13 +1957,14 @@ public function testUnexpectedEndOfConnectionWillResultsInErrorOnRequestStream() public function testErrorInChunkedDecoderNeverClosesConnection() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->never())->method('close'); $this->connection->expects($this->once())->method('pause'); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1906,13 +1979,14 @@ public function testErrorInChunkedDecoderNeverClosesConnection() public function testErrorInLengthLimitedStreamNeverClosesConnection() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { return \React\Promise\resolve(new Response()); }); $this->connection->expects($this->never())->method('close'); $this->connection->expects($this->once())->method('pause'); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -1928,7 +2002,7 @@ public function testErrorInLengthLimitedStreamNeverClosesConnection() public function testCloseRequestWillPauseConnection() { - $server = new Server($this->socket, function ($request) { + $server = new Server(function ($request) { $request->getBody()->close(); return \React\Promise\resolve(new Response()); }); @@ -1936,6 +2010,7 @@ public function testCloseRequestWillPauseConnection() $this->connection->expects($this->never())->method('close'); $this->connection->expects($this->once())->method('pause'); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -1949,7 +2024,7 @@ public function testEndEventWillBeEmittedOnSimpleRequest() $endEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function ($request) use ($dataEvent, $closeEvent, $endEvent, $errorEvent){ + $server = new Server(function ($request) use ($dataEvent, $closeEvent, $endEvent, $errorEvent){ $request->getBody()->on('data', $dataEvent); $request->getBody()->on('close', $closeEvent); $request->getBody()->on('end', $endEvent); @@ -1961,6 +2036,7 @@ public function testEndEventWillBeEmittedOnSimpleRequest() $this->connection->expects($this->once())->method('pause'); $this->connection->expects($this->never())->method('close'); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -1975,7 +2051,7 @@ public function testRequestWithoutDefinedLengthWillIgnoreDataEvent() $closeEvent = $this->expectCallableOnce(); $errorEvent = $this->expectCallableNever(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { + $server = new Server(function (ServerRequestInterface $request) use ($dataEvent, $endEvent, $closeEvent, $errorEvent) { $request->getBody()->on('data', $dataEvent); $request->getBody()->on('end', $endEvent); $request->getBody()->on('close', $closeEvent); @@ -1984,6 +2060,7 @@ public function testRequestWithoutDefinedLengthWillIgnoreDataEvent() return \React\Promise\resolve(new Response()); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -1995,7 +2072,7 @@ public function testRequestWithoutDefinedLengthWillIgnoreDataEvent() public function testResponseWillBeChunkDecodedByDefault() { $stream = new ThroughStream(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($stream) { + $server = new Server(function (ServerRequestInterface $request) use ($stream) { $response = new Response(200, array(), $stream); return \React\Promise\resolve($response); }); @@ -2012,6 +2089,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -2025,7 +2103,7 @@ function ($data) use (&$buffer) { public function testContentLengthWillBeRemovedForResponseStream() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { $response = new Response( 200, array( @@ -2050,6 +2128,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -2064,7 +2143,7 @@ function ($data) use (&$buffer) { public function testOnlyAllowChunkedEncoding() { $stream = new ThroughStream(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($stream) { + $server = new Server(function (ServerRequestInterface $request) use ($stream) { $response = new Response( 200, array( @@ -2088,6 +2167,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -2102,7 +2182,7 @@ function ($data) use (&$buffer) { public function testDateHeaderWillBeAddedWhenNoneIsGiven() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { return \React\Promise\resolve(new Response()); }); @@ -2118,6 +2198,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -2131,7 +2212,7 @@ function ($data) use (&$buffer) { public function testAddCustomDateHeader() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { $response = new Response(200, array("Date" => "Tue, 15 Nov 1994 08:12:31 GMT")); return \React\Promise\resolve($response); }); @@ -2148,6 +2229,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -2161,7 +2243,7 @@ function ($data) use (&$buffer) { public function testRemoveDateHeader() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { $response = new Response(200, array('Date' => '')); return \React\Promise\resolve($response); }); @@ -2178,6 +2260,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -2193,7 +2276,7 @@ public function testOnlyChunkedEncodingIsAllowedForTransferEncoding() { $error = null; - $server = new Server($this->socket, $this->expectCallableNever()); + $server = new Server($this->expectCallableNever()); $server->on('error', function ($exception) use (&$error) { $error = $exception; }); @@ -2209,6 +2292,8 @@ function ($data) use (&$buffer) { } ) ); + + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -2228,7 +2313,7 @@ public function testOnlyChunkedEncodingIsAllowedForTransferEncodingWithHttp10() { $error = null; - $server = new Server($this->socket, $this->expectCallableNever()); + $server = new Server($this->expectCallableNever()); $server->on('error', function ($exception) use (&$error) { $error = $exception; }); @@ -2244,6 +2329,8 @@ function ($data) use (&$buffer) { } ) ); + + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.0\r\n"; @@ -2259,7 +2346,7 @@ function ($data) use (&$buffer) { public function test100ContinueRequestWillBeHandled() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { return \React\Promise\resolve(new Response()); }); @@ -2275,6 +2362,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -2290,7 +2378,7 @@ function ($data) use (&$buffer) { public function testContinueWontBeSendForHttp10() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { return \React\Promise\resolve(new Response()); }); @@ -2306,6 +2394,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.0\r\n"; @@ -2319,7 +2408,7 @@ function ($data) use (&$buffer) { public function testContinueWithLaterResponse() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { return \React\Promise\resolve(new Response()); }); @@ -2336,6 +2425,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -2355,14 +2445,14 @@ function ($data) use (&$buffer) { */ public function testInvalidCallbackFunctionLeadsToException() { - $server = new Server($this->socket, 'invalid'); + $server = new Server('invalid'); } public function testHttpBodyStreamAsBodyWillStreamData() { $input = new ThroughStream(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($input) { + $server = new Server(function (ServerRequestInterface $request) use ($input) { $response = new Response(200, array(), $input); return \React\Promise\resolve($response); }); @@ -2379,6 +2469,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -2397,7 +2488,7 @@ public function testHttpBodyStreamWithContentLengthWillStreamTillLength() { $input = new ThroughStream(); - $server = new Server($this->socket, function (ServerRequestInterface $request) use ($input) { + $server = new Server(function (ServerRequestInterface $request) use ($input) { $response = new Response(200, array('Content-Length' => 5), $input); return \React\Promise\resolve($response); }); @@ -2414,6 +2505,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -2431,7 +2523,7 @@ function ($data) use (&$buffer) { public function testCallbackFunctionReturnsPromise() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { return \React\Promise\resolve(new Response()); }); @@ -2447,6 +2539,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -2458,7 +2551,7 @@ function ($data) use (&$buffer) { public function testReturnInvalidTypeWillResultInError() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { return "invalid"; }); @@ -2479,6 +2572,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.0\r\n\r\n"; @@ -2493,7 +2587,7 @@ function ($data) use (&$buffer) { public function testResolveWrongTypeInPromiseWillResultInError() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { return \React\Promise\resolve("invalid"); }); @@ -2509,6 +2603,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.0\r\n\r\n"; @@ -2522,7 +2617,7 @@ function ($data) use (&$buffer) { public function testRejectedPromiseWillResultInErrorMessage() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { return new Promise(function ($resolve, $reject) { $reject(new \Exception()); }); @@ -2541,6 +2636,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.0\r\n\r\n"; @@ -2554,7 +2650,7 @@ function ($data) use (&$buffer) { public function testExcpetionInCallbackWillResultInErrorMessage() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { return new Promise(function ($resolve, $reject) { throw new \Exception('Bad call'); }); @@ -2573,6 +2669,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.0\r\n\r\n"; @@ -2586,7 +2683,7 @@ function ($data) use (&$buffer) { public function testHeaderWillAlwaysBeContentLengthForStringBody() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { return new Response(200, array('Transfer-Encoding' => 'chunked'), 'hello'); }); @@ -2602,6 +2699,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.0\r\n\r\n"; @@ -2619,7 +2717,7 @@ function ($data) use (&$buffer) { public function testReturnRequestWillBeHandled() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { return new Response(); }); @@ -2635,6 +2733,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.0\r\n\r\n"; @@ -2648,7 +2747,7 @@ function ($data) use (&$buffer) { public function testExceptionThrowInCallBackFunctionWillResultInErrorMessage() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { throw new \Exception('hello'); }); @@ -2669,6 +2768,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.0\r\n\r\n"; @@ -2684,7 +2784,7 @@ function ($data) use (&$buffer) { public function testRejectOfNonExceptionWillResultInErrorMessage() { - $server = new Server($this->socket, function (ServerRequestInterface $request) { + $server = new Server(function (ServerRequestInterface $request) { return new Promise(function ($resolve, $reject) { $reject('Invalid type'); }); @@ -2707,6 +2807,7 @@ function ($data) use (&$buffer) { ) ); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.0\r\n\r\n"; @@ -2722,7 +2823,7 @@ function ($data) use (&$buffer) { public function testServerRequestParams() { $requestValidation = null; - $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestValidation) { + $server = new Server(function (ServerRequestInterface $request) use (&$requestValidation) { $requestValidation = $request; return new Response(); }); @@ -2737,6 +2838,7 @@ public function testServerRequestParams() ->method('getLocalAddress') ->willReturn('127.0.0.1:8080'); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = $this->createGetRequest(); @@ -2756,11 +2858,12 @@ public function testServerRequestParams() public function testQueryParametersWillBeAddedToRequest() { $requestValidation = null; - $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestValidation) { + $server = new Server(function (ServerRequestInterface $request) use (&$requestValidation) { $requestValidation = $request; return new Response(); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET /foo.php?hello=world&test=bar HTTP/1.0\r\n\r\n"; @@ -2776,11 +2879,12 @@ public function testQueryParametersWillBeAddedToRequest() public function testCookieWillBeAddedToServerRequest() { $requestValidation = null; - $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestValidation) { + $server = new Server(function (ServerRequestInterface $request) use (&$requestValidation) { $requestValidation = $request; return new Response(); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -2797,11 +2901,12 @@ public function testCookieWillBeAddedToServerRequest() public function testMultipleCookiesWontBeAddedToServerRequest() { $requestValidation = null; - $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestValidation) { + $server = new Server(function (ServerRequestInterface $request) use (&$requestValidation) { $requestValidation = $request; return new Response(); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; @@ -2818,11 +2923,12 @@ public function testMultipleCookiesWontBeAddedToServerRequest() public function testCookieWithSeparatorWillBeAddedToServerRequest() { $requestValidation = null; - $server = new Server($this->socket, function (ServerRequestInterface $request) use (&$requestValidation) { + $server = new Server(function (ServerRequestInterface $request) use (&$requestValidation) { $requestValidation = $request; return new Response(); }); + $server->listen($this->socket); $this->socket->emit('connection', array($this->connection)); $data = "GET / HTTP/1.1\r\n"; From f4bc56427ebda47e23822180206dcf551c0d9f50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Mon, 29 May 2017 17:41:07 +0200 Subject: [PATCH 127/128] Prepare v0.7.0 release --- CHANGELOG.md | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++- README.md | 2 +- 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3575457..1a9f09f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,134 @@ # Changelog -## 0.6.0 (2016-03-09) +## 0.7.0 (2017-05-29) + +* Feature / BC break: Use PSR-7 (http-message) standard and + `Request-In-Response-Out`-style request handler callback. + Pass standard PSR-7 `ServerRequestInterface` and expect any standard + PSR-7 `ResponseInterface` in return for the request handler callback. + (#146 and #152 and #170 by @legionth) + + ```php + // old + $app = function (Request $request, Response $response) { + $response->writeHead(200, array('Content-Type' => 'text/plain')); + $response->end("Hello world!\n"); + }; + + // new + $app = function (ServerRequestInterface $request) { + return new Response( + 200, + array('Content-Type' => 'text/plain'), + "Hello world!\n" + ); + }; + ``` + + A `Content-Length` header will automatically be included if the size can be + determined from the response body. + (#164 by @maciejmrozinski) + + The request handler callback will automatically make sure that responses to + HEAD requests and certain status codes, such as `204` (No Content), never + contain a response body. + (#156 by @clue) + + The intermediary `100 Continue` response will automatically be sent if + demanded by a HTTP/1.1 client. + (#144 by @legionth) + + The request handler callback can now return a standard `Promise` if + processing the request needs some time, such as when querying a database. + Similarly, the request handler may return a streaming response if the + response body comes from a `ReadableStreamInterface` or its size is + unknown in advance. + + ```php + // old + $app = function (Request $request, Response $response) use ($db) { + $db->query()->then(function ($result) use ($response) { + $response->writeHead(200, array('Content-Type' => 'text/plain')); + $response->end($result); + }); + }; + + // new + $app = function (ServerRequestInterface $request) use ($db) { + return $db->query()->then(function ($result) { + return new Response( + 200, + array('Content-Type' => 'text/plain'), + $result + ); + }); + }; + ``` + + Pending promies and response streams will automatically be canceled once the + client connection closes. + (#187 and #188 by @clue) + + The `ServerRequestInterface` contains the full effective request URI, + server-side parameters, query parameters and parsed cookies values as + defined in PSR-7. + (#167 by @clue and #174, #175 and #180 by @legionth) + + ```php + $app = function (ServerRequestInterface $request) { + return new Response( + 200, + array('Content-Type' => 'text/plain'), + $request->getUri()->getScheme() + ); + }; + ``` + + Advanced: Support duplex stream response for `Upgrade` requests such as + `Upgrade: WebSocket` or custom protocols and `CONNECT` requests + (#189 and #190 by @clue) + + > Note that the request body will currently not be buffered and parsed by + default, which depending on your particilar use-case, may limit + interoperability with the PSR-7 (http-message) ecosystem. + The provided streaming request body interfaces allow you to perform + buffering and parsing as needed in the request handler callback. + See also the README and examples for more details. + +* Feature / BC break: Replace `request` listener with callback function and + use `listen()` method to support multiple listening sockets + (#97 by @legionth and #193 by @clue) + + ```php + // old + $server = new Server($socket); + $server->on('request', $app); + + // new + $server = new Server($app); + $server->listen($socket); + ``` + +* Feature: Support the more advanced HTTP requests, such as + `OPTIONS * HTTP/1.1` (`OPTIONS` method in asterisk-form), + `GET http://example.com/path HTTP/1.1` (plain proxy requests in absolute-form), + `CONNECT example.com:443 HTTP/1.1` (`CONNECT` proxy requests in authority-form) + and sanitize `Host` header value across all requests. + (#157, #158, #161, #165, #169 and #173 by @clue) + +* Feature: Forward compatibility with Socket v1.0, v0.8, v0.7 and v0.6 and + forward compatibility with Stream v1.0 and v0.7 + (#154, #163, #183, #184 and #191 by @clue) + +* Feature: Simplify examples to ease getting started and + add benchmarking example + (#151 and #162 by @clue) + +* Improve test suite by adding tests for case insensitive chunked transfer + encoding and ignoring HHVM test failures until Travis tests work again. + (#150 by @legionth and #185 by @clue) + +## 0.6.0 (2017-03-09) * Feature / BC break: The `Request` and `Response` objects now follow strict stream semantics and their respective methods and events. diff --git a/README.md b/README.md index 26cb2998..f09371d8 100644 --- a/README.md +++ b/README.md @@ -659,7 +659,7 @@ The recommended way to install this library is [through Composer](http://getcomp This will install the latest supported version: ```bash -$ composer require react/http:^0.6 +$ composer require react/http:^0.7 ``` More details about version upgrades can be found in the [CHANGELOG](CHANGELOG.md). From 9ab8d8497cf593d96dcecca49f7eba4b74f8b30c Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Wed, 31 May 2017 07:37:39 +0200 Subject: [PATCH 128/128] Converted MultipartParser to use PSR-7 request with a HttpBodyStream --- src/StreamingBodyParser/MultipartParser.php | 48 ++++++--- .../MultipartParserTest.php | 98 ++++++++++--------- 2 files changed, 83 insertions(+), 63 deletions(-) diff --git a/src/StreamingBodyParser/MultipartParser.php b/src/StreamingBodyParser/MultipartParser.php index 2beb8874..1080428e 100644 --- a/src/StreamingBodyParser/MultipartParser.php +++ b/src/StreamingBodyParser/MultipartParser.php @@ -3,8 +3,8 @@ namespace React\Http\StreamingBodyParser; use Evenement\EventEmitterTrait; -use React\Http\File; -use React\Http\Request; +use Psr\Http\Message\RequestInterface; +use React\Http\HttpBodyStream; use React\Promise\CancellablePromiseInterface; use React\Promise\Deferred; use React\Stream\ThroughStream; @@ -34,10 +34,15 @@ class MultipartParser implements ParserInterface protected $boundary; /** - * @var Request + * @var RequestInterface */ protected $request; + /** + * @var HttpBodyStream + */ + protected $body; + /** * @var CancellablePromiseInterface */ @@ -49,31 +54,42 @@ class MultipartParser implements ParserInterface protected $onDataCallable; /** - * @param Request $request + * @param RequestInterface $request * @return ParserInterface */ - public static function create(Request $request) + public static function create(RequestInterface $request) { return new static($request); } - private function __construct(Request $request) + private function __construct(RequestInterface $request) { + $this->onDataCallable = [$this, 'onData']; $this->promise = (new Deferred(function () { - $this->request->removeListener('data', $this->onDataCallable); - $this->request->close(); + $this->body->removeListener('data', $this->onDataCallable); + $this->body->close(); }))->promise(); $this->request = $request; - $headers = $this->request->getHeaders(); - $headers = array_change_key_case($headers, CASE_LOWER); - preg_match('/boundary="?(.*)"?$/', $headers['content-type'], $matches); + $this->body = $this->request->getBody(); + + $dataMethod = $this->determineOnDataMethod(); + $this->setOnDataListener([$this, $dataMethod]); + } - $dataMethod = 'findBoundary'; + protected function determineOnDataMethod() + { + if (!$this->request->hasHeader('content-type')) { + return 'findBoundary'; + } + + $contentType = $this->request->getHeaderLine('content-type'); + preg_match('/boundary="?(.*)"?$/', $contentType, $matches); if (isset($matches[1])) { $this->setBoundary($matches[1]); - $dataMethod = 'onData'; + return 'onData'; } - $this->setOnDataListener([$this, $dataMethod]); + + return 'findBoundary'; } protected function setBoundary($boundary) @@ -279,9 +295,9 @@ protected function getFieldFromHeader(array $header, $field) protected function setOnDataListener(callable $callable) { - $this->request->removeListener('data', $this->onDataCallable); + $this->body->removeListener('data', $this->onDataCallable); $this->onDataCallable = $callable; - $this->request->on('data', $this->onDataCallable); + $this->body->on('data', $this->onDataCallable); } public function cancel() diff --git a/tests/StreamingBodyParser/MultipartParserTest.php b/tests/StreamingBodyParser/MultipartParserTest.php index 27863388..6d8b7779 100644 --- a/tests/StreamingBodyParser/MultipartParserTest.php +++ b/tests/StreamingBodyParser/MultipartParserTest.php @@ -2,10 +2,12 @@ namespace React\Tests\Http\StreamingBodyParser; -use React\Http\FileInterface; +use Psr\Http\Message\UploadedFileInterface; +use React\Http\HttpBodyStream; use React\Http\StreamingBodyParser\MultipartParser; -use React\Http\Request; +use React\Stream\ThroughStream; use React\Tests\Http\TestCase; +use RingCentral\Psr7\Request; class MultipartParserTest extends TestCase { @@ -15,17 +17,18 @@ public function testPostKey() $files = []; $post = []; + $stream = new ThroughStream(); $boundary = "---------------------------5844729766471062541057622570"; - $request = new Request('POST', 'http://example.com/', [], 1.1, [ + $request = new Request('POST', 'http://example.com/', array( 'Content-Type' => 'multipart/mixed; boundary=' . $boundary, - ]); + ), new HttpBodyStream($stream, 0), 1.1); - $parser = new MultipartParser($request); + $parser = MultipartParser::create($request); $parser->on('post', function ($key, $value) use (&$post) { $post[$key] = $value; }); - $parser->on('file', function ($name, FileInterface $file) use (&$files) { + $parser->on('file', function ($name, UploadedFileInterface $file) use (&$files) { $files[] = [$name, $file]; }); @@ -39,7 +42,7 @@ public function testPostKey() $data .= "second\r\n"; $data .= "--$boundary--\r\n"; - $request->emit('data', [$data]); + $stream->write($data); $this->assertEmpty($files); $this->assertEquals( @@ -56,18 +59,19 @@ public function testFileUpload() $files = []; $post = []; + $stream = new ThroughStream(); $boundary = "---------------------------12758086162038677464950549563"; - $request = new Request('POST', 'http://example.com/', [], 1.1, [ + $request = new Request('POST', 'http://example.com/', array( 'Content-Type' => 'multipart/form-data', - ]); + ), new HttpBodyStream($stream, 0), 1.1); - $multipart = new MultipartParser($request); + $multipart = MultipartParser::create($request); $multipart->on('post', function ($key, $value) use (&$post) { $post[] = [$key => $value]; }); - $multipart->on('file', function ($name, FileInterface $file, $headers) use (&$files) { + $multipart->on('file', function ($name, /*UploadedFileInterface*/ $file, $headers) use (&$files) { $files[] = [$name, $file, $headers]; }); @@ -81,46 +85,46 @@ public function testFileUpload() $data .= "Content-Disposition: form-data; name=\"users[two]\"\r\n"; $data .= "\r\n"; $data .= "second\r\n"; - $request->emit('data', [$data]); - $request->emit('data', ["--$boundary\r\n"]); - $request->emit('data', ["Content-disposition: form-data; name=\"user\"\r\n"]); - $request->emit('data', ["\r\n"]); - $request->emit('data', ["single\r\n"]); - $request->emit('data', ["--$boundary\r\n"]); - $request->emit('data', ["content-Disposition: form-data; name=\"user2\"\r\n"]); - $request->emit('data', ["\r\n"]); - $request->emit('data', ["second\r\n"]); - $request->emit('data', ["--$boundary\r\n"]); - $request->emit('data', ["Content-Disposition: form-data; name=\"users[]\"\r\n"]); - $request->emit('data', ["\r\n"]); - $request->emit('data', ["first in array\r\n"]); - $request->emit('data', ["--$boundary\r\n"]); - $request->emit('data', ["Content-Disposition: form-data; name=\"users[]\"\r\n"]); - $request->emit('data', ["\r\n"]); - $request->emit('data', ["second in array\r\n"]); - $request->emit('data', ["--$boundary\r\n"]); - $request->emit('data', ["Content-Disposition: form-data; name=\"file\"; filename=\"Us er.php\"\r\n"]); - $request->emit('data', ["Content-type: text/php\r\n"]); - $request->emit('data', ["\r\n"]); - $request->emit('data', ["emit('data', ["\r\n"]); + $stream->write($data); + $stream->write("--$boundary\r\n"); + $stream->write("Content-disposition: form-data; name=\"user\"\r\n"); + $stream->write("\r\n"); + $stream->write("single\r\n"); + $stream->write("--$boundary\r\n"); + $stream->write("content-Disposition: form-data; name=\"user2\"\r\n"); + $stream->write("\r\n"); + $stream->write("second\r\n"); + $stream->write("--$boundary\r\n"); + $stream->write("Content-Disposition: form-data; name=\"users[]\"\r\n"); + $stream->write("\r\n"); + $stream->write("first in array\r\n"); + $stream->write("--$boundary\r\n"); + $stream->write("Content-Disposition: form-data; name=\"users[]\"\r\n"); + $stream->write("\r\n"); + $stream->write("second in array\r\n"); + $stream->write("--$boundary\r\n"); + $stream->write("Content-Disposition: form-data; name=\"file\"; filename=\"Us er.php\"\r\n"); + $stream->write("Content-type: text/php\r\n"); + $stream->write("\r\n"); + $stream->write("write("\r\n"); $line = "--$boundary"; $lines = str_split($line, round(strlen($line) / 2)); - $request->emit('data', [$lines[0]]); - $request->emit('data', [$lines[1]]); - $request->emit('data', ["\r\n"]); - $request->emit('data', ["Content-Disposition: form-data; name=\"files[]\"; filename=\"blank.gif\"\r\n"]); - $request->emit('data', ["content-Type: image/gif\r\n"]); - $request->emit('data', ["X-Foo-Bar: base64\r\n"]); - $request->emit('data', ["\r\n"]); - $request->emit('data', [$file . "\r\n"]); - $request->emit('data', ["--$boundary\r\n"]); - $request->emit('data', ["Content-Disposition: form-data; name=\"files[]\"; filename=\"User.php\"\r\n" . + $stream->write($lines[0]); + $stream->write($lines[1]); + $stream->write("\r\n"); + $stream->write("Content-Disposition: form-data; name=\"files[]\"; filename=\"blank.gif\"\r\n"); + $stream->write("content-Type: image/gif\r\n"); + $stream->write("X-Foo-Bar: base64\r\n"); + $stream->write("\r\n"); + $stream->write($file . "\r\n"); + $stream->write("--$boundary\r\n"); + $stream->write("Content-Disposition: form-data; name=\"files[]\"; filename=\"User.php\"\r\n" . "Content-Type: text/php\r\n" . "\r\n" . - "emit('data', ["\r\n"]); - $request->emit('data', ["--$boundary--\r\n"]); + "write("\r\n"); + $stream->write("--$boundary--\r\n"); $this->assertEquals(6, count($post)); $this->assertEquals(