Completed
Push — dev ( 917e74...424ba7 )
by Marcin
23s queued 11s
created

Converter::getPrimitiveMappingConfigOrThrow()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 15
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 3.0987

Importance

Changes 0
Metric Value
cc 3
eloc 8
nc 3
nop 1
dl 0
loc 15
ccs 7
cts 9
cp 0.7778
crap 3.0987
rs 10
c 0
b 0
f 0
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-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 Illuminate\Support\Facades\Log;
18
use Illuminate\Support\Facades\Config;
19
20
21
/**
22
 * Data converter
23
 */
24
class Converter
25
{
26
    /** @var array */
27
    protected $classes = [];
28
29
    /** @var array */
30
    protected $primitives = [];
31
32
    /** @var bool */
33
    protected $debug_enabled = false;
34
35
    /**
36
     * Converter constructor.
37
     *
38
     * @throws \RuntimeException
39
     */
40 43
    public function __construct()
41
    {
42 43
        $this->classes = static::getClassesMapping() ?? [];
43 42
        $this->primitives = static::getPrimitivesMapping() ?? [];
44
45 40
	    $this->debug_enabled = Config::get(ResponseBuilder::CONF_KEY_CONVERTER_DEBUG_KEY, false);
46 40
    }
47
48
    /**
49
     * Returns local copy of configuration mapping for data classes.
50
     *
51
     * @return array
52
     */
53 5
    public function getClasses(): array
54
    {
55 5
        return $this->classes;
56
    }
57
58
	/**
59
	 * Returns local copy of configuration mapping for primitives.
60
	 *
61
	 * @return array
62
	 */
63
	public function getPrimitives(): array
64
    {
65
    	return $this->primitives;
66
    }
67
68
	/**
69
	 * Returns "converter/primitives" entry for given primitive object or throws exception if no config found.
70
	 * Throws \RuntimeException if there's no config "classes" mapping entry for this object configured.
71
	 * Throws \InvalidArgumentException if No data conversion mapping configured for given class.
72
	 *
73
	 * @param boolean|string|double|array|int $data Primitive to get config for.
74
	 *
75
	 * @return array
76
	 *
77
	 * @throws \InvalidArgumentException
78
	 */
79 14
    protected function getPrimitiveMappingConfigOrThrow($data): array
80
    {
81 14
	    $result = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $result is dead and can be removed.
Loading history...
82
83 14
	    $type = \gettype($data);
84 14
	    $result = $this->primitives[ $type ] ?? null;
85 14
	    if ($result === null) {
86
		    throw new \InvalidArgumentException(sprintf('No data conversion mapping configured for "%s" primitive.', $type));
87
	    }
88
89 14
	    if ($this->debug_enabled) {
90
		    Log::debug(__CLASS__ . ": Converting primitive type of '{$type}' to data node '{$result[ResponseBuilder::KEY_KEY]}'.");
91
	    }
92
93 14
	    return $result;
94
    }
95
96
    /**
97
     * Returns "converter/map" mapping configured for given $data object class or throws exception if not found.
98
     * Throws \RuntimeException if there's no config "classes" mapping entry for this object configured.
99
     * Throws \InvalidArgumentException if No data conversion mapping configured for given class.
100
     *
101
     * @param object $data Object to get config for.
102
     *
103
     * @return array
104
     *
105
     * @throws \InvalidArgumentException
106
     */
107 16
    protected function getClassMappingConfigOrThrow(object $data): array
108
    {
109 16
        $result = null;
110 16
        $debug_result = '';
111
112
        // check for exact class name match...
113 16
        $cls = \get_class($data);
114 16
        if (\is_string($cls)) {
0 ignored issues
show
introduced by
The condition is_string($cls) is always true.
Loading history...
115 16
	        if (\array_key_exists($cls, $this->classes)) {
116 11
		        $result = $this->classes[ $cls ];
117 11
		        $debug_result = 'exact config match';
118
	        } else {
119
		        // no exact match, then lets try with `instanceof`
120 5
		        foreach (\array_keys($this->getClasses()) as $class_name) {
121 5
			        if ($data instanceof $class_name) {
122 4
				        $result = $this->classes[ $class_name ];
123 4
				        $debug_result = "subclass of {$class_name}";
124 4
				        break;
125
			        }
126
		        }
127
	        }
128
        }
129
130 16
        if ($result === null) {
131 1
            throw new \InvalidArgumentException(sprintf('No data conversion mapping configured for "%s" class.', $cls));
132
        }
133
134 15
        if ($this->debug_enabled) {
135
			Log::debug(__CLASS__ . ": Converting {$cls} using {$result[ResponseBuilder::KEY_HANDLER]} because: {$debug_result}.");
136
        }
137
138 15
	    return $result;
139
    }
140
141
	/**
142
	 * Checks if we have "classes" mapping configured given class name.
143
	 * Returns @true if there's valid config for this class.
144
	 * Throws \RuntimeException if there's no config "classes" mapping entry for this object configured.
145
	 * Throws \InvalidArgumentException if No data conversion mapping configured for given class.
146
	 *
147
	 * @param string $cls Name of the class to check mapping for.
148
	 *
149
	 * @return array
150
	 *
151
	 * @throws \InvalidArgumentException
152
	 */
153
	protected function getClassMappingConfigOrThrowByName(string $cls): array
154
	{
155
		$result = null;
156
		$debug_result = '';
157
158
		// check for exact class name match...
159
		if (\array_key_exists($cls, $this->classes)) {
160
			$result = $this->classes[ $cls ];
161
			$debug_result = 'exact config match';
162
		}
163
164
		if ($result === null) {
165
			throw new \InvalidArgumentException(sprintf('No data conversion mapping configured for "%s" class.', $cls));
166
		}
167
168
		if ($this->debug_enabled) {
169
			Log::debug(__CLASS__ . ": Converting {$cls} using {$result[ResponseBuilder::KEY_HANDLER]} because: {$debug_result}.");
170
		}
171
172
		return $result;
173
	}
174
175
    /**
176
     * Main entry for data conversion
177
     *
178
     * @param object|array|null $data
179
     *
180
     * @return mixed|null
181
     *
182
     * @throws \InvalidArgumentException
183
     */
184 40
    public function convert($data = null): ?array
185
    {
186 40
        if ($data === null) {
187 15
            return null;
188
        }
189
190 25
        $result = null;
191
192 25
	    Validator::assertIsType('data', $data, [
193 25
		    Validator::TYPE_ARRAY,
194 25
		    Validator::TYPE_BOOL,
195 25
		    Validator::TYPE_DOUBLE,
196 25
		    Validator::TYPE_INTEGER,
197 25
		    Validator::TYPE_STRING,
198 25
		    Validator::TYPE_OBJECT,
199
	    ]);
200
201 25
	    if ($result === null && \is_object($data)) {
202 11
		    $cfg = $this->getClassMappingConfigOrThrow($data);
203 10
		    $worker = new $cfg[ ResponseBuilder::KEY_HANDLER ]();
204 10
		    $result = [$cfg[ ResponseBuilder::KEY_KEY ] => $worker->convert($data, $cfg)];
205
	    }
206
207 24
	    if ($result === null && \is_array($data)) {
208 10
	        $cfg = $this->getPrimitiveMappingConfigOrThrow($data);
209
210 10
	        if ($this->hasNonNumericKeys($data)){
211 6
		        $result = $this->convertArray($data);
212
	        } else {
213 4
		        $result = [$cfg[ ResponseBuilder::KEY_KEY ] => $this->convertArray($data)];
214
	        }
215
        }
216
217 23
	    if ( \is_bool($data) || \is_float($data) || \is_int($data) || \is_string($data)) {
0 ignored issues
show
introduced by
The condition is_float($data) is always false.
Loading history...
introduced by
The condition is_int($data) is always false.
Loading history...
introduced by
The condition is_string($data) is always false.
Loading history...
218 4
		    $result = [$this->getPrimitiveMappingConfigOrThrow($data)[ ResponseBuilder::KEY_KEY ] => $data];
219
	    }
220
221 23
	    return $result;
222
    }
223
224
	/**
225
	 * Checks if given array uses custom (non numeric) keys.
226
	 *
227
	 * @param array $data
228
	 *
229
	 * @return bool
230
	 */
231 10
    protected function hasNonNumericKeys(array $data): bool
232
    {
233 10
	    foreach (\array_keys($data) as $key) {
234 10
	    	if (!\is_int($key)) {
235 6
	    		return true;
236
		    }
237
    	}
238
239 4
	    return false;
240
    }
241
242
    /**
243
     * Recursively walks $data array and converts all known objects if found. Note
244
     * $data array is passed by reference so source $data array may be modified.
245
     *
246
     * @param array $data array to recursively convert known elements of
247
     *
248
     * @return array
249
     *
250
     * @throws \RuntimeException
251
     */
252 10
    protected function convertArray(array $data): array
253
    {
254
        // This is to ensure that we either have array with user provided keys i.e. ['foo'=>'bar'], which will then
255
        // be turned into JSON object or array without user specified keys (['bar']) which we would return as JSON
256
        // array. But you can't mix these two as the final JSON would not produce predictable results.
257 10
        $string_keys_cnt = 0;
258 10
        $int_keys_cnt = 0;
259 10
        foreach ($data as $key => $val) {
260 10
            if (\is_int($key)) {
261 6
                $int_keys_cnt++;
262
            } else {
263 6
                $string_keys_cnt++;
264
            }
265
266 10
            if (($string_keys_cnt > 0) && ($int_keys_cnt > 0)) {
267 1
                throw new \RuntimeException(
268
                    'Invalid data array. Either set own keys for all the items or do not specify any keys at all. ' .
269 1
                    'Arrays with mixed keys are not supported by design.');
270
            }
271
        }
272
273 9
        foreach ($data as $key => $val) {
274 9
            if (\is_array($val)) {
275 4
                $data[ $key ] = $this->convertArray($val);
276 9
            } elseif (\is_object($val)) {
277 5
                $cfg = $this->getClassMappingConfigOrThrow($val);
278 5
                $worker = new $cfg[ ResponseBuilder::KEY_HANDLER ]();
279 5
                $converted_data = $worker->convert($val, $cfg);
280 5
                $data[ $key ] = $converted_data;
281
            }
282
        }
283
284 9
        return $data;
285
    }
286
287
    /**
288
     * Reads and validates "converter/map" config mapping
289
     *
290
     * @return array Classes mapping as specified in configuration or empty array if configuration found
291
     *
292
     * @throws \RuntimeException if config mapping is technically invalid (i.e. not array etc).
293
     */
294 47
    protected static function getClassesMapping(): array
295
    {
296 47
        $classes = Config::get(ResponseBuilder::CONF_KEY_CONVERTER_CLASSES) ?? [];
297
298 47
	    if (!\is_array($classes)) {
299 3
		    throw new \RuntimeException(
300 3
			    \sprintf('CONFIG: "%s" mapping must be an array (%s given)', ResponseBuilder::CONF_KEY_CONVERTER_CLASSES, \gettype($classes)));
301
	    }
302
303 44
	    if (!empty($classes)) {
304
		    $mandatory_keys = [
305 43
			    ResponseBuilder::KEY_HANDLER,
306
			    ResponseBuilder::KEY_KEY,
307
		    ];
308 43
		    foreach ($classes as $class_name => $class_config) {
309 43
			    if (!\is_array($class_config)) {
310
				    throw new \InvalidArgumentException(sprintf("CONFIG: Config for '{$class_name}' class must be an array (%s given).", \gettype($class_config)));
311
			    }
312 43
			    foreach ($mandatory_keys as $key_name) {
313 43
				    if (!\array_key_exists($key_name, $class_config)) {
314 1
					    throw new \RuntimeException("CONFIG: Missing '{$key_name}' for '{$class_name}' class mapping");
315
				    }
316
			    }
317
		    }
318
	    }
319
320 43
        return $classes;
321
    }
322
323
	/**
324
	 * Reads and validates "converter/primitives" config mapping
325
	 *
326
	 * @return array Primitives mapping config as specified in configuration or empty array if configuration found
327
	 *
328
	 * @throws \RuntimeException if config mapping is technically invalid (i.e. not array etc).
329
	 */
330 44
	protected static function getPrimitivesMapping(): array
331
	{
332 44
		$primitives = Config::get(ResponseBuilder::CONF_KEY_CONVERTER_PRIMITIVES) ?? [];
333
334 44
		if (!\is_array($primitives)) {
335 1
			throw new \RuntimeException(
336 1
				\sprintf('CONFIG: "%s" mapping must be an array (%s given)', ResponseBuilder::CONF_KEY_CONVERTER_PRIMITIVES, \gettype($primitives)));
337
		}
338
339 43
		if (!empty($primitives)) {
340
			$mandatory_keys = [
341 42
				ResponseBuilder::KEY_KEY,
342
			];
343
344 42
			foreach ($primitives as $type => $config) {
345 42
				if (!\is_array($config)) {
346 1
					throw new \InvalidArgumentException(sprintf("CONFIG: Config for '{$type}' primitive must be an array (%s given).", \gettype($config)));
347
				}
348 42
				foreach ($mandatory_keys as $key_name) {
349 42
					if (!\array_key_exists($key_name, $config)) {
350 1
						throw new \RuntimeException("CONFIG: Missing '{$key_name}' for '{$type}' primitive mapping");
351
					}
352
				}
353
			}
354
		}
355
356 41
		return $primitives;
357
	}
358
}
359