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
6 changes: 6 additions & 0 deletions .changelog/json-response-body.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: fixed
type: patch
---

Keep JsonResponse payload access and replacement usable after a generic PSR-7 body replacement, with explicit decoding and stream errors.
33 changes: 28 additions & 5 deletions src/JsonResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
* This class MUST be used when returning JSON payloads over HTTP responses.
* It automatically sets the 'Content-Type' header to 'application/json' with the specified charset.
*
* @method PayloadStreamInterface getBody() Retrieves the response body as a JSON stream.
* A body replaced through PSR-7 withBody() may be any StreamInterface.
*/
final class JsonResponse extends Response implements PayloadResponseInterface
{
Expand All @@ -55,14 +55,34 @@ public function __construct(mixed $payload = [], string $charset = 'utf-8', arra
/**
* Retrieves the payload contained in the response body.
*
* This method MUST return the same payload provided during construction or via withPayload().
* Original payload streams return their stored payload. Generic readable,
* seekable streams are decoded as associative JSON without changing the cursor.
* Invalid JSON throws JsonException; unreadable, detached or non-seekable
* generic streams throw RuntimeException rather than being consumed.
*
* @throws \JsonException
* @throws \RuntimeException
*
* @return mixed the decoded JSON payload
*/
public function getPayload(): mixed
{
return $this->getBody()
->getPayload();
$body = $this->getBody();
if ($body instanceof PayloadStreamInterface) {
return $body->getPayload();
}

if (!$body->isReadable() || !$body->isSeekable()) {
throw new \RuntimeException('A generic JSON body must be readable and seekable to retrieve its payload.');
}

$position = $body->tell();
try {
$body->rewind();
return json_decode($body->getContents(), true, 512, \JSON_THROW_ON_ERROR);
} finally {
$body->seek($position);
}
}

/**
Expand All @@ -78,6 +98,9 @@ public function getPayload(): mixed
*/
public function withPayload(mixed $payload): self
{
return $this->withBody($this->getBody()->withPayload($payload));
$body = $this->getBody();
return $this->withBody(
$body instanceof PayloadStreamInterface ? $body->withPayload($payload) : new JsonStream($payload)
);
}
}
73 changes: 73 additions & 0 deletions tests/JsonResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,77 @@ public static function providerCharsets(): array
'custom charset' => ['iso-8859-1'],
];
}

#[Test]
public function genericJsonBodyIsDecodedWithoutMovingItsCursor(): void
{
$original = new JsonResponse(['original' => true]);
$stream = \Nyholm\Psr7\Stream::create('{"replacement":true}');
$stream->seek(5);
$replacement = $original->withBody($stream);

self::assertSame(['replacement' => true], $replacement->getPayload());
self::assertSame(5, $stream->tell());
self::assertSame(['original' => true], $original->getPayload());
$updated = $replacement->withPayload(['next' => true]);
self::assertSame(['next' => true], $updated->getPayload());
self::assertSame($stream, $replacement->getBody());
}

#[Test]
public function invalidGenericBodyHasAnExplicitJsonErrorAndRemainsReplaceable(): void
{
$stream = \Nyholm\Psr7\Stream::create('not json');
$stream->seek(2);
$response = (new JsonResponse())->withBody($stream);
self::assertSame(['recovered' => true], $response->withPayload(['recovered' => true])->getPayload());
try {
$response->getPayload();
self::fail('Invalid JSON must not be silently accepted.');
} catch (\JsonException) {
self::assertSame(2, $stream->tell());
}
}

#[Test]
public function detachedGenericBodyHasAnExplicitStreamErrorAndRemainsReplaceable(): void
{
$stream = \Nyholm\Psr7\Stream::create('{}');
fclose($stream->detach());
$response = (new JsonResponse())->withBody($stream);
self::assertSame('recovered', $response->withPayload('recovered')->getPayload());
$this->expectException(\RuntimeException::class);
$response->getPayload();
}

#[Test]
public function genericJsonNullIsNotConfusedWithInvalidJson(): void
{
$response = (new JsonResponse())->withBody(\Nyholm\Psr7\Stream::create('null'));
self::assertNull($response->getPayload());
}

#[Test]
public function nonSeekableGenericBodyIsNotConsumed(): void
{
$body = $this->createMock(\Psr\Http\Message\StreamInterface::class);
$body->method('isReadable')->willReturn(true);
$body->method('isSeekable')->willReturn(false);
$body->expects(self::never())->method('getContents');
$response = (new JsonResponse())->withBody($body);
self::assertSame(['new' => true], $response->withPayload(['new' => true])->getPayload());
$this->expectException(\RuntimeException::class);
$response->getPayload();
}

#[Test]
public function payloadReplacementPreservesExistingJsonStreamEncodingOptionsAndHeaders(): void
{
$stream = new \FastForward\Http\Message\JsonStream(['old' => true], \JSON_PRETTY_PRINT);
$response = (new JsonResponse([], 'iso-8859-1'))->withBody($stream);
$updated = $response->withPayload(['new' => true]);
self::assertStringContainsString("\n", (string) $updated->getBody());
self::assertSame($response->getHeaders(), $updated->getHeaders());
self::assertSame(['old' => true], $response->getPayload());
}
}
Loading