Skip to content

Commit

Permalink
SapiEmitter now emits streaming (#602)
Browse files Browse the repository at this point in the history
  • Loading branch information
roxblnfk authored Mar 24, 2022
1 parent 673f680 commit 3d26acc
Show file tree
Hide file tree
Showing 9 changed files with 605 additions and 5 deletions.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
"mikey179/vfsstream": "^1.6",
"mockery/mockery": "^1.3",
"phpunit/phpunit": "^8.5|^9.5",
"nyholm/psr7": "^1.5.0",
"ramsey/uuid": "^4.2",
"rector/rector": "0.12.15",
"spiral/broadcast": "^2.0",
Expand Down
4 changes: 4 additions & 0 deletions src/Framework/Http/SapiDispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ public function serve(EmitterInterface $emitter = null): void
{
// On demand to save some memory.

// Disable buffering
while (\ob_get_level() > 0) {
\ob_end_flush();
}
/**
* @var Http $http
* @var EmitterInterface $emitter
Expand Down
5 changes: 3 additions & 2 deletions src/Http/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@
},
"require-dev": {
"phpunit/phpunit": "^8.5|^9.5",
"mockery/mockery": "^1.3",
"laminas/laminas-diactoros": "^2.4"
"mockery/mockery": "^1.5",
"laminas/laminas-diactoros": "^2.8",
"nyholm/psr7": "^1.5.0"
},
"autoload": {
"psr-4": {
Expand Down
19 changes: 16 additions & 3 deletions src/Http/src/Emitter/SapiEmitter.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,14 @@

/**
* Source code has been extracted from Zend/Diactoros.
*
* @codeCoverageIgnore
*/
final class SapiEmitter implements EmitterInterface
{
/**
* @var positive-int Preferred chunk size to be read from the stream before emitting.
*/
public int $bufferSize = 2_097_152; // 2MB

/**
* Emits a response for a PHP SAPI environment.
*
Expand All @@ -50,7 +53,17 @@ public function emit(ResponseInterface $response): bool
*/
private function emitBody(ResponseInterface $response): void
{
echo $response->getBody();
$body = $response->getBody();
if ($body->isSeekable()) {
$body->rewind();
}
if (!$body->isReadable()) {
return;
}
while (!$body->eof()) {
echo $body->read($this->bufferSize);
flush();
}
}

/**
Expand Down
192 changes: 192 additions & 0 deletions src/Http/tests/SapiEmitter/SapiEmitterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
<?php

declare(strict_types=1);

namespace Spiral\Tests\Http\SapiEmitter;

include 'Support/httpFunctionMocks.php';

use Nyholm\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
use Spiral\Http\Exception\EmitterException;
use Spiral\Http\Emitter\SapiEmitter;
use Spiral\Tests\Http\SapiEmitter\Support\HTTPFunctions;
use Spiral\Tests\Http\SapiEmitter\Support\NotReadableStream;

/**
* @runInSeparateProcess
*/
final class SapiEmitterTest extends TestCase
{
public function setUp(): void
{
HTTPFunctions::reset();
}

public static function tearDownAfterClass(): void
{
HTTPFunctions::reset();
}

public function testEmit(): void
{
$body = 'Example body';
$response = $this->createResponse(200, ['X-Test' => 1], $body);

$this->createEmitter()->emit($response);

$this->assertEquals(200, $this->getResponseCode());
$this->assertContains('X-Test: 1', $this->getHeaders());
// $this->assertContains('Content-Length: ' . strlen($body), $this->getHeaders());
$this->expectOutputString($body);
}

public function testEmitterWithNotReadableStream(): void
{
$body = new NotReadableStream();
$response = $this->createResponse(200, ['X-Test' => 42], $body);

$this->createEmitter()->emit($response);

$this->assertEquals(200, $this->getResponseCode());
$this->assertCount(1, $this->getHeaders());
$this->assertContains('X-Test: 42', $this->getHeaders());
}

public function testContentLengthNotOverwrittenIfPresent(): void
{
$length = 100;
$response = $this->createResponse(200, ['Content-Length' => $length, 'X-Test' => 1], 'Example body');

$this->createEmitter()->emit($response);

$this->assertEquals(200, $this->getResponseCode());
$this->assertCount(2, $this->getHeaders());
$this->assertContains('X-Test: 1', $this->getHeaders());
$this->assertContains('Content-Length: ' . $length, $this->getHeaders());
$this->expectOutputString('Example body');
}

public function testContentFullyEmitted(): void
{
$body = 'Example body';
$response = $this->createResponse(200, ['Content-length' => 1, 'X-Test' => 1], $body);

$this->createEmitter()->emit($response);

$this->expectOutputString($body);
}

public function testSentHeadersRemoved(): void
{
HTTPFunctions::header('Cookie-Set: First Cookie');
HTTPFunctions::header('X-Test: 1');
$body = 'Example body';
$response = $this->createResponse(200, [], $body);

$this->createEmitter()->emit($response);

// $this->assertEquals(['Content-Length: ' . strlen($body)], $this->getHeaders());
$this->assertEquals([
'Cookie-Set: First Cookie',
'X-Test: 1',
// 'Content-Length: ' . strlen($body)
], $this->getHeaders());
$this->expectOutputString($body);
}

public function testExceptionWhenHeadersHaveBeenSent(): void
{
$body = 'Example body';
$response = $this->createResponse(200, [], $body);
HTTPFunctions::set_headers_sent(true, 'test-file.php', 200);

$this->expectException(EmitterException::class);
$this->expectExceptionMessage('Unable to emit response, headers already send.');

$this->createEmitter()->emit($response);
}

public function testExceptionBufferWithData(): void
{
ob_start();
$body = 'Example body';
$response = $this->createResponse(200, [], $body);

$this->expectException(EmitterException::class);
$this->expectExceptionMessage('Unable to emit response, found non closed buffered output.');

try {
echo 'some data';
$this->createEmitter()->emit($response);
} catch (\Throwable $e) {
throw $e;
} finally {
\ob_end_clean();
}
}

public function testEmitDuplicateHeaders(): void
{
$body = 'Example body';
$response = $this->createResponse(200, [], $body)
->withHeader('X-Test', '1')
->withAddedHeader('X-Test', '2')
->withAddedHeader('X-Test', '3; 3.5')
->withHeader('Cookie-Set', '1')
->withAddedHeader('cookie-Set', '2')
->withAddedHeader('Cookie-set', '3');

(new SapiEmitter())->emit($response);
$this->assertEquals(200, $this->getResponseCode());
$this->assertContains('X-Test: 1', $this->getHeaders());
$this->assertContains('X-Test: 2', $this->getHeaders());
$this->assertContains('X-Test: 3; 3.5', $this->getHeaders());
$this->assertContains('Cookie-Set: 1', $this->getHeaders());
$this->assertContains('Cookie-Set: 2', $this->getHeaders());
$this->assertContains('Cookie-Set: 3', $this->getHeaders());
// $this->assertContains('Content-Length: ' . strlen($body), $this->getHeaders());
$this->expectOutputString($body);
}

private function createEmitter(?int $bufferSize = null): SapiEmitter
{
$emitter = new SapiEmitter();
if ($bufferSize !== null) {
$emitter->bufferSize = $bufferSize;
}
return $emitter;
}

private function createResponse(
int $status = 200,
array $headers = [],
$body = null,
string $version = '1.1'
): ResponseInterface {
$response = (new Response())
->withStatus($status)
->withProtocolVersion($version);
foreach ($headers as $header => $value) {
$response = $response->withHeader($header, $value);
}
if ($body instanceof StreamInterface) {
$response = $response->withBody($body);
} elseif (is_string($body)) {
$response->getBody()->write($body);
}
return $response;
}

private function getHeaders(): array
{
return HTTPFunctions::headers_list();
}

private function getResponseCode(): int
{
return HTTPFunctions::http_response_code();
}
}
124 changes: 124 additions & 0 deletions src/Http/tests/SapiEmitter/Support/HTTPFunctions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php
/**
*
* This class used to override some header*() functions and http_response_code()
*
* We put these into the SapiEmitter namespace, so that SapiEmitter will use these versions of header*() and
* http_response_code() when we test its output.
*/

declare(strict_types=1);

namespace Spiral\Tests\Http\SapiEmitter\Support;

/**
* @source https://github.com/yiisoft/yii-web/blob/master/tests/Emitter/Support/HTTPFunctions.php
* @license MIT
* @copyright Yii Software LLC (http://www.yiisoft.com) All rights reserved.
*/
class HTTPFunctions
{
/** @var string[][] */
private static $headers = [];
/** @var int */
private static $responseCode = 200;
private static $headersSent = false;
private static string $headersSentFile = '';
private static int $headersSentLine = 0;

/**
* Reset state
*/
public static function reset(): void
{
self::$headers = [];
self::$responseCode = 200;
self::$headersSent = false;
self::$headersSentFile = '';
self::$headersSentLine = 0;
}

/**
* Set header_sent() state
*/
public static function set_headers_sent(bool $value = false, string $file = '', int $line = 0): void
{
static::$headersSent = $value;
static::$headersSentFile = $file;
static::$headersSentLine = $line;
}

/**
* Check if headers have been sent
*/
public static function headers_sent(&$file = null, &$line = null): bool
{
$file = static::$headersSentFile;
$line = static::$headersSentLine;
return static::$headersSent;
}

/**
* Send a raw HTTP header
*/
public static function header(string $string, bool $replace = true, ?int $http_response_code = null): void
{
if (strpos($string, 'HTTP/') !== 0) {
$header = strtolower(explode(':', $string, 2)[0]);
if ($replace || !array_key_exists($header, self::$headers)) {
self::$headers[$header] = [];
}
self::$headers[$header][] = $string;
}
if ($http_response_code !== null) {
self::$responseCode = $http_response_code;
}
}

/**
* Remove previously set headers
*/
public static function header_remove(?string $header = null): void
{
if ($header === null) {
self::$headers = [];
} else {
unset(self::$headers[strtolower($header)]);
}
}

/**
* Returns a list of response headers sent
*
* @return string[]
*/
public static function headers_list(): array
{
$result = [];
foreach (self::$headers as $values) {
foreach ($values as $header) {
$result[] = $header;
}
}
return $result;
}

/**
* Get or Set the HTTP response code
*/
public static function http_response_code(?int $response_code = null): int
{
if ($response_code !== null) {
self::$responseCode = $response_code;
}
return self::$responseCode;
}

/**
* Check header is exists
*/
public static function hasHeader(string $header): bool
{
return array_key_exists(strtolower($header), self::$headers);
}
}
Loading

0 comments on commit 3d26acc

Please sign in to comment.