Completed
Push — master ( 277a70...42ae0f )
by Marcin
24s queued 11s
created

ExceptionHandlerHelper   A

Complexity

Total Complexity 26

Size/Duplication

Total Lines 262
Duplicated Lines 0 %

Test Coverage

Coverage 95.4%

Importance

Changes 14
Bugs 0 Features 0
Metric Value
eloc 97
c 14
b 0
f 0
dl 0
loc 262
ccs 83
cts 87
cp 0.954
rs 10
wmc 26

7 Methods

Rating   Name   Duplication   Size   Complexity  
A render() 0 22 4
A getExceptionHandlerConfig() 0 40 1
A unauthenticated() 0 9 1
A getErrorMessageForException() 0 13 3
A processException() 0 31 6
B error() 0 47 6
A getHandler() 0 24 5
1
<?php
2
declare(strict_types=1);
3
4
namespace MarcinOrlowski\ResponseBuilder;
5
6
/**
7
 * Exception handler using ResponseBuilder to return JSON even in such hard tines
8
 *
9
 * @package   MarcinOrlowski\ResponseBuilder
10
 *
11
 * @author    Marcin Orlowski <mail (#) marcinOrlowski (.) com>
12
 * @copyright 2016-2020 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 Exception;
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 Symfony\Component\HttpFoundation\Response as HttpResponse;
24
use Symfony\Component\HttpKernel\Exception\HttpException;
25
use MarcinOrlowski\ResponseBuilder\ResponseBuilder as RB;
26
27
/**
28
 * Class ExceptionHandlerHelper
29
 */
30
class ExceptionHandlerHelper
31
{
32
	/**
33
	 * Render an exception into valid API response.
34
	 *
35
	 * @param \Illuminate\Http\Request $request Request object
36
	 * @param \Exception               $ex      Exception
37
	 *
38
	 * @return HttpResponse
39
	 */
40 4
	public static function render(/** @scrutinizer ignore-unused */ $request, Exception $ex): HttpResponse
41
	{
42 4
		$result = null;
43
44 4
		$cfg = static::getHandler($ex);
45
		do {
46 4
			if ($cfg === null) {
47
				// Default handler MUST be present by design and always return something useful.
48 2
				$cfg = self::getExceptionHandlerConfig()[ RB::KEY_DEFAULT ];
49
			}
50
51 4
			$handler = new $cfg[ RB::KEY_HANDLER ]();
52 4
			$handler_result = $handler->handle($cfg[ RB::KEY_CONFIG ], $ex);
53 4
			if ($handler_result !== null) {
54 4
				$result = self::processException($ex, $handler_result);
55
			} else {
56
				// Let's fall back to default handler in next round.
57
				$cfg = null;
58
			}
59 4
		} while ($result === null);
60
61 4
		return $result;
62
	}
63
64
	/**
65
	 * Handles given exception and produces valid HTTP response object.
66
	 *
67
	 * @param \Exception $ex                 Exception to be handled.
68
	 * @param array      $ex_cfg             ExceptionHandler's config excerpt related to $ex exception type.
69
	 * @param int        $fallback_http_code HTTP code to be assigned to produced $ex related response in
70
	 *                                       case configuration array lacks own `http_code` value. Default
71
	 *                                       HttpResponse::HTTP_INTERNAL_SERVER_ERROR
72
	 *
73
	 * @return \Symfony\Component\HttpFoundation\Response
74
	 */
75 6
	protected static function processException(\Exception $ex, array $ex_cfg,
76
	                                           int $fallback_http_code = HttpResponse::HTTP_INTERNAL_SERVER_ERROR)
77
	{
78 6
		$api_code = $ex_cfg['api_code'];
79 6
		$http_code = $ex_cfg['http_code'] ?? $fallback_http_code;
80 6
		$msg_key = $ex_cfg['msg_key'] ?? null;
81 6
		$msg_enforce = $ex_cfg['msg_enforce'] ?? false;
82
83
		// No message key, let's get exception message and if there's nothing useful, fallback to built-in one.
84 6
		$msg = $ex->getMessage();
85
		$placeholders = [
86 6
			'api_code' => $api_code,
87 6
			'message'  => ($msg !== '') ? $msg : '???',
88
		];
89
90
		// shall we enforce error message?
91 6
		if ($msg_enforce) {
92
			// yes, please.
93
			// there's no msg_key configured for this exact code, so let's obtain our default message
94 1
			$msg = ($msg_key === null) ? static::getErrorMessageForException($ex, $http_code, $placeholders)
95 1
				: Lang::get($msg_key, $placeholders);
96
		} else {
97
			// nothing enforced, handling pipeline: ex_message -> user_defined_msg -> http_ex -> default
98 5
			if ($msg === '') {
99 2
				$msg = ($msg_key === null) ? static::getErrorMessageForException($ex, $http_code, $placeholders)
100 2
					: Lang::get($msg_key, $placeholders);
101
			}
102
		}
103
104
		// Lets' try to build the error response with what we have now
105 6
		return static::error($ex, $api_code, $http_code, $msg);
106
	}
107
108
	/**
109
	 * Returns error message for given exception. If exception message is empty, then falls back to
110
	 * `default` handler either for HttpException (if $ex is instance of it), or generic `default`
111
	 * config.
112
	 *
113
	 * @param \Exception $ex
114
	 * @param int        $http_code
115
	 * @param array      $placeholders
116
	 *
117
	 * @return string
118
	 */
119 2
	protected static function getErrorMessageForException(\Exception $ex, int $http_code, array $placeholders): string
120
	{
121
		// exception message is uselss, lets go deeper
122 2
		if ($ex instanceof HttpException) {
123
			$error_message = Lang::get("response-builder::builder.http_{$http_code}", $placeholders);
124
		} else {
125
			// Still got nothing? Fall back to built-in generic message for this type of exception.
126 2
			$key = BaseApiCodes::getCodeMessageKey(($ex instanceof HttpException)
127 2
				? BaseApiCodes::EX_HTTP_EXCEPTION() : BaseApiCodes::NO_ERROR_MESSAGE());
128 2
			$error_message = Lang::get($key, $placeholders);
129
		}
130
131 2
		return $error_message;
132
	}
133
134
	/**
135
	 * Convert an authentication exception into an unauthenticated response.
136
	 *
137
	 * @param \Illuminate\Http\Request                 $request
138
	 * @param \Illuminate\Auth\AuthenticationException $exception
139
	 *
140
	 * @return HttpResponse
141
	 */
142 1
	protected function unauthenticated(/** @scrutinizer ignore-unused */ $request,
143
	                                                                     AuthException $exception): HttpResponse
144
	{
145 1
		$cfg = self::getExceptionHandlerConfig();
146
147
		// This config entry is guaranted to exist. Enforced by tests.
148 1
		$cfg = $cfg[ HttpException::class ][ RB::KEY_CONFIG ][ HttpResponse::HTTP_UNAUTHORIZED ];
149
150 1
		return static::processException($exception, $cfg, HttpResponse::HTTP_UNAUTHORIZED);
151
	}
152
153
	/**
154
	 * Process single error and produce valid API response.
155
	 *
156
	 * @param Exception $ex Exception to be handled.
157
	 * @param integer   $api_code
158
	 * @param integer   $http_code
159
	 * @param string    $error_message
160
	 *
161
	 * @return HttpResponse
162
	 */
163 9
	protected static function error(Exception $ex,
164
	                                int $api_code, int $http_code = null, string $error_message = null): HttpResponse
165
	{
166 9
		$ex_http_code = ($ex instanceof HttpException) ? $ex->getStatusCode() : $ex->getCode();
167 9
		$http_code = $http_code ?? $ex_http_code;
168 9
		$error_message = $error_message ?? '';
169
170
		// Check if we now have valid HTTP error code for this case or need to make one up.
171
		// We cannot throw any exception if codes are invalid because we are in Exception Handler already.
172 9
		if ($http_code < RB::ERROR_HTTP_CODE_MIN) {
173
			// Not a valid code, let's try to get the exception status.
174 2
			$http_code = $ex_http_code;
175
		}
176
		// Can it be considered a valid HTTP error code?
177 9
		if ($http_code < RB::ERROR_HTTP_CODE_MIN) {
178
			// We now handle uncaught exception, so we cannot throw another one if there's
179
			// something wrong with the configuration, so we try to recover and use built-in
180
			// codes instead.
181
			// FIXME: We should log this event as (warning or error?)
182 1
			$http_code = RB::DEFAULT_HTTP_CODE_ERROR;
183
		}
184
185
		// If we have trace data debugging enabled, let's gather some debug info and add to the response.
186 9
		$debug_data = null;
187 9
		if (Config::get(RB::CONF_KEY_DEBUG_EX_TRACE_ENABLED, false)) {
188
			$debug_data = [
189 1
				Config::get(RB::CONF_KEY_DEBUG_EX_TRACE_KEY, RB::KEY_TRACE) => [
190 1
					RB::KEY_CLASS => \get_class($ex),
191 1
					RB::KEY_FILE  => $ex->getFile(),
192 1
					RB::KEY_LINE  => $ex->getLine(),
193
				],
194
			];
195
		}
196
197
		// If this is ValidationException, add all the messages from MessageBag to the data node.
198 9
		$data = null;
199 9
		if ($ex instanceof ValidationException) {
200
			/** @var ValidationException $ex */
201 1
			$data = [RB::KEY_MESSAGES => $ex->validator->errors()->messages()];
202
		}
203
204 9
		return RB::asError($api_code)
205 9
			->withMessage($error_message)
206 9
			->withHttpCode($http_code)
207 9
			->withData($data)
0 ignored issues
show
Bug introduced by
It seems like $data can also be of type array<string,array>; however, parameter $data of MarcinOrlowski\ResponseB...onseBuilder::withData() does only seem to accept null, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

207
			->withData(/** @scrutinizer ignore-type */ $data)
Loading history...
208 9
			->withDebugData($debug_data)
209 9
			->build();
210
	}
211
212
	/**
213
	 * Returns ExceptionHandlerHelper configration array with user configuration merged into built-in defaults.
214
	 *
215
	 * @return array
216
	 */
217 7
	protected static function getExceptionHandlerConfig(): array
218
	{
219
		$default_config = [
220
			HttpException::class         => [
221 7
				'handler' => HttpExceptionHandler::class,
0 ignored issues
show
Bug introduced by
The type MarcinOrlowski\ResponseB...er\HttpExceptionHandler was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
222
				'pri'     => -100,
223
				'config'  => [
224
					// used by unauthenticated() to obtain api and http code for the exception
225 7
					HttpResponse::HTTP_UNAUTHORIZED         => [
226 7
						RB::KEY_API_CODE => BaseApiCodes::EX_AUTHENTICATION_EXCEPTION(),
227
					],
228
					// Required by ValidationException handler
229 7
					HttpResponse::HTTP_UNPROCESSABLE_ENTITY => [
230 7
						RB::KEY_API_CODE => BaseApiCodes::EX_VALIDATION_EXCEPTION(),
231
					],
232
233
					RB::KEY_DEFAULT => [
234 7
						RB::KEY_API_CODE  => BaseApiCodes::EX_UNCAUGHT_EXCEPTION(),
235 7
						RB::KEY_HTTP_CODE => HttpResponse::HTTP_INTERNAL_SERVER_ERROR,
236
					],
237
				],
238
				// default config is built into handler.
239
			],
240
241
			// default handler is mandatory. `default` entry MUST have both `api_code` and `http_code` set.
242
			RB::KEY_DEFAULT => [
243
				'handler' => DefaultExceptionHandler::class,
244
				'pri'     => -127,
245
				'config'  => [
246 7
					RB::KEY_API_CODE  => BaseApiCodes::EX_UNCAUGHT_EXCEPTION(),
247 7
					RB::KEY_HTTP_CODE => HttpResponse::HTTP_INTERNAL_SERVER_ERROR,
248
				],
249
			],
250
		];
251
252 7
		$cfg = Util::mergeConfig($default_config,
253 7
			\Config::get(RB::CONF_KEY_EXCEPTION_HANDLER, []));
254 7
		Util::sortArrayByPri($cfg);
255
256 7
		return $cfg;
257
	}
258
259
260
	/**
261
	 * Returns name of exception handler class, configured to process specified exception class or @null if no
262
	 * exception handler can be determined.
263
	 *
264
	 * @param string $cls Name of exception class to handle
265
	 *
266
	 * @return array|null
267
	 */
268 4
	protected static function getHandler(Exception $ex): ?array
269
	{
270 4
		$result = null;
271
272 4
		$cls = \get_class($ex);
273 4
		if (\is_string($cls)) {
0 ignored issues
show
introduced by
The condition is_string($cls) is always true.
Loading history...
274 4
			$cfg = self::getExceptionHandlerConfig();
275
276
			// check for exact class name match...
277 4
			if (\array_key_exists($cls, $cfg)) {
278 2
				$result = $cfg[ $cls ];
279
			} else {
280
				// no exact match, then lets try with `instanceof`
281
				// Config entries are already sorted by priority.
282 2
				foreach (\array_keys($cfg) as $class_name) {
283 2
					if ($ex instanceof $class_name) {
284
						$result = $cfg[ $class_name ];
285
						break;
286
					}
287
				}
288
			}
289
		}
290
291 4
		return $result;
292
	}
293
294
}
295