diff --git a/composer.json b/composer.json index 6b3bd767..402de709 100644 --- a/composer.json +++ b/composer.json @@ -19,9 +19,10 @@ "yiisoft/yii2": "^2.0.53|^22" }, "require-dev": { - "infection/infection": "^0.27|^0.31", "httpsoft/http-message": "^1.1", + "infection/infection": "^0.27|^0.31", "maglnet/composer-require-checker": "^4.1", + "php-forge/support": "^0.1.0", "phpstan/extension-installer": "^1.4", "phpstan/phpstan-strict-rules": "^2.0.3", "phpunit/phpunit": "^10.5", diff --git a/src/http/ErrorHandler.php b/src/http/ErrorHandler.php index 65632b44..872e70d0 100644 --- a/src/http/ErrorHandler.php +++ b/src/http/ErrorHandler.php @@ -41,6 +41,23 @@ */ final class ErrorHandler extends \yii\web\ErrorHandler { + /** + * Default configuration for creating fallback Response instances. + * + * @phpstan-var array + */ + public array $defaultResponseConfig = [ + 'charset' => 'UTF-8', + ]; + + /** + * Template Response instance for error handling. + * + * When set, this Response instance will be used as a base for error responses, preserving configured format, + * formatters, and other settings. + */ + private Response|null $templateResponse = null; + /** * Clears all output buffers above the minimum required level. * @@ -129,6 +146,19 @@ public function handleException($exception): Response return $response; } + /** + * Sets the template Response for error handling. + * + * The provided Response will be used as a template for error responses, preserving configuration such as format, + * charset, and formatters. The Response will be cleared of any existing data before use. + * + * @param Response $response Template response with desired configuration. + */ + public function setResponse(Response $response): void + { + $this->templateResponse = $response; + } + /** * Handles fallback exception rendering when an error occurs during exception processing. * @@ -145,14 +175,11 @@ public function handleException($exception): Response */ protected function handleFallbackExceptionMessage($exception, $previousException): Response { - $response = new Response(); + $response = $this->createErrorResponse(); $msg = "An Error occurred while handling another error:\n"; - $msg .= $exception; - $msg .= "\nPrevious exception:\n"; - $msg .= $previousException; $response->data = 'An internal server error occurred.'; @@ -193,7 +220,7 @@ protected function handleFallbackExceptionMessage($exception, $previousException */ protected function renderException($exception): Response { - $response = new Response(); + $response = $this->createErrorResponse(); $response->setStatusCodeByException($exception); @@ -217,7 +244,6 @@ protected function renderException($exception): Response } $file = $useErrorView ? $this->errorView : $this->exceptionView; - $response->data = $this->renderFile($file, ['exception' => $exception]); } } elseif ($response->format === Response::FORMAT_RAW) { @@ -228,4 +254,20 @@ protected function renderException($exception): Response return $response; } + + /** + * Creates a Response instance for error handling. + * + * Uses the template Response if available, otherwise creates a new instance with default configuration. + * + * @return Response Clean Response instance ready for error content. + */ + private function createErrorResponse(): Response + { + $response = $this->templateResponse ?? new Response($this->defaultResponseConfig); + + $response->clear(); + + return $response; + } } diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php index 2e58fa60..0656e05a 100644 --- a/src/http/StatelessApplication.php +++ b/src/http/StatelessApplication.php @@ -405,6 +405,7 @@ protected function reset(ServerRequestInterface $request): void $this->requestedAction = null; $this->requestedParams = []; + $this->errorHandler->setResponse($this->response); $this->request->setPsr7Request($request); $this->session->close(); diff --git a/tests/TestCase.php b/tests/TestCase.php index 673678f7..020bcee0 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -140,9 +140,6 @@ protected function statelessApplication($config = []): StatelessApplication 'scriptFile' => __DIR__ . '/index.php', 'scriptUrl' => '/index.php', ], - 'response' => [ - 'charset' => 'UTF-8', - ], 'user' => [ 'enableAutoLogin' => false, 'identityClass' => Identity::class, diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index 042db5d2..2497bcc5 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -5,6 +5,7 @@ namespace yii2\extensions\psrbridge\tests\http; use HttpSoft\Message\{ServerRequestFactory, StreamFactory, UploadedFileFactory}; +use PHPForge\Support\Assert; use PHPUnit\Framework\Attributes\{DataProviderExternal, Group, RequiresPhpExtension}; use Psr\Http\Message\{ServerRequestFactoryInterface, StreamFactoryInterface, UploadedFileFactoryInterface}; use stdClass; @@ -429,6 +430,8 @@ public function testContainerResolvesPsrFactoriesWithDefinitions(): void $container = $app->container(); + $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); + self::assertTrue( $container->has(ServerRequestFactoryInterface::class), "Container should have definition for 'ServerRequestFactoryInterface', ensuring PSR-7 request factory is " . @@ -696,6 +699,8 @@ public function testRecalculateMemoryLimitAfterResetAndIniChange(): void $app = $this->statelessApplication(); + $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); + $firstCalculation = $app->getMemoryLimit(); $app->setMemoryLimit(0); @@ -1745,7 +1750,7 @@ public function testUseErrorViewLogicWithDebugFalseAndException(): void "Response 'content-type' should be 'text/html; charset=UTF-8' for error response when 'Exception' " . "occurs and 'debug' mode is disabled in 'StatelessApplication'.", ); - self::assertStringContainsString( + Assert::equalsWithoutLE( << Custom error page from errorAction. @@ -1801,7 +1806,7 @@ public function testUseErrorViewLogicWithDebugFalseAndUserException(): void "Response 'content-type' should be 'text/html; charset=UTF-8' for error response when 'UserException' " . "occurs and 'debug' mode is disabled in 'StatelessApplication'.", ); - self::assertStringContainsString( + Assert::equalsWithoutLE( << Custom error page from errorAction. @@ -1854,7 +1859,7 @@ public function testUseErrorViewLogicWithDebugTrueAndUserException(): void "Response 'content-type' should be 'text/html; charset=UTF-8' for error response when 'UserException'" . "occurs and 'debug' mode is enabled in 'StatelessApplication'.", ); - self::assertStringContainsString( + Assert::equalsWithoutLE( << Custom error page from errorAction. @@ -1872,6 +1877,63 @@ public function testUseErrorViewLogicWithDebugTrueAndUserException(): void ); } + public function testUseErrorViewLogicWithNonHtmlFormat(): void + { + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/trigger-exception', + ]; + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $app = $this->statelessApplication( + [ + 'components' => [ + 'errorHandler' => [ + 'errorAction' => 'site/error', + ], + 'response' => [ + 'format' => Response::FORMAT_JSON, + ], + ], + ], + ); + + $response = $app->handle($request); + $responseBody = $response->getBody()->getContents(); + + self::assertSame( + 500, + $response->getStatusCode(), + "Response 'status code' should be '500' when a 'Exception' occurs with JSON format in " . + "'StatelessApplication', indicating an 'internal server error'.", + ); + self::assertSame( + 'application/json; charset=UTF-8', + $response->getHeaders()['content-type'][0] ?? '', + "Response 'content-type' should be 'application/json; charset=UTF-8' for error response when 'Exception'" . + "occurs with JSON format in 'StatelessApplication'.", + ); + self::assertStringNotContainsString( + 'Custom error page from errorAction.', + $responseBody, + "Response 'body' should NOT contain 'Custom error page from errorAction' when format is JSON " . + "because useErrorView should be false regardless of YII_DEBUG or exception type in 'StatelessApplication'.", + ); + + $decodedResponse = Json::decode($responseBody); + + self::assertIsArray( + $decodedResponse, + 'JSON response should be decodable to array', + ); + self::assertArrayHasKey( + 'message', + $decodedResponse, + 'JSON error response should contain message key', + ); + } + /** * @throws InvalidConfigException if the configuration is invalid or incomplete. */ diff --git a/tests/support/stub/SiteController.php b/tests/support/stub/SiteController.php index 60006347..133f1349 100644 --- a/tests/support/stub/SiteController.php +++ b/tests/support/stub/SiteController.php @@ -70,8 +70,6 @@ public function actionCookie(): void public function actionError(): string { - $this->response->format = Response::FORMAT_HTML; - $exception = Yii::$app->errorHandler->exception; if ($exception !== null) {