Skip to content

Commit

Permalink
Skip sending body and Content-Length for responses with no body
Browse files Browse the repository at this point in the history
  • Loading branch information
clue committed Nov 5, 2021
1 parent 4d46b01 commit a260ce7
Show file tree
Hide file tree
Showing 4 changed files with 213 additions and 7 deletions.
42 changes: 42 additions & 0 deletions examples/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,48 @@
);
});

$app->get('/etag/', function (ServerRequestInterface $request) {
$etag = '"_"';
if ($request->getHeaderLine('If-None-Match') === $etag) {
return new React\Http\Message\Response(
304,
[
'ETag' => $etag
],
''
);
}

return new React\Http\Message\Response(
200,
[
'ETag' => $etag
],
''
);
});
$app->get('/etag/{etag:[a-z]+}', function (ServerRequestInterface $request) {
$etag = '"' . $request->getAttribute('etag') . '"';
if ($request->getHeaderLine('If-None-Match') === $etag) {
return new React\Http\Message\Response(
304,
[
'ETag' => $etag,
'Content-Length' => strlen($etag) - 1
],
''
);
}

return new React\Http\Message\Response(
200,
[
'ETag' => $etag
],
$request->getAttribute('etag') . "\n"
);
});

$app->map(['GET', 'POST'], '/headers', function (ServerRequestInterface $request) {
// Returns a JSON representation of all request headers passed to this endpoint.
// Note that this assumes UTF-8 data in request headers and may break for other encodings,
Expand Down
19 changes: 14 additions & 5 deletions src/SapiHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,17 @@ public function requestFromGlobals(): ServerRequestInterface
*/
public function sendResponse(ResponseInterface $response): void
{
header($_SERVER['SERVER_PROTOCOL'] . ' ' . $response->getStatusCode() . ' ' . $response->getReasonPhrase());
$status = $response->getStatusCode();
$body = $response->getBody();

header($_SERVER['SERVER_PROTOCOL'] . ' ' . $status . ' ' . $response->getReasonPhrase());

// automatically assign "Content-Length" response header if known and not already present
if (!$response->hasHeader('Content-Length') && $response->getBody()->getSize() !== null) {
$response = $response->withHeader('Content-Length', (string)$response->getBody()->getSize());
if ($status === 204) {
// 204 MUST NOT include "Content-Length" response header
$response = $response->withoutHeader('Content-Length');
} elseif (!$response->hasHeader('Content-Length') && $body->getSize() !== null && ($status !== 304 || $body->getSize() !== 0)) {
// automatically assign "Content-Length" response header if known and not already present
$response = $response->withHeader('Content-Length', (string) $body->getSize());
}

// remove default "Content-Type" header set by PHP (default_mimetype)
Expand All @@ -105,7 +111,10 @@ public function sendResponse(ResponseInterface $response): void
}
ini_set('default_charset', $old);

$body = $response->getBody();
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'HEAD' || $status === 204 || $status === 304) {
$body->close();
return;
}

if ($body instanceof ReadableStreamInterface) {
// try to disable nginx buffering (http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering)
Expand Down
152 changes: 151 additions & 1 deletion tests/SapiHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,95 @@ public function testSendResponseSendsJsonResponseWithGivenHeadersAndBodyAndAssig
$this->assertEquals(array_merge($previous, ['Content-Type: application/json', 'Content-Length: 2']), xdebug_get_headers());
}

/**
* @backupGlobals enabled
*/
public function testSendResponseSendsJsonResponseWithGivenHeadersAndMatchingContentLengthButEmptyBodyForHeadRequest()
{
if (headers_sent() || !function_exists('xdebug_get_headers')) {
$this->markTestSkipped('Test requires running phpunit with --stderr and Xdebug enabled');
}

$_SERVER['REQUEST_METHOD'] = 'HEAD';
$_SERVER['SERVER_PROTOCOL'] = 'http/1.1';
$sapi = new SapiHandler();
$response = new Response(200, ['Content-Type' => 'application/json'], '{}');

$this->expectOutputString('');
$sapi->sendResponse($response);

$previous = ['Content-Type:'];
$this->assertEquals(array_merge($previous, ['Content-Type: application/json', 'Content-Length: 2']), xdebug_get_headers());
}

public function testSendResponseSendsEmptyBodyWithGivenHeadersAndAssignsNoContentLengthForNoContentResponse()
{
if (headers_sent() || !function_exists('xdebug_get_headers')) {
$this->markTestSkipped('Test requires running phpunit with --stderr and Xdebug enabled');
}

$_SERVER['SERVER_PROTOCOL'] = 'http/1.1';
$sapi = new SapiHandler();
$response = new Response(204, ['Content-Type' => 'application/json'], '{}');

$this->expectOutputString('');
$sapi->sendResponse($response);

$previous = ['Content-Type:', 'Content-Length: 2'];
$this->assertEquals(array_merge($previous, ['Content-Type: application/json']), xdebug_get_headers());
}

public function testSendResponseSendsEmptyBodyWithGivenHeadersButWithoutExplicitContentLengthForNoContentResponse()
{
if (headers_sent() || !function_exists('xdebug_get_headers')) {
$this->markTestSkipped('Test requires running phpunit with --stderr and Xdebug enabled');
}

$_SERVER['SERVER_PROTOCOL'] = 'http/1.1';
$sapi = new SapiHandler();
$response = new Response(204, ['Content-Type' => 'application/json', 'Content-Length' => 2], '{}');

$this->expectOutputString('');
$sapi->sendResponse($response);

$previous = ['Content-Type:', 'Content-Length: 2'];
$this->assertEquals(array_merge($previous, ['Content-Type: application/json']), xdebug_get_headers());
}

public function testSendResponseSendsEmptyBodyWithGivenHeadersAndAssignsContentLengthForNotModifiedResponse()
{
if (headers_sent() || !function_exists('xdebug_get_headers')) {
$this->markTestSkipped('Test requires running phpunit with --stderr and Xdebug enabled');
}

$_SERVER['SERVER_PROTOCOL'] = 'http/1.1';
$sapi = new SapiHandler();
$response = new Response(304, ['Content-Type' => 'application/json'], 'null');

$this->expectOutputString('');
$sapi->sendResponse($response);

$previous = ['Content-Type:'];
$this->assertEquals(array_merge($previous, ['Content-Type: application/json', 'Content-Length: 4']), xdebug_get_headers());
}

public function testSendResponseSendsEmptyBodyWithGivenHeadersAndExplicitContentLengthForNotModifiedResponse()
{
if (headers_sent() || !function_exists('xdebug_get_headers')) {
$this->markTestSkipped('Test requires running phpunit with --stderr and Xdebug enabled');
}

$_SERVER['SERVER_PROTOCOL'] = 'http/1.1';
$sapi = new SapiHandler();
$response = new Response(304, ['Content-Type' => 'application/json', 'Content-Length' => '2'], '');

$this->expectOutputString('');
$sapi->sendResponse($response);

$previous = ['Content-Type:'];
$this->assertEquals(array_merge($previous, ['Content-Type: application/json', 'Content-Length: 2']), xdebug_get_headers());
}

public function testSendResponseSendsStreamingResponseWithNoHeadersAndBodyFromStreamData()
{
if (headers_sent() || !function_exists('xdebug_get_headers')) {
Expand All @@ -190,6 +279,67 @@ public function testSendResponseSendsStreamingResponseWithNoHeadersAndBodyFromSt
$body->end('test');
}

/**
* @backupGlobals enabled
*/
public function testSendResponseClosesStreamingResponseAndSendsResponseWithNoHeadersAndBodyForHeadRequest()
{
if (headers_sent() || !function_exists('xdebug_get_headers')) {
$this->markTestSkipped('Test requires running phpunit with --stderr and Xdebug enabled');
}

$_SERVER['REQUEST_METHOD'] = 'HEAD';
$_SERVER['SERVER_PROTOCOL'] = 'http/1.1';
$sapi = new SapiHandler();
$body = new ThroughStream();
$response = new Response(200, [], $body);

$this->expectOutputString('');
$sapi->sendResponse($response);

$previous = ['Content-Type:', 'Content-Length: 2', 'Content-Type:'];
$this->assertEquals(array_merge($previous, ['Content-Type:']), xdebug_get_headers());
$this->assertFalse($body->isReadable());
}

public function testSendResponseClosesStreamingResponseAndSendsResponseWithNoHeadersAndBodyForNotModifiedResponse()
{
if (headers_sent() || !function_exists('xdebug_get_headers')) {
$this->markTestSkipped('Test requires running phpunit with --stderr and Xdebug enabled');
}

$_SERVER['SERVER_PROTOCOL'] = 'http/1.1';
$sapi = new SapiHandler();
$body = new ThroughStream();
$response = new Response(304, [], $body);

$this->expectOutputString('');
$sapi->sendResponse($response);

$previous = ['Content-Type:', 'Content-Length: 2', 'Content-Type:', 'Content-Type:'];
$this->assertEquals(array_merge($previous, ['Content-Type:']), xdebug_get_headers());
$this->assertFalse($body->isReadable());
}

public function testSendResponseClosesStreamingResponseAndSendsResponseWithNoHeadersAndBodyForNoContentResponse()
{
if (headers_sent() || !function_exists('xdebug_get_headers')) {
$this->markTestSkipped('Test requires running phpunit with --stderr and Xdebug enabled');
}

$_SERVER['SERVER_PROTOCOL'] = 'http/1.1';
$sapi = new SapiHandler();
$body = new ThroughStream();
$response = new Response(204, [], $body);

$this->expectOutputString('');
$sapi->sendResponse($response);

$previous = ['Content-Type:', 'Content-Length: 2', 'Content-Type:', 'Content-Type:', 'Content-Type:'];
$this->assertEquals(array_merge($previous, ['Content-Type:']), xdebug_get_headers());
$this->assertFalse($body->isReadable());
}

public function testSendResponseSendsStreamingResponseWithNoHeadersAndBodyFromStreamDataAndNoBufferHeaderForNginxServer()
{
if (headers_sent() || !function_exists('xdebug_get_headers')) {
Expand All @@ -205,7 +355,7 @@ public function testSendResponseSendsStreamingResponseWithNoHeadersAndBodyFromSt
$this->expectOutputString('test');
$sapi->sendResponse($response);

$previous = ['Content-Type:', 'Content-Length: 2', 'Content-Type:'];
$previous = ['Content-Type:', 'Content-Length: 2', 'Content-Type:', 'Content-Type:', 'Content-Type:', 'Content-Type:'];
$this->assertEquals(array_merge($previous, ['Content-Type:', 'X-Accel-Buffering: no']), xdebug_get_headers());

$body->end('test');
Expand Down
7 changes: 6 additions & 1 deletion tests/acceptance.sh
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,16 @@ out=$(curl -v $base/method -X DELETE 2>&1); match "HTTP/.* 200" && match "DE
out=$(curl -v $base/method -X OPTIONS 2>&1); match "HTTP/.* 200" && match "OPTIONS"
out=$(curl -v $base -X OPTIONS --request-target "*" 2>&1); skipif "Server: nginx" && match "HTTP/.* 200" # skip nginx (400)

out=$(curl -v $base/etag/ 2>&1); match "HTTP/.* 200" && match -iP "Content-Length: 0[\r\n]" && match -iP "Etag: \"_\""
out=$(curl -v $base/etag/ -H 'If-None-Match: "_"' 2>&1); skipif "Server: ReactPHP" && match "HTTP/.* 304" && notmatch -i "Content-Length" && match -iP "Etag: \"_\"" # skip built-in webserver (always includes Content-Length : 0)
out=$(curl -v $base/etag/a 2>&1); match "HTTP/.* 200" && match -iP "Content-Length: 2[\r\n]" && match -iP "Etag: \"a\""
out=$(curl -v $base/etag/a -H 'If-None-Match: "a"' 2>&1); skipif "Server: ReactPHP" && skipif "Server: Apache" && match "HTTP/.* 304" && match -iP "Content-Length: 2[\r\n]" && match -iP "Etag: \"a\"" # skip built-in webserver (always includes Content-Length: 0) and Apache (no Content-Length)

out=$(curl -v $base/headers -H 'Accept: text/html' 2>&1); match "HTTP/.* 200" && match "\"Accept\": \"text/html\""
out=$(curl -v $base/headers -d 'name=Alice' 2>&1); match "HTTP/.* 200" && match "\"Content-Type\": \"application/x-www-form-urlencoded\"" && match "\"Content-Length\": \"10\""
out=$(curl -v $base/headers -u user:pass 2>&1); match "HTTP/.* 200" && match "\"Authorization\": \"Basic dXNlcjpwYXNz\""
out=$(curl -v $base/headers 2>&1); match "HTTP/.* 200" && notmatch -i "\"Content-Type\"" && notmatch -i "\"Content-Length\""
out=$(curl -v $base/headers -H User-Agent: -H Accept: -H Host: -10 2>&1); skipif "Server: ReactPHP" && match "HTTP/.* 200" && match "{}" # skip built-in webserver (always includes Host)
out=$(curl -v $base/headers -H User-Agent: -H Accept: -H Host: -10 2>&1); match "HTTP/.* 200" && match "{}"
out=$(curl -v $base/headers -H 'Content-Length: 0' 2>&1); match "HTTP/.* 200" && match "\"Content-Length\": \"0\""
out=$(curl -v $base/headers -H 'Empty;' 2>&1); match "HTTP/.* 200" && match "\"Empty\": \"\""
out=$(curl -v $base/headers -H 'Content-Type;' 2>&1); skipif "Server: Apache" && match "HTTP/.* 200" && match "\"Content-Type\": \"\"" # skip Apache (discards empty Content-Type)
Expand Down

0 comments on commit a260ce7

Please sign in to comment.