Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support HTTP/1.1 and HTTP/1.0 #125

Merged
merged 4 commits into from
Feb 19, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()

Expand All @@ -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:
Expand Down
45 changes: 29 additions & 16 deletions src/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,27 @@
* 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
*/
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.
Expand All @@ -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();
Expand Down Expand Up @@ -87,19 +98,25 @@ 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
* @see Request::expectsContinue()
*/
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.');
}
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -180,15 +192,16 @@ 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]);
}
}

$headers['Transfer-Encoding'] = 'chunked';
$this->chunkedEncoding = true;
}

$data = $this->formatHead($status, $headers);
Expand All @@ -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);
Expand Down
14 changes: 11 additions & 3 deletions src/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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')) {
Expand Down
34 changes: 33 additions & 1 deletion tests/ResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand All @@ -46,7 +65,6 @@ public function testResponseShouldBeChunkedEvenWithOtherTransferEncoding()
$response->writeHead(200, array('transfer-encoding' => 'custom'));
}


public function testResponseShouldNotBeChunkedWithContentLength()
{
$expected = '';
Expand Down Expand Up @@ -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()
{
Expand Down
92 changes: 92 additions & 0 deletions tests/ServerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down