From 05e582f1a380bf07d055d49e64e6a01de054beb6 Mon Sep 17 00:00:00 2001 From: Jeroeny Date: Sat, 2 Sep 2023 16:16:41 +0200 Subject: [PATCH] support root-level Generator in StreamedJsonResponse --- .../Component/HttpFoundation/CHANGELOG.md | 1 + .../HttpFoundation/StreamedJsonResponse.php | 99 +++++++++++-------- .../Tests/StreamedJsonResponseTest.php | 21 +++- 3 files changed, 79 insertions(+), 42 deletions(-) diff --git a/src/Symfony/Component/HttpFoundation/CHANGELOG.md b/src/Symfony/Component/HttpFoundation/CHANGELOG.md index 5f1f6d5ce86a1..04267c3e975db 100644 --- a/src/Symfony/Component/HttpFoundation/CHANGELOG.md +++ b/src/Symfony/Component/HttpFoundation/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Make `HeaderBag::getDate()`, `Response::getDate()`, `getExpires()` and `getLastModified()` return a `DateTimeImmutable` + * Support root-level `Generator` in `StreamedJsonResponse` 6.3 --- diff --git a/src/Symfony/Component/HttpFoundation/StreamedJsonResponse.php b/src/Symfony/Component/HttpFoundation/StreamedJsonResponse.php index cf858a5eb70a9..5b20ce910a5ae 100644 --- a/src/Symfony/Component/HttpFoundation/StreamedJsonResponse.php +++ b/src/Symfony/Component/HttpFoundation/StreamedJsonResponse.php @@ -47,13 +47,13 @@ class StreamedJsonResponse extends StreamedResponse private const PLACEHOLDER = '__symfony_json__'; /** - * @param mixed[] $data JSON Data containing PHP generators which will be streamed as list of data + * @param mixed[] $data JSON Data containing PHP generators which will be streamed as list of data or a Generator * @param int $status The HTTP status code (200 "OK" by default) * @param array $headers An array of HTTP headers * @param int $encodingOptions Flags for the json_encode() function */ public function __construct( - private readonly array $data, + private readonly iterable $data, int $status = 200, array $headers = [], private int $encodingOptions = JsonResponse::DEFAULT_ENCODING_OPTIONS, @@ -66,11 +66,35 @@ public function __construct( } private function stream(): void + { + $jsonEncodingOptions = \JSON_THROW_ON_ERROR | $this->encodingOptions; + $keyEncodingOptions = $jsonEncodingOptions & ~\JSON_NUMERIC_CHECK; + + $this->streamData($this->data, $jsonEncodingOptions, $keyEncodingOptions); + } + + private function streamData(mixed $data, int $jsonEncodingOptions, int $keyEncodingOptions): void + { + if (\is_array($data)) { + $this->streamArray($data, $jsonEncodingOptions, $keyEncodingOptions); + + return; + } + + if (is_iterable($data) && !$data instanceof \JsonSerializable) { + $this->streamIterable($data, $jsonEncodingOptions, $keyEncodingOptions); + + return; + } + + echo json_encode($data, $jsonEncodingOptions); + } + + private function streamArray(array $data, int $jsonEncodingOptions, int $keyEncodingOptions): void { $generators = []; - $structure = $this->data; - array_walk_recursive($structure, function (&$item, $key) use (&$generators) { + array_walk_recursive($data, function (&$item, $key) use (&$generators) { if (self::PLACEHOLDER === $key) { // if the placeholder is already in the structure it should be replaced with a new one that explode // works like expected for the structure @@ -88,56 +112,51 @@ private function stream(): void } }); - $jsonEncodingOptions = \JSON_THROW_ON_ERROR | $this->encodingOptions; - $keyEncodingOptions = $jsonEncodingOptions & ~\JSON_NUMERIC_CHECK; - - $jsonParts = explode('"'.self::PLACEHOLDER.'"', json_encode($structure, $jsonEncodingOptions)); + $jsonParts = explode('"'.self::PLACEHOLDER.'"', json_encode($data, $jsonEncodingOptions)); foreach ($generators as $index => $generator) { // send first and between parts of the structure echo $jsonParts[$index]; - if ($generator instanceof \JsonSerializable || !$generator instanceof \Traversable) { - // the placeholders, JsonSerializable and none traversable items in the structure are rendered here - echo json_encode($generator, $jsonEncodingOptions); - - continue; - } + $this->streamData($generator, $jsonEncodingOptions, $keyEncodingOptions); + } - $isFirstItem = true; - $startTag = '['; - - foreach ($generator as $key => $item) { - if ($isFirstItem) { - $isFirstItem = false; - // depending on the first elements key the generator is detected as a list or map - // we can not check for a whole list or map because that would hurt the performance - // of the streamed response which is the main goal of this response class - if (0 !== $key) { - $startTag = '{'; - } - - echo $startTag; - } else { - // if not first element of the generic, a separator is required between the elements - echo ','; - } + // send last part of the structure + echo $jsonParts[array_key_last($jsonParts)]; + } - if ('{' === $startTag) { - echo json_encode((string) $key, $keyEncodingOptions).':'; + private function streamIterable(iterable $iterable, int $jsonEncodingOptions, int $keyEncodingOptions): void + { + $isFirstItem = true; + $startTag = '['; + + foreach ($iterable as $key => $item) { + if ($isFirstItem) { + $isFirstItem = false; + // depending on the first elements key the generator is detected as a list or map + // we can not check for a whole list or map because that would hurt the performance + // of the streamed response which is the main goal of this response class + if (0 !== $key) { + $startTag = '{'; } - echo json_encode($item, $jsonEncodingOptions); + echo $startTag; + } else { + // if not first element of the generic, a separator is required between the elements + echo ','; } - if ($isFirstItem) { // indicates that the generator was empty - echo '['; + if ('{' === $startTag) { + echo json_encode((string) $key, $keyEncodingOptions).':'; } - echo '[' === $startTag ? ']' : '}'; + $this->streamData($item, $jsonEncodingOptions, $keyEncodingOptions); } - // send last part of the structure - echo $jsonParts[array_key_last($jsonParts)]; + if ($isFirstItem) { // indicates that the generator was empty + echo '['; + } + + echo '[' === $startTag ? ']' : '}'; } } diff --git a/src/Symfony/Component/HttpFoundation/Tests/StreamedJsonResponseTest.php b/src/Symfony/Component/HttpFoundation/Tests/StreamedJsonResponseTest.php index 046f7dae434f9..db76cd3ae8a27 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/StreamedJsonResponseTest.php +++ b/src/Symfony/Component/HttpFoundation/Tests/StreamedJsonResponseTest.php @@ -30,6 +30,23 @@ public function testResponseSimpleList() $this->assertSame('{"_embedded":{"articles":["Article 1","Article 2","Article 3"],"news":["News 1","News 2","News 3"]}}', $content); } + public function testResponseSimpleGenerator() + { + $content = $this->createSendResponse($this->generatorSimple('Article')); + + $this->assertSame('["Article 1","Article 2","Article 3"]', $content); + } + + public function testResponseNestedGenerator() + { + $content = $this->createSendResponse((function (): iterable { + yield 'articles' => $this->generatorSimple('Article'); + yield 'news' => $this->generatorSimple('News'); + })()); + + $this->assertSame('{"articles":["Article 1","Article 2","Article 3"],"news":["News 1","News 2","News 3"]}', $content); + } + public function testResponseEmptyList() { $content = $this->createSendResponse( @@ -220,9 +237,9 @@ public function testEncodingOptions() } /** - * @param mixed[] $data + * @param iterable $data */ - private function createSendResponse(array $data): string + private function createSendResponse(iterable $data): string { $response = new StreamedJsonResponse($data);