Skip to content

Commit 96cedff

Browse files
authored
Fix for empty response body when debugging unseekable streams (#537)
* fix for empty response body when debugging unseekable streams * run cs fixer and add test * run cs fixer
1 parent 1307b1d commit 96cedff

4 files changed

Lines changed: 178 additions & 3 deletions

File tree

src/Http/Response.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use function implode;
1010
use SimpleXMLElement;
1111
use function is_array;
12+
use GuzzleHttp\Psr7\Utils;
1213
use function mb_strtolower;
1314
use Saloon\Traits\Macroable;
1415
use InvalidArgumentException;
@@ -171,6 +172,21 @@ public function stream(): StreamInterface
171172
return $stream;
172173
}
173174

175+
/**
176+
* Return a new response with the body replaced by a seekable stream containing the given content.
177+
* Use when the original body stream is not seekable (e.g. after debug has consumed it) so
178+
* subsequent callers still receive the full body.
179+
*/
180+
public function withBufferedBody(string $body): static
181+
{
182+
return new static(
183+
$this->psrResponse->withBody(Utils::streamFor($body)),
184+
$this->pendingRequest,
185+
$this->psrRequest,
186+
$this->senderException
187+
);
188+
}
189+
174190
/**
175191
* Get the headers from the response.
176192
*/

src/Traits/HasDebugging.php

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,24 @@ public function debugResponse(?callable $onResponse = null, bool $die = false):
6464
// is shown before it is modified by the user's middleware.
6565

6666
$this->middleware()->onResponse(
67-
callable: static function (Response $response) use ($onResponse, $die): void {
68-
$onResponse($response, $response->getPsrResponse());
69-
67+
callable: static function (Response $response) use ($onResponse, $die): Response {
68+
$stream = $response->getPsrResponse()->getBody();
69+
if ($stream->isSeekable()) {
70+
$onResponse($response, $response->getPsrResponse());
71+
if ($die) {
72+
Debugger::die();
73+
}
74+
75+
return $response;
76+
}
77+
$body = $response->body();
78+
$replaced = $response->withBufferedBody($body);
79+
$onResponse($replaced, $replaced->getPsrResponse());
7080
if ($die) {
7181
Debugger::die();
7282
}
83+
84+
return $replaced;
7385
},
7486
order: PipeOrder::FIRST
7587
);

tests/Feature/DebugTest.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use Saloon\Tests\Fixtures\Requests\UserRequest;
1414
use Saloon\Tests\Fixtures\Connectors\TestConnector;
1515
use Saloon\Tests\Fixtures\Requests\AlwaysThrowRequest;
16+
use Saloon\Tests\Fixtures\Mocking\UnseekableBodyMockResponse;
1617

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

268269
expect($killed)->toBeTrue();
269270
});
271+
272+
test('the response debugger receives a response with full body when the stream is unseekable', function () {
273+
$expectedBody = '{"name":"Jon"}';
274+
$mockClient = new MockClient([
275+
new UnseekableBodyMockResponse(['name' => 'Jon'], 200),
276+
]);
277+
278+
$connector = new TestConnector;
279+
$connector->withMockClient($mockClient);
280+
281+
$debuggerReceivedBody = null;
282+
283+
$connector->debugResponse(function (Response $response) use (&$debuggerReceivedBody) {
284+
$debuggerReceivedBody = $response->body();
285+
});
286+
287+
$connector->send(new UserRequest);
288+
289+
expect($debuggerReceivedBody)->toEqual($expectedBody);
290+
});
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Saloon\Tests\Fixtures\Mocking;
6+
7+
use Saloon\Http\Faking\MockResponse;
8+
use Psr\Http\Message\StreamInterface;
9+
use Psr\Http\Message\ResponseInterface;
10+
use Psr\Http\Message\StreamFactoryInterface;
11+
use Psr\Http\Message\ResponseFactoryInterface;
12+
13+
/**
14+
* A MockResponse that uses an unseekable body stream so we can test
15+
* that the response debugger buffers the body and shows it correctly.
16+
*/
17+
class UnseekableBodyMockResponse extends MockResponse
18+
{
19+
public function createPsrResponse(ResponseFactoryInterface $responseFactory, StreamFactoryInterface $streamFactory): ResponseInterface
20+
{
21+
$response = parent::createPsrResponse($responseFactory, $streamFactory);
22+
$body = (string) $response->getBody();
23+
24+
return $response->withBody(new UnseekableStream($body));
25+
}
26+
}
27+
28+
/**
29+
* Stream that reports isSeekable() === false (e.g. like a network stream).
30+
*/
31+
final class UnseekableStream implements StreamInterface
32+
{
33+
private string $contents;
34+
35+
private int $position = 0;
36+
37+
private bool $closed = false;
38+
39+
public function __construct(string $contents)
40+
{
41+
$this->contents = $contents;
42+
}
43+
44+
public function __toString(): string
45+
{
46+
return $this->contents;
47+
}
48+
49+
public function close(): void
50+
{
51+
$this->closed = true;
52+
}
53+
54+
public function detach()
55+
{
56+
$this->closed = true;
57+
58+
return null;
59+
}
60+
61+
public function getSize(): ?int
62+
{
63+
return mb_strlen($this->contents);
64+
}
65+
66+
public function tell(): int
67+
{
68+
return $this->position;
69+
}
70+
71+
public function eof(): bool
72+
{
73+
return $this->position >= mb_strlen($this->contents);
74+
}
75+
76+
public function isSeekable(): bool
77+
{
78+
return false;
79+
}
80+
81+
public function seek(int $offset, int $whence = SEEK_SET): void
82+
{
83+
throw new \RuntimeException('Stream is not seekable');
84+
}
85+
86+
public function rewind(): void
87+
{
88+
throw new \RuntimeException('Stream is not seekable');
89+
}
90+
91+
public function isWritable(): bool
92+
{
93+
return false;
94+
}
95+
96+
public function write(string $string): int
97+
{
98+
throw new \RuntimeException('Stream is not writable');
99+
}
100+
101+
public function isReadable(): bool
102+
{
103+
return ! $this->closed;
104+
}
105+
106+
public function read(int $length): string
107+
{
108+
$chunk = mb_substr($this->contents, $this->position, $length);
109+
$this->position += mb_strlen($chunk);
110+
111+
return $chunk;
112+
}
113+
114+
public function getContents(): string
115+
{
116+
$remaining = mb_substr($this->contents, $this->position);
117+
$this->position = mb_strlen($this->contents);
118+
119+
return $remaining;
120+
}
121+
122+
public function getMetadata(?string $key = null)
123+
{
124+
return $key === null ? [] : null;
125+
}
126+
}

0 commit comments

Comments
 (0)