-
-
Notifications
You must be signed in to change notification settings - Fork 48
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
Feat: support RR[>=2023.3] streamed responses #130
base: 3.x
Are you sure you want to change the base?
Changes from 3 commits
ed35dce
81c9d7b
07388ec
b1814f0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
<?php | ||
|
||
namespace Baldinof\RoadRunnerBundle\Helpers; | ||
|
||
use Symfony\Component\HttpFoundation\StreamedJsonResponse; | ||
|
||
// Basically copy of Symfony\Component\HttpFoundation\StreamedJsonResponse | ||
// but adds `yield`ing, instead of `echo`s | ||
class StreamedJsonResponseHelper | ||
{ | ||
private static ?\Closure $streamedJsonResponseParameterExtractor = null; | ||
|
||
public static function toGenerator(StreamedJsonResponse $response): \Generator | ||
Check failure on line 13 in src/Helpers/StreamedJsonResponseHelper.php GitHub Actions / Static analysis (PHP 8.1) (lowest dependencies)
Check failure on line 13 in src/Helpers/StreamedJsonResponseHelper.php GitHub Actions / Static analysis (PHP 8.2) (lowest dependencies)
|
||
{ | ||
$placeholder = "__symfony_json__"; | ||
[$data, $encodingOptions] = self::getStreamedJsonResponseParameterExtractor()($response); | ||
|
||
return self::stream($data, $encodingOptions, $placeholder); | ||
} | ||
|
||
private static function stream(iterable $data, int $encodingOptions, string $placeholder): \Generator | ||
{ | ||
$jsonEncodingOptions = \JSON_THROW_ON_ERROR | $encodingOptions; | ||
$keyEncodingOptions = $jsonEncodingOptions & ~\JSON_NUMERIC_CHECK; | ||
|
||
return self::streamData($data, $jsonEncodingOptions, $keyEncodingOptions, $placeholder); | ||
} | ||
|
||
private static function streamData(mixed $data, int $jsonEncodingOptions, int $keyEncodingOptions, string $placeholder): \Generator | ||
{ | ||
if (\is_array($data)) { | ||
foreach (self::streamArray($data, $jsonEncodingOptions, $keyEncodingOptions, $placeholder) as $item) { | ||
yield $item; | ||
} | ||
|
||
return; | ||
} | ||
|
||
if (is_iterable($data) && !$data instanceof \JsonSerializable) { | ||
foreach (self::streamIterable($data, $jsonEncodingOptions, $keyEncodingOptions, $placeholder) as $item) { | ||
yield $item; | ||
} | ||
|
||
return; | ||
} | ||
|
||
yield json_encode($data, $jsonEncodingOptions); | ||
} | ||
|
||
private static function streamArray(array $data, int $jsonEncodingOptions, int $keyEncodingOptions, string $placeholder): \Generator | ||
{ | ||
$generators = []; | ||
|
||
array_walk_recursive($data, function (&$item, $key) use (&$generators, $placeholder) { | ||
if ($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 | ||
$generators[] = $key; | ||
} | ||
|
||
// generators should be used but for better DX all kind of Traversable and objects are supported | ||
if (\is_object($item)) { | ||
$generators[] = $item; | ||
$item = $placeholder; | ||
} elseif ($placeholder === $item) { | ||
// if the placeholder is already in the structure it should be replaced with a new one that explode | ||
// works like expected for the structure | ||
$generators[] = $item; | ||
} | ||
}); | ||
|
||
$jsonParts = explode('"' . $placeholder . '"', json_encode($data, $jsonEncodingOptions)); | ||
Check failure on line 72 in src/Helpers/StreamedJsonResponseHelper.php GitHub Actions / Static analysis (PHP 8.1) (lowest dependencies)
Check failure on line 72 in src/Helpers/StreamedJsonResponseHelper.php GitHub Actions / Static analysis (PHP 8.1) (stable dependencies)
Check failure on line 72 in src/Helpers/StreamedJsonResponseHelper.php GitHub Actions / Static analysis (PHP 8.2) (lowest dependencies)
|
||
|
||
foreach ($generators as $index => $generator) { | ||
// send first and between parts of the structure | ||
yield $jsonParts[$index]; | ||
|
||
foreach (self::streamData($generator, $jsonEncodingOptions, $keyEncodingOptions, $placeholder) as $child) { | ||
yield $child; | ||
} | ||
} | ||
|
||
// send last part of the structure | ||
yield $jsonParts[array_key_last($jsonParts)]; | ||
} | ||
|
||
private static function streamIterable(iterable $iterable, int $jsonEncodingOptions, int $keyEncodingOptions, string $placeholder): \Generator | ||
{ | ||
$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 = '{'; | ||
} | ||
|
||
yield $startTag; | ||
} else { | ||
// if not first element of the generic, a separator is required between the elements | ||
yield ','; | ||
} | ||
|
||
if ('{' === $startTag) { | ||
yield json_encode((string)$key, $keyEncodingOptions) . ':'; | ||
} | ||
|
||
foreach (self::streamData($item, $jsonEncodingOptions, $keyEncodingOptions, $placeholder) as $child) { | ||
yield $child; | ||
} | ||
} | ||
|
||
if ($isFirstItem) { // indicates that the generator was empty | ||
yield '['; | ||
} | ||
|
||
yield '[' === $startTag ? ']' : '}'; | ||
} | ||
|
||
private static function getStreamedJsonResponseParameterExtractor(): \Closure | ||
{ | ||
return self::$streamedJsonResponseParameterExtractor ?? (self::$streamedJsonResponseParameterExtractor = \Closure::bind(static fn(StreamedJsonResponse $binaryFileResponse) => [ | ||
Check failure on line 126 in src/Helpers/StreamedJsonResponseHelper.php GitHub Actions / Static analysis (PHP 8.1) (lowest dependencies)
|
||
$binaryFileResponse->data, | ||
Check failure on line 127 in src/Helpers/StreamedJsonResponseHelper.php GitHub Actions / Static analysis (PHP 8.1) (lowest dependencies)
|
||
$binaryFileResponse->encodingOptions, | ||
Check failure on line 128 in src/Helpers/StreamedJsonResponseHelper.php GitHub Actions / Static analysis (PHP 8.1) (lowest dependencies)
|
||
], null, StreamedJsonResponse::class)); | ||
Check failure on line 129 in src/Helpers/StreamedJsonResponseHelper.php GitHub Actions / Static analysis (PHP 8.1) (lowest dependencies)
|
||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
<?php | ||
|
||
namespace Baldinof\RoadRunnerBundle\Http\Response; | ||
|
||
use Symfony\Component\HttpFoundation\Response; | ||
|
||
/** | ||
* Basically a copy of Symfony's StreamedResponse, | ||
* but the callback needs to be a Generator | ||
*/ | ||
class StreamedResponse extends Response | ||
{ | ||
protected ?\Closure $callback = null; | ||
protected bool $streamed = false; | ||
|
||
private bool $headersSent = false; | ||
|
||
public function __construct(\Generator $callback = null, int $status = 200, array $headers = []) | ||
{ | ||
parent::__construct(null, $status, $headers); | ||
|
||
if (null !== $callback) { | ||
$this->setCallback($callback); | ||
} | ||
} | ||
|
||
public function setCallback(\Generator $callback): static | ||
{ | ||
$this->callback = $callback(...); | ||
Check failure on line 29 in src/Http/Response/StreamedResponse.php GitHub Actions / Static analysis (PHP 8.1) (lowest dependencies)
Check failure on line 29 in src/Http/Response/StreamedResponse.php GitHub Actions / Static analysis (PHP 8.1) (stable dependencies)
Check failure on line 29 in src/Http/Response/StreamedResponse.php GitHub Actions / Static analysis (PHP 8.2) (lowest dependencies)
|
||
|
||
return $this; | ||
} | ||
|
||
public function getCallback(): ?\Generator | ||
{ | ||
if (!isset($this->callback)) { | ||
return null; | ||
} | ||
|
||
return ($this->callback)(...); | ||
Check failure on line 40 in src/Http/Response/StreamedResponse.php GitHub Actions / Static analysis (PHP 8.1) (lowest dependencies)
Check failure on line 40 in src/Http/Response/StreamedResponse.php GitHub Actions / Static analysis (PHP 8.1) (stable dependencies)
Check failure on line 40 in src/Http/Response/StreamedResponse.php GitHub Actions / Static analysis (PHP 8.2) (lowest dependencies)
|
||
} | ||
|
||
public function sendHeaders(int $statusCode = null): static | ||
{ | ||
if ($this->headersSent) { | ||
return $this; | ||
} | ||
|
||
if ($statusCode < 100 || $statusCode >= 200) { | ||
$this->headersSent = true; | ||
} | ||
|
||
return parent::sendHeaders($statusCode); | ||
Check failure on line 53 in src/Http/Response/StreamedResponse.php GitHub Actions / Static analysis (PHP 8.1) (lowest dependencies)
|
||
} | ||
|
||
public function sendContent(): static | ||
{ | ||
if ($this->streamed) { | ||
return $this; | ||
} | ||
|
||
$this->streamed = true; | ||
|
||
if (!isset($this->callback)) { | ||
throw new \LogicException('The Response callback must be set.'); | ||
} | ||
|
||
foreach (($this->callback)() as $value) { | ||
echo $value; | ||
} | ||
|
||
return $this; | ||
} | ||
|
||
public function setContent(?string $content): static | ||
{ | ||
if (null !== $content) { | ||
throw new \LogicException('The content cannot be set on a StreamedResponse instance.'); | ||
} | ||
|
||
$this->streamed = true; | ||
|
||
return $this; | ||
} | ||
|
||
public function getContent(): string|false | ||
{ | ||
return false; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this should still accept a callable, then in the
sendContent
we can test if the callback returned a Generator or nothing. And if so consume and echo the generator