Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 32 additions & 7 deletions src/Phaseolies/Http/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -1570,20 +1570,45 @@ public function prepareBody(mixed $content)

if (
is_array($content) ||
$content instanceof \JsonSerializable ||
is_object($content)
) {
$content = $this->encodeJsonContent($content);
}

$this->setBody($content);

return $content;
}

/**
* Encode a value for JSON responses without assuming every object has toArray().
*
* @param mixed $content
* @param int $options
* @return string
*/
protected function encodeJsonContent(mixed $content, int $options = 0): string
{
if ($content instanceof \Phaseolies\Support\Collection) {
return json_encode($content->toArray(), $options);
}

if (
$content instanceof \Phaseolies\Database\Entity\Model ||
$content instanceof \Phaseolies\Database\Entity\Builder ||
$content instanceof \JsonSerializable ||
$content instanceof \stdClass ||
$content instanceof \ArrayObject
$content instanceof \ArrayObject ||
is_array($content)
) {
$content = json_encode($content);
} elseif ($content instanceof \Phaseolies\Support\Collection || is_object($content)) {
$content = json_encode($content->toArray());
return json_encode($content, $options);
}

$this->setBody($content);
if (is_object($content) && method_exists($content, 'toArray')) {
return json_encode($content->toArray(), $options);
}

return $content;
return json_encode($content, $options);
}

/**
Expand Down
126 changes: 30 additions & 96 deletions src/Phaseolies/Http/Response/JsonResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,124 +2,58 @@

namespace Phaseolies\Http\Response;

use Phaseolies\Http\Response\Stream\StreamedResponse;
use Phaseolies\Http\Response;

class JsonResponse extends StreamedResponse
class JsonResponse extends Response
{
private const PLACEHOLDER = '__symfony_json__';
// Encode <, >, ', &, and " characters in the JSON, making it also safe to be embedded into HTML.
// 15 === JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT
public const DEFAULT_ENCODING_OPTIONS = 15;

/**
* @param mixed[] $data JSON Data containing PHP generators which will be streamed as list of data or a Generator
* @param int $status The HTTP status code (200 "OK" by default)
* @param array<string, string|string[]> $headers An array of HTTP headers
* @param int $encodingOptions Flags for the json_encode() function
* @param mixed $data The payload to encode as JSON.
* @param int $status The HTTP status code (200 "OK" by default).
* @param array<string, string|string[]> $headers An array of HTTP headers.
* @param int $encodingOptions Flags for the json_encode() function.
*/
public function __construct(
private readonly mixed $data,
int $status = 200,
array $headers = [],
private int $encodingOptions = self::DEFAULT_ENCODING_OPTIONS,
) {
parent::__construct($this->stream(...), $status, $headers);
parent::__construct(null, $status, $headers);

$this->setOriginal($data);
$this->setBody($this->encodePayload($data));

$this->prepareBody($data);
if (!$this->headers->get('Content-Type')) {
$this->headers->set('Content-Type', 'application/json');
}
}

private function stream(): void
{
$jsonEncodingOptions = \JSON_THROW_ON_ERROR | $this->encodingOptions;
$keyEncodingOptions = $jsonEncodingOptions & ~\JSON_NUMERIC_CHECK;

$this->streamData($this->data, $jsonEncodingOptions, $keyEncodingOptions);
}

private function streamData(mixed $data, int $jsonEncodingOptions, int $keyEncodingOptions): void
/**
* Get the original payload assigned to the response.
*
* @return mixed
*/
public function getData(): mixed
{
if (\is_array($data)) {
$this->streamArray($data, $jsonEncodingOptions, $keyEncodingOptions);

return;
}

if (is_iterable($data) && !$data instanceof \JsonSerializable) {
$this->streamIterable($data, $jsonEncodingOptions, $keyEncodingOptions);

return;
}

echo json_encode($data ?? new \stdClass(), $jsonEncodingOptions);
return $this->data;
}

private function streamArray(array $data, int $jsonEncodingOptions, int $keyEncodingOptions): void
{
$generators = [];

array_walk_recursive($data, function (&$item, $key) use (&$generators) {
if (self::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 = self::PLACEHOLDER;
} elseif (self::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('"' . self::PLACEHOLDER . '"', json_encode($data, $jsonEncodingOptions));

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

$this->streamData($generator, $jsonEncodingOptions, $keyEncodingOptions);
}

// send last part of the structure
echo $jsonParts[array_key_last($jsonParts)];
}

private function streamIterable(iterable $iterable, int $jsonEncodingOptions, int $keyEncodingOptions): void
/**
* Encode the JSON payload once so the stored body matches what gets sent.
*
* @param mixed $data
* @return string
*/
protected function encodePayload(mixed $data): string
{
$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 = '{';
}

echo $startTag;
} else {
// if not first element of the generic, a separator is required between the elements
echo ',';
}

if ('{' === $startTag) {
echo json_encode((string) $key, $keyEncodingOptions) . ':';
}

$this->streamData($item, $jsonEncodingOptions, $keyEncodingOptions);
}

if ($isFirstItem) { // indicates that the generator was empty
echo '[';
if ($data === null) {
return json_encode(new \stdClass(), \JSON_THROW_ON_ERROR | $this->encodingOptions);
}

echo '[' === $startTag ? ']' : '}';
return $this->encodeJsonContent($data, \JSON_THROW_ON_ERROR | $this->encodingOptions);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ public function __construct(
) {
parent::__construct($this->stream(...), $status, $headers);

$this->prepareBody($data);
$this->setOriginal($data);
if (!$this->headers->get('Content-Type')) {
$this->headers->set('Content-Type', 'application/json');
}
Expand Down
42 changes: 41 additions & 1 deletion tests/ResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

use Phaseolies\Http\Response;
use Phaseolies\Http\Request;
use Phaseolies\Http\Response\JsonResponse;
use Phaseolies\Http\Response\Stream\StreamedJsonResponse;
use Phaseolies\Http\Response\ResponseHeaderBag;
use Phaseolies\Http\Exceptions\HttpException;
use PHPUnit\Framework\TestCase;
Expand Down Expand Up @@ -128,7 +130,45 @@ public function testRender()
public function testJson()
{
$jsonResponse = $this->response->json(['test' => 'value'], 201);
$this->assertInstanceOf(Response\JsonResponse::class, $jsonResponse);
$this->assertInstanceOf(JsonResponse::class, $jsonResponse);
}

public function testJsonResponseUsesBufferedBodyAsSingleSourceOfTruth()
{
$jsonResponse = $this->response->json(['test' => 'value'], 201);

ob_start();
$jsonResponse->sendContent();
$output = ob_get_clean();

$this->assertSame('{"test":"value"}', $jsonResponse->getBody());
$this->assertSame($jsonResponse->getBody(), $output);
$this->assertSame('application/json', $jsonResponse->headers->get('Content-Type'));
}

public function testStreamedJsonResponseDoesNotCachePretendBody()
{
$response = new StreamedJsonResponse([['test' => 'value']]);

ob_start();
$response->sendContent();
$output = ob_get_clean();

$this->assertNull($response->body);
$this->assertSame('[{"test":"value"}]', $output);
}

public function testPrepareBodySerializesGenericObjectWithoutToArray()
{
$payload = new class {
public string $name = 'Doppar';
};

$body = $this->response->prepareBody($payload);

$this->assertSame('{"name":"Doppar"}', $body);
$this->assertSame($payload, $this->response->getOriginal());
$this->assertSame('{"name":"Doppar"}', $this->response->getBody());
}

public function testText()
Expand Down
Loading