diff --git a/CHANGELOG.md b/CHANGELOG.md index e9b0cf1..0271ea3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ # Yii Error Handler Change Log -## 4.3.3 under development +## 5.0.0 under development -- no changes in this release. +- Chg #162: Replace deprecated `ThrowableResponseFactory` class usage to new one, and remove it (@vjik) ## 4.3.2 January 09, 2026 diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..7dbab11 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,13 @@ +# Upgrading Instructions for Yii Error Handler + +This file contains the upgrade notes. These notes highlight changes that could break your +application when you upgrade the package from one version to another. + +> **Important!** The following upgrading instructions are cumulative. That is, if you want +> to upgrade from version A to version C and there is version B between A and C, you need +> to follow the instructions for both A and B. + +## Upgrade from 4.x + +- `Yiisoft\ErrorHandler\Factory\ThrowableResponseFactory` was removed, use + `Yiisoft\ErrorHandler\ThrowableResponseFactory` instead. diff --git a/config/di-web.php b/config/di-web.php index b6bc5d6..b5367a7 100644 --- a/config/di-web.php +++ b/config/di-web.php @@ -2,9 +2,14 @@ declare(strict_types=1); -use Yiisoft\ErrorHandler\Factory\ThrowableResponseFactory; +use Psr\Container\ContainerInterface; +use Yiisoft\Definitions\DynamicReference; use Yiisoft\ErrorHandler\Renderer\HtmlRenderer; +use Yiisoft\ErrorHandler\RendererProvider\CompositeRendererProvider; +use Yiisoft\ErrorHandler\RendererProvider\ContentTypeRendererProvider; +use Yiisoft\ErrorHandler\RendererProvider\HeadRendererProvider; use Yiisoft\ErrorHandler\ThrowableRendererInterface; +use Yiisoft\ErrorHandler\ThrowableResponseFactory; use Yiisoft\ErrorHandler\ThrowableResponseFactoryInterface; /** @@ -13,5 +18,15 @@ return [ ThrowableRendererInterface::class => HtmlRenderer::class, - ThrowableResponseFactoryInterface::class => ThrowableResponseFactory::class, + ThrowableResponseFactoryInterface::class => [ + 'class' => ThrowableResponseFactory::class, + '__construct()' => [ + 'rendererProvider' => DynamicReference::to( + static fn(ContainerInterface $container) => new CompositeRendererProvider( + new HeadRendererProvider(), + new ContentTypeRendererProvider($container), + ) + ), + ], + ], ]; diff --git a/src/Factory/ThrowableResponseFactory.php b/src/Factory/ThrowableResponseFactory.php deleted file mode 100644 index 6a16c1b..0000000 --- a/src/Factory/ThrowableResponseFactory.php +++ /dev/null @@ -1,190 +0,0 @@ -> - */ - private array $renderers = [ - 'application/json' => JsonRenderer::class, - 'application/xml' => XmlRenderer::class, - 'text/xml' => XmlRenderer::class, - 'text/plain' => PlainTextRenderer::class, - 'text/html' => HtmlRenderer::class, - '*/*' => HtmlRenderer::class, - ]; - private ?string $contentType = null; - - public function __construct( - private readonly ResponseFactoryInterface $responseFactory, - private readonly ErrorHandler $errorHandler, - private readonly ContainerInterface $container, - ?HeadersProvider $headersProvider = null, - ) { - $this->headersProvider = $headersProvider ?? new HeadersProvider(); - } - - public function create(Throwable $throwable, ServerRequestInterface $request): ResponseInterface - { - $contentType = $this->contentType ?? $this->getContentType($request); - $renderer = $request->getMethod() === Method::HEAD ? new HeaderRenderer() : $this->getRenderer($contentType); - - $data = $this->errorHandler->handle($throwable, $renderer, $request); - $response = $this->responseFactory->createResponse(Status::INTERNAL_SERVER_ERROR); - foreach ($this->headersProvider->getAll() as $name => $value) { - $response = $response->withHeader($name, $value); - } - return $data->addToResponse($response->withHeader(Header::CONTENT_TYPE, $contentType)); - } - - /** - * Returns a new instance with the specified content type and renderer class. - * - * @param string $contentType The content type to add associated renderers for. - * @param string $rendererClass The classname implementing the {@see ThrowableRendererInterface}. - */ - public function withRenderer(string $contentType, string $rendererClass): self - { - if (!is_subclass_of($rendererClass, ThrowableRendererInterface::class)) { - throw new InvalidArgumentException(sprintf( - 'Class "%s" does not implement "%s".', - $rendererClass, - ThrowableRendererInterface::class, - )); - } - - $new = clone $this; - $new->renderers[$this->normalizeContentType($contentType)] = $rendererClass; - return $new; - } - - /** - * Returns a new instance without renderers by the specified content types. - * - * @param string[] $contentTypes The content types to remove associated renderers for. - * If not specified, all renderers will be removed. - */ - public function withoutRenderers(string ...$contentTypes): self - { - $new = clone $this; - - if (count($contentTypes) === 0) { - $new->renderers = []; - return $new; - } - - foreach ($contentTypes as $contentType) { - unset($new->renderers[$this->normalizeContentType($contentType)]); - } - - return $new; - } - - /** - * Force content type to respond with regardless of request. - * - * @param string $contentType The content type to respond with regardless of request. - */ - public function forceContentType(string $contentType): self - { - $contentType = $this->normalizeContentType($contentType); - - if (!isset($this->renderers[$contentType])) { - throw new InvalidArgumentException(sprintf('The renderer for %s is not set.', $contentType)); - } - - $new = clone $this; - $new->contentType = $contentType; - return $new; - } - - /** - * Returns the renderer by the specified content type, or null if the renderer was not set. - * - * @param string $contentType The content type associated with the renderer. - */ - private function getRenderer(string $contentType): ?ThrowableRendererInterface - { - if (isset($this->renderers[$contentType])) { - /** @var ThrowableRendererInterface */ - return $this->container->get($this->renderers[$contentType]); - } - - return null; - } - - /** - * Returns the priority content type from the accept request header. - * - * @return string The priority content type. - */ - private function getContentType(ServerRequestInterface $request): string - { - try { - foreach (HeaderValueHelper::getSortedAcceptTypes($request->getHeader(Header::ACCEPT)) as $header) { - if (array_key_exists($header, $this->renderers)) { - return $header; - } - } - } catch (InvalidArgumentException) { - // The Accept header contains an invalid q factor. - } - - return '*/*'; - } - - /** - * Normalizes the content type. - * - * @param string $contentType The raw content type. - * - * @return string Normalized content type. - */ - private function normalizeContentType(string $contentType): string - { - if (!str_contains($contentType, '/')) { - throw new InvalidArgumentException('Invalid content type.'); - } - - return strtolower(trim($contentType)); - } -} diff --git a/tests/Factory/ThrowableResponseFactoryTest.php b/tests/Factory/ThrowableResponseFactoryTest.php deleted file mode 100644 index 0c9ae08..0000000 --- a/tests/Factory/ThrowableResponseFactoryTest.php +++ /dev/null @@ -1,258 +0,0 @@ -createThrowableResponseFactory() - ->create( - $this->createThrowable(), - $this->createServerRequest('HEAD', ['Accept' => ['test/html']]) - ); - $response->getBody()->rewind(); - $content = $response->getBody()->getContents(); - - $this->assertEmpty($content); - $this->assertSame([HeaderRenderer::DEFAULT_ERROR_MESSAGE], $response->getHeader('X-Error-Message')); - } - - public function testHandleWithFailAcceptRequestHeader(): void - { - $response = $this - ->createThrowableResponseFactory() - ->create( - $this->createThrowable(), - $this->createServerRequest('GET', ['Accept' => ['text/plain;q=2.0']]) - ); - $response - ->getBody() - ->rewind(); - $content = $response - ->getBody() - ->getContents(); - - $this->assertNotSame(PlainTextRenderer::DEFAULT_ERROR_MESSAGE, $content); - $this->assertStringContainsString('createThrowableResponseFactory() - ->withRenderer($mimeType, PlainTextRenderer::class); - $response = $factory->create( - $this->createThrowable(), - $this->createServerRequest('GET', ['Accept' => [$mimeType]]) - ); - $response - ->getBody() - ->rewind(); - $content = $response - ->getBody() - ->getContents(); - - $this->assertSame(PlainTextRenderer::DEFAULT_ERROR_MESSAGE, $content); - } - - public function testThrownExceptionWithRendererIsNotImplementThrowableRendererInterface() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Class "' . self::class . '" does not implement "' . ThrowableRendererInterface::class . '".', - ); - $this - ->createThrowableResponseFactory() - ->withRenderer('test/test', self::class); - } - - public function testThrownExceptionWithInvalidContentType() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid content type.'); - $this - ->createThrowableResponseFactory() - ->withRenderer('test invalid content type', PlainTextRenderer::class); - } - - public function testWithoutRenderers(): void - { - $factory = $this - ->createThrowableResponseFactory() - ->withoutRenderers(); - $response = $factory->create( - $this->createThrowable(), - $this->createServerRequest('GET', ['Accept' => ['test/html']]) - ); - $response - ->getBody() - ->rewind(); - $content = $response - ->getBody() - ->getContents(); - - $this->assertSame(PlainTextRenderer::DEFAULT_ERROR_MESSAGE, $content); - } - - public function testWithoutRenderer(): void - { - $factory = $this - ->createThrowableResponseFactory() - ->withoutRenderers('*/*'); - $response = $factory->create( - $this->createThrowable(), - $this->createServerRequest('GET', ['Accept' => ['test/html']]) - ); - $response - ->getBody() - ->rewind(); - $content = $response - ->getBody() - ->getContents(); - - $this->assertSame(PlainTextRenderer::DEFAULT_ERROR_MESSAGE, $content); - } - - public function testAdvancedAcceptHeader(): void - { - $contentType = 'text/html;version=2'; - $factory = $this - ->createThrowableResponseFactory() - ->withRenderer($contentType, PlainTextRenderer::class); - $response = $factory->create( - $this->createThrowable(), - $this->createServerRequest('GET', ['Accept' => ['text/html', $contentType]]) - ); - $response - ->getBody() - ->rewind(); - $content = $response - ->getBody() - ->getContents(); - - $this->assertSame(PlainTextRenderer::DEFAULT_ERROR_MESSAGE, $content); - } - - public function testDefaultContentType(): void - { - $factory = $this - ->createThrowableResponseFactory() - ->withRenderer('*/*', PlainTextRenderer::class); - $response = $factory->create( - $this->createThrowable(), - $this->createServerRequest('GET', ['Accept' => ['test/test']]) - ); - $response - ->getBody() - ->rewind(); - $content = $response - ->getBody() - ->getContents(); - - $this->assertSame(PlainTextRenderer::DEFAULT_ERROR_MESSAGE, $content); - } - - public function testForceContentType(): void - { - $factory = $this - ->createThrowableResponseFactory() - ->forceContentType('application/json'); - $response = $factory->create( - $this->createThrowable(), - $this->createServerRequest('GET', ['Accept' => ['text/xml']]) - ); - $response - ->getBody() - ->rewind(); - - $this->assertSame('application/json', $response->getHeaderLine(Header::CONTENT_TYPE)); - } - - public function testForceContentTypeSetInvalidType(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The renderer for image/gif is not set.'); - $this - ->createThrowableResponseFactory() - ->forceContentType('image/gif'); - } - - public function testAddedHeaders(): void - { - $provider = new HeadersProvider([ - 'X-Default' => 'default', - 'Content-Type' => 'incorrect', - ]); - $provider->add('X-Test', 'test'); - $provider->add('X-Test2', ['test2', 'test3']); - $factory = $this - ->createThrowableResponseFactory(provider: $provider) - ->withRenderer('*/*', PlainTextRenderer::class); - $response = $factory->create( - $this->createThrowable(), - $this->createServerRequest('GET', ['Accept' => ['test/test']]) - ); - $headers = $response->getHeaders(); - - $this->assertArrayHasKey('Content-Type', $headers); - $this->assertNotEquals('incorrect', $headers['Content-Type']); - - $this->assertArrayHasKey('X-Default', $headers); - $this->assertEquals(['default'], $headers['X-Default']); - $this->assertArrayHasKey('X-Test', $headers); - $this->assertEquals(['test'], $headers['X-Test']); - $this->assertArrayHasKey('X-Test2', $headers); - $this->assertEquals(['test2', 'test3'], $headers['X-Test2']); - } - - private function createThrowableResponseFactory( - ?HeadersProvider $provider = null, - ): ThrowableResponseFactoryInterface { - $container = new SimpleContainer([], fn (string $className): object => new $className()); - return new ThrowableResponseFactory( - new ResponseFactory(), - $this->createErrorHandler(), - $container, - $provider ?? new HeadersProvider() - ); - } - - private function createErrorHandler(): ErrorHandler - { - $logger = $this->createMock(LoggerInterface::class); - return new ErrorHandler($logger, new PlainTextRenderer()); - } - - private function createServerRequest(string $method, array $headers = []): ServerRequestInterface - { - return new ServerRequest([], [], [], [], [], $method, '/', $headers); - } - - private function createThrowable(): Throwable - { - return new RuntimeException(); - } -}