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
38 changes: 37 additions & 1 deletion src/Http/ResponseEmitter.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@

use Psr\Http\Message\ResponseInterface;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Laminas\HttpHandlerRunner\Emitter\EmitterInterface;

class ResponseEmitter
{
public function __construct(protected ?EmitterInterface $emitter = null)
{
$this->emitter = $emitter ?? new SapiEmitter();
}

/**
* Emit a response.
*
Expand All @@ -23,6 +29,36 @@ public function emit(ResponseInterface $response)
return;
}

(new SapiEmitter())->emit($response);
// If the output buffer has content, SapiEmitter will throw an exception.
// We circumvent this by stacking a new buffer, emitting into it,
// and then echoing the result.
if ($this->isOutputBufferDirty()) {
$this->emitWithStackedBuffer($response);
return;
}

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

/**
* Check if the output buffer has existing content.
*/
protected function isOutputBufferDirty(): bool
{
return ob_get_level() > 0 && ob_get_length() > 0;
}

/**
* Emit the response into a clean buffer to bypass SapiEmitter's strict checks.
*/
protected function emitWithStackedBuffer(ResponseInterface $response): void
{
ob_start();

try {
$this->emitter->emit($response);
} finally {
echo ob_get_clean();
}
}
}
63 changes: 39 additions & 24 deletions tests/Unit/Http/ResponseEmitterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
use PHPUnit\Framework\Attributes\Test;
use Rareloop\Lumberjack\Http\ResponseEmitter;
use Laminas\Diactoros\Response\TextResponse;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use Laminas\HttpHandlerRunner\Emitter\EmitterInterface;
use Mockery;
use phpmock\MockBuilder;

#[RunTestsInSeparateProcesses]
#[PreserveGlobalState(false)]
class ResponseEmitterTest extends TestCase
{
use \Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;

#[Test]
public function emit_should_echo_body_when_headers_sent(): void
{
Expand All @@ -39,7 +39,7 @@ public function emit_should_echo_body_when_headers_sent(): void
}

#[Test]
public function emit_should_use_sapi_emitter_when_headers_not_sent(): void
public function emit_should_use_provided_emitter_when_headers_not_sent(): void
{
$builder = new MockBuilder();
$builder->setNamespace('Rareloop\Lumberjack\Http')
Expand All @@ -50,38 +50,53 @@ public function emit_should_use_sapi_emitter_when_headers_not_sent(): void
$mock = $builder->build();
$mock->enable();

// SapiEmitter uses the global header() function.
// We mock it in the SapiEmitter's namespace to prevent it from actually sending headers
$headerBuilder = new MockBuilder();
$headerBuilder->setNamespace('Laminas\HttpHandlerRunner\Emitter')
->setName('header')
->setFunction(function () {
});
$headerMock = $headerBuilder->build();
$headerMock->enable();
$response = new TextResponse('Hello World');
$innerEmitter = Mockery::mock(EmitterInterface::class);

$innerEmitter->shouldReceive('emit')->once()->with($response)->andReturn(true);

// SapiEmitterTrait checks for namespaced headers_sent
$emitterHeadersSentBuilder = new MockBuilder();
$emitterHeadersSentBuilder->setNamespace('Laminas\HttpHandlerRunner\Emitter')
$emitter = new ResponseEmitter($innerEmitter);
$emitter->emit($response);

$mock->disable();
}

#[Test]
public function emit_should_use_stacked_buffer_when_output_buffer_has_content_but_headers_not_sent(): void
{
$builder = new MockBuilder();
$builder->setNamespace('Rareloop\Lumberjack\Http')
->setName('headers_sent')
->setFunction(function () {
return false;
});
$emitterHeadersSentMock = $emitterHeadersSentBuilder->build();
$emitterHeadersSentMock->enable();
$mock = $builder->build();
$mock->enable();

$response = new TextResponse('Hello World');
$emitter = new ResponseEmitter();
$innerEmitter = Mockery::mock(EmitterInterface::class);

// We expect the inner emitter to be called.
// We'll make it echo something to verify it's captured by the stacked buffer
$innerEmitter->shouldReceive('emit')->once()->with($response)->andReturnUsing(function () {
echo 'Hello World';
return true;
});

$emitter = new ResponseEmitter($innerEmitter);

// Start output buffering and put some content in it
ob_start();
echo 'Existing output';

// We expect no exception now
$emitter->emit($response);

// Final output should contain both existing and new content
$output = ob_get_clean();

// SapiEmitter echoes the body, so we should see 'Hello World'
$this->assertSame('Hello World', $output);
$this->assertSame('Existing outputHello World', $output);

$mock->disable();
$headerMock->disable();
$emitterHeadersSentMock->disable();
}
}