Skip to content

Commit

Permalink
Merge branch 'master' into solution
Browse files Browse the repository at this point in the history
xepozz authored Oct 14, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
2 parents a2b5afd + e98f687 commit 5c2c4b0
Showing 11 changed files with 570 additions and 442 deletions.
3 changes: 2 additions & 1 deletion .github/workflows/rector.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
on:
pull_request:
pull_request_target:
paths-ignore:
- 'docs/**'
- 'README.md'
@@ -17,6 +17,7 @@ jobs:
secrets:
token: ${{ secrets.YIISOFT_GITHUB_TOKEN }}
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
os: >-
['ubuntu-latest']
php: >-
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -2,7 +2,8 @@

## 3.3.1 under development

- no changes in this release.
- 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

68 changes: 47 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
@@ -6,9 +6,9 @@
<br>
</p>

[![Latest Stable Version](https://poser.pugx.org/yiisoft/error-handler/v/stable.png)](https://packagist.org/packages/yiisoft/error-handler)
[![Total Downloads](https://poser.pugx.org/yiisoft/error-handler/downloads.png)](https://packagist.org/packages/yiisoft/error-handler)
[![Build status](https://github.com/yiisoft/error-handler/workflows/build/badge.svg)](https://github.com/yiisoft/error-handler/actions?query=workflow%3Abuild)
[![Latest Stable Version](https://poser.pugx.org/yiisoft/error-handler/v)](https://packagist.org/packages/yiisoft/error-handler)
[![Total Downloads](https://poser.pugx.org/yiisoft/error-handler/downloads)](https://packagist.org/packages/yiisoft/error-handler)
[![Build status](https://github.com/yiisoft/error-handler/actions/workflows/build.yml/badge.svg)](https://github.com/yiisoft/error-handler/actions/workflows/build.yml)
[![Code coverage](https://codecov.io/gh/yiisoft/error-handler/graph/badge.svg?token=3ZC0QHQHIN)](https://codecov.io/gh/yiisoft/error-handler)
[![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2Ferror-handler%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/error-handler/master)
[![static analysis](https://github.com/yiisoft/error-handler/workflows/static%20analysis/badge.svg)](https://github.com/yiisoft/error-handler/actions?query=workflow%3A%22static+analysis%22)
@@ -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

3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -51,7 +51,8 @@
"httpsoft/http-message": "^1.0.9",
"maglnet/composer-require-checker": "^4.4",
"phpunit/phpunit": "^9.5",
"rector/rector": "^1.0",
"psr/event-dispatcher": "^1.0",
"rector/rector": "^1.2",
"roave/infection-static-analysis-plugin": "^1.16",
"spatie/phpunit-watcher": "^1.23",
"vimeo/psalm": "^4.30|^5.25",
3 changes: 3 additions & 0 deletions config/di-web.php
Original file line number Diff line number Diff line change
@@ -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
@@ -16,4 +18,5 @@
'solutionProviders' => $params['yiisoft/error-handler']['solutionProviders'],
],
],
ThrowableResponseFactoryInterface::class => ThrowableResponseFactory::class,
];
3 changes: 1 addition & 2 deletions src/ErrorHandler.php
Original file line number Diff line number Diff line change
@@ -10,7 +10,6 @@
use Throwable;
use Yiisoft\ErrorHandler\Event\ApplicationError;
use Yiisoft\ErrorHandler\Exception\ErrorException;
use Yiisoft\ErrorHandler\Renderer\PlainTextRenderer;
use Yiisoft\Http\Status;

use function error_get_last;
@@ -69,7 +68,7 @@ public function handle(
$renderer ??= $this->defaultRenderer;

try {
$this->logger->error(PlainTextRenderer::throwableToString($t), ['throwable' => $t]);
$this->logger->error($t->getMessage(), ['throwable' => $t]);
return $this->debug ? $renderer->renderVerbose($t, $request) : $renderer->render($t, $request);
} catch (Throwable $t) {
return new ErrorData((string) $t);
188 changes: 188 additions & 0 deletions src/Factory/ThrowableResponseFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
<?php

declare(strict_types=1);

namespace Yiisoft\ErrorHandler\Factory;

use Throwable;
use InvalidArgumentException;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Yiisoft\ErrorHandler\ErrorHandler;
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\ErrorHandler\ThrowableResponseFactoryInterface;
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;

/**
* `ThrowableResponseFactory` renders `Throwable` object
* and produces a response according to the content type provided by the client.
*/
final class ThrowableResponseFactory implements ThrowableResponseFactoryInterface
{
private HeadersProvider $headersProvider;

/**
* @psalm-var array<string,class-string<ThrowableRendererInterface>>
*/
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));
}
}
179 changes: 6 additions & 173 deletions src/Middleware/ErrorCatcher.php
Original file line number Diff line number Diff line change
@@ -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<string,class-string<ThrowableRendererInterface>>
*/
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));
}
}
20 changes: 20 additions & 0 deletions src/ThrowableResponseFactoryInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Yiisoft\ErrorHandler;

use Throwable;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
* `ThrowableResponseFactoryInterface` produces a response for `Throwable` object.
*/
interface ThrowableResponseFactoryInterface
{
/**
* Handles a `Throwable` object and produces a response.
*/
public function create(Throwable $throwable, ServerRequestInterface $request): ResponseInterface;
}
262 changes: 262 additions & 0 deletions tests/Factory/ThrowableResponseFactoryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
<?php

declare(strict_types=1);

namespace Yiisoft\ErrorHandler\Tests\Factory;

use HttpSoft\Message\ResponseFactory;
use HttpSoft\Message\ServerRequest;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Throwable;
use Yiisoft\ErrorHandler\ErrorHandler;
use Yiisoft\ErrorHandler\Factory\ThrowableResponseFactory;
use Yiisoft\ErrorHandler\HeadersProvider;
use Yiisoft\ErrorHandler\Renderer\HeaderRenderer;
use Yiisoft\ErrorHandler\Renderer\PlainTextRenderer;
use Yiisoft\ErrorHandler\ThrowableRendererInterface;
use Yiisoft\ErrorHandler\ThrowableResponseFactoryInterface;
use Yiisoft\Http\Header;
use Yiisoft\Test\Support\Container\SimpleContainer;

final class ThrowableResponseFactoryTest extends TestCase
{
public function testHandleWithHeadRequestMethod(): void
{
$response = $this
->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('<html', $content);
}

public function testAddedRenderer(): void
{
$mimeType = 'test/test';
$factory = $this
->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();
}
}
280 changes: 37 additions & 243 deletions tests/Middleware/ErrorCatcherTest.php
Original file line number Diff line number Diff line change
@@ -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('<html', $content);
}

public function testProcessWithFailedEventDispatcher(): void
{
$eventDispatcher = $this->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('<html', $content);
}

public function testAddedRenderer(): void
{
$mimeType = 'test/test';
$catcher = $this
->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

0 comments on commit 5c2c4b0

Please sign in to comment.