Added Test coverage for Emitter class.

This commit is contained in:
Abdulmhsen B. A. A.
2024-09-17 16:52:24 +03:00
parent a24c12334c
commit 50fa386002
5 changed files with 266 additions and 26 deletions

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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
View 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();
}
}

View File

@@ -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'
);
}
}