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}

Branches

Below are the source code lines that represent each code branch as identified by Xdebug. Please note a branch is not necessarily coterminous with a line, a line may contain multiple branches and therefore show up more than once. Please also be aware that some branches may be implicit rather than explicit, e.g. an if statement always has an else as part of its logical flow even if you didn't write one.

Handler->__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    ) {}
Handler->debugHandler
40    public function debugHandler(DebugHandler $debugHandler): void
41    {
42        $this->debugHandler = $debugHandler;
43    }
Handler->escape
238    private function escape(string $value): string
239    {
240        return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
241    }
Handler->fallback
194    protected function fallback(Throwable $exception): Response
195    {
196        $status = $this->status($exception);
197        $title = $exception instanceof HttpError
198            ? $exception->title()
197        $title = $exception instanceof HttpError
198            ? $exception->title()
199            : '500 Internal Server Error';
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    }
Handler->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;
93        $exception = new ErrorException($message, 0, $level, $file, $line);
94
95        if (($level & $this->exceptionLevels) !== 0) {
97            throw $exception;
100        if ($this->logger === null) {
102            return false;
105        $this->logger->notice('PHP diagnostic', ['exception' => $exception]);
106
107        // Prevent PHP from reporting the logged diagnostic again.
108        return true;
109    }
Handler->log
219    protected function log(string|int $logLevel, Throwable $exception): void
220    {
221        $this->logger?->log($logLevel, 'Matched exception', ['exception' => $exception]);
222    }
Handler->logUnmatched
233    protected function logUnmatched(Throwable $exception): void
234    {
235        $this->logger?->alert('Unmatched exception', ['exception' => $exception]);
236    }
Handler->logger
45    public function logger(?Logger $logger = null): void
46    {
47        $this->logger = $logger;
48    }
Handler->normalize
177    protected function normalize(Throwable $exception, Request $request): Throwable
178    {
179        if ($exception instanceof NotFoundException) {
180            return new HttpNotFound($request, previous: $exception);
183        if ($exception instanceof MethodNotAllowedException) {
184            return new HttpMethodNotAllowed(
185                $request,
191        return $exception;
192    }
Handler->process
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);
56            return $handler->handle($request);
57        } catch (Throwable $e) {
58            return $this->response($e, $request);
58            return $this->response($e, $request);
60            restore_error_handler();
61        }
62    }
Handler->recordServerException
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)) {
228        if ($this->status($exception) >= 500 && class_exists(ServerConsole::class)) {
228        if ($this->status($exception) >= 500 && class_exists(ServerConsole::class)) {
228        if ($this->status($exception) >= 500 && class_exists(ServerConsole::class)) {
228        if ($this->status($exception) >= 500 && class_exists(ServerConsole::class)) {
228        if ($this->status($exception) >= 500 && class_exists(ServerConsole::class)) {
229            ServerConsole::recordException($exception, trace: $this->debug);
230        }
231    }
231    }
Handler->renderer
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;
76        $classes = (array) $exceptions;
77        $entry = new RendererEntry($classes, $renderer);
78        $this->renderers[] = $entry;
79
80        return $entry;
81    }
Handler->response
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) {
117        foreach ($this->renderers as $entry) {
118            if (!$entry->matches($exception)) {
119                continue;
122            $renderer = $entry->renderer;
123            $logLevel = $entry->logLevel();
124            break;
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) {
131        if ($renderer) {
132            $this->recordServerException($exception);
133
134            return $renderer->render(
135                $exception,
136                $this->responseFactory,
137                $request,
138                $this->debug,
142        if ($this->debug) {
143            if ($this->debugHandler) {
144                $this->recordServerException($exception);
145
146                return $this->debugHandler->handle($exception, $this->responseFactory);
149            throw $exception;
152        if ($this->defaultRenderer) {
153            $logLevel = $this->defaultRenderer->logLevel();
154
155            if ($logLevel !== null) {
155            if ($logLevel !== null) {
156                $this->log($logLevel, $exception);
158                $this->logUnmatched($exception);
159            }
160
161            $this->recordServerException($exception);
161            $this->recordServerException($exception);
162
163            return $this->defaultRenderer->renderer->render(
164                $exception,
165                $this->responseFactory,
166                $request,
167                $this->debug,
171        $this->logUnmatched($exception);
172        $this->recordServerException($exception);
173
174        return $this->fallback($exception);
175    }
Handler->status
208    protected function status(Throwable $exception): int
209    {
210        if (!$exception instanceof HttpError) {
211            return 500;
214        $status = $exception->statusCode();
215
216        return $status >= 400 && $status <= 599 ? $status : 500;
216        return $status >= 400 && $status <= 599 ? $status : 500;
216        return $status >= 400 && $status <= 599 ? $status : 500;
216        return $status >= 400 && $status <= 599 ? $status : 500;
216        return $status >= 400 && $status <= 599 ? $status : 500;
216        return $status >= 400 && $status <= 599 ? $status : 500;
217    }