Completed
Push — master ( bc1f52...d1acb5 )
by Marcin
20s queued 18s
created

ExceptionHandlerHelper::getHandler()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 24
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 5.1158

Importance

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

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