Passed
Pull Request — master (#75)
by Dmitriy
12:34
created

ErrorCatcher   A

Complexity

Total Complexity 21

Size/Duplication

Total Lines 159
Duplicated Lines 0 %

Test Coverage

Coverage 98.11%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
wmc 21
eloc 52
c 1
b 0
f 0
dl 0
loc 159
ccs 52
cts 53
cp 0.9811
rs 10

9 Methods

Rating   Name   Duplication   Size   Complexity  
A withRenderer() 0 13 2
A forceContentType() 0 11 2
A __construct() 0 6 1
A normalizeContentType() 0 7 2
A getContentType() 0 13 4
A withoutRenderers() 0 14 3
A getRenderer() 0 8 2
A process() 0 9 3
A generateErrorResponse() 0 9 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\ErrorHandler\Middleware;
6
7
use InvalidArgumentException;
8
use Psr\Container\ContainerInterface;
9
use Psr\EventDispatcher\EventDispatcherInterface;
10
use Psr\Http\Message\ResponseFactoryInterface;
11
use Psr\Http\Message\ResponseInterface;
12
use Psr\Http\Message\ServerRequestInterface;
13
use Psr\Http\Server\MiddlewareInterface;
14
use Psr\Http\Server\RequestHandlerInterface;
15
use Throwable;
16
use Yiisoft\ErrorHandler\Event\ApplicationError;
17
use Yiisoft\ErrorHandler\ErrorHandler;
18
use Yiisoft\ErrorHandler\Renderer\HeaderRenderer;
19
use Yiisoft\ErrorHandler\Renderer\HtmlRenderer;
20
use Yiisoft\ErrorHandler\Renderer\JsonRenderer;
21
use Yiisoft\ErrorHandler\Renderer\PlainTextRenderer;
22
use Yiisoft\ErrorHandler\Renderer\XmlRenderer;
23
use Yiisoft\ErrorHandler\ThrowableRendererInterface;
24
use Yiisoft\Http\Header;
25
use Yiisoft\Http\HeaderValueHelper;
26
use Yiisoft\Http\Method;
27
use Yiisoft\Http\Status;
28
29
use function array_key_exists;
30
use function count;
31
use function is_subclass_of;
32
use function sprintf;
33
use function strtolower;
34
use function trim;
35
36
/**
37
 * `ErrorCatcher` catches all throwables from the next middlewares and renders it
38
 * according to the content type passed by the client.
39
 */
40
final class ErrorCatcher implements MiddlewareInterface
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 11
    public function __construct(
56
        private ResponseFactoryInterface $responseFactory,
57
        private ErrorHandler $errorHandler,
58
        private ContainerInterface $container,
59
        private ?EventDispatcherInterface $eventDispatcher = null,
60
    ) {
61 11
    }
62
63
    /**
64
     * Returns a new instance with the specified content type and renderer class.
65
     *
66
     * @param string $contentType The content type to add associated renderers for.
67
     * @param string $rendererClass The classname implementing the {@see ThrowableRendererInterface}.
68
     */
69 5
    public function withRenderer(string $contentType, string $rendererClass): self
70
    {
71 5
        if (!is_subclass_of($rendererClass, ThrowableRendererInterface::class)) {
72 1
            throw new InvalidArgumentException(sprintf(
73 1
                'Class "%s" does not implement "%s".',
74 1
                $rendererClass,
75 1
                ThrowableRendererInterface::class,
76 1
            ));
77
        }
78
79 4
        $new = clone $this;
80 4
        $new->renderers[$this->normalizeContentType($contentType)] = $rendererClass;
81 3
        return $new;
82
    }
83
84
    /**
85
     * Returns a new instance without renderers by the specified content types.
86
     *
87
     * @param string[] $contentTypes The content types to remove associated renderers for.
88
     * If not specified, all renderers will be removed.
89
     */
90 2
    public function withoutRenderers(string ...$contentTypes): self
91
    {
92 2
        $new = clone $this;
93
94 2
        if (count($contentTypes) === 0) {
95 1
            $new->renderers = [];
96 1
            return $new;
97
        }
98
99 1
        foreach ($contentTypes as $contentType) {
100 1
            unset($new->renderers[$this->normalizeContentType($contentType)]);
101
        }
102
103 1
        return $new;
104
    }
105
106
    /**
107
     * Force content type to respond with regardless of request.
108
     *
109
     * @param string $contentType The content type to respond with regardless of request.
110
     */
111 2
    public function forceContentType(string $contentType): self
112
    {
113 2
        $contentType = $this->normalizeContentType($contentType);
114
115 2
        if (!isset($this->renderers[$contentType])) {
116 1
            throw new InvalidArgumentException(sprintf('The renderer for %s is not set.', $contentType));
117
        }
118
119 1
        $new = clone $this;
120 1
        $new->contentType = $contentType;
121 1
        return $new;
122
    }
123
124 8
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
125
    {
126
        try {
127 8
            return $handler->handle($request);
128 8
        } catch (Throwable $t) {
129 8
            if ($this->eventDispatcher !== null) {
130
                $this->eventDispatcher->dispatch(new ApplicationError($t));
131
            }
132 8
            return $this->generateErrorResponse($t, $request);
133
        }
134
    }
135
136
    /**
137
     * Generates a response with error information.
138
     */
139 8
    private function generateErrorResponse(Throwable $t, ServerRequestInterface $request): ResponseInterface
140
    {
141 8
        $contentType = $this->contentType ?? $this->getContentType($request);
142 8
        $renderer = $request->getMethod() === Method::HEAD ? new HeaderRenderer() : $this->getRenderer($contentType);
143
144 8
        $data = $this->errorHandler->handle($t, $renderer, $request);
145 8
        $response = $this->responseFactory->createResponse(Status::INTERNAL_SERVER_ERROR);
146
147 8
        return $data->addToResponse($response->withHeader(Header::CONTENT_TYPE, $contentType));
148
    }
149
150
    /**
151
     * Returns the renderer by the specified content type, or null if the renderer was not set.
152
     *
153
     * @param string $contentType The content type associated with the renderer.
154
     */
155 7
    private function getRenderer(string $contentType): ?ThrowableRendererInterface
156
    {
157 7
        if (isset($this->renderers[$contentType])) {
158
            /** @var ThrowableRendererInterface */
159 5
            return $this->container->get($this->renderers[$contentType]);
160
        }
161
162 2
        return null;
163
    }
164
165
    /**
166
     * Returns the priority content type from the accept request header.
167
     *
168
     * @return string The priority content type.
169
     */
170 7
    private function getContentType(ServerRequestInterface $request): string
171
    {
172
        try {
173 7
            foreach (HeaderValueHelper::getSortedAcceptTypes($request->getHeader(Header::ACCEPT)) as $header) {
174 6
                if (array_key_exists($header, $this->renderers)) {
175 2
                    return $header;
176
                }
177
            }
178 1
        } catch (InvalidArgumentException) {
179
            // The Accept header contains an invalid q factor.
180
        }
181
182 5
        return '*/*';
183
    }
184
185
    /**
186
     * Normalizes the content type.
187
     *
188
     * @param string $contentType The raw content type.
189
     *
190
     * @return string Normalized content type.
191
     */
192 7
    private function normalizeContentType(string $contentType): string
193
    {
194 7
        if (!str_contains($contentType, '/')) {
195 1
            throw new InvalidArgumentException('Invalid content type.');
196
        }
197
198 6
        return strtolower(trim($contentType));
199
    }
200
}
201