Skip to content

Commit

Permalink
TASK: Runtime::renderResponse will try to jsonSerialize result if n…
Browse files Browse the repository at this point in the history
…ot a string

After a discussion with Christian we found it to be a more workable behaviour than throwing an exception.
  • Loading branch information
mhsdesign committed Feb 23, 2024
1 parent 4f46529 commit e2f7562
Show file tree
Hide file tree
Showing 4 changed files with 207 additions and 8 deletions.
25 changes: 17 additions & 8 deletions Neos.Fusion/Classes/Core/Runtime.php
Expand Up @@ -13,7 +13,6 @@

use GuzzleHttp\Psr7\Message;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Utils;
use Psr\Http\Message\ResponseInterface;
use Neos\Eel\Utility as EelUtility;
use Neos\Flow\Annotations as Flow;
Expand Down Expand Up @@ -261,6 +260,9 @@ public function getLastEvaluationStatus()
return $this->lastEvaluationStatus;
}

/**
* todo
*/
public function renderResponse(string $fusionPath, array $contextArray): ResponseInterface
{
/** Unlike pushContextArray, we don't allow to overrule fusion globals {@see self::pushContext} */
Expand Down Expand Up @@ -289,14 +291,21 @@ public function renderResponse(string $fusionPath, array $contextArray): Respons
return Message::parseResponse($output);
}

$stream = match (true) {
is_string($output),
$output instanceof \Stringable => Utils::streamFor((string)$output),
$output === null, $output === false => Utils::streamFor(''),
default => throw new \RuntimeException(sprintf('Cannot render %s into http response body.', get_debug_type($output)), 1706454898)
};
if (is_string($output) || $output instanceof \Stringable || $output === null) {
return new Response(body: $output);
}

if (is_array($output) || $output instanceof \JsonSerializable || $output instanceof \stdClass || is_bool($output)) {
try {
$jsonSerialized = json_encode($output, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new \RuntimeException(sprintf('Cannot render %s into http response body.', get_debug_type($output)), 1708713158, $e);
}
$jsonResponse = new Response(body: $jsonSerialized);
return $jsonResponse->withHeader('Content-Type', 'application/json');
}

return new Response(body: $stream);
throw new \RuntimeException(sprintf('Cannot render %s into http response body.', get_debug_type($output)), 1706454898);
});
}

Expand Down
@@ -0,0 +1,5 @@

jsonSerializeable = Neos.Fusion:DataStructure {
my = 'array'
with = 'values'
}
14 changes: 14 additions & 0 deletions Neos.Fusion/Tests/Functional/View/FusionViewTest.php
Expand Up @@ -78,6 +78,20 @@ public function fusionViewReturnsHttpResponseFromHttpMessagePrototype()
self::assertSame('application/json', $response->getHeaderLine('Content-Type'));
}

/**
* @test
*/
public function fusionViewJsonSerializesOutputIfNotString()
{
$view = $this->buildView('Foo\Bar\Controller\TestController', 'index');
$view->setFusionPath('jsonSerializeable');
$response = $view->render();
self::assertInstanceOf(ResponseInterface::class, $response);
self::assertSame('{"my":"array","with":"values"}', $response->getBody()->getContents());
self::assertSame(200, $response->getStatusCode());
self::assertSame('application/json', $response->getHeaderLine('Content-Type'));
}

/**
* Prepare a FusionView for testing that Mocks a request with the given controller and action names.
*
Expand Down
171 changes: 171 additions & 0 deletions Neos.Fusion/Tests/Unit/Core/RuntimeTest.php
Expand Up @@ -11,6 +11,7 @@
* source code.
*/

use GuzzleHttp\Psr7\Message;
use Neos\Eel\EelEvaluatorInterface;
use Neos\Eel\ProtectedContext;
use Neos\Flow\Exception;
Expand All @@ -22,6 +23,7 @@
use Neos\Fusion\Core\Runtime;
use Neos\Fusion\Exception\RuntimeException;
use Neos\Fusion\FusionObjects\ValueImplementation;
use Psr\Http\Message\ResponseInterface;

class RuntimeTest extends UnitTestCase
{
Expand Down Expand Up @@ -207,6 +209,18 @@ public function pushContextIsNotAllowedToOverrideFusionGlobals()
$runtime->pushContext('request', 'anything');
}

/**
* @test
*/
public function renderResponseIsNotAllowedToOverrideFusionGlobals()
{
$this->expectException(\Neos\Fusion\Exception::class);
$this->expectExceptionMessage('Overriding Fusion global variable "request" via @context is not allowed.');
$runtime = new Runtime(FusionConfiguration::fromArray([]), FusionGlobals::fromArray(['request' => 'fixed']));

$runtime->renderResponse('foo', ['request' =>'anything']);
}

/**
* Legacy compatible layer to possibly override fusion globals like "request".
* This functionality is only allowed for internal packages.
Expand All @@ -222,4 +236,161 @@ public function pushContextArrayIsAllowedToOverrideFusionGlobals()
$runtime->pushContextArray(['bing' => 'beer', 'request' => 'anything']);
self::assertTrue(true);
}

public static function renderResponseExamples(): iterable
{
yield 'simple string' => [
'rawValue' => 'my string',
'response' => <<<'TEXT'
HTTP/1.1 200 OK
my string
TEXT
];

yield 'string cast object (\Stringable)' => [
'rawValue' => new class implements \Stringable, \JsonSerializable {
public function __toString()
{
return 'my string karsten';
}
// __toString is preferred
public function jsonSerialize(): mixed
{
return ['my string'];
}
},
'response' => <<<'TEXT'
HTTP/1.1 200 OK
my string karsten
TEXT
];

yield 'empty string' => [
'rawValue' => '',
'response' => <<<'TEXT'
HTTP/1.1 200 OK
TEXT
];

yield 'null value' => [
'rawValue' => null,
'response' => <<<'TEXT'
HTTP/1.1 200 OK
TEXT
];

yield 'stringified http response string is upcasted' => [
'rawValue' => <<<'TEXT'
HTTP/1.1 418 OK
Content-Type: text/html
X-MyCustomHeader: marc
<!DOCTYPE html>
<head></head>
<body>Hello World</body>
TEXT,
'response' => <<<'TEXT'
HTTP/1.1 418 OK
Content-Type: text/html
X-MyCustomHeader: marc
<!DOCTYPE html>
<head></head>
<body>Hello World</body>
TEXT
];

yield 'json serialize array' => [
'rawValue' => ['my' => 'array', 'with' => 'values'],
'response' => <<<'TEXT'
HTTP/1.1 200 OK
Content-Type: application/json
{"my":"array","with":"values"}
TEXT
];

yield 'json serialize \stdClass' => [
'rawValue' => (object)[],
'response' => <<<'TEXT'
HTTP/1.1 200 OK
Content-Type: application/json
{}
TEXT
];

yield 'json serialize object (\JsonSerializable)' => [
'rawValue' => new class implements \JsonSerializable {
public function jsonSerialize(): mixed
{
return ['my' => 'object', 'with' => 'values'];
}
},
'response' => <<<'TEXT'
HTTP/1.1 200 OK
Content-Type: application/json
{"my":"object","with":"values"}
TEXT
];

yield 'json serialize boolean' => [
'rawValue' => false,
'response' => <<<'TEXT'
HTTP/1.1 200 OK
Content-Type: application/json
false
TEXT
];
}

/**
* @test
* @dataProvider renderResponseExamples
*/
public function renderResponse(mixed $rawValue, string $expectedHttpResponseString)
{
$runtime = $this->getMockBuilder(Runtime::class)
->setConstructorArgs([FusionConfiguration::fromArray([]), FusionGlobals::empty()])
->onlyMethods(['render'])
->getMock();

$runtime->expects(self::once())->method('render')->willReturn(
is_string($rawValue) ? str_replace("\n", "\r\n", $rawValue) : $rawValue
);

$response = $runtime->renderResponse('/path', []);

self::assertInstanceOf(ResponseInterface::class, $response);
self::assertSame(str_replace("\n", "\r\n", $expectedHttpResponseString), Message::toString($response));
}

/**
* @test
*/
public function renderResponseThrowsIfNotStringableOrJsonSerializeable()
{
$illegalValue = new class {
};
$this->expectExceptionMessage('Cannot render class@anonymous into http response body.');

$runtime = $this->getMockBuilder(Runtime::class)
->setConstructorArgs([FusionConfiguration::fromArray([]), FusionGlobals::empty()])
->onlyMethods(['render'])
->getMock();

$runtime->expects(self::once())->method('render')->willReturn(
$illegalValue
);

$runtime->renderResponse('/path', []);
}
}

0 comments on commit e2f7562

Please sign in to comment.