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