Passed
Pull Request — master (#204)
by Marcin
16:42 queued 06:40
created

ExceptionHandlerHelper   A

Complexity

Total Complexity 28

Size/Duplication

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