Passed
Pull Request — master (#31)
by Evgeniy
08:01
created

ErrorCatcher::generateErrorResponse()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 2

Importance

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