Completed
Pull Request — master (#90)
by Marcin
05:35 queued 02:13
created

ResponseBuilder::hasClassesMapping()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 1
dl 0
loc 3
rs 10
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-2019 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 Illuminate\Support\Facades\Config;
18
use Illuminate\Support\Facades\Response;
19
use Symfony\Component\HttpFoundation\Response as HttpResponse;
20
21
22
/**
23
 * Builds standardized HttpResponse response object
24
 */
25
class ResponseBuilder
26
{
27
	/**
28
	 * Default HTTP code to be used with success responses
29
	 */
30
	public const DEFAULT_HTTP_CODE_OK = HttpResponse::HTTP_OK;
31
32
	/**
33
	 * Default HTTP code to be used with error responses
34
	 */
35
	public const DEFAULT_HTTP_CODE_ERROR = HttpResponse::HTTP_BAD_REQUEST;
36
37
	/**
38
	 * Min allowed HTTP code for errorXXX()
39
	 */
40
	public const ERROR_HTTP_CODE_MIN = 400;
41
42
	/**
43
	 * Max allowed HTTP code for errorXXX()
44
	 */
45
	public const ERROR_HTTP_CODE_MAX = 599;
46
47
	/**
48
	 * Configuration keys
49
	 */
50
	public const CONF_KEY_DEBUG_DEBUG_KEY        = 'response_builder.debug.debug_key';
51
	public const CONF_KEY_DEBUG_EX_TRACE_ENABLED = 'response_builder.debug.exception_handler.trace_enabled';
52
	public const CONF_KEY_DEBUG_EX_TRACE_KEY     = 'response_builder.debug.exception_handler.trace_key';
53
	public const CONF_KEY_MAP                    = 'response_builder.map';
54
	public const CONF_KEY_ENCODING_OPTIONS       = 'response_builder.encoding_options';
55
	public const CONF_KEY_CLASSES                = 'response_builder.classes';
56
	public const CONF_KEY_MIN_CODE               = 'response_builder.min_code';
57
	public const CONF_KEY_MAX_CODE               = 'response_builder.max_code';
58
	public const CONF_KEY_RESPONSE_KEY_MAP       = 'response_builder.map';
59
60
	/**
61
	 * Default keys to be used by exception handler while adding debug information
62
	 */
63
	public const KEY_DEBUG   = 'debug';
64
	public const KEY_TRACE   = 'trace';
65
	public const KEY_CLASS   = 'class';
66
	public const KEY_FILE    = 'file';
67
	public const KEY_LINE    = 'line';
68
	public const KEY_KEY     = 'key';
69
	public const KEY_METHOD  = 'method';
70
	public const KEY_SUCCESS = 'success';
71
	public const KEY_CODE    = 'code';
72
	public const KEY_LOCALE  = 'locale';
73
	public const KEY_MESSAGE = 'message';
74
	public const KEY_DATA    = 'data';
75
76
	/**
77
	 * Default key to be used by exception handler while processing ValidationException
78
	 * to return all the error messages
79
	 */
80
	public const KEY_MESSAGES = 'messages';
81
82
	/**
83
	 * Default JSON encoding options. Must be specified as final value (i.e. 271) and NOT
84
	 * exression i.e. `JSON_HEX_TAG|JSON_HEX_APOS|...` as such syntax is not yet supported
85
	 * by PHP.
86
	 *
87
	 * 271 = JSON_HEX_TAG|JSON_HEX_APOS|JSON_HEX_AMP|JSON_HEX_QUOT|JSON_UNESCAPED_UNICODE
88
	 */
89
	public const DEFAULT_ENCODING_OPTIONS = 271;
90
91
	/**
92
	 * Reads and validates "classes" config mapping
93
	 *
94
	 * @return array Classes mapping as specified in configuration or empty array if configuration found
95
	 *
96
	 * @throws \RuntimeException if "classes" mapping is technically invalid (i.e. not array etc).
97
	 */
98
	protected static function getClassesMapping(): ?array
99
	{
100
		$classes = Config::get(self::CONF_KEY_CLASSES);
101
102
		if ($classes !== null) {
103
			if (!is_array($classes)) {
104
				throw new \RuntimeException(
105
					sprintf('CONFIG: "classes" mapping must be an array (%s given)', gettype($classes)));
106
			}
107
108
			$mandatory_keys = [
109
				static::KEY_KEY,
110
				static::KEY_METHOD,
111
			];
112
			foreach ($classes as $class_name => $class_config) {
113
				foreach ($mandatory_keys as $key_name) {
114
					if (!array_key_exists($key_name, $class_config)) {
115
						throw new \RuntimeException("CONFIG: Missing '{$key_name}' for '{$class_name}' class mapping");
116
					}
117
				}
118
			}
119
		} else {
120
			$classes = [];
121
		}
122
123
		return $classes;
124
	}
125
126
	/**
127
	 * Checks if we have "classes" mapping configured for $data object class.
128
	 * Returns @true if there's valid config for this class.
129
	 *
130
	 * @param object $data Object to check mapping for.
131
	 *
132
	 * @return bool
133
	 *
134
	 * @throws \InvalidArgumentException if $data is not an object.
135
	 */
136
	protected static function hasClassesMapping(object $data): bool
137
	{
138
		return array_key_exists(get_class($data), static::getClassesMapping());
139
	}
140
141
	/**
142
	 * Recursively walks $data array and converts all known objects if found. Note
143
	 * $data array is passed by reference so source $data array may be modified.
144
	 *
145
	 * @param array $classes "classes" config mapping array
146
	 * @param array $data    array to recursively convert known elements of
147
	 *
148
	 * @return void
149
	 */
150
	protected static function convert(array $classes, array &$data): void
151
	{
152
		foreach ($data as $data_key => &$data_val) {
153
			if (is_array($data_val)) {
154
				static::convert($classes, $data_val);
155
			} elseif (is_object($data_val)) {
156
				$obj_class_name = get_class($data_val);
157
				if (array_key_exists($obj_class_name, $classes)) {
158
					$conversion_method = $classes[ $obj_class_name ][ static::KEY_METHOD ];
159
					$converted = $data_val->$conversion_method();
160
					$data[ $data_key ] = $converted;
161
				}
162
			}
163
		}
164
	}
165
166
	/**
167
	 * Returns success
168
	 *
169
	 * @param object|array|null $data             payload to be returned as 'data' node, @null if none
170
	 * @param integer|null      $api_code         API code to be returned with the response or @null for default `OK` code
171
	 * @param array|null        $lang_args        arguments passed to Lang if message associated with API code uses placeholders
172
	 * @param integer|null      $http_code        HTTP return code to be set for this response or @null for default (200)
173
	 * @param integer|null      $encoding_options see http://php.net/manual/en/function.json-encode.php or @null to use
174
	 *                                            config's value or defaults
175
	 *
176
	 * @return HttpResponse
177
	 */
178
	public static function success($data = null, $api_code = null, array $lang_args = null,
179
	                               int $http_code = null, int $encoding_options = null): HttpResponse
180
	{
181
		return static::buildSuccessResponse($data, $api_code, $lang_args, $http_code, $encoding_options);
182
	}
183
184
	/**
185
	 * Returns success
186
	 *
187
	 * @param integer|null $api_code  API code to be returned with the response or @null for default `OK` code
188
	 * @param array|null   $lang_args arguments passed to Lang if message associated with API code uses placeholders
189
	 * @param integer|null $http_code HTTP return code to be set for this response or @null for default (200)
190
	 *
191
	 * @return HttpResponse
192
	 */
193
	public static function successWithCode(int $api_code = null, array $lang_args = null, int $http_code = null): HttpResponse
194
	{
195
		return static::success(null, $api_code, $lang_args, $http_code);
196
	}
197
198
	/**
199
	 * Returns success with custom HTTP code
200
	 *
201
	 * @param integer|null $http_code HTTP return code to be set for this response. If @null is passed, falls back
202
	 *                                to DEFAULT_HTTP_CODE_OK.
203
	 *
204
	 * @return HttpResponse
205
	 */
206
	public static function successWithHttpCode(int $http_code = null): HttpResponse
207
	{
208
		return static::buildSuccessResponse(null, BaseApiCodes::OK(), [], $http_code);
209
	}
210
211
	/**
212
	 * @param object|array|null $data             payload to be returned as 'data' node, @null if none
213
	 * @param integer|null      $api_code         API code to be returned with the response or @null for `OK` code
214
	 * @param array|null        $lang_args        arguments passed to Lang if message associated with API code uses placeholders
215
	 * @param integer|null      $http_code        HTTP return code to be set for this response
216
	 * @param integer|null      $encoding_options see http://php.net/manual/en/function.json-encode.php or @null to use
217
	 *                                            config's value or defaults
218
	 *
219
	 * @return HttpResponse
220
	 *
221
	 * @throws \InvalidArgumentException Thrown when provided arguments are invalid.
222
	 */
223
	protected static function buildSuccessResponse($data = null, int $api_code = null, array $lang_args = null,
224
	                                               int $http_code = null, int $encoding_options = null): HttpResponse
225
	{
226
		$http_code = $http_code ?? static::DEFAULT_HTTP_CODE_OK;
227
		$api_code = $api_code ?? BaseApiCodes::OK();
228
229
		Validator::assertInt('api_code', $api_code);
230
		Validator::assertInt('http_code', $http_code);
231
		Validator::assertIntRange('http_code', $http_code, 200, 299);
232
233
		return static::make(true, $api_code, $api_code, $data, $http_code, $lang_args, null, $encoding_options);
234
	}
235
236
	/**
237
	 * Builds error Response object. Supports optional arguments passed to Lang::get() if associated error
238
	 * message uses placeholders as well as return data payload
239
	 *
240
	 * @param integer           $api_code         API code to be returned with the response
241
	 * @param array|null        $lang_args        arguments array passed to Lang::get() for messages with placeholders
242
	 * @param object|array|null $data             payload array to be returned in 'data' node or response object
243
	 * @param integer|null      $http_code        optional HTTP status code to be used with this response or @null for default
244
	 * @param integer|null      $encoding_options see http://php.net/manual/en/function.json-encode.php or @null to use
245
	 *                                            config's value or defaults
246
	 *
247
	 * @return HttpResponse
248
	 */
249
	public static function error(int $api_code, array $lang_args = null, $data = null, int $http_code = null,
250
	                             int $encoding_options = null): HttpResponse
251
	{
252
		return static::buildErrorResponse($data, $api_code, $http_code, $lang_args, $encoding_options);
253
	}
254
255
	/**
256
	 * @param integer           $api_code         API code to be returned with the response
257
	 * @param object|array|null $data             payload to be returned as 'data' node, @null if none
258
	 * @param array|null        $lang_args        arguments array passed to Lang::get() for messages with placeholders
259
	 * @param integer|null      $encoding_options see http://php.net/manual/en/function.json-encode.php or @null to use
260
	 *                                            config's value or defaults
261
	 *
262
	 * @return HttpResponse
263
	 */
264
	public static function errorWithData(int $api_code, $data, array $lang_args = null,
265
	                                     int $encoding_options = null): HttpResponse
266
	{
267
		return static::buildErrorResponse($data, $api_code, null, $lang_args, $encoding_options);
268
	}
269
270
	/**
271
	 * @param integer           $api_code         API code to be returned with the response
272
	 * @param object|array|null $data             payload to be returned as 'data' node, @null if none
273
	 * @param integer|null      $http_code        HTTP error code to be returned with this Cannot be @null
274
	 * @param array|null        $lang_args        arguments array passed to Lang::get() for messages with placeholders
275
	 * @param integer|null      $encoding_options see http://php.net/manual/en/function.json-encode.php or @null to use
276
	 *                                            config's value or defaults
277
	 *
278
	 * @return HttpResponse
279
	 *
280
	 * @throws \InvalidArgumentException if http_code is @null
281
	 */
282
	public static function errorWithDataAndHttpCode(int $api_code, $data, int $http_code, array $lang_args = null,
283
	                                                int $encoding_options = null): HttpResponse
284
	{
285
		return static::buildErrorResponse($data, $api_code, $http_code, $lang_args, $encoding_options);
286
	}
287
288
	/**
289
	 * @param integer      $api_code  API code to be returned with the response
290
	 * @param integer|null $http_code HTTP return code to be set for this response or @null for default
291
	 * @param array|null   $lang_args arguments array passed to Lang::get() for messages with placeholders
292
	 *
293
	 * @return HttpResponse
294
	 *
295
	 * @throws \InvalidArgumentException if http_code is @null
296
	 */
297
	public static function errorWithHttpCode(int $api_code, int $http_code, array $lang_args = null): HttpResponse
298
	{
299
		return static::buildErrorResponse(null, $api_code, $http_code, $lang_args);
300
	}
301
302
	/**
303
	 * @param integer           $api_code         API code to be returned with the response
304
	 * @param string            $error_message    custom message to be returned as part of error response
305
	 * @param object|array|null $data             payload to be returned as 'data' node, @null if none
306
	 * @param integer|null      $http_code        optional HTTP status code to be used with this response or @null for defaults
307
	 * @param integer|null      $encoding_options see http://php.net/manual/en/function.json-encode.php or @null to use config's
308
	 *                                            value or defaults
309
	 *
310
	 * @return HttpResponse
311
	 */
312
	public static function errorWithMessageAndData(int $api_code, string $error_message, $data,
313
	                                               int $http_code = null, int $encoding_options = null): HttpResponse
314
	{
315
		return static::buildErrorResponse($data, $api_code, $http_code, null,
316
			$error_message, null, $encoding_options);
317
	}
318
319
	/**
320
	 * @param integer           $api_code         API code to be returned with the response
321
	 * @param string            $error_message    custom message to be returned as part of error response
322
	 * @param object|array|null $data             payload to be returned as 'data' node, @null if none
323
	 * @param integer|null      $http_code        optional HTTP status code to be used with this response or @null for defaults
324
	 * @param integer|null      $encoding_options see http://php.net/manual/en/function.json-encode.php or @null to use
325
	 *                                            config's value or defaults
326
	 * @param array|null        $debug_data       optional debug data array to be added to returned JSON.
327
	 *
328
	 * @return HttpResponse
329
	 */
330
	public static function errorWithMessageAndDataAndDebug(int $api_code, string $error_message, $data,
331
	                                                       int $http_code = null, int $encoding_options = null,
332
	                                                       array $debug_data = null): HttpResponse
333
	{
334
		return static::buildErrorResponse($data, $api_code, $http_code, null,
335
			$error_message, null, $encoding_options, $debug_data);
336
	}
337
338
	/**
339
	 * @param integer      $api_code      API code to be returned with the response
340
	 * @param string       $error_message custom message to be returned as part of error response
341
	 * @param integer|null $http_code     optional HTTP status code to be used with this response or @null for defaults
342
	 *
343
	 * @return HttpResponse
344
	 */
345
	public static function errorWithMessage(int $api_code, string $error_message, int $http_code = null): HttpResponse
346
	{
347
		return static::buildErrorResponse(null, $api_code, $http_code, null, $error_message);
348
	}
349
350
	/**
351
	 * Builds error Response object. Supports optional arguments passed to Lang::get() if associated error message
352
	 * uses placeholders as well as return data payload
353
	 *
354
	 * @param object|array|null $data             payload array to be returned in 'data' node or response object or @null if none
355
	 * @param integer           $api_code         API code to be returned with the response
356
	 * @param integer|null      $http_code        optional HTTP status code to be used with this response or @null for default
357
	 * @param array|null        $lang_args        arguments array passed to Lang::get() for messages with placeholders
358
	 * @param string|null       $message          custom message to be returned as part of error response
359
	 * @param array|null        $headers          optional HTTP headers to be returned in error response
360
	 * @param integer|null      $encoding_options see see json_encode() docs for valid option values. Use @null to fall back to
361
	 *                                            config's value or defaults
362
	 * @param array|null        $debug_data       optional debug data array to be added to returned JSON.
363
	 *
364
	 * @return HttpResponse
365
	 *
366
	 * @throws \InvalidArgumentException Thrown if $code is not correct, outside the range, equals OK code etc.
367
	 *
368
	 * @noinspection MoreThanThreeArgumentsInspection
369
	 */
370
	protected static function buildErrorResponse($data, int $api_code, int $http_code = null, array $lang_args = null,
371
	                                             string $message = null, array $headers = null, int $encoding_options = null,
372
	                                             array $debug_data = null): HttpResponse
373
	{
374
		$http_code = $http_code ?? static::DEFAULT_HTTP_CODE_ERROR;
375
		$headers = $headers ?? [];
376
377
		$code_ok = BaseApiCodes::OK();
378
379
		Validator::assertInt('api_code', $api_code);
380
		if ($api_code !== $code_ok) {
381
			Validator::assertIntRange('api_code', $api_code, BaseApiCodes::getMinCode(), BaseApiCodes::getMaxCode());
382
		}
383
		if ($api_code === $code_ok) {
384
			throw new \InvalidArgumentException("Error response cannot use api_code of value  {$code_ok} which is reserved for OK");
385
		}
386
387
		Validator::assertInt('http_code', $http_code);
388
		Validator::assertIntRange('http_code', $http_code, static::ERROR_HTTP_CODE_MIN, static::ERROR_HTTP_CODE_MAX);
389
390
		$message_or_api_code = $message ?? $api_code;
391
392
		return static::make(false, $api_code, $message_or_api_code, $data, $http_code,
393
			$lang_args, $headers, $encoding_options, $debug_data);
394
	}
395
396
	/**
397
	 * @param boolean           $success             @true if response indicate success, @false otherwise
398
	 * @param integer           $api_code            API code to be returned with the response
399
	 * @param string|integer    $message_or_api_code message string or valid API code
400
	 * @param object|array|null $data                optional additional data to be included in response object
401
	 * @param integer|null      $http_code           return HTTP code for build Response object
402
	 * @param array|null        $lang_args           arguments array passed to Lang::get() for messages with placeholders
403
	 * @param array|null        $headers             optional HTTP headers to be returned in the response
404
	 * @param integer|null      $encoding_options    see http://php.net/manual/en/function.json-encode.php
405
	 * @param array|null        $debug_data          optional debug data array to be added to returned JSON.
406
	 *
407
	 * @return HttpResponse
408
	 *
409
	 * @throws \InvalidArgumentException If $api_code is neither a string nor valid integer code.
410
	 * @throws \InvalidArgumentException if $data is an object of class that is not configured in "classes" mapping.
411
	 *
412
	 * @noinspection MoreThanThreeArgumentsInspection
413
	 */
414
	protected static function make(bool $success, int $api_code, $message_or_api_code, $data = null,
415
	                               int $http_code = null, array $lang_args = null, array $headers = null,
416
	                               int $encoding_options = null, array $debug_data = null): HttpResponse
417
	{
418
		$headers = $headers ?? [];
419
		$http_code = $http_code ?? ($success ? static::DEFAULT_HTTP_CODE_OK : static::DEFAULT_HTTP_CODE_ERROR);
420
		$encoding_options = $encoding_options ?? Config::get(self::CONF_KEY_ENCODING_OPTIONS, static::DEFAULT_ENCODING_OPTIONS);
421
422
		Validator::assertInt('encoding_options', $encoding_options);
423
424
		Validator::assertInt('api_code', $api_code);
425
		if (!BaseApiCodes::isCodeValid($api_code)) {
426
			$min = BaseApiCodes::getMinCode();
427
			$max = BaseApiCodes::getMaxCode();
428
			throw new \InvalidArgumentException("API code value ({$api_code}) is out of allowed range {$min}-{$max}");
429
		}
430
431
		if (!(is_int($message_or_api_code) || is_string($message_or_api_code))) {
0 ignored issues
show
introduced by
The condition is_string($message_or_api_code) is always true.
Loading history...
432
			throw new \InvalidArgumentException(
433
				sprintf('Message must be either string or resolvable integer API code (%s given)',
434
					gettype($message_or_api_code))
435
			);
436
		}
437
438
		// we got code, not message string, so we need to check if we have the mapping for
439
		// this string already configured.
440
		if (is_int($message_or_api_code)) {
441
			$key = BaseApiCodes::getCodeMessageKey($message_or_api_code);
442
			if ($key === null) {
443
				// nope, let's get the default one instead
444
				$key = BaseApiCodes::getCodeMessageKey($success ? BaseApiCodes::OK() : BaseApiCodes::NO_ERROR_MESSAGE());
445
			}
446
447
			$lang_args = $lang_args ?? ['api_code' => $message_or_api_code];
448
			$message_or_api_code = \Lang::get($key, $lang_args);
449
		}
450
451
		return Response::json(
452
			static::buildResponse($success, $api_code, $message_or_api_code, $data, $debug_data),
453
			$http_code, $headers, $encoding_options
454
		);
455
	}
456
457
	/**
458
	 * Creates standardised API response array. If you set APP_DEBUG to true, 'code_hex' field will be
459
	 * additionally added to reported JSON for easier manual debugging.
460
	 *
461
	 * @param boolean           $success    @true if response indicates success, @false otherwise
462
	 * @param integer           $api_code   response code
463
	 * @param string            $message    message to return
464
	 * @param object|array|null $data       API response data if any
465
	 * @param array|null        $debug_data optional debug data array to be added to returned JSON.
466
	 *
467
	 * @return array response ready to be encoded as json and sent back to client
468
	 *
469
	 * @throws \RuntimeException in case of missing or invalid "classes" mapping configuration
470
	 */
471
	protected static function buildResponse(bool $success, int $api_code, string $message, $data = null,
472
	                                        array $debug_data = null): array
473
	{
474
		// ensure $data is either @null, array or object of class with configured mapping.
475
		if ($data !== null) {
476
			if (!is_array($data) && !is_object($data)) {
477
				throw new \InvalidArgumentException(
478
					sprintf('Invalid payload data. Must be null, array or class with mapping ("%s" given).', gettype($data)));
479
			}
480
481
			if (is_object($data) && !static::hasClassesMapping($data)) {
482
				throw new \InvalidArgumentException(sprintf('No mapping configured for "%s" class.', get_class($data)));
483
			}
484
485
			// Preliminary validation passed. Let's walk and convert...
486
			// we can do some auto-conversion on known class types, so check for that first
487
			/** @var array $classes */
488
			$classes = static::getClassesMapping();
489
			if (($classes !== null) && (count($classes) > 0)) {
490
				if (is_array($data)) {
491
					static::convert($classes, $data);
492
				} elseif (is_object($data)) {
493
					$obj_class_name = get_class($data);
494
					if (array_key_exists($obj_class_name, $classes)) {
495
						$conversion_method = $classes[ $obj_class_name ][ static::KEY_METHOD ];
496
						$data = [$classes[ $obj_class_name ][ static::KEY_KEY ] => $data->$conversion_method()];
497
					}
498
				}
499
			}
500
		}
501
502
		if ($data !== null && !is_object($data)) {
503
			// ensure we get object in final JSON structure in data node
504
			$data = (object)$data;
505
		}
506
507
		/** @noinspection PhpUndefinedClassInspection */
508
		$response = [
509
			static::KEY_SUCCESS => $success,
510
			static::KEY_CODE    => $api_code,
511
			static::KEY_LOCALE  => \App::getLocale(),
512
			static::KEY_MESSAGE => $message,
513
			static::KEY_DATA    => $data,
514
		];
515
516
		if ($debug_data !== null) {
517
			$debug_key = Config::get(static::CONF_KEY_DEBUG_DEBUG_KEY, self::KEY_DEBUG);
518
			$response[ $debug_key ] = $debug_data;
519
		}
520
521
		return $response;
522
	}
523
524
525
}
526