Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/enhance' into enhance
Browse files Browse the repository at this point in the history
  • Loading branch information
xepozz committed Oct 14, 2024
2 parents 852c311 + 7d369db commit 28b647d
Show file tree
Hide file tree
Showing 11 changed files with 570 additions and 441 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'
Expand All @@ -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: >-
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
## 3.4.0 under development

- 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` class (@olegbaturin)

## 3.3.0 July 11, 2024

Expand Down
68 changes: 47 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions config/di-web.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

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
*/

return [
ThrowableRendererInterface::class => HtmlRenderer::class,
ThrowableResponseFactoryInterface::class => ThrowableResponseFactory::class,
];
3 changes: 1 addition & 2 deletions src/ErrorHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]);

Check warning on line 71 in src/ErrorHandler.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.1-ubuntu-latest

Escaped Mutant for Mutator "ArrayItemRemoval": --- Original +++ New @@ @@ { $renderer ??= $this->defaultRenderer; try { - $this->logger->error($t->getMessage(), ['throwable' => $t]); + $this->logger->error($t->getMessage(), []); return $this->debug ? $renderer->renderVerbose($t, $request) : $renderer->render($t, $request); } catch (Throwable $t) { return new ErrorData((string) $t);

Check warning on line 71 in src/ErrorHandler.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.1-ubuntu-latest

Escaped Mutant for Mutator "MethodCallRemoval": --- Original +++ New @@ @@ { $renderer ??= $this->defaultRenderer; try { - $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);
return $this->debug ? $renderer->renderVerbose($t, $request) : $renderer->render($t, $request);
} catch (Throwable $t) {
return new ErrorData((string) $t);
Expand Down
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));
}
}
Loading

0 comments on commit 28b647d

Please sign in to comment.