Skip to content
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
29 changes: 23 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -377,13 +379,28 @@ $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.

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 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:

Expand Down
51 changes: 33 additions & 18 deletions src/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@
use React\Socket\ConnectionInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use RingCentral;
use React\Stream\ReadableStream;
use React\Promise\Promise;
use RingCentral\Psr7 as Psr7Implementation;

/**
* The `Server` class is responsible for handling incoming connections and then
Expand Down Expand Up @@ -176,7 +175,8 @@ public function handleRequest(ConnectionInterface $conn, RequestInterface $reque
// 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);
$request = $request->withProtocolVersion('1.1');
return $this->writeError($conn, 505, $request);
}

// HTTP/1.1 requests MUST include a valid host header (host and optional port)
Expand All @@ -193,7 +193,7 @@ public function handleRequest(ConnectionInterface $conn, RequestInterface $reque
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);
return $this->writeError($conn, 400, $request);
}
}

Expand All @@ -203,7 +203,7 @@ public function handleRequest(ConnectionInterface $conn, RequestInterface $reque

if (strtolower($request->getHeaderLine('Transfer-Encoding')) !== 'chunked') {
$this->emit('error', array(new \InvalidArgumentException('Only chunked-encoding is allowed for Transfer-Encoding')));
return $this->writeError($conn, 501);
return $this->writeError($conn, 501, $request);
}

$stream = new ChunkedDecoder($stream);
Expand All @@ -219,7 +219,7 @@ public function handleRequest(ConnectionInterface $conn, RequestInterface $reque
if ((string)$contentLength !== (string)$string) {
// Content-Length value is not an integer or not a single integer
$this->emit('error', array(new \InvalidArgumentException('The value of `Content-Length` is not valid')));
return $this->writeError($conn, 400);
return $this->writeError($conn, 400, $request);
}

$stream = new LengthLimitedStream($stream, $contentLength);
Expand Down Expand Up @@ -251,17 +251,17 @@ function ($response) use ($that, $conn, $request) {
$exception = new \RuntimeException($message);

$that->emit('error', array($exception));
return $that->writeError($conn, 500);
return $that->writeError($conn, 500, $request);
}
$that->handleResponse($conn, $response, $request->getProtocolVersion());
$that->handleResponse($conn, $request, $response);
},
function ($error) use ($that, $conn) {
function ($error) use ($that, $conn, $request) {
$message = 'The response callback is expected to resolve with an object implementing Psr\Http\Message\ResponseInterface, but rejected with "%s" instead.';
$message = sprintf($message, is_object($error) ? get_class($error) : gettype($error));
$exception = new \RuntimeException($message, null, $error instanceof \Exception ? $error : null);

$that->emit('error', array($exception));
return $that->writeError($conn, 500);
return $that->writeError($conn, 500, $request);
}
);

Expand All @@ -274,7 +274,7 @@ function ($error) use ($that, $conn) {
}

/** @internal */
public function writeError(ConnectionInterface $conn, $code)
public function writeError(ConnectionInterface $conn, $code, RequestInterface $request = null)
{
$message = 'Error ' . $code;
if (isset(ResponseCodes::$statusTexts[$code])) {
Expand All @@ -289,14 +289,18 @@ public function writeError(ConnectionInterface $conn, $code)
$message
);

$this->handleResponse($conn, $response, '1.1');
if ($request === null) {
$request = new Psr7Implementation\Request('GET', '/', array(), null, '1.1');
}

$this->handleResponse($conn, $request, $response);
}


/** @internal */
public function handleResponse(ConnectionInterface $connection, ResponseInterface $response, $protocolVersion)
public function handleResponse(ConnectionInterface $connection, RequestInterface $request, ResponseInterface $response)
{
$response = $response->withProtocolVersion($protocolVersion);
$response = $response->withProtocolVersion($request->getProtocolVersion());

// assign default "X-Powered-By" header as first for history reasons
if (!$response->hasHeader('X-Powered-By')) {
Expand All @@ -321,24 +325,35 @@ public function handleResponse(ConnectionInterface $connection, ResponseInterfac

if (!$response->getBody() instanceof HttpBodyStream) {
$response = $response->withHeader('Content-Length', $response->getBody()->getSize());
} elseif (!$response->hasHeader('Content-Length') && $protocolVersion === '1.1') {
} elseif (!$response->hasHeader('Content-Length') && $request->getProtocolVersion() === '1.1') {
// assign chunked transfer-encoding if no 'content-length' is given for HTTP/1.1 responses
$response = $response->withHeader('Transfer-Encoding', 'chunked');
}

// HTTP/1.1 assumes persistent connection support by default
// we do not support persistent connections, so let the client know
if ($protocolVersion === '1.1') {
if ($request->getProtocolVersion() === '1.1') {
$response = $response->withHeader('Connection', 'close');
}

// response code 1xx and 204 MUST NOT include Content-Length or Transfer-Encoding header
$code = $response->getStatusCode();
if (($code >= 100 && $code < 200) || $code === 204) {
$response = $response->withoutHeader('Content-Length')->withoutHeader('Transfer-Encoding');
}

// response to HEAD and 1xx, 204 and 304 responses MUST NOT include a body
if ($request->getMethod() === 'HEAD' || ($code >= 100 && $code < 200) || $code === 204 || $code === 304) {
$response = $response->withBody(Psr7Implementation\stream_for(''));
}

$this->handleResponseBody($response, $connection);
}

private function handleResponseBody(ResponseInterface $response, ConnectionInterface $connection)
{
if (!$response->getBody() instanceof HttpBodyStream) {
$connection->write(RingCentral\Psr7\str($response));
$connection->write(Psr7Implementation\str($response));
return $connection->end();
}

Expand All @@ -349,7 +364,7 @@ private function handleResponseBody(ResponseInterface $response, ConnectionInter
$stream = new ChunkedEncoder($body);
}

$connection->write(RingCentral\Psr7\str($response));
$connection->write(Psr7Implementation\str($response));
$stream->pipe($connection);
}
}
152 changes: 152 additions & 0 deletions tests/ServerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,89 @@ 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 testResponseContainsNoResponseBodyAndNoContentLengthForNoContentStatus()
{
$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("\r\n\Content-Length: 3\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->assertContains("\r\nContent-Length: 3\r\n", $buffer);
$this->assertNotContains("bye", $buffer);
}

public function testRequestInvalidHttpProtocolVersionWillEmitErrorAndSendErrorResponse()
{
$error = null;
Expand Down Expand Up @@ -980,6 +1063,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;
Expand Down Expand Up @@ -1452,6 +1571,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) {
Expand Down