Skip to content

Commit

Permalink
Extract response generator from ErrorCatcher middleware (#133)
Browse files Browse the repository at this point in the history
Co-authored-by: Sergei Predvoditelev <[email protected]>
  • Loading branch information
olegbaturin and vjik authored Oct 10, 2024
1 parent f2c23eb commit e98f687
Show file tree
Hide file tree
Showing 9 changed files with 562 additions and 434 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
62 changes: 44 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
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
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
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,
];
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 e98f687

Please sign in to comment.