Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
85 / 85
96.49% covered (success)
96.49%
55 / 57
36.49% covered (danger)
36.49%
27 / 74
85.71% covered (warning)
85.71%
12 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Handler
100.00% covered (success)
100.00%
85 / 85
96.49% covered (success)
96.49%
55 / 57
36.49% covered (danger)
36.49%
27 / 74
100.00% covered (success)
100.00%
14 / 14
294.36
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 debugHandler
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 logger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 process
100.00% covered (success)
100.00%
5 / 5
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 4
100.00% covered (success)
100.00%
1 / 1
2
 renderer
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 handleError
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 response
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
19 / 19
16.67% covered (danger)
16.67%
8 / 48
100.00% covered (success)
100.00%
1 / 1
55.88
 normalize
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 fallback
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 status
100.00% covered (success)
100.00%
4 / 4
87.50% covered (warning)
87.50%
7 / 8
40.00% covered (danger)
40.00%
2 / 5
100.00% covered (success)
100.00%
1 / 1
7.46
 log
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 recordServerException
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 logUnmatched
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 escape
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Celemas\Core\Error;
6
7use Celemas\Core\Exception\HttpError;
8use Celemas\Core\Exception\HttpMethodNotAllowed;
9use Celemas\Core\Exception\HttpNotFound;
10use Celemas\Core\Server\Console as ServerConsole;
11use Celemas\Router\Exception\MethodNotAllowedException;
12use Celemas\Router\Exception\NotFoundException;
13use ErrorException;
14use Override;
15use Psr\Http\Message\ResponseFactoryInterface as ResponseFactory;
16use Psr\Http\Message\ResponseInterface as Response;
17use Psr\Http\Message\ServerRequestInterface as Request;
18use Psr\Http\Server\MiddlewareInterface as Middleware;
19use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
20use Psr\Log\LoggerInterface as Logger;
21use Throwable;
22
23/** @api */
24class Handler implements Middleware
25{
26    protected ?Logger $logger = null;
27    protected ?DebugHandler $debugHandler = null;
28
29    /** @var list<RendererEntry> */
30    protected array $renderers = [];
31
32    protected ?RendererEntry $defaultRenderer = null;
33
34    public function __construct(
35        protected readonly ResponseFactory $responseFactory,
36        protected readonly bool $debug = false,
37    ) {}
38
39    public function debugHandler(DebugHandler $debugHandler): void
40    {
41        $this->debugHandler = $debugHandler;
42    }
43
44    public function logger(?Logger $logger = null): void
45    {
46        $this->logger = $logger;
47    }
48
49    #[Override]
50    public function process(Request $request, RequestHandler $handler): Response
51    {
52        set_error_handler([$this, 'handleError'], E_ALL);
53
54        try {
55            return $handler->handle($request);
56        } catch (Throwable $e) {
57            return $this->response($e, $request);
58        } finally {
59            restore_error_handler();
60        }
61    }
62
63    /**
64     * @param class-string<Throwable>|list<class-string<Throwable>>|null $exceptions
65     */
66    public function renderer(Renderer $renderer, string|array|null $exceptions = null): RendererEntry
67    {
68        if ($exceptions === null) {
69            $entry = new RendererEntry([], $renderer);
70            $this->defaultRenderer = $entry;
71
72            return $entry;
73        }
74
75        $classes = (array) $exceptions;
76        $entry = new RendererEntry($classes, $renderer);
77        $this->renderers[] = $entry;
78
79        return $entry;
80    }
81
82    public function handleError(
83        int $level,
84        string $message,
85        string $file = '',
86        int $line = 0,
87    ): bool {
88        if (($level & error_reporting()) !== 0) {
89            throw new ErrorException($message, 0, $level, $file, $line);
90        }
91
92        return false;
93    }
94
95    public function response(Throwable $exception, Request $request): Response
96    {
97        $exception = $this->normalize($exception, $request);
98        $renderer = null;
99        $logLevel = null;
100
101        foreach ($this->renderers as $entry) {
102            if (!$entry->matches($exception)) {
103                continue;
104            }
105
106            $renderer = $entry->renderer;
107            $logLevel = $entry->logLevel();
108            break;
109        }
110
111        if ($logLevel !== null) {
112            $this->log($logLevel, $exception);
113        }
114
115        if ($renderer) {
116            $this->recordServerException($exception);
117
118            return $renderer->render(
119                $exception,
120                $this->responseFactory,
121                $request,
122                $this->debug,
123            );
124        }
125
126        if ($this->debug) {
127            if ($this->debugHandler) {
128                $this->recordServerException($exception);
129
130                return $this->debugHandler->handle($exception, $this->responseFactory);
131            }
132
133            throw $exception;
134        }
135
136        if ($this->defaultRenderer) {
137            $logLevel = $this->defaultRenderer->logLevel();
138
139            if ($logLevel !== null) {
140                $this->log($logLevel, $exception);
141            } else {
142                $this->logUnmatched($exception);
143            }
144
145            $this->recordServerException($exception);
146
147            return $this->defaultRenderer->renderer->render(
148                $exception,
149                $this->responseFactory,
150                $request,
151                $this->debug,
152            );
153        }
154
155        $this->logUnmatched($exception);
156        $this->recordServerException($exception);
157
158        return $this->fallback($exception);
159    }
160
161    protected function normalize(Throwable $exception, Request $request): Throwable
162    {
163        if ($exception instanceof NotFoundException) {
164            return new HttpNotFound($request, previous: $exception);
165        }
166
167        if ($exception instanceof MethodNotAllowedException) {
168            return new HttpMethodNotAllowed(
169                $request,
170                ['allowed' => $exception->allowedMethods()],
171                previous: $exception,
172            );
173        }
174
175        return $exception;
176    }
177
178    protected function fallback(Throwable $exception): Response
179    {
180        $status = $this->status($exception);
181        $title = $exception instanceof HttpError
182            ? $exception->title()
183            : '500 Internal Server Error';
184        $response = $this->responseFactory
185            ->createResponse($status)
186            ->withHeader('Content-Type', 'text/html; charset=utf-8');
187        $response->getBody()->write('<h1>' . $this->escape($title) . '</h1>');
188
189        return $response;
190    }
191
192    protected function status(Throwable $exception): int
193    {
194        if (!$exception instanceof HttpError) {
195            return 500;
196        }
197
198        $status = $exception->statusCode();
199
200        return $status >= 400 && $status <= 599 ? $status : 500;
201    }
202
203    protected function log(string|int $logLevel, Throwable $exception): void
204    {
205        $this->logger?->log($logLevel, 'Matched exception', ['exception' => $exception]);
206    }
207
208    protected function recordServerException(Throwable $exception): void
209    {
210        if ($this->status($exception) >= 500) {
211            ServerConsole::recordException($exception, trace: $this->debug);
212        }
213    }
214
215    protected function logUnmatched(Throwable $exception): void
216    {
217        $this->logger?->alert('Unmatched exception', ['exception' => $exception]);
218    }
219
220    private function escape(string $value): string
221    {
222        return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
223    }
224}