Skip to content

[HttpFoundation] Support root-level Generator in StreamedJsonResponse #51538

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Oct 1, 2023
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
1 change: 1 addition & 0 deletions src/Symfony/Component/HttpFoundation/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ CHANGELOG
---

* Make `HeaderBag::getDate()`, `Response::getDate()`, `getExpires()` and `getLastModified()` return a `DateTimeImmutable`
* Support root-level `Generator` in `StreamedJsonResponse`

6.3
---
Expand Down
99 changes: 59 additions & 40 deletions src/Symfony/Component/HttpFoundation/StreamedJsonResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string|string[]> $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,
Expand All @@ -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
Expand All @@ -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 ? ']' : '}';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -220,9 +237,9 @@ public function testEncodingOptions()
}

/**
* @param mixed[] $data
* @param iterable<mixed> $data
*/
private function createSendResponse(array $data): string
private function createSendResponse(iterable $data): string
{
$response = new StreamedJsonResponse($data);

Expand Down