diff --git a/examples/index.php b/examples/index.php index 7256554..3cc7546 100644 --- a/examples/index.php +++ b/examples/index.php @@ -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, diff --git a/src/SapiHandler.php b/src/SapiHandler.php index c22caad..e34f86f 100644 --- a/src/SapiHandler.php +++ b/src/SapiHandler.php @@ -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) @@ -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) diff --git a/tests/SapiHandlerTest.php b/tests/SapiHandlerTest.php index cf7879b..14b7e60 100644 --- a/tests/SapiHandlerTest.php +++ b/tests/SapiHandlerTest.php @@ -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')) { @@ -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')) { @@ -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'); diff --git a/tests/acceptance.sh b/tests/acceptance.sh index 4607a25..397f376 100755 --- a/tests/acceptance.sh +++ b/tests/acceptance.sh @@ -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)