Skip to content

Commit 7d8700f

Browse files
alexander-schranzGromNaNro0NLstofderrabus
committed
Add StreamedJsonResponse for efficient JSON streaming
Co-authored-by: Alexander Schranz <alexander@sulu.io> Co-authored-by: Jérôme Tamarelle <jerome@tamarelle.net> Co-authored-by: Roland Franssen <franssen.roland@gmail.com> Co-authored-by: Christophe Coevoet <stof@notk.org> Co-authored-by: Alexander M. tTurek <me@derrabus.de> Co-authored-by: Jules Pietri <heah@heahprod.com> Co-authored-by: Oskar Stark <oskarstark@googlemail.com> Co-authored-by: Robin Chalas <robin.chalas@gmail.com> Co-authored-by: Jérémy Derussé <jeremy@derusse.com> Co-authored-by: Nicolas Grekas <nicolas.grekas@gmail.com>
1 parent 934948f commit 7d8700f

File tree

3 files changed

+362
-0
lines changed

3 files changed

+362
-0
lines changed

src/Symfony/Component/HttpFoundation/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
6.2
55
---
66

7+
* Add `StreamedJsonResponse` class for efficient JSON streaming
78
* The HTTP cache store uses the `xxh128` algorithm
89
* Deprecate calling `JsonResponse::setCallback()`, `Response::setExpires/setLastModified/setEtag()`, `MockArraySessionStorage/NativeSessionStorage::setMetadataBag()`, `NativeSessionStorage::setSaveHandler()` without arguments
910
* Add request matchers under the `Symfony\Component\HttpFoundation\RequestMatcher` namespace
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpFoundation;
13+
14+
/**
15+
* StreamedJsonResponse represents a streamed HTTP response for JSON.
16+
*
17+
* A StreamedJsonResponse uses a structure and generics to create an
18+
* efficient resource-saving JSON response.
19+
*
20+
* It is recommended to use flush() function after a specific number of items to directly stream the data.
21+
*
22+
* @see flush()
23+
*
24+
* @author Alexander Schranz <alexander@sulu.io>
25+
*
26+
* Example usage:
27+
*
28+
* function loadArticles(): \Generator
29+
* // some streamed loading
30+
* yield ['title' => 'Article 1'];
31+
* yield ['title' => 'Article 2'];
32+
* yield ['title' => 'Article 3'];
33+
* // recommended to use flush() after every specific number of items
34+
* }),
35+
*
36+
* $response = new StreamedJsonResponse(
37+
* // json structure with generators in which will be streamed
38+
* [
39+
* '_embedded' => [
40+
* 'articles' => loadArticles(), // any generator which you want to stream as list of data
41+
* ],
42+
* ],
43+
* );
44+
*/
45+
class StreamedJsonResponse extends StreamedResponse
46+
{
47+
private const PLACEHOLDER = '__symfony_json__';
48+
49+
/**
50+
* @param mixed[] $data JSON Data containing PHP generators which will be streamed as list of data
51+
* @param int $status The HTTP status code (200 "OK" by default)
52+
* @param array<string, string|string[]> $headers An array of HTTP headers
53+
* @param int $encodingOptions Flags for the json_encode() function
54+
*/
55+
public function __construct(
56+
private readonly array $data,
57+
int $status = 200,
58+
array $headers = [],
59+
private int $encodingOptions = JsonResponse::DEFAULT_ENCODING_OPTIONS,
60+
) {
61+
parent::__construct($this->stream(...), $status, $headers);
62+
63+
if (!$this->headers->get('Content-Type')) {
64+
$this->headers->set('Content-Type', 'application/json');
65+
}
66+
}
67+
68+
private function stream(): void
69+
{
70+
$generators = [];
71+
$structure = $this->data;
72+
73+
array_walk_recursive($structure, function (&$item, $key) use (&$generators) {
74+
if (self::PLACEHOLDER === $key) {
75+
// if the placeholder is already in the structure it should be replaced with a new one that explode
76+
// works like expected for the structure
77+
$generators[] = $item;
78+
}
79+
80+
// generators should be used but for better DX all kind of Traversable are supported
81+
if ($item instanceof \Traversable || $item instanceof \JsonSerializable) {
82+
$generators[] = $item;
83+
$item = self::PLACEHOLDER;
84+
} elseif (self::PLACEHOLDER === $item) {
85+
// if the placeholder is already in the structure it should be replaced with a new one that explode
86+
// works like expected for the structure
87+
$generators[] = $item;
88+
}
89+
});
90+
91+
$jsonEncodingOptions = \JSON_THROW_ON_ERROR | $this->encodingOptions;
92+
$keyEncodingOptions = $jsonEncodingOptions & ~\JSON_NUMERIC_CHECK;
93+
94+
$jsonParts = explode('"'.self::PLACEHOLDER.'"', json_encode($structure, $jsonEncodingOptions));
95+
96+
foreach ($generators as $index => $generator) {
97+
// send first and between parts of the structure
98+
echo $jsonParts[$index];
99+
100+
if (self::PLACEHOLDER === $generator || $generator instanceof \JsonSerializable) {
101+
// the placeholders and JsonSerializable items in the structure are rendered here
102+
echo json_encode($generator, $jsonEncodingOptions);
103+
104+
continue;
105+
}
106+
107+
$isFirstItem = true;
108+
$startTag = '[';
109+
foreach ($generator as $key => $item) {
110+
if ($isFirstItem) {
111+
$isFirstItem = false;
112+
// depending on the first elements key the generator is detected as a list or map
113+
// we can not check for a whole list or map because that would hurt the performance
114+
// of the streamed response which is the main goal of this response class
115+
if (0 !== $key) {
116+
$startTag = '{';
117+
}
118+
119+
echo $startTag;
120+
} else {
121+
// if not first element of the generic, a separator is required between the elements
122+
echo ',';
123+
}
124+
125+
if ('{' === $startTag) {
126+
echo json_encode((string) $key, $keyEncodingOptions).':';
127+
}
128+
129+
echo json_encode($item, $jsonEncodingOptions);
130+
}
131+
132+
echo '[' === $startTag ? ']' : '}';
133+
}
134+
135+
// send last part of the structure
136+
echo $jsonParts[array_key_last($jsonParts)];
137+
}
138+
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpFoundation\Tests;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\HttpFoundation\StreamedJsonResponse;
16+
17+
class StreamedJsonResponseTest extends TestCase
18+
{
19+
public function testResponseSimpleList()
20+
{
21+
$content = $this->createSendResponse(
22+
[
23+
'_embedded' => [
24+
'articles' => $this->generatorSimple('Article'),
25+
'news' => $this->generatorSimple('News'),
26+
],
27+
],
28+
);
29+
30+
$this->assertSame('{"_embedded":{"articles":["Article 1","Article 2","Article 3"],"news":["News 1","News 2","News 3"]}}', $content);
31+
}
32+
33+
public function testResponseObjectsList()
34+
{
35+
$content = $this->createSendResponse(
36+
[
37+
'_embedded' => [
38+
'articles' => $this->generatorArray('Article'),
39+
],
40+
],
41+
);
42+
43+
$this->assertSame('{"_embedded":{"articles":[{"title":"Article 1"},{"title":"Article 2"},{"title":"Article 3"}]}}', $content);
44+
}
45+
46+
public function testResponseWithoutGenerator()
47+
{
48+
// while it is not the intended usage, all kind of iterables should be supported for good DX
49+
$content = $this->createSendResponse(
50+
[
51+
'_embedded' => [
52+
'articles' => ['Article 1', 'Article 2', 'Article 3'],
53+
],
54+
],
55+
);
56+
57+
$this->assertSame('{"_embedded":{"articles":["Article 1","Article 2","Article 3"]}}', $content);
58+
}
59+
60+
public function testResponseWithPlaceholder()
61+
{
62+
// the placeholder must not conflict with generator injection
63+
$content = $this->createSendResponse(
64+
[
65+
'_embedded' => [
66+
'articles' => $this->generatorArray('Article'),
67+
'placeholder' => '__symfony_json__',
68+
'news' => $this->generatorSimple('News'),
69+
],
70+
'placeholder' => '__symfony_json__',
71+
],
72+
);
73+
74+
$this->assertSame('{"_embedded":{"articles":[{"title":"Article 1"},{"title":"Article 2"},{"title":"Article 3"}],"placeholder":"__symfony_json__","news":["News 1","News 2","News 3"]},"placeholder":"__symfony_json__"}', $content);
75+
}
76+
77+
public function testResponseWithMixedKeyType()
78+
{
79+
$content = $this->createSendResponse(
80+
[
81+
'_embedded' => [
82+
'list' => (function (): \Generator {
83+
yield 0 => 'test';
84+
yield 'key' => 'value';
85+
})(),
86+
'map' => (function (): \Generator {
87+
yield 'key' => 'value';
88+
yield 0 => 'test';
89+
})(),
90+
'integer' => (function (): \Generator {
91+
yield 1 => 'one';
92+
yield 3 => 'three';
93+
})(),
94+
],
95+
]
96+
);
97+
98+
$this->assertSame('{"_embedded":{"list":["test","value"],"map":{"key":"value","0":"test"},"integer":{"1":"one","3":"three"}}}', $content);
99+
}
100+
101+
public function testResponseOtherTraversable()
102+
{
103+
$arrayObject = new \ArrayObject(['__symfony_json__' => '__symfony_json__']);
104+
105+
$iteratorAggregate = new class() implements \IteratorAggregate {
106+
public function getIterator(): \Traversable
107+
{
108+
return new \ArrayIterator(['__symfony_json__']);
109+
}
110+
};
111+
112+
$jsonSerializable = new class() implements \IteratorAggregate, \JsonSerializable {
113+
public function getIterator(): \Traversable
114+
{
115+
return new \ArrayIterator(['This should be ignored']);
116+
}
117+
118+
public function jsonSerialize(): mixed
119+
{
120+
return ['__symfony_json__' => '__symfony_json__'];
121+
}
122+
};
123+
124+
// while Generators should be used for performance reasons, the object should also work with any Traversable
125+
// to make things easier for a developer
126+
$content = $this->createSendResponse(
127+
[
128+
'arrayObject' => $arrayObject,
129+
'iteratorAggregate' => $iteratorAggregate,
130+
'jsonSerializable' => $jsonSerializable,
131+
// add a Generator to make sure it still work in combination with other Traversable objects
132+
'articles' => $this->generatorArray('Article'),
133+
],
134+
);
135+
136+
$this->assertSame('{"arrayObject":{"__symfony_json__":"__symfony_json__"},"iteratorAggregate":["__symfony_json__"],"jsonSerializable":{"__symfony_json__":"__symfony_json__"},"articles":[{"title":"Article 1"},{"title":"Article 2"},{"title":"Article 3"}]}', $content);
137+
}
138+
139+
public function testPlaceholderAsKeyAndValueInStructure()
140+
{
141+
$content = $this->createSendResponse(
142+
[
143+
'__symfony_json__' => '__symfony_json__',
144+
'articles' => $this->generatorArray('Article'),
145+
],
146+
);
147+
148+
$this->assertSame('{"__symfony_json__":"__symfony_json__","articles":[{"title":"Article 1"},{"title":"Article 2"},{"title":"Article 3"}]}', $content);
149+
}
150+
151+
public function testResponseStatusCode()
152+
{
153+
$response = new StreamedJsonResponse([], 201);
154+
155+
$this->assertSame(201, $response->getStatusCode());
156+
}
157+
158+
public function testResponseHeaders()
159+
{
160+
$response = new StreamedJsonResponse([], 200, ['X-Test' => 'Test']);
161+
162+
$this->assertSame('Test', $response->headers->get('X-Test'));
163+
}
164+
165+
public function testCustomContentType()
166+
{
167+
$response = new StreamedJsonResponse([], 200, ['Content-Type' => 'application/json+stream']);
168+
169+
$this->assertSame('application/json+stream', $response->headers->get('Content-Type'));
170+
}
171+
172+
public function testEncodingOptions()
173+
{
174+
$response = new StreamedJsonResponse([
175+
'_embedded' => [
176+
'count' => '2', // options are applied to the initial json encode
177+
'values' => (function (): \Generator {
178+
yield 'with/unescaped/slash' => 'With/a/slash'; // options are applied to key and values
179+
yield '3' => '3'; // numeric check for value, but not for the key
180+
})(),
181+
],
182+
], encodingOptions: \JSON_UNESCAPED_SLASHES | \JSON_NUMERIC_CHECK);
183+
184+
ob_start();
185+
$response->send();
186+
$content = ob_get_clean();
187+
188+
$this->assertSame('{"_embedded":{"count":2,"values":{"with/unescaped/slash":"With/a/slash","3":3}}}', $content);
189+
}
190+
191+
/**
192+
* @param mixed[] $data
193+
*/
194+
private function createSendResponse(array $data): string
195+
{
196+
$response = new StreamedJsonResponse($data);
197+
198+
ob_start();
199+
$response->send();
200+
201+
return ob_get_clean();
202+
}
203+
204+
/**
205+
* @return \Generator<int, string>
206+
*/
207+
private function generatorSimple(string $test): \Generator
208+
{
209+
yield $test.' 1';
210+
yield $test.' 2';
211+
yield $test.' 3';
212+
}
213+
214+
/**
215+
* @return \Generator<int, array{title: string}>
216+
*/
217+
private function generatorArray(string $test): \Generator
218+
{
219+
yield ['title' => $test.' 1'];
220+
yield ['title' => $test.' 2'];
221+
yield ['title' => $test.' 3'];
222+
}
223+
}

0 commit comments

Comments
 (0)