From bf09d770afa857c6c58307e22ae4b68ae125ccb7 Mon Sep 17 00:00:00 2001 From: mhsdesign <85400359+mhsdesign@users.noreply.github.com> Date: Fri, 23 Feb 2024 19:39:53 +0100 Subject: [PATCH] TASK: `Runtime::renderResponse` will try to jsonSerialize result if not a string After a discussion with Christian we found it to be a more workable behaviour than throwing an exception. --- Neos.Fusion/Classes/Core/Runtime.php | 23 ++- .../Fixtures/Fusion/JsonSerializesPath.fusion | 5 + .../Tests/Functional/View/FusionViewTest.php | 14 ++ Neos.Fusion/Tests/Unit/Core/RuntimeTest.php | 171 ++++++++++++++++++ 4 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 Neos.Fusion/Tests/Functional/View/Fixtures/Fusion/JsonSerializesPath.fusion diff --git a/Neos.Fusion/Classes/Core/Runtime.php b/Neos.Fusion/Classes/Core/Runtime.php index 588626b3555..624a1c0771c 100644 --- a/Neos.Fusion/Classes/Core/Runtime.php +++ b/Neos.Fusion/Classes/Core/Runtime.php @@ -289,14 +289,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) - }; - - return new Response(body: $stream); + 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'); + } + + throw new \RuntimeException(sprintf('Cannot render %s into http response body.', get_debug_type($output)), 1706454898); }); } diff --git a/Neos.Fusion/Tests/Functional/View/Fixtures/Fusion/JsonSerializesPath.fusion b/Neos.Fusion/Tests/Functional/View/Fixtures/Fusion/JsonSerializesPath.fusion new file mode 100644 index 00000000000..ca59920f7d3 --- /dev/null +++ b/Neos.Fusion/Tests/Functional/View/Fixtures/Fusion/JsonSerializesPath.fusion @@ -0,0 +1,5 @@ + +jsonSerializeable = Neos.Fusion:DataStructure { + my = 'array' + with = 'values' +} diff --git a/Neos.Fusion/Tests/Functional/View/FusionViewTest.php b/Neos.Fusion/Tests/Functional/View/FusionViewTest.php index 7ccace20963..6dd106eead3 100644 --- a/Neos.Fusion/Tests/Functional/View/FusionViewTest.php +++ b/Neos.Fusion/Tests/Functional/View/FusionViewTest.php @@ -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. * diff --git a/Neos.Fusion/Tests/Unit/Core/RuntimeTest.php b/Neos.Fusion/Tests/Unit/Core/RuntimeTest.php index bc9920a0140..d0c9dc88997 100644 --- a/Neos.Fusion/Tests/Unit/Core/RuntimeTest.php +++ b/Neos.Fusion/Tests/Unit/Core/RuntimeTest.php @@ -11,6 +11,7 @@ * source code. */ +use GuzzleHttp\Psr7\Message; use Neos\Eel\EelEvaluatorInterface; use Neos\Eel\ProtectedContext; use Neos\Flow\Exception; @@ -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 { @@ -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. @@ -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 + + + + Hello World + TEXT, + 'response' => <<<'TEXT' + HTTP/1.1 418 OK + Content-Type: text/html + X-MyCustomHeader: marc + + + + Hello World + 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', []); + } }