diff --git a/.changelog/json-response-body.md b/.changelog/json-response-body.md new file mode 100644 index 0000000..224ffd4 --- /dev/null +++ b/.changelog/json-response-body.md @@ -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. diff --git a/src/JsonResponse.php b/src/JsonResponse.php index 1a726d5..19955ea 100644 --- a/src/JsonResponse.php +++ b/src/JsonResponse.php @@ -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 { @@ -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); + } } /** @@ -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) + ); } } diff --git a/tests/JsonResponseTest.php b/tests/JsonResponseTest.php index 2001d17..107f6c5 100644 --- a/tests/JsonResponseTest.php +++ b/tests/JsonResponseTest.php @@ -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()); + } }