From e98f687851b950bf92c4d59343b41fe00fcd18dc Mon Sep 17 00:00:00 2001 From: olegbaturin Date: Thu, 10 Oct 2024 18:24:50 +0700 Subject: [PATCH] Extract response generator from `ErrorCatcher` middleware (#133) Co-authored-by: Sergei Predvoditelev --- CHANGELOG.md | 1 + README.md | 62 ++-- composer.json | 1 + config/di-web.php | 3 + src/Factory/ThrowableResponseFactory.php | 188 ++++++++++++ src/Middleware/ErrorCatcher.php | 179 +---------- src/ThrowableResponseFactoryInterface.php | 20 ++ .../Factory/ThrowableResponseFactoryTest.php | 262 ++++++++++++++++ tests/Middleware/ErrorCatcherTest.php | 280 +++--------------- 9 files changed, 562 insertions(+), 434 deletions(-) create mode 100644 src/Factory/ThrowableResponseFactory.php create mode 100644 src/ThrowableResponseFactoryInterface.php create mode 100644 tests/Factory/ThrowableResponseFactoryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 525049c..9d03717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 3.3.1 under development - 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` class (@olegbaturin) ## 3.3.0 July 11, 2024 diff --git a/README.md b/README.md index 4d602c0..36fa6f3 100644 --- a/README.md +++ b/README.md @@ -121,47 +121,73 @@ $errorHandler = new ErrorHandler($logger, $renderer); For more information about creating your own renders and examples of rendering error data, [see here](https://github.com/yiisoft/docs/blob/master/guide/en/runtime/handling-errors.md#rendering-error-data). -### Using middleware for catching unhandled errors +### Using a factory to create a response -`Yiisoft\ErrorHandler\Middleware\ErrorCatcher` is a [PSR-15](https://www.php-fig.org/psr/psr-15/) middleware that -catches exceptions that appear during middleware stack execution and passes them to the handler. +`Yiisoft\ErrorHandler\Factory\ThrowableResponseFactory` renders `Throwable` object and produces a response according to the content type provided by the client. ```php -use Yiisoft\ErrorHandler\Middleware\ErrorCatcher; +use Yiisoft\ErrorHandler\Factory\ThrowableResponseFactory; /** + * @var \Throwable $throwable * @var \Psr\Container\ContainerInterface $container * @var \Psr\Http\Message\ResponseFactoryInterface $responseFactory * @var \Psr\Http\Message\ServerRequestInterface $request - * @var \Psr\Http\Server\RequestHandlerInterface $handler * @var \Yiisoft\ErrorHandler\ErrorHandler $errorHandler - * @var \Yiisoft\ErrorHandler\ThrowableRendererInterface $renderer */ -$errorCatcher = new ErrorCatcher($responseFactory, $errorHandler, $container); +$throwableResponseFactory = new ThrowableResponseFactory($responseFactory, $errorHandler, $container); -// In any case, it will return an instance of the `Psr\Http\Message\ResponseInterface`. -// Either the expected response, or a response with error information. -$response = $errorCatcher->process($request, $handler); +// Creating an instance of the `Psr\Http\Message\ResponseInterface` with error information. +$response = $throwableResponseFactory->create($throwable, $request); ``` -The error catcher chooses how to render an exception based on accept HTTP header. If it is `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. +`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: ```php // Returns a new instance without renderers by the specified content types. -$errorCatcher = $errorCatcher->withoutRenderers('application/xml', 'text/xml'); +$throwableResponseFactory = $throwableResponseFactory->withoutRenderers('application/xml', 'text/xml'); // Returns a new instance with the specified content type and renderer class. -$errorCatcher = $errorCatcher->withRenderer('my/format', new MyRenderer()); +$throwableResponseFactory = $throwableResponseFactory->withRenderer('my/format', new MyRenderer()); // Returns a new instance with the specified force content type to respond with regardless of request. -$errorCatcher = $errorCatcher->forceContentType('application/json'); +$throwableResponseFactory = $throwableResponseFactory->forceContentType('application/json'); +``` + +### Using a middleware for catching unhandled errors + +`Yiisoft\ErrorHandler\Middleware\ErrorCatcher` is a [PSR-15](https://www.php-fig.org/psr/psr-15/) middleware that +catches exceptions raised during middleware stack execution and passes them to the instance of `Yiisoft\ErrorHandler\ThrowableResponseFactoryInterface` to create a response. + +```php +use Yiisoft\ErrorHandler\Middleware\ErrorCatcher; + +/** + * @var \Psr\EventDispatcher\EventDispatcherInterface $eventDispatcher + * @var \Psr\Http\Message\ServerRequestInterface $request + * @var \Psr\Http\Server\RequestHandlerInterface $handler + * @var \Yiisoft\ErrorHandler\ThrowableResponseFactoryInterface $throwableResponseFactory + */ + +$errorCatcher = new ErrorCatcher($throwableResponseFactory); + +// In any case, it will return an instance of the `Psr\Http\Message\ResponseInterface`. +// Either the expected response, or a response with error information. +$response = $errorCatcher->process($request, $handler); +``` + +`Yiisoft\ErrorHandler\Middleware\ErrorCatcher` can be instantiated with [PSR-14](https://www.php-fig.org/psr/psr-14/) event dispatcher as an optional dependency. +In this case `\Yiisoft\ErrorHandler\Event\ApplicationError` will be dispatched when `ErrorCatcher` catches an error. + +```php +$errorCatcher = new ErrorCatcher($throwableResponseFactory, $eventDispatcher); ``` -### Using middleware for mapping certain exceptions to custom responses +### Using a middleware for mapping certain exceptions to custom responses `Yiisoft\ErrorHandler\Middleware\ExceptionResponder` is a [PSR-15](https://www.php-fig.org/psr/psr-15/) middleware that maps certain exceptions to custom responses. @@ -196,7 +222,7 @@ In the application middleware stack `Yiisoft\ErrorHandler\Middleware\ExceptionRe ## Events -- When `ErrorCatcher` catches an error it dispatches `\Yiisoft\ErrorHandler\Event\ApplicationError` event. +- When `ErrorCatcher` catches an error it optionally dispatches `\Yiisoft\ErrorHandler\Event\ApplicationError` event. Instance of `Psr\EventDispatcher\EventDispatcherInterface` must be provided to the `ErrorCatcher`. ## Friendly Exceptions diff --git a/composer.json b/composer.json index d5e5531..2a8772c 100644 --- a/composer.json +++ b/composer.json @@ -51,6 +51,7 @@ "httpsoft/http-message": "^1.0.9", "maglnet/composer-require-checker": "^4.4", "phpunit/phpunit": "^9.5", + "psr/event-dispatcher": "^1.0", "rector/rector": "^1.2", "roave/infection-static-analysis-plugin": "^1.16", "spatie/phpunit-watcher": "^1.23", diff --git a/config/di-web.php b/config/di-web.php index c0f814f..b6bc5d6 100644 --- a/config/di-web.php +++ b/config/di-web.php @@ -2,8 +2,10 @@ declare(strict_types=1); +use Yiisoft\ErrorHandler\Factory\ThrowableResponseFactory; use Yiisoft\ErrorHandler\Renderer\HtmlRenderer; use Yiisoft\ErrorHandler\ThrowableRendererInterface; +use Yiisoft\ErrorHandler\ThrowableResponseFactoryInterface; /** * @var array $params @@ -11,4 +13,5 @@ return [ ThrowableRendererInterface::class => HtmlRenderer::class, + ThrowableResponseFactoryInterface::class => ThrowableResponseFactory::class, ]; diff --git a/src/Factory/ThrowableResponseFactory.php b/src/Factory/ThrowableResponseFactory.php new file mode 100644 index 0000000..dd9db73 --- /dev/null +++ b/src/Factory/ThrowableResponseFactory.php @@ -0,0 +1,188 @@ +> + */ + 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 ResponseFactoryInterface $responseFactory, + private ErrorHandler $errorHandler, + private 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/src/Middleware/ErrorCatcher.php b/src/Middleware/ErrorCatcher.php index bbfd8ac..73ec0a6 100644 --- a/src/Middleware/ErrorCatcher.php +++ b/src/Middleware/ErrorCatcher.php @@ -4,127 +4,26 @@ namespace Yiisoft\ErrorHandler\Middleware; -use InvalidArgumentException; -use Psr\Container\ContainerInterface; +use Throwable; use Psr\EventDispatcher\EventDispatcherInterface; -use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use Throwable; use Yiisoft\ErrorHandler\CompositeException; -use Yiisoft\ErrorHandler\ErrorHandler; use Yiisoft\ErrorHandler\Event\ApplicationError; -use Yiisoft\ErrorHandler\HeadersProvider; -use Yiisoft\ErrorHandler\Renderer\HeaderRenderer; -use Yiisoft\ErrorHandler\Renderer\HtmlRenderer; -use Yiisoft\ErrorHandler\Renderer\JsonRenderer; -use Yiisoft\ErrorHandler\Renderer\PlainTextRenderer; -use Yiisoft\ErrorHandler\Renderer\XmlRenderer; -use Yiisoft\ErrorHandler\ThrowableRendererInterface; -use Yiisoft\Http\Header; -use Yiisoft\Http\HeaderValueHelper; -use Yiisoft\Http\Method; -use Yiisoft\Http\Status; - -use function array_key_exists; -use function count; -use function is_subclass_of; -use function sprintf; -use function strtolower; -use function trim; +use Yiisoft\ErrorHandler\ThrowableResponseFactoryInterface; /** - * `ErrorCatcher` catches all throwables from the next middlewares and renders it - * according to the content type passed by the client. + * `ErrorCatcher` catches all throwables from the next middlewares + * and renders it with a handler that implements the `ThrowableResponseFactoryInterface`. */ final class ErrorCatcher implements MiddlewareInterface { - private HeadersProvider $headersProvider; - - /** - * @psalm-var array> - */ - 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 ResponseFactoryInterface $responseFactory, - private ErrorHandler $errorHandler, - private ContainerInterface $container, + private ThrowableResponseFactoryInterface $throwableResponseFactory, private ?EventDispatcherInterface $eventDispatcher = null, - HeadersProvider $headersProvider = null, ) { - $this->headersProvider = $headersProvider ?? new HeadersProvider(); - } - - /** - * 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; } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface @@ -137,74 +36,8 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface } catch (Throwable $e) { $t = new CompositeException($e, $t); } - return $this->generateErrorResponse($t, $request); - } - } - - /** - * Generates a response with error information. - */ - private function generateErrorResponse(Throwable $t, ServerRequestInterface $request): ResponseInterface - { - $contentType = $this->contentType ?? $this->getContentType($request); - $renderer = $request->getMethod() === Method::HEAD ? new HeaderRenderer() : $this->getRenderer($contentType); - - $data = $this->errorHandler->handle($t, $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 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 $this->throwableResponseFactory->create($t, $request); } - - return strtolower(trim($contentType)); } } diff --git a/src/ThrowableResponseFactoryInterface.php b/src/ThrowableResponseFactoryInterface.php new file mode 100644 index 0000000..1a16cb9 --- /dev/null +++ b/src/ThrowableResponseFactoryInterface.php @@ -0,0 +1,20 @@ +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(); + } +} diff --git a/tests/Middleware/ErrorCatcherTest.php b/tests/Middleware/ErrorCatcherTest.php index 134d252..30066ca 100644 --- a/tests/Middleware/ErrorCatcherTest.php +++ b/tests/Middleware/ErrorCatcherTest.php @@ -5,281 +5,75 @@ namespace Yiisoft\ErrorHandler\Tests\Middleware; use Psr\EventDispatcher\EventDispatcherInterface; -use HttpSoft\Message\ResponseFactory; +use HttpSoft\Message\Response; use HttpSoft\Message\ServerRequest; -use InvalidArgumentException; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; -use Psr\Log\LoggerInterface; use RuntimeException; -use Yiisoft\ErrorHandler\ErrorHandler; -use Yiisoft\ErrorHandler\HeadersProvider; +use Throwable; use Yiisoft\ErrorHandler\Middleware\ErrorCatcher; -use Yiisoft\ErrorHandler\Renderer\HeaderRenderer; -use Yiisoft\ErrorHandler\Renderer\PlainTextRenderer; -use Yiisoft\ErrorHandler\ThrowableRendererInterface; -use Yiisoft\Http\Header; -use Yiisoft\Test\Support\Container\SimpleContainer; +use Yiisoft\ErrorHandler\ThrowableResponseFactoryInterface; +use Yiisoft\Http\Status; final class ErrorCatcherTest extends TestCase { - public function testProcessWithHeadRequestMethod(): void + public function testSuccess(): void { - $response = $this - ->createErrorCatcher() - ->process( - $this->createServerRequest('HEAD', ['Accept' => ['test/html']]), - $this->createRequestHandlerWithThrowable(), - ); - $response - ->getBody() - ->rewind(); - $content = $response - ->getBody() - ->getContents(); - - $this->assertEmpty($content); - $this->assertSame([HeaderRenderer::DEFAULT_ERROR_MESSAGE], $response->getHeader('X-Error-Message')); - } - - public function testProcessWithFailAcceptRequestHeader(): void - { - $response = $this - ->createErrorCatcher() - ->process( - $this->createServerRequest('GET', ['Accept' => ['text/plain;q=2.0']]), - $this->createRequestHandlerWithThrowable(), - ); - $response - ->getBody() - ->rewind(); - $content = $response - ->getBody() - ->getContents(); - - $this->assertNotSame(PlainTextRenderer::DEFAULT_ERROR_MESSAGE, $content); - $this->assertStringContainsString('createMock(EventDispatcherInterface::class); - $eventDispatcher->method('dispatch')->willThrowException(new \RuntimeException('Event dispatcher error')); - $container = new SimpleContainer([], fn (string $className): object => new $className()); $errorCatcher = new ErrorCatcher( - new ResponseFactory(), - $this->createErrorHandler(), - $container, - $eventDispatcher, + $this->createThrowableResponseFactory(), ); + $handler = new class () implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + return new Response(); + } + }; $response = $errorCatcher->process( - $this->createServerRequest('GET', ['Accept' => ['text/plain;q=2.0']]), - $this->createRequestHandlerWithThrowable(), - ); - $response - ->getBody() - ->rewind(); - $content = $response - ->getBody() - ->getContents(); - $this->assertNotSame(PlainTextRenderer::DEFAULT_ERROR_MESSAGE, $content); - $this->assertStringContainsString('createErrorCatcher() - ->withRenderer($mimeType, PlainTextRenderer::class); - $response = $catcher->process( - $this->createServerRequest('GET', ['Accept' => [$mimeType]]), - $this->createRequestHandlerWithThrowable(), - ); - $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 - ->createErrorCatcher() - ->withRenderer('test/test', self::class); - } - - public function testThrownExceptionWithInvalidContentType() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid content type.'); - $this - ->createErrorCatcher() - ->withRenderer('test invalid content type', PlainTextRenderer::class); - } - - public function testWithoutRenderers(): void - { - $catcher = $this - ->createErrorCatcher() - ->withoutRenderers(); - $response = $catcher->process( - $this->createServerRequest('GET', ['Accept' => ['test/html']]), - $this->createRequestHandlerWithThrowable(), + new ServerRequest(), + $handler ); - $response - ->getBody() - ->rewind(); - $content = $response - ->getBody() - ->getContents(); - $this->assertSame(PlainTextRenderer::DEFAULT_ERROR_MESSAGE, $content); + $this->assertSame(200, $response->getStatusCode()); } - public function testWithoutRenderer(): void + public function testError(): void { - $catcher = $this - ->createErrorCatcher() - ->withoutRenderers('*/*'); - $response = $catcher->process( - $this->createServerRequest('GET', ['Accept' => ['test/html']]), - $this->createRequestHandlerWithThrowable(), - ); - $response - ->getBody() - ->rewind(); - $content = $response - ->getBody() - ->getContents(); - - $this->assertSame(PlainTextRenderer::DEFAULT_ERROR_MESSAGE, $content); - } - - public function testAdvancedAcceptHeader(): void - { - $contentType = 'text/html;version=2'; - $catcher = $this - ->createErrorCatcher() - ->withRenderer($contentType, PlainTextRenderer::class); - $response = $catcher->process( - $this->createServerRequest('GET', ['Accept' => ['text/html', $contentType]]), - $this->createRequestHandlerWithThrowable(), + $errorCatcher = new ErrorCatcher( + $this->createThrowableResponseFactory(), ); - $response - ->getBody() - ->rewind(); - $content = $response - ->getBody() - ->getContents(); - - $this->assertSame(PlainTextRenderer::DEFAULT_ERROR_MESSAGE, $content); - } - - public function testDefaultContentType(): void - { - $catcher = $this - ->createErrorCatcher() - ->withRenderer('*/*', PlainTextRenderer::class); - $response = $catcher->process( - $this->createServerRequest('GET', ['Accept' => ['test/test']]), + $response = $errorCatcher->process( + new ServerRequest(), $this->createRequestHandlerWithThrowable(), ); - $response - ->getBody() - ->rewind(); - $content = $response - ->getBody() - ->getContents(); - $this->assertSame(PlainTextRenderer::DEFAULT_ERROR_MESSAGE, $content); + $this->assertSame(Status::INTERNAL_SERVER_ERROR, $response->getStatusCode()); } - public function testForceContentType(): void + public function testErrorWithEventDispatcher(): void { - $catcher = $this - ->createErrorCatcher() - ->forceContentType('application/json'); - $response = $catcher->process( - $this->createServerRequest('GET', ['Accept' => ['text/xml']]), - $this->createRequestHandlerWithThrowable(), + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->method('dispatch')->willThrowException(new \RuntimeException('Event dispatcher error')); + $errorCatcher = new ErrorCatcher( + $this->createThrowableResponseFactory(), + $eventDispatcher, ); - $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 - ->createErrorCatcher() - ->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']); - $catcher = $this - ->createErrorCatcher(provider: $provider) - ->withRenderer('*/*', PlainTextRenderer::class); - $response = $catcher->process( - $this->createServerRequest('GET', ['Accept' => ['test/test']]), + $response = $errorCatcher->process( + new ServerRequest(), $this->createRequestHandlerWithThrowable(), ); - $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']); + $this->assertSame(Status::INTERNAL_SERVER_ERROR, $response->getStatusCode()); } - private function createErrorCatcher( - HeadersProvider $provider = null, - ): ErrorCatcher { - $container = new SimpleContainer([], fn (string $className): object => new $className()); - return new ErrorCatcher( - new ResponseFactory(), - $this->createErrorHandler(), - $container, - null, - $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 + private function createThrowableResponseFactory(): ThrowableResponseFactoryInterface { - return new ServerRequest([], [], [], [], [], $method, '/', $headers); + return new class () implements ThrowableResponseFactoryInterface { + public function create(Throwable $throwable, ServerRequestInterface $request): ResponseInterface + { + return new Response(Status::INTERNAL_SERVER_ERROR); + } + }; } private function createRequestHandlerWithThrowable(): RequestHandlerInterface