Skip to content

Commit c99c4ec

Browse files
committed
Merge branch '6.4' into 7.0
* 6.4: (24 commits) Fix Twig tests [Scheduler] Fix tests [TwigBridge] Add FormLayoutTestCase class Fix CS Allow sending scheduled messages through the slack API [TwigBridge] Add `AppVariable::getEnabledLocales()` to retrieve the enabled locales [RateLimiter] Add missing dependency [RateLimiter] Add SlidingWindowLimiter::reserve() [Messenger] Add WrappedExceptionsInterface for nested exceptions [Mime] Add `TemplatedEmail::locale()` to set the locale for the email rendering Fix CS [Messenger][Scheduler] Add AsCronTask & AsPeriodicTask attributes [Scheduler] Make debug:scheduler output more useful [Notifier] Tweak some phpdocs [FrameworkBundle] Change BrowserKitAssertionsTrait::getClient() to be protected Fix CS [FrameworkBundle] Allow BrowserKit relative URL redirect assert [Messenger] RejectRedeliveredMessageException should not be retried [Serializer] Make `ProblemNormalizer` give details about Messenger’s `ValidationFailedException` [WebProfilerBundle] Support `!` negation operator in url filter ...
2 parents d837e12 + 5ac0202 commit c99c4ec

File tree

5 files changed

+281
-42
lines changed

5 files changed

+281
-42
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ CHANGELOG
1616
---
1717

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

2021
6.3
2122
---

StreamedJsonResponse.php

Lines changed: 59 additions & 40 deletions
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
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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\Test\Constraint;
13+
14+
use PHPUnit\Framework\Constraint\Constraint;
15+
use Symfony\Component\HttpFoundation\Request;
16+
use Symfony\Component\HttpFoundation\Response;
17+
18+
final class ResponseHeaderLocationSame extends Constraint
19+
{
20+
public function __construct(private Request $request, private string $expectedValue)
21+
{
22+
}
23+
24+
public function toString(): string
25+
{
26+
return sprintf('has header "Location" matching "%s"', $this->expectedValue);
27+
}
28+
29+
protected function matches($other): bool
30+
{
31+
if (!$other instanceof Response) {
32+
return false;
33+
}
34+
35+
$location = $other->headers->get('Location');
36+
37+
if (null === $location) {
38+
return false;
39+
}
40+
41+
return $this->toFullUrl($this->expectedValue) === $this->toFullUrl($location);
42+
}
43+
44+
protected function failureDescription($other): string
45+
{
46+
return 'the Response '.$this->toString();
47+
}
48+
49+
private function toFullUrl(string $url): string
50+
{
51+
if (null === parse_url($url, \PHP_URL_PATH)) {
52+
$url .= '/';
53+
}
54+
55+
if (str_starts_with($url, '//')) {
56+
return sprintf('%s:%s', $this->request->getScheme(), $url);
57+
}
58+
59+
if (str_starts_with($url, '/')) {
60+
return $this->request->getSchemeAndHttpHost().$url;
61+
}
62+
63+
return $url;
64+
}
65+
}

Tests/StreamedJsonResponseTest.php

Lines changed: 19 additions & 2 deletions
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

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
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\Test\Constraint;
13+
14+
use PHPUnit\Framework\ExpectationFailedException;
15+
use PHPUnit\Framework\TestCase;
16+
use Symfony\Component\HttpFoundation\Request;
17+
use Symfony\Component\HttpFoundation\Response;
18+
use Symfony\Component\HttpFoundation\Test\Constraint\ResponseHeaderLocationSame;
19+
20+
class ResponseHeaderLocationSameTest extends TestCase
21+
{
22+
/**
23+
* @dataProvider provideSuccessCases
24+
*/
25+
public function testConstraintSuccess(string $requestUrl, ?string $location, string $expectedLocation)
26+
{
27+
$request = Request::create($requestUrl);
28+
29+
$response = new Response();
30+
if (null !== $location) {
31+
$response->headers->set('Location', $location);
32+
}
33+
34+
$constraint = new ResponseHeaderLocationSame($request, $expectedLocation);
35+
36+
self::assertTrue($constraint->evaluate($response, '', true));
37+
}
38+
39+
public function provideSuccessCases(): iterable
40+
{
41+
yield ['http://example.com', 'http://example.com', 'http://example.com'];
42+
yield ['http://example.com', 'http://example.com', '//example.com'];
43+
yield ['http://example.com', 'http://example.com', '/'];
44+
yield ['http://example.com', '//example.com', 'http://example.com'];
45+
yield ['http://example.com', '//example.com', '//example.com'];
46+
yield ['http://example.com', '//example.com', '/'];
47+
yield ['http://example.com', '/', 'http://example.com'];
48+
yield ['http://example.com', '/', '//example.com'];
49+
yield ['http://example.com', '/', '/'];
50+
51+
yield ['http://example.com/', 'http://example.com/', 'http://example.com/'];
52+
yield ['http://example.com/', 'http://example.com/', '//example.com/'];
53+
yield ['http://example.com/', 'http://example.com/', '/'];
54+
yield ['http://example.com/', '//example.com/', 'http://example.com/'];
55+
yield ['http://example.com/', '//example.com/', '//example.com/'];
56+
yield ['http://example.com/', '//example.com/', '/'];
57+
yield ['http://example.com/', '/', 'http://example.com/'];
58+
yield ['http://example.com/', '/', '//example.com/'];
59+
yield ['http://example.com/', '/', '/'];
60+
61+
yield ['http://example.com/foo', 'http://example.com/', 'http://example.com/'];
62+
yield ['http://example.com/foo', 'http://example.com/', '//example.com/'];
63+
yield ['http://example.com/foo', 'http://example.com/', '/'];
64+
yield ['http://example.com/foo', '//example.com/', 'http://example.com/'];
65+
yield ['http://example.com/foo', '//example.com/', '//example.com/'];
66+
yield ['http://example.com/foo', '//example.com/', '/'];
67+
yield ['http://example.com/foo', '/', 'http://example.com/'];
68+
yield ['http://example.com/foo', '/', '//example.com/'];
69+
yield ['http://example.com/foo', '/', '/'];
70+
71+
yield ['http://example.com/foo', 'http://example.com/bar', 'http://example.com/bar'];
72+
yield ['http://example.com/foo', 'http://example.com/bar', '//example.com/bar'];
73+
yield ['http://example.com/foo', 'http://example.com/bar', '/bar'];
74+
yield ['http://example.com/foo', '//example.com/bar', 'http://example.com/bar'];
75+
yield ['http://example.com/foo', '//example.com/bar', '//example.com/bar'];
76+
yield ['http://example.com/foo', '//example.com/bar', '/bar'];
77+
yield ['http://example.com/foo', '/bar', 'http://example.com/bar'];
78+
yield ['http://example.com/foo', '/bar', '//example.com/bar'];
79+
yield ['http://example.com/foo', '/bar', '/bar'];
80+
81+
yield ['http://example.com', 'http://example.com/bar', 'http://example.com/bar'];
82+
yield ['http://example.com', 'http://example.com/bar', '//example.com/bar'];
83+
yield ['http://example.com', 'http://example.com/bar', '/bar'];
84+
yield ['http://example.com', '//example.com/bar', 'http://example.com/bar'];
85+
yield ['http://example.com', '//example.com/bar', '//example.com/bar'];
86+
yield ['http://example.com', '//example.com/bar', '/bar'];
87+
yield ['http://example.com', '/bar', 'http://example.com/bar'];
88+
yield ['http://example.com', '/bar', '//example.com/bar'];
89+
yield ['http://example.com', '/bar', '/bar'];
90+
91+
yield ['http://example.com/', 'http://another-example.com', 'http://another-example.com'];
92+
}
93+
94+
/**
95+
* @dataProvider provideFailureCases
96+
*/
97+
public function testConstraintFailure(string $requestUrl, ?string $location, string $expectedLocation)
98+
{
99+
$request = Request::create($requestUrl);
100+
101+
$response = new Response();
102+
if (null !== $location) {
103+
$response->headers->set('Location', $location);
104+
}
105+
106+
$constraint = new ResponseHeaderLocationSame($request, $expectedLocation);
107+
108+
self::assertFalse($constraint->evaluate($response, '', true));
109+
110+
$this->expectException(ExpectationFailedException::class);
111+
112+
$constraint->evaluate($response);
113+
}
114+
115+
public function provideFailureCases(): iterable
116+
{
117+
yield ['http://example.com', null, 'http://example.com'];
118+
yield ['http://example.com', null, '//example.com'];
119+
yield ['http://example.com', null, '/'];
120+
121+
yield ['http://example.com', 'http://another-example.com', 'http://example.com'];
122+
yield ['http://example.com', 'http://another-example.com', '//example.com'];
123+
yield ['http://example.com', 'http://another-example.com', '/'];
124+
125+
yield ['http://example.com', 'http://example.com/bar', 'http://example.com'];
126+
yield ['http://example.com', 'http://example.com/bar', '//example.com'];
127+
yield ['http://example.com', 'http://example.com/bar', '/'];
128+
129+
yield ['http://example.com/foo', 'http://example.com/bar', 'http://example.com'];
130+
yield ['http://example.com/foo', 'http://example.com/bar', '//example.com'];
131+
yield ['http://example.com/foo', 'http://example.com/bar', '/'];
132+
133+
yield ['http://example.com/foo', 'http://example.com/bar', 'http://example.com/foo'];
134+
yield ['http://example.com/foo', 'http://example.com/bar', '//example.com/foo'];
135+
yield ['http://example.com/foo', 'http://example.com/bar', '/foo'];
136+
}
137+
}

0 commit comments

Comments
 (0)