Code Coverage |
||||||||||||||||
Lines |
Branches |
Paths |
Functions and Methods |
Classes and Traits |
||||||||||||
| Total | |
100.00% |
85 / 85 |
|
96.49% |
55 / 57 |
|
36.49% |
27 / 74 |
|
85.71% |
12 / 14 |
CRAP | |
0.00% |
0 / 1 |
| Handler | |
100.00% |
85 / 85 |
|
96.49% |
55 / 57 |
|
36.49% |
27 / 74 |
|
100.00% |
14 / 14 |
294.36 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| debugHandler | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| logger | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| process | |
100.00% |
5 / 5 |
|
83.33% |
5 / 6 |
|
0.00% |
0 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| renderer | |
100.00% |
8 / 8 |
|
100.00% |
3 / 3 |
|
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| handleError | |
100.00% |
3 / 3 |
|
100.00% |
3 / 3 |
|
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| response | |
100.00% |
39 / 39 |
|
100.00% |
19 / 19 |
|
16.67% |
8 / 48 |
|
100.00% |
1 / 1 |
55.88 | |||
| normalize | |
100.00% |
9 / 9 |
|
100.00% |
5 / 5 |
|
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| fallback | |
100.00% |
9 / 9 |
|
100.00% |
4 / 4 |
|
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| status | |
100.00% |
4 / 4 |
|
87.50% |
7 / 8 |
|
40.00% |
2 / 5 |
|
100.00% |
1 / 1 |
7.46 | |||
| log | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| recordServerException | |
100.00% |
2 / 2 |
|
100.00% |
3 / 3 |
|
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| logUnmatched | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| escape | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Celemas\Core\Error; |
| 6 | |
| 7 | use Celemas\Core\Exception\HttpError; |
| 8 | use Celemas\Core\Exception\HttpMethodNotAllowed; |
| 9 | use Celemas\Core\Exception\HttpNotFound; |
| 10 | use Celemas\Core\Server\Console as ServerConsole; |
| 11 | use Celemas\Router\Exception\MethodNotAllowedException; |
| 12 | use Celemas\Router\Exception\NotFoundException; |
| 13 | use ErrorException; |
| 14 | use Override; |
| 15 | use Psr\Http\Message\ResponseFactoryInterface as ResponseFactory; |
| 16 | use Psr\Http\Message\ResponseInterface as Response; |
| 17 | use Psr\Http\Message\ServerRequestInterface as Request; |
| 18 | use Psr\Http\Server\MiddlewareInterface as Middleware; |
| 19 | use Psr\Http\Server\RequestHandlerInterface as RequestHandler; |
| 20 | use Psr\Log\LoggerInterface as Logger; |
| 21 | use Throwable; |
| 22 | |
| 23 | /** @api */ |
| 24 | class 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 | } |
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.
| 35 | protected readonly ResponseFactory $responseFactory, |
| 36 | protected readonly bool $debug = false, |
| 37 | ) {} |
| 39 | public function debugHandler(DebugHandler $debugHandler): void |
| 40 | { |
| 41 | $this->debugHandler = $debugHandler; |
| 42 | } |
| 220 | private function escape(string $value): string |
| 221 | { |
| 222 | return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); |
| 223 | } |
| 178 | protected function fallback(Throwable $exception): Response |
| 179 | { |
| 180 | $status = $this->status($exception); |
| 181 | $title = $exception instanceof HttpError |
| 182 | ? $exception->title() |
| 181 | $title = $exception instanceof HttpError |
| 182 | ? $exception->title() |
| 183 | : '500 Internal Server Error'; |
| 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 | } |
| 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); |
| 92 | return false; |
| 93 | } |
| 203 | protected function log(string|int $logLevel, Throwable $exception): void |
| 204 | { |
| 205 | $this->logger?->log($logLevel, 'Matched exception', ['exception' => $exception]); |
| 206 | } |
| 215 | protected function logUnmatched(Throwable $exception): void |
| 216 | { |
| 217 | $this->logger?->alert('Unmatched exception', ['exception' => $exception]); |
| 218 | } |
| 44 | public function logger(?Logger $logger = null): void |
| 45 | { |
| 46 | $this->logger = $logger; |
| 47 | } |
| 161 | protected function normalize(Throwable $exception, Request $request): Throwable |
| 162 | { |
| 163 | if ($exception instanceof NotFoundException) { |
| 164 | return new HttpNotFound($request, previous: $exception); |
| 167 | if ($exception instanceof MethodNotAllowedException) { |
| 168 | return new HttpMethodNotAllowed( |
| 169 | $request, |
| 175 | return $exception; |
| 176 | } |
| 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); |
| 55 | return $handler->handle($request); |
| 56 | } catch (Throwable $e) { |
| 57 | return $this->response($e, $request); |
| 57 | return $this->response($e, $request); |
| 59 | restore_error_handler(); |
| 60 | } |
| 61 | } |
| 208 | protected function recordServerException(Throwable $exception): void |
| 209 | { |
| 210 | if ($this->status($exception) >= 500) { |
| 211 | ServerConsole::recordException($exception, trace: $this->debug); |
| 212 | } |
| 213 | } |
| 213 | } |
| 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; |
| 75 | $classes = (array) $exceptions; |
| 76 | $entry = new RendererEntry($classes, $renderer); |
| 77 | $this->renderers[] = $entry; |
| 78 | |
| 79 | return $entry; |
| 80 | } |
| 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) { |
| 101 | foreach ($this->renderers as $entry) { |
| 102 | if (!$entry->matches($exception)) { |
| 103 | continue; |
| 106 | $renderer = $entry->renderer; |
| 107 | $logLevel = $entry->logLevel(); |
| 108 | break; |
| 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) { |
| 115 | if ($renderer) { |
| 116 | $this->recordServerException($exception); |
| 117 | |
| 118 | return $renderer->render( |
| 119 | $exception, |
| 120 | $this->responseFactory, |
| 121 | $request, |
| 122 | $this->debug, |
| 126 | if ($this->debug) { |
| 127 | if ($this->debugHandler) { |
| 128 | $this->recordServerException($exception); |
| 129 | |
| 130 | return $this->debugHandler->handle($exception, $this->responseFactory); |
| 133 | throw $exception; |
| 136 | if ($this->defaultRenderer) { |
| 137 | $logLevel = $this->defaultRenderer->logLevel(); |
| 138 | |
| 139 | if ($logLevel !== null) { |
| 139 | if ($logLevel !== null) { |
| 140 | $this->log($logLevel, $exception); |
| 142 | $this->logUnmatched($exception); |
| 143 | } |
| 144 | |
| 145 | $this->recordServerException($exception); |
| 145 | $this->recordServerException($exception); |
| 146 | |
| 147 | return $this->defaultRenderer->renderer->render( |
| 148 | $exception, |
| 149 | $this->responseFactory, |
| 150 | $request, |
| 151 | $this->debug, |
| 155 | $this->logUnmatched($exception); |
| 156 | $this->recordServerException($exception); |
| 157 | |
| 158 | return $this->fallback($exception); |
| 159 | } |
| 192 | protected function status(Throwable $exception): int |
| 193 | { |
| 194 | if (!$exception instanceof HttpError) { |
| 195 | return 500; |
| 198 | $status = $exception->statusCode(); |
| 199 | |
| 200 | return $status >= 400 && $status <= 599 ? $status : 500; |
| 200 | return $status >= 400 && $status <= 599 ? $status : 500; |
| 200 | return $status >= 400 && $status <= 599 ? $status : 500; |
| 200 | return $status >= 400 && $status <= 599 ? $status : 500; |
| 200 | return $status >= 400 && $status <= 599 ? $status : 500; |
| 200 | return $status >= 400 && $status <= 599 ? $status : 500; |
| 201 | } |