Completed
Push — master ( ad6910...d77fda )
by Tarmo
18s queued 12s
created

ExceptionSubscriber::determineStatusCode()   B

Complexity

Conditions 8
Paths 14

Size

Total Lines 21
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 8

Importance

Changes 0
Metric Value
cc 8
eloc 12
nc 14
nop 2
dl 0
loc 21
ccs 13
cts 13
cp 1
crap 8
rs 8.4444
c 0
b 0
f 0
1
<?php
2
declare(strict_types = 1);
3
/**
4
 * /src/EventSubscriber/ExceptionSubscriber.php
5
 *
6
 * @author TLe, Tarmo Leppänen <[email protected]>
7
 */
8
9
namespace App\EventSubscriber;
10
11
use App\Exception\interfaces\ClientErrorInterface;
12
use App\Security\UserTypeIdentification;
13
use App\Utils\JSON;
14
use Doctrine\DBAL\Exception;
15
use Doctrine\ORM\ORMException;
16
use JsonException;
17
use Psr\Log\LoggerInterface;
18
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
19
use Symfony\Component\HttpFoundation\Response;
20
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
21
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
22
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
23
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
24
use Symfony\Component\Security\Core\Exception\AuthenticationException;
25
use Throwable;
26
use function array_intersect;
27
use function array_key_exists;
28
use function class_implements;
29
use function count;
30
use function get_class;
31
use function in_array;
32
use function method_exists;
33
use function spl_object_hash;
34
35
/**
36
 * Class ExceptionSubscriber
37
 *
38
 * @package App\EventSubscriber
39
 * @author TLe, Tarmo Leppänen <[email protected]>
40
 */
41
class ExceptionSubscriber implements EventSubscriberInterface
42
{
43
    /**
44
     * @var array<string, bool>
45
     */
46
    private static array $cache = [];
47
48
    /**
49
     * @var array<int, string>
50
     */
51
    private static array $clientExceptions = [
52
        HttpExceptionInterface::class,
53
        ClientErrorInterface::class,
54
    ];
55
56 537
    public function __construct(
57
        private LoggerInterface $logger,
58
        private UserTypeIdentification $userService,
59
        private string $environment,
60
    ) {
61 537
    }
62
63
    /**
64
     * {@inheritdoc}
65
     *
66
     * @return array<string, array<int, string|int>>
67
     */
68 2
    public static function getSubscribedEvents(): array
69
    {
70
        return [
71
            ExceptionEvent::class => [
72 2
                'onKernelException',
73
                -100,
74
            ],
75
        ];
76
    }
77
78
    /**
79
     * Method to handle kernel exception.
80
     *
81
     * @throws JsonException
82
     */
83 347
    public function onKernelException(ExceptionEvent $event): void
84
    {
85
        // Get exception from current event
86 347
        $exception = $event->getThrowable();
87
88
        // Log  error
89 347
        $this->logger->error((string)$exception);
90
91
        // Create new response
92 347
        $response = new Response();
93 347
        $response->headers->set('Content-Type', 'application/json');
94 347
        $response->setStatusCode($this->getStatusCode($exception));
95 347
        $response->setContent(JSON::encode($this->getErrorMessage($exception, $response)));
96
97
        // Send the modified response object to the event
98 347
        $event->setResponse($response);
99 347
    }
100
101
    /**
102
     * Method to get "proper" status code for exception response.
103
     */
104 362
    private function getStatusCode(Throwable $exception): int
105
    {
106 362
        return $this->determineStatusCode($exception, $this->userService->getSecurityUser() !== null);
107
    }
108
109
    /**
110
     * Method to get actual error message.
111
     *
112
     * @return array<string, string|int|array<string, string|int|array<int, string>>>
113
     */
114 347
    private function getErrorMessage(Throwable $exception, Response $response): array
115
    {
116
        // Set base of error message
117 347
        $error = [
118 347
            'message' => $this->getExceptionMessage($exception),
119 347
            'code' => $exception->getCode(),
120 347
            'status' => $response->getStatusCode(),
121
        ];
122
123
        // Attach more info to error response in dev environment
124 347
        if ($this->environment === 'dev') {
125
            $error += [
126
                'debug' => [
127 12
                    'exception' => get_class($exception),
128 12
                    'file' => $exception->getFile(),
129 12
                    'line' => $exception->getLine(),
130 12
                    'message' => $exception->getMessage(),
131 12
                    'trace' => $exception->getTrace(),
132 12
                    'traceString' => $exception->getTraceAsString(),
133
                ],
134
            ];
135
        }
136
137 347
        return $error;
138
    }
139
140
    /**
141
     * Helper method to convert exception message for user. This method is
142
     * used in 'production' environment so, that application won't reveal any
143
     * sensitive error data to users.
144
     */
145 365
    private function getExceptionMessage(Throwable $exception): string
146
    {
147 365
        return $this->environment === 'dev'
148 20
            ? $exception->getMessage()
149 365
            : $this->getMessageForProductionEnvironment($exception);
150
    }
151
152 345
    private function getMessageForProductionEnvironment(Throwable $exception): string
153
    {
154 345
        $message = $exception->getMessage();
155
156 345
        $accessDeniedClasses = [
157
            AccessDeniedHttpException::class,
158
            AccessDeniedException::class,
159
            AuthenticationException::class,
160
        ];
161
162 345
        if (in_array(get_class($exception), $accessDeniedClasses, true)) {
163 114
            $message = 'Access denied.';
164 231
        } elseif ($exception instanceof Exception || $exception instanceof ORMException) {
165
            // Database errors
166 3
            $message = 'Database error.';
167 228
        } elseif (!$this->isClientExceptions($exception)) {
168 54
            $message = 'Internal server error.';
169
        }
170
171 345
        return $message;
172
    }
173
174
    /**
175
     * Method to determine status code for specified exception.
176
     */
177 362
    private function determineStatusCode(Throwable $exception, bool $isUser): int
178
    {
179
        // Default status code is always 500
180 362
        $statusCode = Response::HTTP_INTERNAL_SERVER_ERROR;
181
182
        // HttpExceptionInterface is a special type of exception that holds status code and header details
183 362
        if ($exception instanceof AuthenticationException) {
184 4
            $statusCode = Response::HTTP_UNAUTHORIZED;
185 358
        } elseif ($exception instanceof AccessDeniedException) {
186 6
            $statusCode = $isUser ? Response::HTTP_FORBIDDEN : Response::HTTP_UNAUTHORIZED;
187 352
        } elseif ($exception instanceof HttpExceptionInterface) {
188 287
            $statusCode = $exception->getStatusCode();
189 65
        } elseif ($this->isClientExceptions($exception)) {
190 4
            $statusCode = (int)$exception->getCode();
191
192 4
            if (method_exists($exception, 'getStatusCode')) {
193 4
                $statusCode = $exception->getStatusCode();
194
            }
195
        }
196
197 362
        return $statusCode === 0 ? Response::HTTP_INTERNAL_SERVER_ERROR : $statusCode;
198
    }
199
200
    /**
201
     * Method to check if exception is ok to show to user (client) or not. Note
202
     * that if this returns true exception message is shown as-is to user.
203
     */
204 241
    private function isClientExceptions(Throwable $exception): bool
205
    {
206 241
        $cacheKey = spl_object_hash($exception);
207
208 241
        if (!array_key_exists($cacheKey, self::$cache)) {
209 239
            $intersect = array_intersect((array)class_implements($exception), self::$clientExceptions);
210
211 239
            self::$cache[$cacheKey] = count($intersect) !== 0;
212
        }
213
214 241
        return self::$cache[$cacheKey];
215
    }
216
}
217