Skip to content

Commit d62604d

Browse files
committed
support root-level Generator in StreamedJsonResponse
1 parent d05a040 commit d62604d

File tree

3 files changed

+79
-42
lines changed

3 files changed

+79
-42
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ CHANGELOG
55
---
66

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

910
6.3
1011
---

StreamedJsonResponse.php

+59-40
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,13 @@ class StreamedJsonResponse extends StreamedResponse
4747
private const PLACEHOLDER = '__symfony_json__';
4848

4949
/**
50-
* @param mixed[] $data JSON Data containing PHP generators which will be streamed as list of data
50+
* @param mixed[] $data JSON Data containing PHP generators which will be streamed as list of data or a Generator
5151
* @param int $status The HTTP status code (200 "OK" by default)
5252
* @param array<string, string|string[]> $headers An array of HTTP headers
5353
* @param int $encodingOptions Flags for the json_encode() function
5454
*/
5555
public function __construct(
56-
private readonly array $data,
56+
private readonly iterable $data,
5757
int $status = 200,
5858
array $headers = [],
5959
private int $encodingOptions = JsonResponse::DEFAULT_ENCODING_OPTIONS,
@@ -66,11 +66,35 @@ public function __construct(
6666
}
6767

6868
private function stream(): void
69+
{
70+
$jsonEncodingOptions = \JSON_THROW_ON_ERROR | $this->encodingOptions;
71+
$keyEncodingOptions = $jsonEncodingOptions & ~\JSON_NUMERIC_CHECK;
72+
73+
$this->streamData($this->data, $jsonEncodingOptions, $keyEncodingOptions);
74+
}
75+
76+
private function streamData(mixed $data, int $jsonEncodingOptions, int $keyEncodingOptions): void
77+
{
78+
if (\is_array($data)) {
79+
$this->streamArray($data, $jsonEncodingOptions, $keyEncodingOptions);
80+
81+
return;
82+
}
83+
84+
if (is_iterable($data) && !$data instanceof \JsonSerializable) {
85+
$this->streamIterable($data, $jsonEncodingOptions, $keyEncodingOptions);
86+
87+
return;
88+
}
89+
90+
echo json_encode($data, $jsonEncodingOptions);
91+
}
92+
93+
private function streamArray(array $data, int $jsonEncodingOptions, int $keyEncodingOptions): void
6994
{
7095
$generators = [];
71-
$structure = $this->data;
7296

73-
array_walk_recursive($structure, function (&$item, $key) use (&$generators) {
97+
array_walk_recursive($data, function (&$item, $key) use (&$generators) {
7498
if (self::PLACEHOLDER === $key) {
7599
// if the placeholder is already in the structure it should be replaced with a new one that explode
76100
// works like expected for the structure
@@ -88,56 +112,51 @@ private function stream(): void
88112
}
89113
});
90114

91-
$jsonEncodingOptions = \JSON_THROW_ON_ERROR | $this->encodingOptions;
92-
$keyEncodingOptions = $jsonEncodingOptions & ~\JSON_NUMERIC_CHECK;
93-
94-
$jsonParts = explode('"'.self::PLACEHOLDER.'"', json_encode($structure, $jsonEncodingOptions));
115+
$jsonParts = explode('"'.self::PLACEHOLDER.'"', json_encode($data, $jsonEncodingOptions));
95116

96117
foreach ($generators as $index => $generator) {
97118
// send first and between parts of the structure
98119
echo $jsonParts[$index];
99120

100-
if ($generator instanceof \JsonSerializable || !$generator instanceof \Traversable) {
101-
// the placeholders, JsonSerializable and none traversable items in the structure are rendered here
102-
echo json_encode($generator, $jsonEncodingOptions);
103-
104-
continue;
105-
}
121+
$this->streamData($generator, $jsonEncodingOptions, $keyEncodingOptions);
122+
}
106123

107-
$isFirstItem = true;
108-
$startTag = '[';
109-
110-
foreach ($generator as $key => $item) {
111-
if ($isFirstItem) {
112-
$isFirstItem = false;
113-
// depending on the first elements key the generator is detected as a list or map
114-
// we can not check for a whole list or map because that would hurt the performance
115-
// of the streamed response which is the main goal of this response class
116-
if (0 !== $key) {
117-
$startTag = '{';
118-
}
119-
120-
echo $startTag;
121-
} else {
122-
// if not first element of the generic, a separator is required between the elements
123-
echo ',';
124-
}
124+
// send last part of the structure
125+
echo $jsonParts[array_key_last($jsonParts)];
126+
}
125127

126-
if ('{' === $startTag) {
127-
echo json_encode((string) $key, $keyEncodingOptions).':';
128+
private function streamIterable(iterable $iterable, int $jsonEncodingOptions, int $keyEncodingOptions): void
129+
{
130+
$isFirstItem = true;
131+
$startTag = '[';
132+
133+
foreach ($iterable as $key => $item) {
134+
if ($isFirstItem) {
135+
$isFirstItem = false;
136+
// depending on the first elements key the generator is detected as a list or map
137+
// we can not check for a whole list or map because that would hurt the performance
138+
// of the streamed response which is the main goal of this response class
139+
if (0 !== $key) {
140+
$startTag = '{';
128141
}
129142

130-
echo json_encode($item, $jsonEncodingOptions);
143+
echo $startTag;
144+
} else {
145+
// if not first element of the generic, a separator is required between the elements
146+
echo ',';
131147
}
132148

133-
if ($isFirstItem) { // indicates that the generator was empty
134-
echo '[';
149+
if ('{' === $startTag) {
150+
echo json_encode((string) $key, $keyEncodingOptions).':';
135151
}
136152

137-
echo '[' === $startTag ? ']' : '}';
153+
$this->streamData($item, $jsonEncodingOptions, $keyEncodingOptions);
138154
}
139155

140-
// send last part of the structure
141-
echo $jsonParts[array_key_last($jsonParts)];
156+
if ($isFirstItem) { // indicates that the generator was empty
157+
echo '[';
158+
}
159+
160+
echo '[' === $startTag ? ']' : '}';
142161
}
143162
}

Tests/StreamedJsonResponseTest.php

+19-2
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,23 @@ public function testResponseSimpleList()
3030
$this->assertSame('{"_embedded":{"articles":["Article 1","Article 2","Article 3"],"news":["News 1","News 2","News 3"]}}', $content);
3131
}
3232

33+
public function testResponseSimpleGenerator()
34+
{
35+
$content = $this->createSendResponse($this->generatorSimple('Article'));
36+
37+
$this->assertSame('["Article 1","Article 2","Article 3"]', $content);
38+
}
39+
40+
public function testResponseNestedGenerator()
41+
{
42+
$content = $this->createSendResponse((function (): iterable {
43+
yield 'articles' => $this->generatorSimple('Article');
44+
yield 'news' => $this->generatorSimple('News');
45+
})());
46+
47+
$this->assertSame('{"articles":["Article 1","Article 2","Article 3"],"news":["News 1","News 2","News 3"]}', $content);
48+
}
49+
3350
public function testResponseEmptyList()
3451
{
3552
$content = $this->createSendResponse(
@@ -220,9 +237,9 @@ public function testEncodingOptions()
220237
}
221238

222239
/**
223-
* @param mixed[] $data
240+
* @param iterable<mixed> $data
224241
*/
225-
private function createSendResponse(array $data): string
242+
private function createSendResponse(iterable $data): string
226243
{
227244
$response = new StreamedJsonResponse($data);
228245

0 commit comments

Comments
 (0)