Passed
Pull Request — master (#204)
by Marcin
07:42
created

ExceptionHandlerHelper   A

Complexity

Total Complexity 28

Size/Duplication

Total Lines 336
Duplicated Lines 0 %

Test Coverage

Coverage 95.56%

Importance

Changes 13
Bugs 0 Features 1
Metric Value
eloc 101
c 13
b 0
f 1
dl 0
loc 336
ccs 86
cts 90
cp 0.9556
rs 10
wmc 28

7 Methods

Rating   Name   Duplication   Size   Complexity  
A render() 0 22 4
A getExceptionHandlerConfig() 0 41 1
A getErrorMessageForException() 0 22 4
A getHandler() 0 25 5
A unauthenticated() 0 13 1
B processException() 0 35 7
B error() 0 46 6
1
<?php
2
declare(strict_types=1);
3
4
namespace MarcinOrlowski\ResponseBuilder;
5
6
/**
7
 * Laravel API Response Builder
8
 *
9
 * @package   MarcinOrlowski\ResponseBuilder
10
 *
11
 * @author    Marcin Orlowski <mail (#) marcinOrlowski (.) com>
12
 * @copyright 2016-2021 Marcin Orlowski
13
 * @license   http://www.opensource.org/licenses/mit-license.php MIT
14
 * @link      https://github.com/MarcinOrlowski/laravel-api-response-builder
15
 */
16
17
use Throwable;
18
use Illuminate\Auth\AuthenticationException as AuthException;
19
use Illuminate\Support\Facades\Config;
20
use Illuminate\Support\Facades\Lang;
21
use Illuminate\Validation\ValidationException;
22
use MarcinOrlowski\ResponseBuilder\ExceptionHandlers\DefaultExceptionHandler;
23
use MarcinOrlowski\ResponseBuilder\ExceptionHandlers\HttpExceptionHandler;
24
use Symfony\Component\HttpFoundation\Response as HttpResponse;
25
use Symfony\Component\HttpKernel\Exception\HttpException;
26
use MarcinOrlowski\ResponseBuilder\ResponseBuilder as RB;
27
use MarcinOrlowski\ResponseBuilder\Exceptions as Ex;
28
29
/**
30
 * Exception handler using ResponseBuilder to return JSON even in such hard tines
31
 */
32
class ExceptionHandlerHelper
33
{
34
	/**
35
	 * Render an exception into valid API response.
36
	 *
37
	 * @param \Illuminate\Http\Request $request Request object
38
	 * @param \Throwable               $ex      Throwable to handle
39 4
	 *
40
	 * @return HttpResponse
41 4
	 *
42
	 * @throws Ex\InvalidTypeException
43 4
	 * @throws Ex\NotIntegerException
44
	 * @throws Ex\MissingConfigurationKeyException
45 4
	 * @throws Ex\ConfigurationNotFoundException
46
	 * @throws Ex\IncompatibleTypeException
47 2
	 * @throws Ex\ArrayWithMixedKeysException
48
	 *
49
	 * NOTE: no typehints due to compatibility with Laravel's method signature.
50 4
	 * @noinspection PhpMissingParamTypeInspection
51 4
	 * @noinspection PhpUnusedParameterInspection
52 4
	 */
53 4
	public static function render(/** @scrutinizer ignore-unused */ $request, \Throwable $ex): HttpResponse
54
	{
55
		$result = null;
56
57
		$cfg = static::getHandler($ex);
58 4
		do {
59
			if ($cfg === null) {
60 4
				// Default handler MUST be present by design and always return something useful.
61
				$cfg = self::getExceptionHandlerConfig()[ RB::KEY_DEFAULT ];
62
			}
63
64
			$handler = new $cfg[ RB::KEY_HANDLER ]();
65
			$handler_result = $handler->handle($cfg[ RB::KEY_CONFIG ], $ex);
66
			if ($handler_result !== null) {
67
				$result = self::processException($ex, $handler_result);
68
			} else {
69
				// Let's fall back to default handler in next round.
70
				$cfg = null;
71
			}
72
		} while ($result === null);
73
74 6
		return $result;
75
	}
76
77 6
	/**
78 6
	 * Handles given throwable and produces valid HTTP response object.
79 6
	 *
80 6
	 * @param \Throwable $ex                 Throwable to be handled.
81
	 * @param array      $ex_cfg             ExceptionHandler's config excerpt related to $ex exception type.
82
	 * @param int        $fallback_http_code HTTP code to be assigned to produced $ex related response in
83 6
	 *                                       case configuration array lacks own `http_code` value. Default
84
	 *                                       HttpResponse::HTTP_INTERNAL_SERVER_ERROR
85 6
	 *
86 6
	 * @return \Symfony\Component\HttpFoundation\Response
87
	 *
88
	 * NOTE: no return typehint due to compatibility with Laravel signature.
89
	 * @noinspection PhpMissingReturnTypeInspection
90 6
	 * @noinspection ReturnTypeCanBeDeclaredInspection
91
	 *
92 1
	 * @throws Ex\InvalidTypeException
93
	 * @throws Ex\NotIntegerException
94 1
	 * @throws Ex\MissingConfigurationKeyException
95 1
	 * @throws Ex\ConfigurationNotFoundException
96
	 * @throws Ex\IncompatibleTypeException
97
	 * @throws Ex\ArrayWithMixedKeysException
98
	 */
99 5
	protected static function processException(\Throwable $ex, array $ex_cfg,
100 2
	                                           int $fallback_http_code = HttpResponse::HTTP_INTERNAL_SERVER_ERROR)
101 2
	{
102
		$api_code = $ex_cfg['api_code'];
103
		$http_code = $ex_cfg['http_code'] ?? $fallback_http_code;
104
		$msg_key = $ex_cfg['msg_key'] ?? null;
105
		$msg_enforce = $ex_cfg['msg_enforce'] ?? false;
106 6
107
		// No message key, let's get exception message and if there's nothing useful, fallback to built-in one.
108
		$msg = $ex->getMessage();
109
		$placeholders = [
110
			'api_code' => $api_code,
111
			'message'  => ($msg !== '') ? $msg : '???',
112
		];
113
114
		// shall we enforce error message?
115
		if ($msg_enforce) {
116
			// yes, please.
117
			// there's no msg_key configured for this exact code, so let's obtain our default message
118
			$msg = ($msg_key === null) ? static::getErrorMessageForException($ex, $http_code, $placeholders)
119
				: Lang::get($msg_key, $placeholders);
120 2
		} else if ($msg === '') {
121
			// nothing enforced, handling pipeline: ex_message -> user_defined_msg -> http_ex -> default
122
			$msg = ($msg_key === null) ? static::getErrorMessageForException($ex, $http_code, $placeholders)
123 2
				: Lang::get($msg_key, $placeholders);
124
		}
125
126
		// As Lang::get() is documented to also returning arrays(?)...
127 2
		if (is_array($msg)) {
128 2
			$msg = implode('', $msg);
129 2
		}
130
131
		// Lets' try to build the error response with what we have now
132 2
		/** @noinspection PhpUnhandledExceptionInspection */
133
		return static::error($ex, $api_code, $http_code, $msg);
134
	}
135
136
	/**
137
	 * Returns error message for given exception. If exception message is empty, then falls back to
138
	 * `default` handler either for HttpException (if $ex is instance of it), or generic `default`
139
	 * config.
140
	 *
141
	 * @param \Throwable $ex
142
	 * @param int        $http_code
143 1
	 * @param array      $placeholders
144
	 *
145
	 * @return string
146 1
	 *
147
	 * @throws Ex\MissingConfigurationKeyException
148
	 * @throws Ex\IncompatibleTypeException
149 1
	 * @throws Ex\InvalidTypeException
150
	 * @throws Ex\NotIntegerException
151 1
	 */
152
	protected static function getErrorMessageForException(\Throwable $ex, int $http_code, array $placeholders): string
153
	{
154
		// exception message is uselss, lets go deeper
155
		if ($ex instanceof HttpException) {
156
			$error_message = Lang::get("response-builder::builder.http_{$http_code}", $placeholders);
157
		} else {
158
			// Still got nothing? Fall back to built-in generic message for this type of exception.
159
			$http_ex_cls = HttpException::class;
160
			/** @var object $ex */
161
			$key = BaseApiCodes::getCodeMessageKey($ex instanceof $http_ex_cls
162
				? BaseApiCodes::EX_HTTP_EXCEPTION() : BaseApiCodes::NO_ERROR_MESSAGE());
163
			// Default strings are expected to always be available.
164 9
			/** @var string $key */
165
			$error_message = Lang::get($key, $placeholders);
166
		}
167 9
168 9
		// As Lang::get() is documented to also returning arrays(?)...
169 9
		if (is_array($error_message)) {
170
			$error_message = implode('', $error_message);
171
		}
172
173 9
		return $error_message;
174
	}
175 2
176
	/**
177
	 * Convert an authentication exception into an unauthenticated response.
178 9
	 *
179
	 * @param \Illuminate\Http\Request                 $request
180
	 * @param \Illuminate\Auth\AuthenticationException $exception
181
	 *
182
	 * @return HttpResponse
183 1
	 *
184
	 * @throws Ex\InvalidTypeException
185
	 * @throws Ex\NotIntegerException
186
	 * @throws Ex\MissingConfigurationKeyException
187 9
	 * @throws Ex\ConfigurationNotFoundException
188 9
	 * @throws Ex\IncompatibleTypeException
189
	 * @throws Ex\ArrayWithMixedKeysException
190 1
	 *
191 1
	 * @noinspection PhpUnusedParameterInspection
192 1
	 * @noinspection UnknownInspectionInspection
193 1
	 *
194
	 * NOTE: not typehints due to compatibility with Laravel's method signature.
195
	 * @noinspection PhpMissingParamTypeInspection
196
	 */
197
	protected function unauthenticated(/** @scrutinizer ignore-unused */ $request,
198
	                                                                     AuthException $exception): HttpResponse
199 9
	{
200 9
		$cfg = self::getExceptionHandlerConfig();
201
202 1
		// This config entry is guaranted to exist. Enforced by tests.
203
		$cfg = $cfg[ HttpException::class ][ RB::KEY_CONFIG ][ HttpResponse::HTTP_UNAUTHORIZED ];
204
205 9
		/**
206 9
		 * NOTE: no typehint due to compatibility with Laravel signature.
207 9
		 * @noinspection PhpParamsInspection
208 9
		 */
209 9
		return static::processException($exception, $cfg, HttpResponse::HTTP_UNAUTHORIZED);
210 9
	}
211
212
	/**
213
	 * Process single error and produce valid API response.
214
	 *
215
	 * @param \Throwable  $ex Exception to be handled.
216
	 * @param integer     $api_code
217
	 * @param int|null    $http_code
218 7
	 * @param string|null $error_message
219
	 *
220
	 * @return HttpResponse
221
	 *
222 7
	 * @throws Ex\MissingConfigurationKeyException
223
	 * @throws Ex\ConfigurationNotFoundException
224
	 * @throws Ex\IncompatibleTypeException
225
	 * @throws Ex\ArrayWithMixedKeysException
226 7
	 * @throws Ex\InvalidTypeException
227 7
	 * @throws Ex\NotIntegerException
228
	 */
229
	protected static function error(Throwable $ex,
230 7
	                                int $api_code, int $http_code = null, string $error_message = null): HttpResponse
231 7
	{
232
		$ex_http_code = ($ex instanceof HttpException) ? $ex->getStatusCode() : $ex->getCode();
233
		$http_code = $http_code ?? $ex_http_code;
234 7
		$error_message = $error_message ?? '';
235 7
236 7
		// Check if we now have valid HTTP error code for this case or need to make one up.
237
		// We cannot throw any exception if codes are invalid because we are in Exception Handler already.
238
		if ($http_code < RB::ERROR_HTTP_CODE_MIN) {
239
			// Not a valid code, let's try to get the exception status.
240
			$http_code = $ex_http_code;
241
		}
242
		// Can it be considered a valid HTTP error code?
243 7
		if ($http_code < RB::ERROR_HTTP_CODE_MIN) {
244
			// We now handle uncaught exception, so we cannot throw another one if there's
245
			// something wrong with the configuration, so we try to recover and use built-in
246
			// codes instead.
247 7
			// FIXME: We should log this event as (warning or error?)
248 7
			$http_code = RB::DEFAULT_HTTP_CODE_ERROR;
249
		}
250
251
		// If we have trace data debugging enabled, let's gather some debug info and add to the response.
252
		$debug_data = null;
253 7
		if (Config::get(RB::CONF_KEY_DEBUG_EX_TRACE_ENABLED, false)) {
254 7
			$debug_data = [
255 7
				Config::get(RB::CONF_KEY_DEBUG_EX_TRACE_KEY, RB::KEY_TRACE) => [
256
					RB::KEY_CLASS => \get_class($ex),
257 7
					RB::KEY_FILE  => $ex->getFile(),
258
					RB::KEY_LINE  => $ex->getLine(),
259
				],
260
			];
261
		}
262
263
		// If this is ValidationException, add all the messages from MessageBag to the data node.
264
		$data = null;
265
		if ($ex instanceof ValidationException) {
266
			$data = [RB::KEY_MESSAGES => $ex->validator->errors()->messages()];
267
		}
268
269 4
		return RB::asError($api_code)
270
			->withMessage($error_message)
271 4
			->withHttpCode($http_code)
272
			->withData($data)
273 4
			->withDebugData($debug_data)
274 4
			->build();
275 4
	}
276
277
	/**
278 4
	 * Returns ExceptionHandlerHelper configration array with user configuration merged into built-in defaults.
279 2
	 *
280
	 * @return array
281
	 *
282
	 * @throws Ex\IncompatibleTypeException
283 2
	 * @throws Ex\InvalidTypeException
284 2
	 * @throws Ex\MissingConfigurationKeyException
285
	 * @throws Ex\NotIntegerException
286
	 */
287
	protected static function getExceptionHandlerConfig(): array
288
	{
289
		/** @noinspection PhpUnhandledExceptionInspection */
290
		$default_config = [
291
			HttpException::class         => [
292 4
				'handler' => HttpExceptionHandler::class,
293
				'pri'     => -100,
294
				'config'  => [
295
					// used by unauthenticated() to obtain api and http code for the exception
296
					HttpResponse::HTTP_UNAUTHORIZED         => [
297
						RB::KEY_API_CODE => BaseApiCodes::EX_AUTHENTICATION_EXCEPTION(),
298
					],
299
					// Required by ValidationException handler
300
					HttpResponse::HTTP_UNPROCESSABLE_ENTITY => [
301
						RB::KEY_API_CODE => BaseApiCodes::EX_VALIDATION_EXCEPTION(),
302
					],
303
304
					RB::KEY_DEFAULT => [
305
						RB::KEY_API_CODE  => BaseApiCodes::EX_UNCAUGHT_EXCEPTION(),
306
						RB::KEY_HTTP_CODE => HttpResponse::HTTP_INTERNAL_SERVER_ERROR,
307
					],
308
				],
309
				// default config is built into handler.
310
			],
311
312
			// default handler is mandatory. `default` entry MUST have both `api_code` and `http_code` set.
313
			RB::KEY_DEFAULT => [
314
				'handler' => DefaultExceptionHandler::class,
315
				'pri'     => -127,
316
				'config'  => [
317
					RB::KEY_API_CODE  => BaseApiCodes::EX_UNCAUGHT_EXCEPTION(),
318
					RB::KEY_HTTP_CODE => HttpResponse::HTTP_INTERNAL_SERVER_ERROR,
319
				],
320
			],
321
		];
322
323
		$cfg = Util::mergeConfig($default_config,
324
			\Config::get(RB::CONF_KEY_EXCEPTION_HANDLER, []));
325
		Util::sortArrayByPri($cfg);
326
327
		return $cfg;
328
	}
329
330
	/**
331
	 * Returns name of exception handler class, configured to process specified exception class or @null if no
332
	 * exception handler can be determined.
333
	 *
334
	 * @param \Throwable $ex Exception to handle
335
	 *
336
	 * @return array|null
337
	 *
338
	 * @throws Ex\IncompatibleTypeException
339
	 * @throws Ex\InvalidTypeException
340
	 * @throws Ex\MissingConfigurationKeyException
341
	 * @throws Ex\NotIntegerException
342
	 */
343
	protected static function getHandler(\Throwable $ex): ?array
344
	{
345
		$result = null;
346
347
		$cls = \get_class($ex);
348
		if (\is_string($cls)) {
0 ignored issues
show
introduced by
The condition is_string($cls) is always true.
Loading history...
349
			$cfg = self::getExceptionHandlerConfig();
350
351
			// check for exact class name match...
352
			if (\array_key_exists($cls, $cfg)) {
353
				$result = $cfg[ $cls ];
354
			} else {
355
				// no exact match, then lets try with `instanceof`
356
				// Config entries are already sorted by priority.
357
				foreach (\array_keys($cfg) as $class_name) {
358
					/** @var string $class_name */
359
					if ($ex instanceof $class_name) {
360
						$result = $cfg[ $class_name ];
361
						break;
362
					}
363
				}
364
			}
365
		}
366
367
		return $result;
368
	}
369
370
}
371