Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.17% covered (success)
99.17%
119 / 120
95.00% covered (success)
95.00%
19 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
ExceptionHandler
99.17% covered (success)
99.17%
119 / 120
95.00% covered (success)
95.00%
19 / 20
39
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 setEnvironment
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getEnvironment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLanguage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getLanguage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validateView
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 setDevelopmentView
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getDevelopmentView
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setProductionView
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getProductionView
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 initialize
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 exceptionHandler
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
6.01
 isCli
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isJson
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 sendJson
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 sendHeaders
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 cliError
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 log
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 errorHandler
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
3
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework Debug Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\Debug;
11
12use ErrorException;
13use Framework\CLI\CLI;
14use Framework\Helpers\Isolation;
15use Framework\Language\Language;
16use Framework\Log\Logger;
17use InvalidArgumentException;
18use RuntimeException;
19use Throwable;
20
21/**
22 * Class ExceptionHandler.
23 *
24 * @package debug
25 */
26class ExceptionHandler
27{
28    /**
29     * Development environment.
30     *
31     * @var string
32     */
33    public const DEVELOPMENT = 'development';
34    /**
35     * Production environment.
36     *
37     * @var string
38     */
39    public const PRODUCTION = 'production';
40    protected string $developmentView = __DIR__ . '/Views/exceptions/development.php';
41    protected string $productionView = __DIR__ . '/Views/exceptions/production.php';
42    protected ?Logger $logger = null;
43    protected string $environment = ExceptionHandler::PRODUCTION;
44    protected Language $language;
45    protected bool $testing = false;
46
47    /**
48     * ExceptionHandler constructor.
49     *
50     * @param string $environment
51     * @param Logger|null $logger
52     * @param Language|null $language
53     *
54     * @throws InvalidArgumentException if environment is invalid
55     */
56    public function __construct(
57        string $environment = ExceptionHandler::PRODUCTION,
58        Logger $logger = null,
59        Language $language = null
60    ) {
61        $this->setEnvironment($environment);
62        if ($logger) {
63            $this->logger = $logger;
64        }
65        if ($language) {
66            $this->setLanguage($language);
67        }
68    }
69
70    public function setEnvironment(string $environment) : static
71    {
72        if ( ! \in_array($environment, [
73            static::DEVELOPMENT,
74            static::PRODUCTION,
75        ], true)) {
76            throw new InvalidArgumentException('Invalid environment: ' . $environment);
77        }
78        $this->environment = $environment;
79        return $this;
80    }
81
82    public function getEnvironment() : string
83    {
84        return $this->environment;
85    }
86
87    /**
88     * @return Logger|null
89     */
90    public function getLogger() : ?Logger
91    {
92        return $this->logger;
93    }
94
95    public function setLanguage(Language $language = null) : static
96    {
97        $this->language = $language ?? new Language();
98        $this->language->addDirectory(__DIR__ . '/Languages');
99        return $this;
100    }
101
102    /**
103     * @return Language
104     */
105    public function getLanguage() : Language
106    {
107        if ( ! isset($this->language)) {
108            $this->setLanguage();
109        }
110        return $this->language;
111    }
112
113    protected function validateView(string $file) : string
114    {
115        $realpath = \realpath($file);
116        if ( ! $realpath || ! \is_file($realpath)) {
117            throw new InvalidArgumentException(
118                'Invalid exceptions view file: ' . $file
119            );
120        }
121        return $realpath;
122    }
123
124    public function setDevelopmentView(string $file) : static
125    {
126        $this->developmentView = $this->validateView($file);
127        return $this;
128    }
129
130    public function getDevelopmentView() : string
131    {
132        return $this->developmentView;
133    }
134
135    public function setProductionView(string $file) : static
136    {
137        $this->productionView = $this->validateView($file);
138        return $this;
139    }
140
141    public function getProductionView() : string
142    {
143        return $this->productionView;
144    }
145
146    public function initialize(bool $handleErrors = true) : void
147    {
148        \set_exception_handler([$this, 'exceptionHandler']);
149        if ($handleErrors) {
150            \set_error_handler([$this, 'errorHandler']);
151        }
152    }
153
154    /**
155     * Exception handler.
156     *
157     * @param Throwable $exception The Throwable, exception, instance
158     *
159     * @throws RuntimeException if view file is not found
160     */
161    public function exceptionHandler(Throwable $exception) : void
162    {
163        if (\ob_get_length()) {
164            \ob_end_clean();
165        }
166        $this->log((string) $exception);
167        if ($this->isCli()) {
168            $this->cliError($exception);
169            return;
170        }
171        \http_response_code(500);
172        if ( ! \headers_sent()) {
173            $this->sendHeaders();
174        }
175        if ($this->isJson()) {
176            $this->sendJson($exception);
177            return;
178        }
179        $file = $this->getEnvironment() === static::DEVELOPMENT
180            ? $this->getDevelopmentView()
181            : $this->getProductionView();
182        Isolation::require($file, [
183            'handler' => $this,
184            'exception' => $exception,
185        ]);
186    }
187
188    protected function isCli() : bool
189    {
190        return \PHP_SAPI === 'cli' || \defined('STDIN');
191    }
192
193    protected function isJson() : bool
194    {
195        return isset($_SERVER['HTTP_CONTENT_TYPE'])
196            && \str_starts_with($_SERVER['HTTP_CONTENT_TYPE'], 'application/json');
197    }
198
199    protected function sendJson(Throwable $exception) : void
200    {
201        $data = $this->getEnvironment() === static::DEVELOPMENT
202            ? [
203                'exception' => $exception::class,
204                'message' => $exception->getMessage(),
205                'file' => $exception->getFile(),
206                'line' => $exception->getLine(),
207                'trace' => $exception->getTrace(),
208            ]
209            : [
210                'message' => $this->getLanguage()->render('debug', 'exceptionDescription'),
211            ];
212        echo \json_encode([
213            'status' => [
214                'code' => 500,
215                'reason' => 'Internal Server Error',
216            ],
217            'data' => $data,
218        ], \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE);
219    }
220
221    protected function sendHeaders() : void
222    {
223        $contentType = 'text/html';
224        if ($this->isJson()) {
225            $contentType = 'application/json';
226        }
227        \header('Content-Type: ' . $contentType . '; charset=UTF-8');
228    }
229
230    protected function cliError(Throwable $exception) : void
231    {
232        $language = $this->getLanguage();
233        $message = $language->render('debug', 'exception')
234            . ': ' . $exception::class . \PHP_EOL;
235        $message .= $language->render('debug', 'message')
236            . ': ' . $exception->getMessage() . \PHP_EOL;
237        $message .= $language->render('debug', 'file')
238            . ': ' . $exception->getFile() . \PHP_EOL;
239        $message .= $language->render('debug', 'line')
240            . ': ' . $exception->getLine() . \PHP_EOL;
241        $message .= $language->render('debug', 'trace')
242            . ': ' . $exception->getTraceAsString();
243        CLI::error($message, $this->testing ? null : 1);
244    }
245
246    protected function log(string $message) : void
247    {
248        $this->getLogger()?->logCritical($message);
249    }
250
251    /**
252     * Error handler.
253     *
254     * @param int $errno The level of the error raised
255     * @param string $errstr The error message
256     * @param string|null $errfile The filename that the error was raised in
257     * @param int|null $errline The line number where the error was raised
258     *
259     * @see http://php.net/manual/en/function.set-error-handler.php
260     *
261     * @throws ErrorException if the error is included in the error_reporting
262     *
263     * @return bool
264     */
265    public function errorHandler(
266        int $errno,
267        string $errstr,
268        string $errfile = null,
269        int $errline = null
270    ) : bool {
271        if ( ! (\error_reporting() & $errno)) {
272            return true;
273        }
274        $type = match ($errno) {
275            \E_ERROR => 'Error',
276            \E_WARNING => 'Warning',
277            \E_PARSE => 'Parse',
278            \E_NOTICE => 'Notice',
279            \E_CORE_ERROR => 'Core Error',
280            \E_CORE_WARNING => 'Core Warning',
281            \E_COMPILE_ERROR => 'Compile Error',
282            \E_COMPILE_WARNING => 'Compile Warning',
283            \E_USER_ERROR => 'User Error',
284            \E_USER_WARNING => 'User Warning',
285            \E_USER_NOTICE => 'User Notice',
286            \E_STRICT => 'Strict',
287            \E_RECOVERABLE_ERROR => 'Recoverable Error',
288            \E_DEPRECATED => 'Deprecated',
289            \E_USER_DEPRECATED => 'User Deprecated',
290            \E_ALL => 'All',
291            default => '',
292        };
293        throw new ErrorException(
294            ($type ? $type . ': ' : '') . $errstr,
295            0,
296            $errno,
297            $errfile,
298            $errline
299        );
300    }
301}