StreamedJsonResponse.php 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Component\HttpFoundation;
  11. /**
  12. * StreamedJsonResponse represents a streamed HTTP response for JSON.
  13. *
  14. * A StreamedJsonResponse uses a structure and generics to create an
  15. * efficient resource-saving JSON response.
  16. *
  17. * It is recommended to use flush() function after a specific number of items to directly stream the data.
  18. *
  19. * @see flush()
  20. *
  21. * @author Alexander Schranz <alexander@sulu.io>
  22. *
  23. * Example usage:
  24. *
  25. * function loadArticles(): \Generator
  26. * // some streamed loading
  27. * yield ['title' => 'Article 1'];
  28. * yield ['title' => 'Article 2'];
  29. * yield ['title' => 'Article 3'];
  30. * // recommended to use flush() after every specific number of items
  31. * }),
  32. *
  33. * $response = new StreamedJsonResponse(
  34. * // json structure with generators in which will be streamed
  35. * [
  36. * '_embedded' => [
  37. * 'articles' => loadArticles(), // any generator which you want to stream as list of data
  38. * ],
  39. * ],
  40. * );
  41. */
  42. class StreamedJsonResponse extends StreamedResponse
  43. {
  44. private const PLACEHOLDER = '__symfony_json__';
  45. /**
  46. * @param mixed[] $data JSON Data containing PHP generators which will be streamed as list of data or a Generator
  47. * @param int $status The HTTP status code (200 "OK" by default)
  48. * @param array<string, string|string[]> $headers An array of HTTP headers
  49. * @param int $encodingOptions Flags for the json_encode() function
  50. */
  51. public function __construct(
  52. private readonly iterable $data,
  53. int $status = 200,
  54. array $headers = [],
  55. private int $encodingOptions = JsonResponse::DEFAULT_ENCODING_OPTIONS,
  56. ) {
  57. parent::__construct($this->stream(...), $status, $headers);
  58. if (!$this->headers->get('Content-Type')) {
  59. $this->headers->set('Content-Type', 'application/json');
  60. }
  61. }
  62. private function stream(): void
  63. {
  64. $jsonEncodingOptions = \JSON_THROW_ON_ERROR | $this->encodingOptions;
  65. $keyEncodingOptions = $jsonEncodingOptions & ~\JSON_NUMERIC_CHECK;
  66. $this->streamData($this->data, $jsonEncodingOptions, $keyEncodingOptions);
  67. }
  68. private function streamData(mixed $data, int $jsonEncodingOptions, int $keyEncodingOptions): void
  69. {
  70. if (\is_array($data)) {
  71. $this->streamArray($data, $jsonEncodingOptions, $keyEncodingOptions);
  72. return;
  73. }
  74. if (is_iterable($data) && !$data instanceof \JsonSerializable) {
  75. $this->streamIterable($data, $jsonEncodingOptions, $keyEncodingOptions);
  76. return;
  77. }
  78. echo json_encode($data, $jsonEncodingOptions);
  79. }
  80. private function streamArray(array $data, int $jsonEncodingOptions, int $keyEncodingOptions): void
  81. {
  82. $generators = [];
  83. array_walk_recursive($data, function (&$item, $key) use (&$generators) {
  84. if (self::PLACEHOLDER === $key) {
  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[] = $key;
  88. }
  89. // generators should be used but for better DX all kind of Traversable and objects are supported
  90. if (\is_object($item)) {
  91. $generators[] = $item;
  92. $item = self::PLACEHOLDER;
  93. } elseif (self::PLACEHOLDER === $item) {
  94. // if the placeholder is already in the structure it should be replaced with a new one that explode
  95. // works like expected for the structure
  96. $generators[] = $item;
  97. }
  98. });
  99. $jsonParts = explode('"'.self::PLACEHOLDER.'"', json_encode($data, $jsonEncodingOptions));
  100. foreach ($generators as $index => $generator) {
  101. // send first and between parts of the structure
  102. echo $jsonParts[$index];
  103. $this->streamData($generator, $jsonEncodingOptions, $keyEncodingOptions);
  104. }
  105. // send last part of the structure
  106. echo $jsonParts[array_key_last($jsonParts)];
  107. }
  108. private function streamIterable(iterable $iterable, int $jsonEncodingOptions, int $keyEncodingOptions): void
  109. {
  110. $isFirstItem = true;
  111. $startTag = '[';
  112. foreach ($iterable as $key => $item) {
  113. if ($isFirstItem) {
  114. $isFirstItem = false;
  115. // depending on the first elements key the generator is detected as a list or map
  116. // we can not check for a whole list or map because that would hurt the performance
  117. // of the streamed response which is the main goal of this response class
  118. if (0 !== $key) {
  119. $startTag = '{';
  120. }
  121. echo $startTag;
  122. } else {
  123. // if not first element of the generic, a separator is required between the elements
  124. echo ',';
  125. }
  126. if ('{' === $startTag) {
  127. echo json_encode((string) $key, $keyEncodingOptions).':';
  128. }
  129. $this->streamData($item, $jsonEncodingOptions, $keyEncodingOptions);
  130. }
  131. if ($isFirstItem) { // indicates that the generator was empty
  132. echo '[';
  133. }
  134. echo '[' === $startTag ? ']' : '}';
  135. }
  136. }