Skip to content
Open
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
16 changes: 16 additions & 0 deletions src/Http/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use function implode;
use SimpleXMLElement;
use function is_array;
use GuzzleHttp\Psr7\Utils;
use function mb_strtolower;
use Saloon\Traits\Macroable;
use InvalidArgumentException;
Expand Down Expand Up @@ -171,6 +172,21 @@ public function stream(): StreamInterface
return $stream;
}

/**
* Return a new response with the body replaced by a seekable stream containing the given content.
* Use when the original body stream is not seekable (e.g. after debug has consumed it) so
* subsequent callers still receive the full body.
*/
public function withBufferedBody(string $body): static
{
return new static(
$this->psrResponse->withBody(Utils::streamFor($body)),
$this->pendingRequest,
$this->psrRequest,
$this->senderException
);
}

/**
* Get the headers from the response.
*/
Expand Down
18 changes: 15 additions & 3 deletions src/Traits/HasDebugging.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,24 @@ public function debugResponse(?callable $onResponse = null, bool $die = false):
// is shown before it is modified by the user's middleware.

$this->middleware()->onResponse(
callable: static function (Response $response) use ($onResponse, $die): void {
$onResponse($response, $response->getPsrResponse());

callable: static function (Response $response) use ($onResponse, $die): Response {
$stream = $response->getPsrResponse()->getBody();
if ($stream->isSeekable()) {
$onResponse($response, $response->getPsrResponse());
if ($die) {
Debugger::die();
}

return $response;
}
$body = $response->body();
$replaced = $response->withBufferedBody($body);
$onResponse($replaced, $replaced->getPsrResponse());
if ($die) {
Debugger::die();
}

return $replaced;
},
order: PipeOrder::FIRST
);
Expand Down
21 changes: 21 additions & 0 deletions tests/Feature/DebugTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Saloon\Tests\Fixtures\Requests\UserRequest;
use Saloon\Tests\Fixtures\Connectors\TestConnector;
use Saloon\Tests\Fixtures\Requests\AlwaysThrowRequest;
use Saloon\Tests\Fixtures\Mocking\UnseekableBodyMockResponse;

test('a user can register a request and response debugger on the connector and request', function () {
$mockClient = new MockClient([
Expand Down Expand Up @@ -267,3 +268,23 @@

expect($killed)->toBeTrue();
});

test('the response debugger receives a response with full body when the stream is unseekable', function () {
$expectedBody = '{"name":"Jon"}';
$mockClient = new MockClient([
new UnseekableBodyMockResponse(['name' => 'Jon'], 200),
]);

$connector = new TestConnector;
$connector->withMockClient($mockClient);

$debuggerReceivedBody = null;

$connector->debugResponse(function (Response $response) use (&$debuggerReceivedBody) {
$debuggerReceivedBody = $response->body();
});

$connector->send(new UserRequest);

expect($debuggerReceivedBody)->toEqual($expectedBody);
});
126 changes: 126 additions & 0 deletions tests/Fixtures/Mocking/UnseekableBodyMockResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

declare(strict_types=1);

namespace Saloon\Tests\Fixtures\Mocking;

use Saloon\Http\Faking\MockResponse;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\ResponseFactoryInterface;

/**
* A MockResponse that uses an unseekable body stream so we can test
* that the response debugger buffers the body and shows it correctly.
*/
class UnseekableBodyMockResponse extends MockResponse
{
public function createPsrResponse(ResponseFactoryInterface $responseFactory, StreamFactoryInterface $streamFactory): ResponseInterface
{
$response = parent::createPsrResponse($responseFactory, $streamFactory);
$body = (string) $response->getBody();

return $response->withBody(new UnseekableStream($body));
}
}

/**
* Stream that reports isSeekable() === false (e.g. like a network stream).
*/
final class UnseekableStream implements StreamInterface
{
private string $contents;

private int $position = 0;

private bool $closed = false;

public function __construct(string $contents)
{
$this->contents = $contents;
}

public function __toString(): string
{
return $this->contents;
}

public function close(): void
{
$this->closed = true;
}

public function detach()
{
$this->closed = true;

return null;
}

public function getSize(): ?int
{
return mb_strlen($this->contents);
}

public function tell(): int
{
return $this->position;
}

public function eof(): bool
{
return $this->position >= mb_strlen($this->contents);
}

public function isSeekable(): bool
{
return false;
}

public function seek(int $offset, int $whence = SEEK_SET): void
{
throw new \RuntimeException('Stream is not seekable');
}

public function rewind(): void
{
throw new \RuntimeException('Stream is not seekable');
}

public function isWritable(): bool
{
return false;
}

public function write(string $string): int
{
throw new \RuntimeException('Stream is not writable');
}

public function isReadable(): bool
{
return ! $this->closed;
}

public function read(int $length): string
{
$chunk = mb_substr($this->contents, $this->position, $length);
$this->position += mb_strlen($chunk);

return $chunk;
}

public function getContents(): string
{
$remaining = mb_substr($this->contents, $this->position);
$this->position = mb_strlen($this->contents);

return $remaining;
}

public function getMetadata(?string $key = null)
{
return $key === null ? [] : null;
}
}