Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.77% covered (success)
96.77%
30 / 31
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
ExceptionMiddleware
96.77% covered (success)
96.77%
30 / 31
75.00% covered (warning)
75.00%
3 / 4
13
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 process
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
4
 getHttpStatusCode
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 getErrorLabel
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace App\Middleware;
6
7use App\Domain\Exception\HttpStatusException;
8use App\Renderer\JsonRenderer;
9use Fig\Http\Message\StatusCodeInterface;
10use Psr\Http\Message\ResponseFactoryInterface;
11use Psr\Http\Message\ResponseInterface;
12use Psr\Http\Message\ServerRequestInterface;
13use Psr\Http\Server\MiddlewareInterface;
14use Psr\Http\Server\RequestHandlerInterface;
15use Psr\Log\LoggerInterface;
16use Slim\Exception\HttpException as SlimHttpException;
17use Throwable;
18
19use function sprintf;
20
21/**
22 * Catches every uncaught exception in the request lifecycle and converts it
23 * into a JSON error response with the appropriate HTTP status code.
24 *
25 * Any exception implementing {@see HttpStatusException} declares its own
26 * status code and title via {@see HttpStatusException::getStatusCode()} and
27 * {@see HttpStatusException::getTitle()}. Adding a new exception class does
28 * not require updating this middleware — just implement the interface.
29 *
30 * Slim's framework-level {@see SlimHttpException} (route not found, method
31 * not allowed, etc.) is honored as well. Anything else is treated as an
32 * unhandled server error and returns 500.
33 */
34final class ExceptionMiddleware implements MiddlewareInterface
35{
36    public function __construct(
37        private readonly ResponseFactoryInterface $responseFactory,
38        private readonly JsonRenderer $renderer,
39        private readonly ?LoggerInterface $logger = null,
40    ) {}
41
42    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
43    {
44        try {
45            return $handler->handle($request);
46        } catch (Throwable $exception) {
47            if (isset($this->logger)) {
48                $this->logger->error(
49                    sprintf(
50                        '%s;Code %s;File: %s;Line: %s',
51                        $exception->getMessage(),
52                        $exception->getCode(),
53                        $exception->getFile(),
54                        $exception->getLine(),
55                    ),
56                    $exception->getTrace(),
57                );
58            }
59
60            $statusCode = $this->getHttpStatusCode($exception);
61            $errorLabel = $this->getErrorLabel($exception, $statusCode);
62            $message = $statusCode >= 500
63                ? 'An unexpected error occurred'
64                : $exception->getMessage();
65
66            $response = $this->responseFactory->createResponse($statusCode);
67
68            return $this->renderer->json($response, [
69                'success' => false,
70                'error' => $errorLabel,
71                'message' => $message,
72            ], $statusCode);
73        }
74    }
75
76    private function getHttpStatusCode(Throwable $exception): int
77    {
78        return match (true) {
79            // Application exceptions declare their own status code via the interface.
80            // BadRequestException, AuthenticationException, ForbiddenException,
81            // NotFoundException, ConflictException, and ValidationException all
82            // implement HttpStatusException and are matched here.
83            $exception instanceof HttpStatusException => $exception->getStatusCode(),
84
85            // Slim's framework-level HTTP exceptions (route not found, method not allowed, etc.)
86            $exception instanceof SlimHttpException => $exception->getCode(),
87
88            // Anything else (RuntimeException, PDOException, unhandled errors) is a server failure.
89            default => StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR,
90        };
91    }
92
93    private function getErrorLabel(Throwable $exception, int $statusCode): string
94    {
95        return match (true) {
96            $exception instanceof HttpStatusException => $exception->getTitle(),
97            $exception instanceof SlimHttpException => 'HTTP Error',
98            default => 'Server Error',
99        };
100    }
101}