Skip to content

Commit e98f687

Browse files
olegbaturinvjik
andauthored
Extract response generator from ErrorCatcher middleware (#133)
Co-authored-by: Sergei Predvoditelev <[email protected]>
1 parent f2c23eb commit e98f687

File tree

9 files changed

+562
-434
lines changed

9 files changed

+562
-434
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## 3.3.1 under development
44

55
- Enh #130: Pass exception message instead of rendered exception to logger in `ErrorHandler` (@olegbaturin)
6+
- Enh #133: Extract response generator from `ErrorCatcher` middleware into separate `ThrowableResponseFactory` class (@olegbaturin)
67

78
## 3.3.0 July 11, 2024
89

README.md

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -121,47 +121,73 @@ $errorHandler = new ErrorHandler($logger, $renderer);
121121
For more information about creating your own renders and examples of rendering error data,
122122
[see here](https://github.com/yiisoft/docs/blob/master/guide/en/runtime/handling-errors.md#rendering-error-data).
123123

124-
### Using middleware for catching unhandled errors
124+
### Using a factory to create a response
125125

126-
`Yiisoft\ErrorHandler\Middleware\ErrorCatcher` is a [PSR-15](https://www.php-fig.org/psr/psr-15/) middleware that
127-
catches exceptions that appear during middleware stack execution and passes them to the handler.
126+
`Yiisoft\ErrorHandler\Factory\ThrowableResponseFactory` renders `Throwable` object and produces a response according to the content type provided by the client.
128127

129128
```php
130-
use Yiisoft\ErrorHandler\Middleware\ErrorCatcher;
129+
use Yiisoft\ErrorHandler\Factory\ThrowableResponseFactory;
131130

132131
/**
132+
* @var \Throwable $throwable
133133
* @var \Psr\Container\ContainerInterface $container
134134
* @var \Psr\Http\Message\ResponseFactoryInterface $responseFactory
135135
* @var \Psr\Http\Message\ServerRequestInterface $request
136-
* @var \Psr\Http\Server\RequestHandlerInterface $handler
137136
* @var \Yiisoft\ErrorHandler\ErrorHandler $errorHandler
138-
* @var \Yiisoft\ErrorHandler\ThrowableRendererInterface $renderer
139137
*/
140138

141-
$errorCatcher = new ErrorCatcher($responseFactory, $errorHandler, $container);
139+
$throwableResponseFactory = new ThrowableResponseFactory($responseFactory, $errorHandler, $container);
142140

143-
// In any case, it will return an instance of the `Psr\Http\Message\ResponseInterface`.
144-
// Either the expected response, or a response with error information.
145-
$response = $errorCatcher->process($request, $handler);
141+
// Creating an instance of the `Psr\Http\Message\ResponseInterface` with error information.
142+
$response = $throwableResponseFactory->create($throwable, $request);
146143
```
147144

148-
The error catcher chooses how to render an exception based on accept HTTP header. If it is `text/html`
149-
or any unknown content type, it will use the error or exception HTML template to display errors. For other
150-
mime types, the error handler will choose different renderer that is registered within the error catcher.
145+
`Yiisoft\ErrorHandler\Factory\ThrowableResponseFactory` chooses how to render an exception based on accept HTTP header.
146+
If it's `text/html` or any unknown content type, it will use the error or exception HTML template to display errors.
147+
For other mime types, the error handler will choose different renderer that is registered within the error catcher.
151148
By default, JSON, XML and plain text are supported. You can change this behavior as follows:
152149

153150
```php
154151
// Returns a new instance without renderers by the specified content types.
155-
$errorCatcher = $errorCatcher->withoutRenderers('application/xml', 'text/xml');
152+
$throwableResponseFactory = $throwableResponseFactory->withoutRenderers('application/xml', 'text/xml');
156153

157154
// Returns a new instance with the specified content type and renderer class.
158-
$errorCatcher = $errorCatcher->withRenderer('my/format', new MyRenderer());
155+
$throwableResponseFactory = $throwableResponseFactory->withRenderer('my/format', new MyRenderer());
159156

160157
// Returns a new instance with the specified force content type to respond with regardless of request.
161-
$errorCatcher = $errorCatcher->forceContentType('application/json');
158+
$throwableResponseFactory = $throwableResponseFactory->forceContentType('application/json');
159+
```
160+
161+
### Using a middleware for catching unhandled errors
162+
163+
`Yiisoft\ErrorHandler\Middleware\ErrorCatcher` is a [PSR-15](https://www.php-fig.org/psr/psr-15/) middleware that
164+
catches exceptions raised during middleware stack execution and passes them to the instance of `Yiisoft\ErrorHandler\ThrowableResponseFactoryInterface` to create a response.
165+
166+
```php
167+
use Yiisoft\ErrorHandler\Middleware\ErrorCatcher;
168+
169+
/**
170+
* @var \Psr\EventDispatcher\EventDispatcherInterface $eventDispatcher
171+
* @var \Psr\Http\Message\ServerRequestInterface $request
172+
* @var \Psr\Http\Server\RequestHandlerInterface $handler
173+
* @var \Yiisoft\ErrorHandler\ThrowableResponseFactoryInterface $throwableResponseFactory
174+
*/
175+
176+
$errorCatcher = new ErrorCatcher($throwableResponseFactory);
177+
178+
// In any case, it will return an instance of the `Psr\Http\Message\ResponseInterface`.
179+
// Either the expected response, or a response with error information.
180+
$response = $errorCatcher->process($request, $handler);
181+
```
182+
183+
`Yiisoft\ErrorHandler\Middleware\ErrorCatcher` can be instantiated with [PSR-14](https://www.php-fig.org/psr/psr-14/) event dispatcher as an optional dependency.
184+
In this case `\Yiisoft\ErrorHandler\Event\ApplicationError` will be dispatched when `ErrorCatcher` catches an error.
185+
186+
```php
187+
$errorCatcher = new ErrorCatcher($throwableResponseFactory, $eventDispatcher);
162188
```
163189

164-
### Using middleware for mapping certain exceptions to custom responses
190+
### Using a middleware for mapping certain exceptions to custom responses
165191

166192
`Yiisoft\ErrorHandler\Middleware\ExceptionResponder` is a [PSR-15](https://www.php-fig.org/psr/psr-15/)
167193
middleware that maps certain exceptions to custom responses.
@@ -196,7 +222,7 @@ In the application middleware stack `Yiisoft\ErrorHandler\Middleware\ExceptionRe
196222

197223
## Events
198224

199-
- When `ErrorCatcher` catches an error it dispatches `\Yiisoft\ErrorHandler\Event\ApplicationError` event.
225+
- When `ErrorCatcher` catches an error it optionally dispatches `\Yiisoft\ErrorHandler\Event\ApplicationError` event. Instance of `Psr\EventDispatcher\EventDispatcherInterface` must be provided to the `ErrorCatcher`.
200226

201227
## Friendly Exceptions
202228

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"httpsoft/http-message": "^1.0.9",
5252
"maglnet/composer-require-checker": "^4.4",
5353
"phpunit/phpunit": "^9.5",
54+
"psr/event-dispatcher": "^1.0",
5455
"rector/rector": "^1.2",
5556
"roave/infection-static-analysis-plugin": "^1.16",
5657
"spatie/phpunit-watcher": "^1.23",

config/di-web.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22

33
declare(strict_types=1);
44

5+
use Yiisoft\ErrorHandler\Factory\ThrowableResponseFactory;
56
use Yiisoft\ErrorHandler\Renderer\HtmlRenderer;
67
use Yiisoft\ErrorHandler\ThrowableRendererInterface;
8+
use Yiisoft\ErrorHandler\ThrowableResponseFactoryInterface;
79

810
/**
911
* @var array $params
1012
*/
1113

1214
return [
1315
ThrowableRendererInterface::class => HtmlRenderer::class,
16+
ThrowableResponseFactoryInterface::class => ThrowableResponseFactory::class,
1417
];
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Yiisoft\ErrorHandler\Factory;
6+
7+
use Throwable;
8+
use InvalidArgumentException;
9+
use Psr\Container\ContainerInterface;
10+
use Psr\Http\Message\ResponseFactoryInterface;
11+
use Psr\Http\Message\ResponseInterface;
12+
use Psr\Http\Message\ServerRequestInterface;
13+
use Yiisoft\ErrorHandler\ErrorHandler;
14+
use Yiisoft\ErrorHandler\HeadersProvider;
15+
use Yiisoft\ErrorHandler\Renderer\HeaderRenderer;
16+
use Yiisoft\ErrorHandler\Renderer\HtmlRenderer;
17+
use Yiisoft\ErrorHandler\Renderer\JsonRenderer;
18+
use Yiisoft\ErrorHandler\Renderer\PlainTextRenderer;
19+
use Yiisoft\ErrorHandler\Renderer\XmlRenderer;
20+
use Yiisoft\ErrorHandler\ThrowableRendererInterface;
21+
use Yiisoft\ErrorHandler\ThrowableResponseFactoryInterface;
22+
use Yiisoft\Http\Header;
23+
use Yiisoft\Http\HeaderValueHelper;
24+
use Yiisoft\Http\Method;
25+
use Yiisoft\Http\Status;
26+
27+
use function array_key_exists;
28+
use function count;
29+
use function is_subclass_of;
30+
use function sprintf;
31+
use function strtolower;
32+
use function trim;
33+
34+
/**
35+
* `ThrowableResponseFactory` renders `Throwable` object
36+
* and produces a response according to the content type provided by the client.
37+
*/
38+
final class ThrowableResponseFactory implements ThrowableResponseFactoryInterface
39+
{
40+
private HeadersProvider $headersProvider;
41+
42+
/**
43+
* @psalm-var array<string,class-string<ThrowableRendererInterface>>
44+
*/
45+
private array $renderers = [
46+
'application/json' => JsonRenderer::class,
47+
'application/xml' => XmlRenderer::class,
48+
'text/xml' => XmlRenderer::class,
49+
'text/plain' => PlainTextRenderer::class,
50+
'text/html' => HtmlRenderer::class,
51+
'*/*' => HtmlRenderer::class,
52+
];
53+
private ?string $contentType = null;
54+
55+
public function __construct(
56+
private ResponseFactoryInterface $responseFactory,
57+
private ErrorHandler $errorHandler,
58+
private ContainerInterface $container,
59+
HeadersProvider $headersProvider = null,
60+
) {
61+
$this->headersProvider = $headersProvider ?? new HeadersProvider();
62+
}
63+
64+
public function create(Throwable $throwable, ServerRequestInterface $request): ResponseInterface
65+
{
66+
$contentType = $this->contentType ?? $this->getContentType($request);
67+
$renderer = $request->getMethod() === Method::HEAD ? new HeaderRenderer() : $this->getRenderer($contentType);
68+
69+
$data = $this->errorHandler->handle($throwable, $renderer, $request);
70+
$response = $this->responseFactory->createResponse(Status::INTERNAL_SERVER_ERROR);
71+
foreach ($this->headersProvider->getAll() as $name => $value) {
72+
$response = $response->withHeader($name, $value);
73+
}
74+
return $data->addToResponse($response->withHeader(Header::CONTENT_TYPE, $contentType));
75+
}
76+
77+
/**
78+
* Returns a new instance with the specified content type and renderer class.
79+
*
80+
* @param string $contentType The content type to add associated renderers for.
81+
* @param string $rendererClass The classname implementing the {@see ThrowableRendererInterface}.
82+
*/
83+
public function withRenderer(string $contentType, string $rendererClass): self
84+
{
85+
if (!is_subclass_of($rendererClass, ThrowableRendererInterface::class)) {
86+
throw new InvalidArgumentException(sprintf(
87+
'Class "%s" does not implement "%s".',
88+
$rendererClass,
89+
ThrowableRendererInterface::class,
90+
));
91+
}
92+
93+
$new = clone $this;
94+
$new->renderers[$this->normalizeContentType($contentType)] = $rendererClass;
95+
return $new;
96+
}
97+
98+
/**
99+
* Returns a new instance without renderers by the specified content types.
100+
*
101+
* @param string[] $contentTypes The content types to remove associated renderers for.
102+
* If not specified, all renderers will be removed.
103+
*/
104+
public function withoutRenderers(string ...$contentTypes): self
105+
{
106+
$new = clone $this;
107+
108+
if (count($contentTypes) === 0) {
109+
$new->renderers = [];
110+
return $new;
111+
}
112+
113+
foreach ($contentTypes as $contentType) {
114+
unset($new->renderers[$this->normalizeContentType($contentType)]);
115+
}
116+
117+
return $new;
118+
}
119+
120+
/**
121+
* Force content type to respond with regardless of request.
122+
*
123+
* @param string $contentType The content type to respond with regardless of request.
124+
*/
125+
public function forceContentType(string $contentType): self
126+
{
127+
$contentType = $this->normalizeContentType($contentType);
128+
129+
if (!isset($this->renderers[$contentType])) {
130+
throw new InvalidArgumentException(sprintf('The renderer for %s is not set.', $contentType));
131+
}
132+
133+
$new = clone $this;
134+
$new->contentType = $contentType;
135+
return $new;
136+
}
137+
138+
/**
139+
* Returns the renderer by the specified content type, or null if the renderer was not set.
140+
*
141+
* @param string $contentType The content type associated with the renderer.
142+
*/
143+
private function getRenderer(string $contentType): ?ThrowableRendererInterface
144+
{
145+
if (isset($this->renderers[$contentType])) {
146+
/** @var ThrowableRendererInterface */
147+
return $this->container->get($this->renderers[$contentType]);
148+
}
149+
150+
return null;
151+
}
152+
153+
/**
154+
* Returns the priority content type from the accept request header.
155+
*
156+
* @return string The priority content type.
157+
*/
158+
private function getContentType(ServerRequestInterface $request): string
159+
{
160+
try {
161+
foreach (HeaderValueHelper::getSortedAcceptTypes($request->getHeader(Header::ACCEPT)) as $header) {
162+
if (array_key_exists($header, $this->renderers)) {
163+
return $header;
164+
}
165+
}
166+
} catch (InvalidArgumentException) {
167+
// The Accept header contains an invalid q factor.
168+
}
169+
170+
return '*/*';
171+
}
172+
173+
/**
174+
* Normalizes the content type.
175+
*
176+
* @param string $contentType The raw content type.
177+
*
178+
* @return string Normalized content type.
179+
*/
180+
private function normalizeContentType(string $contentType): string
181+
{
182+
if (!str_contains($contentType, '/')) {
183+
throw new InvalidArgumentException('Invalid content type.');
184+
}
185+
186+
return strtolower(trim($contentType));
187+
}
188+
}

0 commit comments

Comments
 (0)