diff --git a/CHANGELOG.md b/CHANGELOG.md index ba1bca8..44ce865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,12 @@ # Yii Error Handler Change Log -## 4.0.1 under development +## 4.1.0 under development - Bug #142: Fix dark mode argument display issues (@pamparam83) +- Enh #145: Set content type header in renderers (@vjik) +- New #145: Add `Yiisoft\ErrorHandler\ThrowableResponseFactory` that provides a response for `Throwable` object with + renderer provider usage (@vjik) +- Chg #145: Mark `Yiisoft\ErrorHandler\Factory\ThrowableResponseFactory` as deprecated (@vjik) ## 4.0.0 February 05, 2025 @@ -11,7 +15,7 @@ - Chg #139: Change PHP constraint in `composer.json` to `~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0` (@vjik) - Enh #125: Add error code & show function arguments (@xepozz) - Enh #130: Pass exception message instead of rendered exception to logger in `ErrorHandler` (@olegbaturin) -- Enh #133: Extract response generator from `ErrorCatcher` middleware into separate `ThrowableResponseFactory` +- Enh #133: Extract response generator from `ErrorCatcher` middleware into separate `ThrowableResponseFactory` class (@olegbaturin) - Enh #138, #139: Raise the minimum PHP version to 8.1 and minor refactoring (@vjik) - Bug #139: Explicitly mark nullable parameters (@vjik) diff --git a/README.md b/README.md index f985492..f251e5c 100644 --- a/README.md +++ b/README.md @@ -122,10 +122,11 @@ For more information about creating your own renders and examples of rendering e ### Using a factory to create a response -`Yiisoft\ErrorHandler\Factory\ThrowableResponseFactory` renders `Throwable` object and produces a response according to the content type provided by the client. +`Yiisoft\ErrorHandler\ThrowableResponseFactory` renders `Throwable` object and produces a response according to the content type provided by the client. ```php -use Yiisoft\ErrorHandler\Factory\ThrowableResponseFactory; +use Yiisoft\ErrorHandler\RendererProvider; +use Yiisoft\ErrorHandler\ThrowableResponseFactory; /** * @var \Throwable $throwable @@ -135,27 +136,27 @@ use Yiisoft\ErrorHandler\Factory\ThrowableResponseFactory; * @var \Yiisoft\ErrorHandler\ErrorHandler $errorHandler */ -$throwableResponseFactory = new ThrowableResponseFactory($responseFactory, $errorHandler, $container); +$throwableResponseFactory = new ThrowableResponseFactory( + $responseFactory, + $errorHandler, + new RendererProvider\CompositeRendererProvider( + new RendererProvider\HeadRendererProvider(), + new RendererProvider\ContentTypeRendererProvider($container), + ), +); // Creating an instance of the `Psr\Http\Message\ResponseInterface` with error information. $response = $throwableResponseFactory->create($throwable, $request); ``` -`Yiisoft\ErrorHandler\Factory\ThrowableResponseFactory` chooses how to render an exception based on accept HTTP header. -If it's `text/html` or any unknown content type, it will use the error or exception HTML template to display errors. -For other mime types, the error handler will choose different renderer that is registered within the error catcher. -By default, JSON, XML and plain text are supported. You can change this behavior as follows: +`Yiisoft\ErrorHandler\ThrowableResponseFactory` chooses how to render an exception by renderer provider. Providers +available out of the box: -```php -// Returns a new instance without renderers by the specified content types. -$throwableResponseFactory = $throwableResponseFactory->withoutRenderers('application/xml', 'text/xml'); - -// Returns a new instance with the specified content type and renderer class. -$throwableResponseFactory = $throwableResponseFactory->withRenderer('my/format', new MyRenderer()); - -// Returns a new instance with the specified force content type to respond with regardless of request. -$throwableResponseFactory = $throwableResponseFactory->forceContentType('application/json'); -``` +- `HeadRendererProvider` - renders error into HTTP headers. It is used for HEAD requests. +- `ContentTypeRendererProvider` - renders error based on accept HTTP header. By default, JSON, XML and plain text are + supported. +- `ClosureRendererProvider` - allows you to create your own renderer provider using closures. +- `CompositeRendererProvider` - allows you to combine several renderer providers. ### Using a middleware for catching unhandled errors diff --git a/composer.json b/composer.json index 7c1df43..861c116 100644 --- a/composer.json +++ b/composer.json @@ -47,16 +47,16 @@ "yiisoft/injector": "^1.0" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.8", + "bamarni/composer-bin-plugin": "^1.8.2", "httpsoft/http-message": "^1.1.6", - "phpunit/phpunit": "^10.5.44", + "phpunit/phpunit": "^10.5.45", "psr/event-dispatcher": "^1.0", - "rector/rector": "^2.0.7", + "rector/rector": "^2.0.11", "roave/infection-static-analysis-plugin": "^1.35", "spatie/phpunit-watcher": "^1.24", "vimeo/psalm": "^5.26.1 || ^6.9.1", "yiisoft/di": "^1.3", - "yiisoft/test-support": "^3.0.1" + "yiisoft/test-support": "^3.0.2" }, "autoload": { "psr-4": { diff --git a/src/Factory/ThrowableResponseFactory.php b/src/Factory/ThrowableResponseFactory.php index 6daf9ac..6a16c1b 100644 --- a/src/Factory/ThrowableResponseFactory.php +++ b/src/Factory/ThrowableResponseFactory.php @@ -34,6 +34,8 @@ /** * `ThrowableResponseFactory` renders `Throwable` object * and produces a response according to the content type provided by the client. + * + * @deprecated Use {@see \Yiisoft\ErrorHandler\ThrowableResponseFactory} instead. */ final class ThrowableResponseFactory implements ThrowableResponseFactoryInterface { diff --git a/src/Renderer/HeaderRenderer.php b/src/Renderer/HeaderRenderer.php index 385551b..ec65acc 100644 --- a/src/Renderer/HeaderRenderer.php +++ b/src/Renderer/HeaderRenderer.php @@ -8,25 +8,54 @@ use Throwable; use Yiisoft\ErrorHandler\ErrorData; use Yiisoft\ErrorHandler\ThrowableRendererInterface; +use Yiisoft\Http\Header; /** * Formats throwable into HTTP headers. */ final class HeaderRenderer implements ThrowableRendererInterface { + /** + * @param string|null $contentType The content type to be set in the response header. + */ + public function __construct( + private readonly ?string $contentType = null, + ) { + } + public function render(Throwable $t, ?ServerRequestInterface $request = null): ErrorData { - return new ErrorData('', ['X-Error-Message' => self::DEFAULT_ERROR_MESSAGE]); + return new ErrorData( + '', + $this->addContentTypeHeader([ + 'X-Error-Message' => self::DEFAULT_ERROR_MESSAGE, + ]), + ); } public function renderVerbose(Throwable $t, ?ServerRequestInterface $request = null): ErrorData { - return new ErrorData('', [ - 'X-Error-Type' => $t::class, - 'X-Error-Message' => $t->getMessage(), - 'X-Error-Code' => (string) $t->getCode(), - 'X-Error-File' => $t->getFile(), - 'X-Error-Line' => (string) $t->getLine(), - ]); + return new ErrorData( + '', + $this->addContentTypeHeader([ + 'X-Error-Type' => $t::class, + 'X-Error-Message' => $t->getMessage(), + 'X-Error-Code' => (string) $t->getCode(), + 'X-Error-File' => $t->getFile(), + 'X-Error-Line' => (string) $t->getLine(), + ]), + ); + } + + /** + * @param array $headers + * @return array + */ + private function addContentTypeHeader(array $headers): array + { + if ($this->contentType !== null) { + $headers[Header::CONTENT_TYPE] = $this->contentType; + } + return $headers; } } diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php index 9e8fc75..c85acb0 100644 --- a/src/Renderer/HtmlRenderer.php +++ b/src/Renderer/HtmlRenderer.php @@ -14,6 +14,7 @@ use Yiisoft\ErrorHandler\Exception\ErrorException; use Yiisoft\ErrorHandler\ThrowableRendererInterface; use Yiisoft\FriendlyException\FriendlyExceptionInterface; +use Yiisoft\Http\Header; use function array_values; use function dirname; @@ -51,6 +52,8 @@ */ final class HtmlRenderer implements ThrowableRendererInterface { + private const CONTENT_TYPE = 'text/html'; + private readonly GithubMarkdown $markdownParser; /** @@ -158,18 +161,24 @@ public function __construct( public function render(Throwable $t, ?ServerRequestInterface $request = null): ErrorData { - return new ErrorData($this->renderTemplate($this->template, [ - 'request' => $request, - 'throwable' => $t, - ])); + return new ErrorData( + $this->renderTemplate($this->template, [ + 'request' => $request, + 'throwable' => $t, + ]), + [Header::CONTENT_TYPE => self::CONTENT_TYPE], + ); } public function renderVerbose(Throwable $t, ?ServerRequestInterface $request = null): ErrorData { - return new ErrorData($this->renderTemplate($this->verboseTemplate, [ - 'request' => $request, - 'throwable' => $t, - ])); + return new ErrorData( + $this->renderTemplate($this->verboseTemplate, [ + 'request' => $request, + 'throwable' => $t, + ]), + [Header::CONTENT_TYPE => self::CONTENT_TYPE], + ); } /** diff --git a/src/Renderer/JsonRenderer.php b/src/Renderer/JsonRenderer.php index 028084e..fa93af0 100644 --- a/src/Renderer/JsonRenderer.php +++ b/src/Renderer/JsonRenderer.php @@ -8,6 +8,7 @@ use Throwable; use Yiisoft\ErrorHandler\ErrorData; use Yiisoft\ErrorHandler\ThrowableRendererInterface; +use Yiisoft\Http\Header; use function json_encode; @@ -16,6 +17,8 @@ */ final class JsonRenderer implements ThrowableRendererInterface { + private const CONTENT_TYPE = 'application/json'; + public function render(Throwable $t, ?ServerRequestInterface $request = null): ErrorData { return new ErrorData( @@ -24,7 +27,8 @@ public function render(Throwable $t, ?ServerRequestInterface $request = null): E 'message' => self::DEFAULT_ERROR_MESSAGE, ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES - ) + ), + [Header::CONTENT_TYPE => self::CONTENT_TYPE], ); } @@ -41,7 +45,8 @@ public function renderVerbose(Throwable $t, ?ServerRequestInterface $request = n 'trace' => $t->getTrace(), ], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR - ) + ), + [Header::CONTENT_TYPE => self::CONTENT_TYPE], ); } } diff --git a/src/Renderer/PlainTextRenderer.php b/src/Renderer/PlainTextRenderer.php index 1f9ddd5..8856e0e 100644 --- a/src/Renderer/PlainTextRenderer.php +++ b/src/Renderer/PlainTextRenderer.php @@ -8,21 +8,33 @@ use Throwable; use Yiisoft\ErrorHandler\ErrorData; use Yiisoft\ErrorHandler\ThrowableRendererInterface; +use Yiisoft\Http\Header; + +use function sprintf; /** * Formats throwable into plain text string. */ final class PlainTextRenderer implements ThrowableRendererInterface { + public function __construct( + private readonly string $contentType = 'text/plain', + ) { + } + public function render(Throwable $t, ?ServerRequestInterface $request = null): ErrorData { - return new ErrorData(self::DEFAULT_ERROR_MESSAGE); + return new ErrorData( + self::DEFAULT_ERROR_MESSAGE, + [Header::CONTENT_TYPE => $this->contentType], + ); } public function renderVerbose(Throwable $t, ?ServerRequestInterface $request = null): ErrorData { return new ErrorData( - self::throwableToString($t) + self::throwableToString($t), + [Header::CONTENT_TYPE => $this->contentType], ); } diff --git a/src/Renderer/XmlRenderer.php b/src/Renderer/XmlRenderer.php index e7af2cd..e416e56 100644 --- a/src/Renderer/XmlRenderer.php +++ b/src/Renderer/XmlRenderer.php @@ -8,6 +8,7 @@ use Throwable; use Yiisoft\ErrorHandler\ErrorData; use Yiisoft\ErrorHandler\ThrowableRendererInterface; +use Yiisoft\Http\Header; use function str_replace; @@ -16,13 +17,18 @@ */ final class XmlRenderer implements ThrowableRendererInterface { + private const CONTENT_TYPE = 'application/xml'; + public function render(Throwable $t, ?ServerRequestInterface $request = null): ErrorData { $content = ''; $content .= "\n\n"; $content .= $this->tag('message', self::DEFAULT_ERROR_MESSAGE); $content .= ''; - return new ErrorData($content); + return new ErrorData( + $content, + [Header::CONTENT_TYPE => self::CONTENT_TYPE], + ); } public function renderVerbose(Throwable $t, ?ServerRequestInterface $request = null): ErrorData @@ -36,7 +42,10 @@ public function renderVerbose(Throwable $t, ?ServerRequestInterface $request = n $content .= $this->tag('line', (string) $t->getLine()); $content .= $this->tag('trace', $t->getTraceAsString()); $content .= ''; - return new ErrorData($content); + return new ErrorData( + $content, + [Header::CONTENT_TYPE => self::CONTENT_TYPE], + ); } private function tag(string $name, string $value): string diff --git a/src/RendererProvider/ClosureRendererProvider.php b/src/RendererProvider/ClosureRendererProvider.php new file mode 100644 index 0000000..1eafa59 --- /dev/null +++ b/src/RendererProvider/ClosureRendererProvider.php @@ -0,0 +1,41 @@ +|ThrowableRendererInterface|null) + */ +final class ClosureRendererProvider implements RendererProviderInterface +{ + /** + * @psalm-param TClosure $closure + */ + public function __construct( + private readonly Closure $closure, + private readonly ContainerInterface $container, + ) { + } + + public function get(ServerRequestInterface $request): ?ThrowableRendererInterface + { + $result = ($this->closure)($request); + + if (is_string($result)) { + /** @var ThrowableRendererInterface */ + return $this->container->get($result); + } + + return $result; + } +} diff --git a/src/RendererProvider/CompositeRendererProvider.php b/src/RendererProvider/CompositeRendererProvider.php new file mode 100644 index 0000000..d6e5fe6 --- /dev/null +++ b/src/RendererProvider/CompositeRendererProvider.php @@ -0,0 +1,39 @@ + + */ + private readonly array $providers; + + /** + * @no-named-arguments + */ + public function __construct(RendererProviderInterface ...$providers) + { + $this->providers = $providers; + } + + public function get(ServerRequestInterface $request): ?ThrowableRendererInterface + { + foreach ($this->providers as $provider) { + $renderer = $provider->get($request); + if ($renderer !== null) { + return $renderer; + } + } + + return null; + } +} diff --git a/src/RendererProvider/ContentTypeRendererProvider.php b/src/RendererProvider/ContentTypeRendererProvider.php new file mode 100644 index 0000000..e0bf8ef --- /dev/null +++ b/src/RendererProvider/ContentTypeRendererProvider.php @@ -0,0 +1,80 @@ +> + */ + private readonly array $renderers; + + /** + * @psalm-param array>|null $renderers + */ + public function __construct( + private readonly ContainerInterface $container, + ?array $renderers = null, + ) { + $this->renderers = $renderers ?? [ + 'application/json' => JsonRenderer::class, + 'application/xml' => XmlRenderer::class, + 'text/xml' => XmlRenderer::class, + 'text/plain' => PlainTextRenderer::class, + 'text/html' => HtmlRenderer::class, + '*/*' => HtmlRenderer::class, + ]; + } + + public function get(ServerRequestInterface $request): ?ThrowableRendererInterface + { + $rendererClass = $this->selectRendererClass($request); + if ($rendererClass === null) { + return null; + } + + /** @var ThrowableRendererInterface */ + return $this->container->get($rendererClass); + } + + /** + * @psalm-return class-string|null + */ + private function selectRendererClass(ServerRequestInterface $request): ?string + { + $acceptHeader = $request->getHeader(Header::ACCEPT); + + try { + $contentTypes = HeaderValueHelper::getSortedAcceptTypes($acceptHeader); + } catch (InvalidArgumentException) { + // The "Accept" header contains an invalid "q" factor. + return null; + } + + foreach ($contentTypes as $contentType) { + if (array_key_exists($contentType, $this->renderers)) { + return $this->renderers[$contentType]; + } + } + + return null; + } +} diff --git a/src/RendererProvider/HeadRendererProvider.php b/src/RendererProvider/HeadRendererProvider.php new file mode 100644 index 0000000..0d48228 --- /dev/null +++ b/src/RendererProvider/HeadRendererProvider.php @@ -0,0 +1,44 @@ +getMethod() === Method::HEAD) { + return new HeaderRenderer( + $this->getAcceptContentType($request), + ); + } + + return null; + } + + private function getAcceptContentType(ServerRequestInterface $request): ?string + { + $acceptHeader = $request->getHeader(Header::ACCEPT); + + try { + $contentTypes = HeaderValueHelper::getSortedAcceptTypes($acceptHeader); + } catch (InvalidArgumentException) { + // The "Accept" header contains an invalid "q" factor. + return null; + } + + return empty($contentTypes) ? null : reset($contentTypes); + } +} diff --git a/src/RendererProvider/RendererProviderInterface.php b/src/RendererProvider/RendererProviderInterface.php new file mode 100644 index 0000000..04c51e5 --- /dev/null +++ b/src/RendererProvider/RendererProviderInterface.php @@ -0,0 +1,21 @@ +headersProvider = $headersProvider ?? new HeadersProvider(); + } + + public function create(Throwable $throwable, ServerRequestInterface $request): ResponseInterface + { + $renderer = $this->rendererProvider->get($request); + + $response = $this->responseFactory->createResponse(Status::INTERNAL_SERVER_ERROR); + foreach ($this->headersProvider->getAll() as $name => $value) { + $response = $response->withHeader($name, $value); + } + + return $this->errorHandler + ->handle($throwable, $renderer, $request) + ->addToResponse($response); + } +} diff --git a/tests/Renderer/HeaderRendererTest.php b/tests/Renderer/HeaderRendererTest.php index 19187e2..09120ee 100644 --- a/tests/Renderer/HeaderRendererTest.php +++ b/tests/Renderer/HeaderRendererTest.php @@ -8,6 +8,9 @@ use PHPUnit\Framework\TestCase; use RuntimeException; use Yiisoft\ErrorHandler\Renderer\HeaderRenderer; +use Yiisoft\ErrorHandler\Tests\Support\TestHelper; + +use function PHPUnit\Framework\assertSame; final class HeaderRendererTest extends TestCase { @@ -16,13 +19,8 @@ public function testRender(): void $renderer = new HeaderRenderer(); $data = $renderer->render(new RuntimeException()); $response = $data->addToResponse(new Response()); - $response - ->getBody() - ->rewind(); - $this->assertEmpty($response - ->getBody() - ->getContents()); + $this->assertEmpty(TestHelper::getResponseContent($response)); $this->assertSame([HeaderRenderer::DEFAULT_ERROR_MESSAGE], $response->getHeader('X-Error-Message')); } @@ -32,17 +30,23 @@ public function testRenderVerbose(): void $throwable = new RuntimeException(); $data = $renderer->renderVerbose($throwable); $response = $data->addToResponse(new Response()); - $response - ->getBody() - ->rewind(); - $this->assertEmpty($response - ->getBody() - ->getContents()); + $this->assertEmpty(TestHelper::getResponseContent($response)); $this->assertSame([RuntimeException::class], $response->getHeader('X-Error-Type')); $this->assertSame([$throwable->getMessage()], $response->getHeader('X-Error-Message')); $this->assertSame([(string) $throwable->getCode()], $response->getHeader('X-Error-Code')); $this->assertSame([$throwable->getFile()], $response->getHeader('X-Error-File')); $this->assertSame([(string) $throwable->getLine()], $response->getHeader('X-Error-Line')); } + + public function testContentType(): void + { + $renderer = new HeaderRenderer('text/plain'); + + $response = $renderer + ->render(new RuntimeException()) + ->addToResponse(new Response()); + + assertSame('text/plain', $response->getHeaderLine('Content-Type')); + } } diff --git a/tests/Renderer/PlainTextRendererTest.php b/tests/Renderer/PlainTextRendererTest.php index 39964e1..ae8a859 100644 --- a/tests/Renderer/PlainTextRendererTest.php +++ b/tests/Renderer/PlainTextRendererTest.php @@ -4,10 +4,13 @@ namespace Yiisoft\ErrorHandler\Tests\Renderer; +use HttpSoft\Message\Response; use PHPUnit\Framework\TestCase; use RuntimeException; use Yiisoft\ErrorHandler\Renderer\PlainTextRenderer; +use function PHPUnit\Framework\assertSame; + final class PlainTextRendererTest extends TestCase { public function testRender(): void @@ -42,4 +45,15 @@ public function testRenderVerbose(): void $this->assertSame($expectedContent, (string) $data); $this->assertSame($expectedContent, PlainTextRenderer::throwableToString($throwable)); } + + public function testContentType(): void + { + $renderer = new PlainTextRenderer('text/html'); + + $response = $renderer + ->render(new RuntimeException()) + ->addToResponse(new Response()); + + assertSame('text/html', $response->getHeaderLine('Content-Type')); + } } diff --git a/tests/RendererProvider/ClosureRendererProviderTest.php b/tests/RendererProvider/ClosureRendererProviderTest.php new file mode 100644 index 0000000..3bda2d1 --- /dev/null +++ b/tests/RendererProvider/ClosureRendererProviderTest.php @@ -0,0 +1,70 @@ + new PlainTextRenderer(), + ]), + ); + + $request = TestHelper::createRequest(); + $renderer = $provider->get($request); + + assertSame($request, $closureRequest); + assertInstanceOf(PlainTextRenderer::class, $renderer); + } + + public function testRenderer(): void + { + $closureRenderer = new XmlRenderer(); + $provider = new ClosureRendererProvider( + static fn() => $closureRenderer, + new SimpleContainer(), + ); + + $renderer = $provider->get( + TestHelper::createRequest(), + ); + + assertSame($closureRenderer, $renderer); + } + + public function testNull(): void + { + $provider = new ClosureRendererProvider( + static fn() => null, + new SimpleContainer(), + ); + + $renderer = $provider->get( + TestHelper::createRequest(), + ); + + assertNull($renderer); + } +} diff --git a/tests/RendererProvider/CompositeRendererProviderTest.php b/tests/RendererProvider/CompositeRendererProviderTest.php new file mode 100644 index 0000000..f35abc9 --- /dev/null +++ b/tests/RendererProvider/CompositeRendererProviderTest.php @@ -0,0 +1,51 @@ +get( + TestHelper::createRequest('HEAD'), + ); + + assertInstanceOf(HeaderRenderer::class, $renderer); + } + + public function testNotFound(): void + { + $provider = new CompositeRendererProvider( + new ContentTypeRendererProvider(new SimpleContainer()), + new ClosureRendererProvider( + static fn() => null, + new SimpleContainer(), + ), + ); + + $renderer = $provider->get( + TestHelper::createRequest('HEAD'), + ); + + assertNull($renderer); + } +} diff --git a/tests/RendererProvider/ContentTypeRendererProviderTest.php b/tests/RendererProvider/ContentTypeRendererProviderTest.php new file mode 100644 index 0000000..3657dd3 --- /dev/null +++ b/tests/RendererProvider/ContentTypeRendererProviderTest.php @@ -0,0 +1,92 @@ + new JsonRenderer(), + XmlRenderer::class => new XmlRenderer(), + PlainTextRenderer::class => new PlainTextRenderer(), + HtmlRenderer::class => new HtmlRenderer(), + ]), + ); + + $renderer = $provider->get( + TestHelper::createRequest(headers: ['Accept' => $accept]), + ); + + assertInstanceOf($expectedRendererClass, $renderer); + } + + public function testCustomRenderer(): void + { + $provider = new ContentTypeRendererProvider( + new SimpleContainer([ + PlainTextRenderer::class => new PlainTextRenderer(), + ]), + ['text/new' => PlainTextRenderer::class], + ); + + $renderer = $provider->get( + TestHelper::createRequest(headers: ['Accept' => 'text/new']), + ); + + assertInstanceOf(PlainTextRenderer::class, $renderer); + } + + public function testInvalidAccept(): void + { + $provider = new ContentTypeRendererProvider( + new SimpleContainer(), + ); + + $renderer = $provider->get( + TestHelper::createRequest(headers: ['Accept' => 'text/html;q=x']), + ); + + assertNull($renderer); + } + + public function testNonExistRenderer(): void + { + $provider = new ContentTypeRendererProvider( + new SimpleContainer(), + ); + + $renderer = $provider->get( + TestHelper::createRequest(headers: ['Accept' => 'text/unknown']), + ); + + assertNull($renderer); + } +} diff --git a/tests/RendererProvider/HeadRendererProviderTest.php b/tests/RendererProvider/HeadRendererProviderTest.php new file mode 100644 index 0000000..2ee1356 --- /dev/null +++ b/tests/RendererProvider/HeadRendererProviderTest.php @@ -0,0 +1,62 @@ +get( + TestHelper::createRequest($requestMethod), + ); + + assertNull($renderer); + } + + public static function dataHeadRequest(): iterable + { + yield ['', []]; + yield ['text/html', ['Accept' => ['text/html']]]; + yield ['text/html', ['Accept' => ['text/html;q=0.5']]]; + yield ['', ['Accept' => ['text/html;q=x']]]; + } + + #[DataProvider('dataHeadRequest')] + public function testHeadRequest(string $expectedContentType, array $headers): void + { + $provider = new HeadRendererProvider(); + + $renderer = $provider->get( + TestHelper::createRequest('HEAD', headers: $headers), + ); + + assertInstanceOf(HeaderRenderer::class, $renderer); + + $response = $renderer + ->render(new RuntimeException()) + ->addToResponse(new Response()); + + assertSame($expectedContentType, $response->getHeaderLine('Content-Type')); + } +} diff --git a/tests/Support/TestHelper.php b/tests/Support/TestHelper.php new file mode 100644 index 0000000..db285ef --- /dev/null +++ b/tests/Support/TestHelper.php @@ -0,0 +1,33 @@ +createServerRequest($method, $uri, $serverParams); + foreach ($headers as $name => $value) { + $request = $request->withAddedHeader($name, $value); + } + return $request; + } + + public static function getResponseContent(ResponseInterface $response): string + { + $body = $response->getBody(); + $body->rewind(); + return $body->getContents(); + } +} diff --git a/tests/ThrowableResponseFactoryTest.php b/tests/ThrowableResponseFactoryTest.php new file mode 100644 index 0000000..ed5d045 --- /dev/null +++ b/tests/ThrowableResponseFactoryTest.php @@ -0,0 +1,71 @@ +create( + new LogicException('test message'), + TestHelper::createRequest(), + ); + + assertSame(500, $response->getStatusCode()); + assertSame(ThrowableRendererInterface::DEFAULT_ERROR_MESSAGE, TestHelper::getResponseContent($response)); + } + + public function testHeaders(): void + { + $factory = new ThrowableResponseFactory( + new ResponseFactory(), + new ErrorHandler( + new NullLogger(), + new PlainTextRenderer(), + ), + new ContentTypeRendererProvider( + new SimpleContainer(), + ), + new HeadersProvider(['X-Test' => ['on'], 'X-Test-Custom' => 'hello']) + ); + + $response = $factory->create( + new LogicException('test message'), + TestHelper::createRequest(), + ); + + assertTrue($response->hasHeader('X-Test')); + assertSame('on', $response->getHeaderLine('X-Test')); + assertTrue($response->hasHeader('X-Test-Custom')); + assertSame('hello', $response->getHeaderLine('X-Test-Custom')); + } +}