Added Test coverage for Emitter class.
This commit is contained in:
@@ -6,16 +6,21 @@ namespace App\Libs;
|
||||
|
||||
use App\Libs\Exceptions\EmitterException;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\StreamInterface as iStream;
|
||||
|
||||
/**
|
||||
* @psalm-type ParsedRangeType = array{0:string,1:int,2:int,3:int|'*'}
|
||||
*/
|
||||
final readonly class Emitter
|
||||
{
|
||||
public const string HEADER_FUNC = 'header';
|
||||
public const string HEADERS_SENT_FUNC = 'headers_sent';
|
||||
public const string BODY_FUNC = 'body';
|
||||
|
||||
/**
|
||||
* @param int $maxBufferLength int Maximum output buffering size for each iteration.
|
||||
*/
|
||||
public function __construct(protected int $maxBufferLength = 8192)
|
||||
public function __construct(protected int $maxBufferLength = 8192, private array $callers = [])
|
||||
{
|
||||
}
|
||||
|
||||
@@ -71,6 +76,56 @@ final readonly class Emitter
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance with the specified function.
|
||||
*
|
||||
* @param callable $fn The function to use for emitting headers.
|
||||
* @return self A new instance with the specified function.
|
||||
*/
|
||||
public function withHeaderFunc(callable $fn): self
|
||||
{
|
||||
$callers = $this->callers;
|
||||
$callers[self::HEADER_FUNC] = $fn;
|
||||
return new Emitter($this->maxBufferLength, $callers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance with the specified function.
|
||||
*
|
||||
* @param callable $fn the function to call to emit the body.
|
||||
* @return self A new instance with the specified function.
|
||||
*/
|
||||
public function withBodyFunc(callable $fn): self
|
||||
{
|
||||
$callers = $this->callers;
|
||||
$callers[self::BODY_FUNC] = $fn;
|
||||
return new Emitter($this->maxBufferLength, $callers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance with the specified function.
|
||||
*
|
||||
* @param callable $fn the function to call to check if headers have been sent.
|
||||
* @return self A new instance with the specified function.
|
||||
*/
|
||||
public function withHeadersSentFunc(callable $fn): self
|
||||
{
|
||||
$callers = $this->callers;
|
||||
$callers[self::HEADERS_SENT_FUNC] = $fn;
|
||||
return new Emitter($this->maxBufferLength, $callers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance with the specified maximum buffer length.
|
||||
*
|
||||
* @param int $maxBufferLength The maximum buffer length for each iteration.
|
||||
* @return self A new instance with the specified maximum buffer length.
|
||||
*/
|
||||
public function withMaxBufferLength(int $maxBufferLength): self
|
||||
{
|
||||
return new Emitter($maxBufferLength, $this->callers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the message body.
|
||||
*
|
||||
@@ -82,20 +137,22 @@ final readonly class Emitter
|
||||
{
|
||||
$body = $response->getBody();
|
||||
|
||||
if ($body->isSeekable()) {
|
||||
if (true === $body->isSeekable()) {
|
||||
$body->rewind();
|
||||
}
|
||||
|
||||
if (!$body->isReadable() || true === $flushAll) {
|
||||
echo $body;
|
||||
flush();
|
||||
if (false === $body->isReadable() || true === $flushAll) {
|
||||
$this->write($body->getContents());
|
||||
return;
|
||||
}
|
||||
|
||||
while (!$body->eof()) {
|
||||
echo $body->read($maxBuffer);
|
||||
flush();
|
||||
if (CONNECTION_NORMAL !== connection_status()) {
|
||||
$iterations = 0;
|
||||
while (false === $body->eof()) {
|
||||
$iterations++;
|
||||
|
||||
$this->write($body->read($maxBuffer));
|
||||
|
||||
if ($iterations % 5 === 0 && CONNECTION_NORMAL !== connection_status()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -114,6 +171,7 @@ final readonly class Emitter
|
||||
[, $first, $last] = $range;
|
||||
|
||||
$body = $response->getBody();
|
||||
assert($body instanceof iStream, 'Body must be an instance of StreamInterface');
|
||||
|
||||
$length = $last - $first + 1;
|
||||
|
||||
@@ -122,9 +180,8 @@ final readonly class Emitter
|
||||
$first = 0;
|
||||
}
|
||||
|
||||
if (!$body->isReadable() || true === $flushAll) {
|
||||
echo substr($body->getContents(), $first, $length);
|
||||
flush();
|
||||
if (false === $body->isReadable() || true === $flushAll) {
|
||||
$this->write(substr($body->getContents(), $first, $length));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -134,8 +191,7 @@ final readonly class Emitter
|
||||
$contents = $body->read($maxBuffer);
|
||||
$remaining -= strlen($contents);
|
||||
|
||||
echo $contents;
|
||||
flush();
|
||||
$this->write($contents);
|
||||
|
||||
if (CONNECTION_NORMAL !== connection_status()) {
|
||||
break;
|
||||
@@ -143,8 +199,7 @@ final readonly class Emitter
|
||||
}
|
||||
|
||||
if ($remaining > 0 && !$body->eof()) {
|
||||
echo $body->read($remaining);
|
||||
flush();
|
||||
$this->write($body->read($remaining));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +238,7 @@ final readonly class Emitter
|
||||
{
|
||||
$filename = null;
|
||||
$line = null;
|
||||
|
||||
if ($this->headersSent($filename, $line)) {
|
||||
assert(is_string($filename) && is_int($line));
|
||||
throw EmitterException::forHeadersSent($filename, $line);
|
||||
@@ -233,7 +289,7 @@ final readonly class Emitter
|
||||
$statusCode = $response->getStatusCode();
|
||||
|
||||
foreach ($response->getHeaders() as $header => $values) {
|
||||
assert(is_string($header));
|
||||
assert(is_string($header), 'Header name must be a string');
|
||||
$name = $this->filterHeader($header);
|
||||
if (true === str_starts_with($name, 'X-Emitter')) {
|
||||
continue;
|
||||
@@ -251,16 +307,37 @@ final readonly class Emitter
|
||||
*/
|
||||
private function filterHeader(string $header): string
|
||||
{
|
||||
return ucwords($header, '-');
|
||||
return ucwords(strtolower($header), '-');
|
||||
}
|
||||
|
||||
private function headersSent(?string &$filename = null, ?int &$line = null): bool
|
||||
{
|
||||
if (null !== ($caller = $this->callers[self::HEADERS_SENT_FUNC] ?? null)) {
|
||||
return $caller($filename, $line);
|
||||
}
|
||||
|
||||
return headers_sent($filename, $line);
|
||||
}
|
||||
|
||||
private function header(string $headerName, bool $replace, int $statusCode): void
|
||||
{
|
||||
if (null !== ($caller = $this->callers[self::HEADER_FUNC] ?? null)) {
|
||||
$caller($headerName, $replace, $statusCode);
|
||||
return;
|
||||
}
|
||||
|
||||
header($headerName, $replace, $statusCode);
|
||||
}
|
||||
|
||||
private function write(string $data): void
|
||||
{
|
||||
if (null !== ($caller = $this->callers[self::BODY_FUNC] ?? null)) {
|
||||
$caller($data);
|
||||
return;
|
||||
}
|
||||
|
||||
echo $data;
|
||||
flush();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ use RuntimeException;
|
||||
|
||||
class EmitterException extends RuntimeException implements AppExceptionInterface
|
||||
{
|
||||
public const int HEADERS_SENT = 500;
|
||||
public const int OUTPUT_SENT = 501;
|
||||
|
||||
use UseAppException;
|
||||
|
||||
public static function forHeadersSent(string $filename, int $line): self
|
||||
@@ -15,11 +18,11 @@ class EmitterException extends RuntimeException implements AppExceptionInterface
|
||||
return new self(r('Unable to emit response. Headers already sent in %s:%d', [
|
||||
'filename' => $filename,
|
||||
'line' => $line,
|
||||
]));
|
||||
]), code: self::HEADERS_SENT);
|
||||
}
|
||||
|
||||
public static function forOutputSent(): self
|
||||
{
|
||||
return new self('Output has been emitted previously. Cannot emit response.');
|
||||
return new self('Output has been emitted previously. Cannot emit response.', code: self::OUTPUT_SENT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,14 +11,14 @@ final readonly class StreamedBody implements StreamInterface
|
||||
{
|
||||
private mixed $func;
|
||||
|
||||
public function __construct(callable $func)
|
||||
public function __construct(callable $func, private bool $isReadable = true)
|
||||
{
|
||||
$this->func = $func;
|
||||
}
|
||||
|
||||
public static function create(callable $func): StreamInterface
|
||||
public static function create(callable $func, bool $isReadable = true): StreamInterface
|
||||
{
|
||||
return new self($func);
|
||||
return new self($func, isReadable: $isReadable);
|
||||
}
|
||||
|
||||
public function __destruct()
|
||||
@@ -79,7 +79,7 @@ final readonly class StreamedBody implements StreamInterface
|
||||
|
||||
public function isReadable(): bool
|
||||
{
|
||||
return true;
|
||||
return $this->isReadable;
|
||||
}
|
||||
|
||||
public function read($length): string
|
||||
|
||||
155
tests/Libs/EmitterTest.php
Normal file
155
tests/Libs/EmitterTest.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Libs;
|
||||
|
||||
use App\Libs\Emitter;
|
||||
use App\Libs\Enums\Http\Status;
|
||||
use App\Libs\Exceptions\EmitterException;
|
||||
use App\Libs\Response;
|
||||
use App\Libs\Stream;
|
||||
use App\Libs\StreamedBody;
|
||||
use App\Libs\TestCase;
|
||||
|
||||
class EmitterTest extends TestCase
|
||||
{
|
||||
private array $headers = [];
|
||||
private string $body = '';
|
||||
|
||||
private Emitter|null $emitter = null;
|
||||
|
||||
protected function reset(): void
|
||||
{
|
||||
$this->headers = [];
|
||||
$this->body = '';
|
||||
$this->emitter = (new Emitter())
|
||||
->withHeaderFunc(function ($header, $replace, $status) {
|
||||
$this->headers[] = [
|
||||
'header' => $header,
|
||||
'replace' => $replace,
|
||||
'status' => $status
|
||||
];
|
||||
})
|
||||
->withHeadersSentFunc(fn() => false)
|
||||
->withBodyFunc(fn(string $data) => $this->body .= $data)
|
||||
->withMaxBufferLength(8192);
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->reset();
|
||||
parent::setUp();
|
||||
}
|
||||
|
||||
public function test_emitter_headers()
|
||||
{
|
||||
$response = new Response(Status::OK, headers: [
|
||||
'content-type' => 'text/plain',
|
||||
'X-TEST' => 'test',
|
||||
], version: '2.0');
|
||||
|
||||
$this->emitter->__invoke($response);
|
||||
|
||||
$this->assertSame(
|
||||
'Content-Type: text/plain',
|
||||
$this->headers[0]['header'],
|
||||
'Content-Type header is not set correctly.'
|
||||
);
|
||||
|
||||
$this->assertSame(
|
||||
'X-Test: test',
|
||||
$this->headers[1]['header'],
|
||||
'X-TEST header is not set correctly.'
|
||||
);
|
||||
|
||||
$this->assertSame(
|
||||
'HTTP/2.0 200 OK',
|
||||
$this->headers[2]['header'],
|
||||
'Status line is not set correctly.'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_emitter_body()
|
||||
{
|
||||
$response = new Response(Status::OK, headers: [
|
||||
'content-type' => 'text/plain',
|
||||
'X-TEST' => 'test',
|
||||
], body: Stream::create('test'), version: '2.0');
|
||||
|
||||
$this->emitter->__invoke($response);
|
||||
$this->assertSame('test', $this->body, 'Body is not set correctly.');
|
||||
|
||||
$this->reset();
|
||||
$this->emitter->__invoke($response->withHeader('Content-Range', 'bytes 0-1/4'));
|
||||
$this->assertSame('te', $this->body, 'Body is not set correctly.');
|
||||
|
||||
$this->reset();
|
||||
$this->emitter->__invoke($response->withHeader('Content-Range', 'bytes 2-3/4'));
|
||||
$this->assertSame('st', $this->body, 'Body is not set correctly.');
|
||||
|
||||
$this->reset();
|
||||
$this->emitter->__invoke($response->withHeader('Content-Range', 'bytes 2-3/4'));
|
||||
$this->assertSame('st', $this->body, 'Body is not set correctly.');
|
||||
|
||||
$this->reset();
|
||||
$this->emitter->__invoke($response->withHeader('X-Emitter-Max-Buffer-Length', '1'));
|
||||
$this->assertSame('test', $this->body, 'Body is not set correctly.');
|
||||
|
||||
$this->reset();
|
||||
$this->emitter->withMaxBufferLength(1)->__invoke($response->withHeader('Content-Range', 'bytes 0-3/4'));
|
||||
$this->assertSame('test', $this->body, 'Body is not set correctly.');
|
||||
}
|
||||
|
||||
public function test_emitter_body_streamable()
|
||||
{
|
||||
$response = new Response(Status::OK, headers: [
|
||||
'X-Emitter-Flush' => '1',
|
||||
], body: StreamedBody::create(fn() => 'test'));
|
||||
|
||||
$this->emitter->__invoke($response);
|
||||
$this->assertSame('test', $this->body, 'Body is not set correctly.');
|
||||
|
||||
$this->reset();
|
||||
$this->emitter->__invoke(
|
||||
$response->withoutHeader('X-Emitter-Flush')->withBody(
|
||||
StreamedBody::create(fn() => 'test', isReadable: false)
|
||||
)
|
||||
);
|
||||
$this->assertSame('test', $this->body, 'Body is not set correctly.');
|
||||
}
|
||||
|
||||
public function test_fail_conditions()
|
||||
{
|
||||
$response = new Response(Status::OK, headers: [
|
||||
'content-type' => 'text/plain',
|
||||
'X-TEST' => 'test',
|
||||
], body: Stream::create('test'), version: '2.0');
|
||||
|
||||
|
||||
$emitter = $this->emitter->withHeadersSentFunc(function (&$file, &$line): bool {
|
||||
$file = 'test';
|
||||
$line = 1;
|
||||
return true;
|
||||
});
|
||||
|
||||
$this->checkException(
|
||||
closure: fn() => $emitter->__invoke($response),
|
||||
reason: 'Headers already sent.',
|
||||
exception: EmitterException::class,
|
||||
exceptionCode: EmitterException::HEADERS_SENT
|
||||
);
|
||||
|
||||
ob_start();
|
||||
echo 'foo';
|
||||
|
||||
$this->checkException(
|
||||
closure: fn() => $this->emitter->__invoke($response),
|
||||
reason: 'Headers already sent.',
|
||||
exception: EmitterException::class,
|
||||
exceptionCode: EmitterException::OUTPUT_SENT
|
||||
);
|
||||
|
||||
ob_end_clean();
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,9 @@ use RuntimeException;
|
||||
|
||||
class StreamedBodyTest extends TestCase
|
||||
{
|
||||
private function getStream(Closure $fn = null): StreamedBody
|
||||
private function getStream(Closure $fn = null, bool $isReadable = true): StreamedBody
|
||||
{
|
||||
return new StreamedBody($fn ?? fn() => 'test');
|
||||
return new StreamedBody($fn ?? fn() => 'test', isReadable: $isReadable);
|
||||
}
|
||||
|
||||
public function test_expectations()
|
||||
@@ -65,5 +65,10 @@ class StreamedBodyTest extends TestCase
|
||||
reason: 'write(): Must throw an exception as closure is not writable',
|
||||
exception: RuntimeException::class,
|
||||
);
|
||||
|
||||
$this->assertFalse(
|
||||
$this->getStream(isReadable: false)->isReadable(),
|
||||
'isReadable(): Must return false as closure is not readable'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user