Lines 80.55% 58 / 72
Methods 83.33% 5 / 6
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 onKernelException 100.00% 25 / 25 100.00% 1 / 1 9
 handleNotFound 44.00% 11 / 25 0.00% 0 / 1 15.61
 handleServerError 100.00% 12 / 12 100.00% 1 / 1 1
 handleErrorResponse 100.00% 4 / 4 100.00% 1 / 1 2
 formatExceptionMessage 100.00% 5 / 5 100.00% 1 / 1 1
40readonly class WebExceptionListener
41{
42    public function __construct(
43        private ?ContainerInterface $container = null,
44    ) {
45    }
46
47    public function onKernelException(ExceptionEvent $event): void
48    {
49        $request = $event->getRequest();
50        $pathInfo = $request->getPathInfo();
51        $baseUrl = '/' . ltrim(rtrim($request->getBaseUrl(), characters: '/'), characters: '/');
52        $loginPath = $baseUrl === '/' ? '/login' : $baseUrl . '/login';
53
54        // Skip API requests — handled by ApiExceptionListener
55        if (str_starts_with($pathInfo, '/api/') || (bool) $request->attributes->get('_api_context', false)) {
56            return;
57        }
58
59        $throwable = $event->getThrowable();
60
61        $response = match (true) {
62            $throwable instanceof ResourceNotFoundException,
63            $throwable instanceof NotFoundHttpException,
64                => $this->handleNotFound($event),
65            $throwable instanceof UnauthorizedHttpException => new RedirectResponse(url: $loginPath),
66            $throwable instanceof ForbiddenException => $this->handleErrorResponse(
67                'An error occurred: :message at line :line at :file',
68                'Forbidden',
69                Response::HTTP_FORBIDDEN,
70                $throwable,
71            ),
72            $throwable instanceof BadRequestException => $this->handleErrorResponse(
73                'An error occurred: :message at line :line at :file',
74                'Bad Request',
75                Response::HTTP_BAD_REQUEST,
76                $throwable,
77            ),
78            default => $this->handleServerError($throwable),
79        };
80
81        $event->setResponse($response);
82    }
83
84    private function handleNotFound(ExceptionEvent $event): Response
85    {
86        $request = $event->getRequest();
87        $throwable = $event->getThrowable();
88
89        try {
90            if (!$this->container instanceof ContainerInterface) {
91                throw new \RuntimeException('Container is required to render the styled 404 page.');
92            }
93
94            $request->attributes->set('_route', 'public.404');
95            $request->attributes->set('_controller', PageNotFoundController::class . '::index');
96            $controllerResolver = new ContainerControllerResolver($this->container);
97            $argumentResolver = new ArgumentResolver();
98            $controller = $controllerResolver->getController($request);
99            if ($controller === false) {
100                throw new \RuntimeException('No controller found for the 404 route.');
101            }
102
103            if (is_array($controller) && $controller[0] instanceof AbstractController) {
104                $controller[0]->setContainer($this->container);
105            }
106
107            /* @mago-expect analysis:less-specific-nested-argument-type - Symfony's resolver returns a generic callable */
108            $arguments = $argumentResolver->getArguments($request, $controller);
109            /* @mago-expect analysis:less-specific-nested-argument-type - Symfony's resolver returns a generic callable */
110            $response = call_user_func_array($controller, $arguments);
111            if (!$response instanceof Response) {
112                throw new \RuntimeException('The 404 controller did not return a response.');
113            }
114
115            return $response;
116        } catch (Throwable) {
117            return $this->handleErrorResponse(
118                'Not Found: :message at line :line at :file',
119                'Not Found',
120                Response::HTTP_NOT_FOUND,
121                $throwable,
122            );
123        }
124    }
125
126    private function handleServerError(Throwable $throwable): Response
127    {
128        error_log(sprintf(
129            'Unhandled exception: %s at %s:%d',
130            $throwable->getMessage(),
131            $throwable->getFile(),
132            $throwable->getLine(),
133        ));
134
135        return $this->handleErrorResponse(
136            'Internal Server Error: :message at line :line at :file',
137            'Internal Server Error',
138            Response::HTTP_INTERNAL_SERVER_ERROR,
139            $throwable,
140        );
141    }
142
143    private function handleErrorResponse(
144        string $debugTemplate,
145        string $fallbackMessage,
146        int $statusCode,
147        Throwable $throwable,
148    ): Response {
149        $message = Environment::isDebugMode()
150            ? $this->formatExceptionMessage($debugTemplate, $throwable)
151            : $fallbackMessage;
152
153        return new Response(content: $message, status: $statusCode);
154    }
155
156    private function formatExceptionMessage(string $template, Throwable $throwable): string
157    {
158        return strtr($template, [
159            ':message' => $throwable->getMessage(),
160            ':line' => (string) $throwable->getLine(),
161            ':file' => $throwable->getFile(),
162        ]);
163    }
164}