diff --git a/README.md b/README.md index 88549055..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) { @@ -178,6 +180,13 @@ 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()`](#writehead) for more details. + See the above usage example and the class outline for details. #### writeContinue() @@ -210,12 +219,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. +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 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() @@ -234,7 +246,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 91ae4358..952ae0d6 100644 --- a/src/Response.php +++ b/src/Response.php @@ -14,6 +14,13 @@ * 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 @@ -21,11 +28,13 @@ */ class Response extends EventEmitter implements WritableStreamInterface { + private $conn; + private $protocolVersion; + private $closed = false; private $writable = true; - private $conn; private $headWritten = false; - private $chunkedEncoding = true; + private $chunkedEncoding = false; /** * The constructor is internal, you SHOULD NOT call this yourself. @@ -36,9 +45,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(); @@ -87,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 @@ -100,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.'); } @@ -122,7 +139,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: @@ -167,11 +184,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( @@ -180,8 +192,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]); @@ -189,6 +201,7 @@ public function writeHead($status = 200, array $headers = array()) } $headers['Transfer-Encoding'] = 'chunked'; + $this->chunkedEncoding = true; } $data = $this->formatHead($status, $headers); @@ -201,7 +214,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 7ff523c1..cdc017f7 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,7 +109,13 @@ public function handleConnection(ConnectionInterface $conn) /** @internal */ public function handleRequest(ConnectionInterface $conn, Request $request) { - $response = new Response($conn); + // 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')); if (!$this->listeners('request')) { diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 27948504..6200934b 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -26,6 +26,25 @@ public function testResponseShouldBeChunkedByDefault() $response->writeHead(); } + 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(); + } + public function testResponseShouldBeChunkedEvenWithOtherTransferEncoding() { $expected = ''; @@ -46,7 +65,6 @@ public function testResponseShouldBeChunkedEvenWithOtherTransferEncoding() $response->writeHead(200, array('transfer-encoding' => 'custom')); } - public function testResponseShouldNotBeChunkedWithContentLength() { $expected = ''; @@ -221,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() { diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 305bb31a..bd0fdcf7 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -202,6 +202,98 @@ function ($data) use (&$buffer) { $this->assertContains("\r\nX-Powered-By: React/alpha\r\n", $buffer); } + public function testResponseContainsSameRequestProtocolVersionAndChunkedBodyForHttp11() + { + $server = new Server($this->socket); + $server->on('request', function (Request $request, Response $response) { + $response->writeHead(); + $response->end('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\n\r\n"; + $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 testResponseContainsSameRequestProtocolVersionAndRawBodyForHttp10() + { + $server = new Server($this->socket); + $server->on('request', function (Request $request, Response $response) { + $response->writeHead(); + $response->end('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.0\r\n\r\n"; + $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 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;