Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
74 / 74
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
ApiExceptionListener
100.00% covered (success)
100.00%
74 / 74
100.00% covered (success)
100.00%
3 / 3
31
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
1
 onKernelException
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
9
 createProblemDetailsResponse
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
21
1<?php
2
3/**
4 * Exception listener for API requests
5 *
6 * Converts exceptions to RFC 7807 ProblemDetails JSON responses for API endpoints.
7 *
8 * This Source Code Form is subject to the terms of the Mozilla Public License,
9 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
10 * obtain one at https://mozilla.org/MPL/2.0/.
11 *
12 * @package   phpMyFAQ
13 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
14 * @copyright 2026 phpMyFAQ Team
15 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
16 * @link      https://www.phpmyfaq.de
17 * @since     2026-02-15
18 */
19
20declare(strict_types=1);
21
22namespace phpMyFAQ\EventListener;
23
24use phpMyFAQ\Api\ProblemDetails;
25use phpMyFAQ\Configuration;
26use phpMyFAQ\Controller\Exception\ForbiddenException;
27use phpMyFAQ\Environment;
28use Symfony\Component\HttpFoundation\Exception\BadRequestException;
29use Symfony\Component\HttpFoundation\Request;
30use Symfony\Component\HttpFoundation\Response;
31use Symfony\Component\HttpKernel\Event\ExceptionEvent;
32use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
33use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
34use Symfony\Component\Routing\Exception\ResourceNotFoundException;
35use Throwable;
36
37readonly class ApiExceptionListener
38{
39    public function __construct(
40        private ?Configuration $configuration = null,
41    ) {
42    }
43
44    public function onKernelException(ExceptionEvent $event): void
45    {
46        $request = $event->getRequest();
47        $pathInfo = $request->getPathInfo();
48
49        // Only handle API requests
50        if (!str_starts_with($pathInfo, '/api/') && !$request->attributes->get('_api_context', false)) {
51            return;
52        }
53
54        $throwable = $event->getThrowable();
55
56        [$status, $defaultDetail] = match (true) {
57            $throwable instanceof ResourceNotFoundException, $throwable instanceof NotFoundHttpException => [
58                Response::HTTP_NOT_FOUND,
59                'The requested resource was not found.',
60            ],
61            $throwable instanceof UnauthorizedHttpException => [
62                Response::HTTP_UNAUTHORIZED,
63                'Unauthorized access.',
64            ],
65            $throwable instanceof ForbiddenException => [
66                Response::HTTP_FORBIDDEN,
67                'Access to this resource is forbidden.',
68            ],
69            $throwable instanceof BadRequestException => [
70                Response::HTTP_BAD_REQUEST,
71                'The request could not be understood or was missing required parameters.',
72            ],
73            default => [
74                Response::HTTP_INTERNAL_SERVER_ERROR,
75                'An unexpected error occurred while processing your request.',
76            ],
77        };
78
79        if ($status === Response::HTTP_INTERNAL_SERVER_ERROR) {
80            error_log(sprintf(
81                'Unhandled exception in API: %s at %s:%d',
82                $throwable->getMessage(),
83                $throwable->getFile(),
84                $throwable->getLine(),
85            ));
86        }
87
88        $response = $this->createProblemDetailsResponse($request, $status, $throwable, $defaultDetail);
89        $event->setResponse($response);
90    }
91
92    private function createProblemDetailsResponse(
93        Request $request,
94        int $status,
95        Throwable $throwable,
96        string $defaultDetail,
97    ): Response {
98        $baseUrl = '';
99        if ($this->configuration !== null) {
100            $baseUrl = rtrim($this->configuration->getDefaultUrl(), characters: '/');
101        }
102
103        $type = match ($status) {
104            Response::HTTP_BAD_REQUEST => $baseUrl . '/problems/bad-request',
105            Response::HTTP_UNAUTHORIZED => $baseUrl . '/problems/unauthorized',
106            Response::HTTP_FORBIDDEN => $baseUrl . '/problems/forbidden',
107            Response::HTTP_NOT_FOUND => $baseUrl . '/problems/not-found',
108            Response::HTTP_CONFLICT => $baseUrl . '/problems/conflict',
109            Response::HTTP_UNPROCESSABLE_ENTITY => $baseUrl . '/problems/validation-error',
110            Response::HTTP_TOO_MANY_REQUESTS => $baseUrl . '/problems/rate-limited',
111            Response::HTTP_INTERNAL_SERVER_ERROR => $baseUrl . '/problems/internal-server-error',
112            default => $baseUrl . '/problems/http-error',
113        };
114
115        $title = match ($status) {
116            Response::HTTP_BAD_REQUEST => 'Bad Request',
117            Response::HTTP_UNAUTHORIZED => 'Unauthorized',
118            Response::HTTP_FORBIDDEN => 'Forbidden',
119            Response::HTTP_NOT_FOUND => 'Resource not found',
120            Response::HTTP_CONFLICT => 'Conflict',
121            Response::HTTP_UNPROCESSABLE_ENTITY => 'Validation failed',
122            Response::HTTP_TOO_MANY_REQUESTS => 'Too many requests',
123            Response::HTTP_INTERNAL_SERVER_ERROR => 'Internal Server Error',
124            default => 'HTTP error',
125        };
126
127        $detail = Environment::isDebugMode()
128            ? $throwable->getMessage() . ' at line ' . $throwable->getLine() . ' in ' . $throwable->getFile()
129            : $defaultDetail;
130
131        $problemDetails = new ProblemDetails(
132            type: $type,
133            title: $title,
134            status: $status,
135            detail: $detail,
136            instance: $request->getPathInfo(),
137        );
138
139        $response = new Response(
140            content: (string) json_encode($problemDetails->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
141            status: $status,
142        );
143        $response->headers->set('Content-Type', 'application/problem+json');
144
145        return $response;
146    }
147}