diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 22517ad5c7d3f..9d80f497b2c89 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -452,6 +452,7 @@ public function load(array $configs, ContainerBuilder $container) $this->registerSecretsConfiguration($config['secrets'], $container, $loader); $container->getDefinition('exception_listener')->replaceArgument(3, $config['exceptions']); + $container->getDefinition('error_handler.error_renderer.html')->replaceArgument(6, $config['exceptions']); if ($this->isConfigEnabled($container, $config['serializer'])) { if (!class_exists(\Symfony\Component\Serializer\Serializer::class)) { @@ -459,6 +460,8 @@ public function load(array $configs, ContainerBuilder $container) } $this->registerSerializerConfiguration($config['serializer'], $container, $loader); + + $container->getDefinition('error_handler.error_renderer.serializer')->replaceArgument(4, $config['exceptions']); } if ($propertyInfoEnabled) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/error_renderer.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/error_renderer.php index 67f28ce44d838..abc0c67e60cff 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/error_renderer.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/error_renderer.php @@ -30,6 +30,7 @@ ->factory([HtmlErrorRenderer::class, 'getAndCleanOutputBuffer']) ->args([service('request_stack')]), service('logger')->nullOnInvalid(), + abstract_arg('Configuration per exception class'), ]) ->alias('error_renderer.html', 'error_handler.error_renderer.html') diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php index dfb2589cba315..0176f8677b75f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php @@ -208,6 +208,7 @@ inline_service() ->factory([HtmlErrorRenderer::class, 'isDebug']) ->args([service('request_stack'), param('kernel.debug')]), + abstract_arg('Configuration per exception class'), ]) ; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php index 00b8d8aafbd5a..0b96a819e3e51 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php @@ -106,7 +106,7 @@ param('kernel.error_controller'), service('logger')->nullOnInvalid(), param('kernel.debug'), - abstract_arg('an exceptions to log & status code mapping'), + abstract_arg('Configuration per exception class'), ]) ->tag('kernel.event_subscriber') ->tag('monolog.logger', ['channel' => 'request']) diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 71e4940ff0a4b..eb57788da90cc 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -23,7 +23,7 @@ "symfony/dependency-injection": "^5.3|^6.0", "symfony/deprecation-contracts": "^2.1|^3", "symfony/event-dispatcher": "^5.1|^6.0", - "symfony/error-handler": "^4.4.1|^5.0.1|^6.0", + "symfony/error-handler": "^5.4.2|^6.0.2", "symfony/http-foundation": "^5.3|^6.0", "symfony/http-kernel": "^5.4|^6.0", "symfony/polyfill-mbstring": "~1.0", diff --git a/src/Symfony/Component/ErrorHandler/ErrorRenderer/HtmlErrorRenderer.php b/src/Symfony/Component/ErrorHandler/ErrorRenderer/HtmlErrorRenderer.php index 6f12185a068ca..a12149f369579 100644 --- a/src/Symfony/Component/ErrorHandler/ErrorRenderer/HtmlErrorRenderer.php +++ b/src/Symfony/Component/ErrorHandler/ErrorRenderer/HtmlErrorRenderer.php @@ -39,15 +39,17 @@ class HtmlErrorRenderer implements ErrorRendererInterface private $projectDir; private $outputBuffer; private $logger; + private $exceptionsMapping; private static $template = 'views/error.html.php'; /** - * @param bool|callable $debug The debugging mode as a boolean or a callable that should return it + * @param bool|callable $debug The debugging mode as a boolean or a callable that should return it * @param string|FileLinkFormatter|null $fileLinkFormat - * @param bool|callable $outputBuffer The output buffer as a string or a callable that should return it + * @param bool|callable $outputBuffer The output buffer as a string or a callable that should return it + * @param array $exceptionsMapping Configuration per exception class */ - public function __construct($debug = false, string $charset = null, $fileLinkFormat = null, string $projectDir = null, $outputBuffer = '', LoggerInterface $logger = null) + public function __construct($debug = false, string $charset = null, $fileLinkFormat = null, string $projectDir = null, $outputBuffer = '', LoggerInterface $logger = null, array $exceptionsMapping = []) { if (!\is_bool($debug) && !\is_callable($debug)) { throw new \TypeError(sprintf('Argument 1 passed to "%s()" must be a boolean or a callable, "%s" given.', __METHOD__, \gettype($debug))); @@ -63,6 +65,7 @@ public function __construct($debug = false, string $charset = null, $fileLinkFor $this->projectDir = $projectDir; $this->outputBuffer = $outputBuffer; $this->logger = $logger; + $this->exceptionsMapping = $exceptionsMapping; } /** @@ -76,7 +79,14 @@ public function render(\Throwable $exception): FlattenException $headers['X-Debug-Exception-File'] = rawurlencode($exception->getFile()).':'.$exception->getLine(); } - $exception = FlattenException::createFromThrowable($exception, null, $headers); + $statusCode = 500; + foreach ($this->exceptionsMapping as $class => $config) { + if ($exception instanceof $class && $config['status_code']) { + $statusCode = $config['status_code']; + break; + } + } + $exception = FlattenException::createFromThrowable($exception, $statusCode, $headers); return $exception->setAsString($this->renderException($exception)); } @@ -262,8 +272,6 @@ private function formatFile(string $file, int $line, string $text = null): strin * @param string $file A file path * @param int $line The selected line number * @param int $srcContext The number of displayed lines around or -1 for the whole file - * - * @return string */ private function fileExcerpt(string $file, int $line, int $srcContext = 3): string { diff --git a/src/Symfony/Component/ErrorHandler/ErrorRenderer/SerializerErrorRenderer.php b/src/Symfony/Component/ErrorHandler/ErrorRenderer/SerializerErrorRenderer.php index cec8e4d413dce..f49bd3aec94cd 100644 --- a/src/Symfony/Component/ErrorHandler/ErrorRenderer/SerializerErrorRenderer.php +++ b/src/Symfony/Component/ErrorHandler/ErrorRenderer/SerializerErrorRenderer.php @@ -28,13 +28,15 @@ class SerializerErrorRenderer implements ErrorRendererInterface private $format; private $fallbackErrorRenderer; private $debug; + private $exceptionsMapping; /** - * @param string|callable(FlattenException) $format The format as a string or a callable that should return it - * formats not supported by Request::getMimeTypes() should be given as mime types - * @param bool|callable $debug The debugging mode as a boolean or a callable that should return it + * @param string|callable(FlattenException) $format The format as a string or a callable that should return it + * formats not supported by Request::getMimeTypes() should be given as mime types + * @param bool|callable $debug The debugging mode as a boolean or a callable that should return it + * @param array $exceptionsMapping Configuration per exception class */ - public function __construct(SerializerInterface $serializer, $format, ErrorRendererInterface $fallbackErrorRenderer = null, $debug = false) + public function __construct(SerializerInterface $serializer, $format, ErrorRendererInterface $fallbackErrorRenderer = null, $debug = false, array $exceptionsMapping = []) { if (!\is_string($format) && !\is_callable($format)) { throw new \TypeError(sprintf('Argument 2 passed to "%s()" must be a string or a callable, "%s" given.', __METHOD__, \gettype($format))); @@ -48,6 +50,7 @@ public function __construct(SerializerInterface $serializer, $format, ErrorRende $this->format = $format; $this->fallbackErrorRenderer = $fallbackErrorRenderer ?? new HtmlErrorRenderer(); $this->debug = $debug; + $this->exceptionsMapping = $exceptionsMapping; } /** @@ -62,7 +65,15 @@ public function render(\Throwable $exception): FlattenException $headers['X-Debug-Exception-File'] = rawurlencode($exception->getFile()).':'.$exception->getLine(); } - $flattenException = FlattenException::createFromThrowable($exception, null, $headers); + $statusCode = 500; + foreach ($this->exceptionsMapping as $class => $config) { + if ($exception instanceof $class && $config['status_code']) { + $statusCode = $config['status_code']; + break; + } + } + + $flattenException = FlattenException::createFromThrowable($exception, $statusCode, $headers); try { $format = \is_string($this->format) ? $this->format : ($this->format)($flattenException); diff --git a/src/Symfony/Component/ErrorHandler/Tests/ErrorRenderer/HtmlErrorRendererTest.php b/src/Symfony/Component/ErrorHandler/Tests/ErrorRenderer/HtmlErrorRendererTest.php index f292d0f79618f..34ed98faa47b6 100644 --- a/src/Symfony/Component/ErrorHandler/Tests/ErrorRenderer/HtmlErrorRendererTest.php +++ b/src/Symfony/Component/ErrorHandler/Tests/ErrorRenderer/HtmlErrorRendererTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\ErrorHandler\ErrorRenderer\HtmlErrorRenderer; +use Symfony\Component\HttpFoundation\Response; class HtmlErrorRendererTest extends TestCase { @@ -40,6 +41,22 @@ public function getRenderData(): iterable %ACodestin Search App %A

The server returned a "500 Internal Server Error".

%A +HTML; + + $expectedDebugWithStatusCode = << + + +%ACodestin Search App +%A
%A + +HTML; + + $expectedNonDebugWithStatusCode = << + +%ACodestin Search App +%A

The server returned a "418 I'm a teapot".

%A HTML; yield '->render() returns the HTML content WITH stack traces in debug mode' => [ @@ -53,5 +70,27 @@ public function getRenderData(): iterable new HtmlErrorRenderer(false), $expectedNonDebug, ]; + + yield '->render() returns the HTML content WITH stack traces in debug mode and contains the correct status code' => [ + new \RuntimeException('Foo'), + new HtmlErrorRenderer(true, null, null, null, '', null, [ + \RuntimeException::class => [ + 'status_code' => Response::HTTP_I_AM_A_TEAPOT, + 'log_level' => null, + ], + ]), + $expectedDebugWithStatusCode, + ]; + + yield '->render() returns the HTML content WITHOUT stack traces in non-debug mode and contains the correct status code' => [ + new \RuntimeException('Foo'), + new HtmlErrorRenderer(false, null, null, null, '', null, [ + \RuntimeException::class => [ + 'status_code' => Response::HTTP_I_AM_A_TEAPOT, + 'log_level' => null, + ], + ]), + $expectedNonDebugWithStatusCode, + ]; } } diff --git a/src/Symfony/Component/Serializer/Tests/ErrorRenderer/SerializerErrorRendererTest.php b/src/Symfony/Component/Serializer/Tests/ErrorRenderer/SerializerErrorRendererTest.php new file mode 100644 index 0000000000000..2be2692b74674 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/ErrorRenderer/SerializerErrorRendererTest.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\ErrorRenderer; + +use function json_decode; +use PHPUnit\Framework\TestCase; +use Symfony\Component\ErrorHandler\ErrorRenderer\SerializerErrorRenderer; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; + +/** + * @author Alexey Deriyenko + */ +class SerializerErrorRendererTest extends TestCase +{ + /** + * @dataProvider getRenderData + */ + public function testRenderReturnsJson(\Throwable $exception, SerializerErrorRenderer $serializerErrorRenderer) + { + $this->assertJson($serializerErrorRenderer->render($exception)->getAsString()); + } + + /** + * @dataProvider getRenderData + */ + public function testRenderReturnsJsonWithCorrectStatusCode(\Throwable $exception, SerializerErrorRenderer $serializerErrorRenderer, int $expectedStatusCode) + { + $statusCodeFromJson = json_decode($serializerErrorRenderer->render($exception)->getAsString())->statusCode; + $this->assertEquals($expectedStatusCode, $statusCodeFromJson); + } + + /** + * @dataProvider getRenderData + */ + public function testRenderReturnsJsonWithCorrectStatusText(\Throwable $exception, SerializerErrorRenderer $serializerErrorRenderer, int $expectedStatusCode, string $expectedStatusText) + { + $statusTextFromJson = json_decode($serializerErrorRenderer->render($exception)->getAsString())->statusText; + $this->assertEquals($expectedStatusText, $statusTextFromJson); + } + + public function getRenderData(): iterable + { + yield '->render() returns the JSON content without exception mapping config' => [ + new \RuntimeException('Foo'), + new SerializerErrorRenderer(new Serializer([new ObjectNormalizer()], [new JsonEncoder()]), 'json'), + Response::HTTP_INTERNAL_SERVER_ERROR, + Response::$statusTexts[Response::HTTP_INTERNAL_SERVER_ERROR], + ]; + + yield '->render() returns the JSON content with exception mapping config' => [ + new \RuntimeException('Foo'), + new SerializerErrorRenderer(new Serializer([new ObjectNormalizer()], [new JsonEncoder()]), 'json', null, false, [ + \RuntimeException::class => [ + 'status_code' => Response::HTTP_I_AM_A_TEAPOT, + 'log_level' => null, + ], + ]), + Response::HTTP_I_AM_A_TEAPOT, + Response::$statusTexts[Response::HTTP_I_AM_A_TEAPOT], + ]; + } +}