Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
91 / 91
96.97% covered (success)
96.97%
64 / 66
36.25% covered (danger)
36.25%
29 / 80
85.71% covered (warning)
85.71%
12 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Handler
100.00% covered (success)
100.00%
91 / 91
96.97% covered (success)
96.97%
64 / 66
36.25% covered (danger)
36.25%
29 / 80
100.00% covered (success)
100.00%
14 / 14
352.38
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
100.00% covered (success)
100.00%
6 / 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%
9 / 9
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 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
87.50% covered (warning)
87.50%
7 / 8
33.33% covered (danger)
33.33%
2 / 6
100.00% covered (success)
100.00%
1 / 1
5.67
 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 Celema\Core\Error;
6
7use Celema\Core\Exception\HttpError;
8use Celema\Core\Exception\HttpMethodNotAllowed;
9use Celema\Core\Exception\HttpNotFound;
10use Celema\Router\Exception\MethodNotAllowedException;
11use Celema\Router\Exception\NotFoundException;
12use Celema\Server\Console as ServerConsole;
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        protected readonly int $exceptionLevels = E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED,
38    ) {}
39
40    public function debugHandler(DebugHandler $debugHandler): void
41    {
42        $this->debugHandler = $debugHandler;
43    }
44
45    public function logger(?Logger $logger = null): void
46    {
47        $this->logger = $logger;
48    }
49
50    #[Override]
51    public function process(Request $request, RequestHandler $handler): Response
52    {
53        set_error_handler([$this, 'handleError'], E_ALL);
54
55        try {
56            return $handler->handle($request);
57        } catch (Throwable $e) {
58            return $this->response($e, $request);
59        } finally {
60            restore_error_handler();
61        }
62    }
63
64    /**
65     * @param class-string<Throwable>|list<class-string<Throwable>>|null $exceptions
66     */
67    public function renderer(Renderer $renderer, string|array|null $exceptions = null): RendererEntry
68    {
69        if ($exceptions === null) {
70            $entry = new RendererEntry([], $renderer);
71            $this->defaultRenderer = $entry;
72
73            return $entry;
74        }
75
76        $classes = (array) $exceptions;
77        $entry = new RendererEntry($classes, $renderer);
78        $this->renderers[] = $entry;
79
80        return $entry;
81    }
82
83    public function handleError(
84        int $level,
85        string $message,
86        string $file = '',
87        int $line = 0,
88    ): bool {
89        if (($level & error_reporting()) === 0) {
90            return false;
91        }
92
93        $exception = new ErrorException($message, 0, $level, $file, $line);
94
95        if (($level & $this->exceptionLevels) !== 0) {
96            // Converted diagnostics continue through the normal exception flow.
97            throw $exception;
98        }
99
100        if ($this->logger === null) {
101            // Let PHP report diagnostics when Core has no logger.
102            return false;
103        }
104
105        $this->logger->notice('PHP diagnostic', ['exception' => $exception]);
106
107        // Prevent PHP from reporting the logged diagnostic again.
108        return true;
109    }
110
111    public function response(Throwable $exception, Request $request): Response
112    {
113        $exception = $this->normalize($exception, $request);
114        $renderer = null;
115        $logLevel = null;
116
117        foreach ($this->renderers as $entry) {
118            if (!$entry->matches($exception)) {
119                continue;
120            }
121
122            $renderer = $entry->renderer;
123            $logLevel = $entry->logLevel();
124            break;
125        }
126
127        if ($logLevel !== null) {
128            $this->log($logLevel, $exception);
129        }
130
131        if ($renderer) {
132            $this->recordServerException($exception);
133
134            return $renderer->render(
135                $exception,
136                $this->responseFactory,
137                $request,
138                $this->debug,
139            );
140        }
141
142        if ($this->debug) {
143            if ($this->debugHandler) {
144                $this->recordServerException($exception);
145
146                return $this->debugHandler->handle($exception, $this->responseFactory);
147            }
148
149            throw $exception;
150        }
151
152        if ($this->defaultRenderer) {
153            $logLevel = $this->defaultRenderer->logLevel();
154
155            if ($logLevel !== null) {
156                $this->log($logLevel, $exception);
157            } else {
158                $this->logUnmatched($exception);
159            }
160
161            $this->recordServerException($exception);
162
163            return $this->defaultRenderer->renderer->render(
164                $exception,
165                $this->responseFactory,
166                $request,
167                $this->debug,
168            );
169        }
170
171        $this->logUnmatched($exception);
172        $this->recordServerException($exception);
173
174        return $this->fallback($exception);
175    }
176
177    protected function normalize(Throwable $exception, Request $request): Throwable
178    {
179        if ($exception instanceof NotFoundException) {
180            return new HttpNotFound($request, previous: $exception);
181        }
182
183        if ($exception instanceof MethodNotAllowedException) {
184            return new HttpMethodNotAllowed(
185                $request,
186                ['allowed' => $exception->allowedMethods()],
187                previous: $exception,
188            );
189        }
190
191        return $exception;
192    }
193
194    protected function fallback(Throwable $exception): Response
195    {
196        $status = $this->status($exception);
197        $title = $exception instanceof HttpError
198            ? $exception->title()
199            : '500 Internal Server Error';
200        $response = $this->responseFactory
201            ->createResponse($status)
202            ->withHeader('Content-Type', 'text/html; charset=utf-8');
203        $response->getBody()->write('<h1>' . $this->escape($title) . '</h1>');
204
205        return $response;
206    }
207
208    protected function status(Throwable $exception): int
209    {
210        if (!$exception instanceof HttpError) {
211            return 500;
212        }
213
214        $status = $exception->statusCode();
215
216        return $status >= 400 && $status <= 599 ? $status : 500;
217    }
218
219    protected function log(string|int $logLevel, Throwable $exception): void
220    {
221        $this->logger?->log($logLevel, 'Matched exception', ['exception' => $exception]);
222    }
223
224    protected function recordServerException(Throwable $exception): void
225    {
226        // The celema/server dev server is an optional dependency; report
227        // handled server errors to its request log when it is installed.
228        if ($this->status($exception) >= 500 && class_exists(ServerConsole::class)) {
229            ServerConsole::recordException($exception, trace: $this->debug);
230        }
231    }
232
233    protected function logUnmatched(Throwable $exception): void
234    {
235        $this->logger?->alert('Unmatched exception', ['exception' => $exception]);
236    }
237
238    private function escape(string $value): string
239    {
240        return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
241    }
242}