Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
114 / 114
98.80% covered (success)
98.80%
82 / 83
46.07% covered (danger)
46.07%
41 / 89
93.33% covered (success)
93.33%
14 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Handler
100.00% covered (success)
100.00%
114 / 114
98.80% covered (success)
98.80%
82 / 83
46.07% covered (danger)
46.07%
41 / 89
100.00% covered (success)
100.00%
15 / 15
318.73
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
25.00% covered (danger)
25.00%
1 / 4
100.00% covered (success)
100.00%
1 / 1
3.69
 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%
19 / 19
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%
41 / 41
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
100.00% covered (success)
100.00%
8 / 8
80.00% covered (warning)
80.00%
4 / 5
100.00% covered (success)
100.00%
1 / 1
4.13
 log
100.00% covered (success)
100.00%
6 / 6
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
 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%
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
 diagnosticType
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 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 Psr\Log\LogLevel;
22use Throwable;
23
24/** @api */
25class Handler implements Middleware
26{
27    protected ?Logger $logger = null;
28    protected ?DebugHandler $debugHandler = null;
29
30    /** @var list<RendererEntry> */
31    protected array $renderers = [];
32
33    protected ?RendererEntry $defaultRenderer = null;
34
35    public function __construct(
36        protected readonly ResponseFactory $responseFactory,
37        protected readonly bool $debug = false,
38        protected readonly int $exceptionLevels = E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED,
39    ) {}
40
41    public function debugHandler(DebugHandler $debugHandler): void
42    {
43        $this->debugHandler = $debugHandler;
44    }
45
46    public function logger(?Logger $logger = null): void
47    {
48        $this->logger = $logger;
49    }
50
51    #[Override]
52    public function process(Request $request, RequestHandler $handler): Response
53    {
54        set_error_handler([$this, 'handleError'], E_ALL);
55
56        try {
57            return $handler->handle($request);
58        } catch (Throwable $e) {
59            return $this->response($e, $request);
60        } finally {
61            restore_error_handler();
62        }
63    }
64
65    /**
66     * @param class-string<Throwable>|list<class-string<Throwable>>|null $exceptions
67     */
68    public function renderer(Renderer $renderer, string|array|null $exceptions = null): RendererEntry
69    {
70        if ($exceptions === null) {
71            $entry = new RendererEntry([], $renderer);
72            $this->defaultRenderer = $entry;
73
74            return $entry;
75        }
76
77        $classes = (array) $exceptions;
78        $entry = new RendererEntry($classes, $renderer);
79        $this->renderers[] = $entry;
80
81        return $entry;
82    }
83
84    public function handleError(
85        int $level,
86        string $message,
87        string $file = '',
88        int $line = 0,
89    ): bool {
90        if (($level & error_reporting()) === 0) {
91            return false;
92        }
93
94        $exception = new ErrorException($message, 0, $level, $file, $line);
95
96        if (($level & $this->exceptionLevels) !== 0) {
97            // Converted diagnostics continue through the normal exception flow.
98            throw $exception;
99        }
100
101        if ($this->logger === null) {
102            // Let PHP report diagnostics when Core has no logger.
103            return false;
104        }
105
106        // True keeps PHP from reporting a logged diagnostic again. One the
107        // logger failed on is left to PHP rather than thrown at the line
108        // that raised it.
109        return Log::attempt(
110            $this->logger,
111            LogLevel::NOTICE,
112            'PHP {type}: {diagnostic} in {file} on line {line}',
113            $exception,
114            [
115                'type' => self::diagnosticType($level),
116                'diagnostic' => $message,
117                'file' => $file,
118                'line' => $line,
119            ],
120        );
121    }
122
123    public function response(Throwable $exception, Request $request): Response
124    {
125        $exception = $this->normalize($exception, $request);
126        $renderer = null;
127        $logLevel = null;
128
129        foreach ($this->renderers as $entry) {
130            if (!$entry->matches($exception)) {
131                continue;
132            }
133
134            $renderer = $entry->renderer;
135            $logLevel = $entry->logLevel();
136            break;
137        }
138
139        if ($logLevel !== null) {
140            $this->log($logLevel, $exception, $request);
141        }
142
143        if ($renderer) {
144            $this->recordServerException($exception);
145
146            return $renderer->render(
147                $exception,
148                $this->responseFactory,
149                $request,
150                $this->debug,
151            );
152        }
153
154        if ($this->debug) {
155            $debugHandler = $this->debugHandler;
156
157            if ($debugHandler) {
158                $this->recordServerException($exception);
159
160                return $debugHandler->handle($exception, $this->responseFactory);
161            }
162
163            throw $exception;
164        }
165
166        $defaultRenderer = $this->defaultRenderer;
167
168        if ($defaultRenderer) {
169            $logLevel = $defaultRenderer->logLevel();
170
171            if ($logLevel !== null) {
172                $this->log($logLevel, $exception, $request);
173            } else {
174                $this->logUnmatched($exception, $request);
175            }
176
177            $this->recordServerException($exception);
178
179            return $defaultRenderer->renderer->render(
180                $exception,
181                $this->responseFactory,
182                $request,
183                $this->debug,
184            );
185        }
186
187        $this->logUnmatched($exception, $request);
188        $this->recordServerException($exception);
189
190        return $this->fallback($exception);
191    }
192
193    protected function normalize(Throwable $exception, Request $request): Throwable
194    {
195        if ($exception instanceof NotFoundException) {
196            return new HttpNotFound($request, previous: $exception);
197        }
198
199        if ($exception instanceof MethodNotAllowedException) {
200            return new HttpMethodNotAllowed(
201                $request,
202                ['allowed' => $exception->allowedMethods()],
203                previous: $exception,
204            );
205        }
206
207        return $exception;
208    }
209
210    protected function fallback(Throwable $exception): Response
211    {
212        $status = $this->status($exception);
213        $title = $exception instanceof HttpError
214            ? $exception->title()
215            : '500 Internal Server Error';
216        $response = $this->responseFactory
217            ->createResponse($status)
218            ->withHeader('Content-Type', 'text/html; charset=utf-8');
219        $response->getBody()->write('<h1>' . $this->escape($title) . '</h1>');
220
221        return $response;
222    }
223
224    protected function status(Throwable $exception): int
225    {
226        if (!$exception instanceof HttpError) {
227            return 500;
228        }
229
230        $status = $exception->statusCode();
231
232        return $status >= 400 && $status <= 599 ? $status : 500;
233    }
234
235    protected function log(string|int $logLevel, Throwable $exception, Request $request): void
236    {
237        $status = $this->status($exception);
238        $kind = $status >= 500 ? 'Server' : 'Client';
239
240        Log::write($this->logger, $logLevel, $kind . ' error {status} for {method} {path}', $exception, [
241            'status' => $status,
242            ...Log::request($request),
243        ]);
244    }
245
246    protected function recordServerException(Throwable $exception): void
247    {
248        // The celema/server dev server is an optional dependency; report
249        // handled server errors to its request log when it is installed.
250        if ($this->status($exception) >= 500 && class_exists(ServerConsole::class)) {
251            ServerConsole::recordException($exception, trace: $this->debug);
252        }
253    }
254
255    protected function logUnmatched(Throwable $exception, Request $request): void
256    {
257        // A client error is no server failure; a renderer entry's log level
258        // can still record it.
259        if ($this->status($exception) < 500) {
260            return;
261        }
262
263        $this->log(LogLevel::CRITICAL, $exception, $request);
264    }
265
266    private static function diagnosticType(int $level): string
267    {
268        return match ($level) {
269            E_DEPRECATED, E_USER_DEPRECATED => 'Deprecated',
270            E_NOTICE, E_USER_NOTICE => 'Notice',
271            E_WARNING, E_USER_WARNING => 'Warning',
272            default => 'Error',
273        };
274    }
275
276    private function escape(string $value): string
277    {
278        return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
279    }
280}