Completed
Push — master ( 812d68...15b189 )
by Pieter
17s queued 10s
created

ApieObjectAccessNormalizer::denormalize()   F

Complexity

Conditions 15
Paths 304

Size

Total Lines 68
Code Lines 44

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 1 Features 0
Metric Value
cc 15
eloc 44
c 2
b 1
f 0
nc 304
nop 4
dl 0
loc 68
rs 3.7833

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace W2w\Lib\ApieObjectAccessNormalizer\Normalizers;
4
5
use ReflectionClass;
6
use stdClass;
7
use Symfony\Component\PropertyInfo\Type;
8
use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException;
9
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
10
use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface;
11
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
12
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
13
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
14
use Symfony\Component\Serializer\SerializerAwareInterface;
15
use Symfony\Component\Serializer\SerializerAwareTrait;
16
use Throwable;
17
use Traversable;
18
use W2w\Lib\ApieObjectAccessNormalizer\Errors\ErrorBag;
19
use W2w\Lib\ApieObjectAccessNormalizer\Exceptions\CouldNotConvertException;
20
use W2w\Lib\ApieObjectAccessNormalizer\Exceptions\ValidationException;
21
use W2w\Lib\ApieObjectAccessNormalizer\NameConverters\NullNameConverter;
22
use W2w\Lib\ApieObjectAccessNormalizer\ObjectAccess\FilteredObjectAccess;
23
use W2w\Lib\ApieObjectAccessNormalizer\ObjectAccess\ObjectAccess;
24
use W2w\Lib\ApieObjectAccessNormalizer\ObjectAccess\ObjectAccessInterface;
25
use W2w\Lib\ApieObjectAccessNormalizer\Utils;
26
27
/**
28
 * Normalizes any classes to arrays and viceversa using a class implementing ObjectAccessInterface.
29
 */
30
class ApieObjectAccessNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface
31
{
32
    use SerializerAwareTrait;
33
34
    /**
35
     * @var ObjectAccessInterface
36
     */
37
    private $objectAccess;
38
39
    /**
40
     * @var NameConverterInterface|AdvancedNameConverterInterface
41
     */
42
    private $nameConverter;
43
44
    /**
45
     * @var ClassMetadataFactoryInterface|null
46
     */
47
    private $classMetadataFactory;
48
49
    public function __construct(
50
        ObjectAccessInterface $objectAccess = null,
51
        NameConverterInterface $nameConverter = null,
52
        ClassMetadataFactoryInterface $classMetadataFactory = null
53
    ) {
54
        $this->objectAccess = $objectAccess ?? new ObjectAccess();
55
        $this->nameConverter = $nameConverter ?? new NullNameConverter();
56
        $this->classMetadataFactory = $classMetadataFactory;
57
    }
58
59
    /**
60
     * {@inheritDoc}
61
     */
62
    public function denormalize($data, $type, $format = null, array $context = [])
63
    {
64
        if ($data instanceof stdClass) {
65
            $data = json_decode(json_encode($data), true);
66
        }
67
68
        // initialize context.
69
        $context = $this->sanitizeContext($context);
70
        if (empty($context['object_to_populate'])) {
71
            $object = $this->instantiate($data, $type, $context['object_access'], $format, $context);
72
            // skip setters that were already set in the constructor (and allows a different type then the setter)
73
            foreach (array_keys($context['object_access']->getConstructorArguments(new ReflectionClass($type))) as $skippedField) {
74
                unset($data[$skippedField]);
75
            }
76
        } else {
77
            $object = $context['object_to_populate'];
78
        }
79
        $context['object_hierarchy'][] = $object;
80
        /** @var ObjectAccessInterface $objectAccess */
81
        $objectAccess = $context['object_access'];
82
        if ($this->classMetadataFactory && isset($context['groups'])) {
83
            $objectAccess = $this->filterObjectAccess($objectAccess, $type, $context['groups']);
84
        }
85
        $reflClass = new ReflectionClass($object);
86
        $setterFields = $objectAccess->getSetterFields($reflClass);
87
        $errors = new ErrorBag($context['key_prefix']);
88
        // iterate over all fields that can be set and try to call them.
89
        foreach ($setterFields as $denormalizedFieldName) {
90
            try {
91
                $fieldName = $this->nameConverter->normalize($denormalizedFieldName, $type, $format, $context);
0 ignored issues
show
Unused Code introduced by
The call to Symfony\Component\Serial...rInterface::normalize() has too many arguments starting with $type. ( Ignorable by Annotation )

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

91
                /** @scrutinizer ignore-call */ 
92
                $fieldName = $this->nameConverter->normalize($denormalizedFieldName, $type, $format, $context);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
92
            } catch (Throwable $throwable) {
93
                // this means the actual field name can not be normalized, so is this a validation error or an internal error?
94
                $errors->addThrowable($denormalizedFieldName, $throwable);
95
                continue;
96
            }
97
            // actual field does not exist in the $data, so we do not need to call it.
98
            if (!array_key_exists($fieldName, $data)) {
99
                continue;
100
            }
101
            $succeeded = false;
102
            $foundErrors = [];
103
            // try all setters and see if we can call it.
104
            foreach ($objectAccess->getSetterTypes($reflClass, $denormalizedFieldName) as $getterType) {
105
                try {
106
                    $result = $this->denormalizeType($data, $denormalizedFieldName, $fieldName, $getterType, $format, $context);
107
                    $objectAccess->setValue($object, $denormalizedFieldName, $result);
108
                    $succeeded = true;
109
                } catch (Throwable $throwable) {
110
                    $foundErrors[] = $throwable;
111
                }
112
            }
113
            if (!$succeeded) {
114
                if ($foundErrors) {
115
                    $errors->addThrowable($denormalizedFieldName, reset($foundErrors));
116
                } else {
117
                    // if no typehints exist we end up here.
118
                    try {
119
                        $objectAccess->setValue($object, $denormalizedFieldName, $data[$fieldName]);
120
                    } catch (Throwable $throwable) {
121
                        $errors->addThrowable($denormalizedFieldName, $throwable);
122
                    }
123
                }
124
            }
125
        }
126
        if ($errors->hasErrors()) {
127
            throw new ValidationException($errors);
128
        }
129
        return $object;
130
    }
131
132
    /**
133
     * Try to convert a field value to the wanted Type.
134
     *
135
     * @internal
136
     *
137
     * @param array $data
138
     * @param string $denormalizedFieldName
139
     * @param string $fieldName
140
     * @param Type $type
141
     * @param string|null $format
142
     * @param array $context
143
     * @return array|bool|float|int|string|null
144
     */
145
    public function denormalizeType(array $data, string $denormalizedFieldName, string $fieldName, Type $type, ?string $format = null, array $context = [])
146
    {
147
        if (null === ($data[$fieldName] ?? null) && $type->isNullable()) {
148
            return null;
149
        }
150
        switch ($type->getBuiltinType()) {
151
            case Type::BUILTIN_TYPE_INT:
152
                return Utils::toInt($data[$fieldName]);
153
            case Type::BUILTIN_TYPE_FLOAT:
154
                return Utils::toFloat($data[$fieldName]);
155
            case Type::BUILTIN_TYPE_STRING:
156
                return Utils::toString($data[$fieldName]);
157
            case Type::BUILTIN_TYPE_BOOL:
158
                return Utils::toBool($data[$fieldName]);
159
            case Type::BUILTIN_TYPE_OBJECT:
160
                $newContext = $context;
161
                unset($newContext['object_to_populate']);
162
                $newContext['key_prefix'] = $context['key_prefix'] ? ($context['key_prefix'] . '.' . $denormalizedFieldName) : $denormalizedFieldName;
163
                $newContext['collection_resource'] = $type->getCollectionValueType() ? $type->getCollectionValueType()->getClassName() : null;
164
                return $this->serializer->denormalize(
165
                    $data[$fieldName],
166
                    $type->getClassName() ?? 'stdClass',
167
                    $format,
168
                    $newContext
169
                );
170
            case Type::BUILTIN_TYPE_ARRAY:
171
                $subType = $type->getCollectionValueType();
172
                if ($subType && $subType->getClassName()) {
173
                    $newContext = $context;
174
                    unset($newContext['object_to_populate']);
175
                    $newContext['key_prefix'] = $context['key_prefix'] ? ($context['key_prefix'] . '.' . $denormalizedFieldName) : $denormalizedFieldName;
176
                    $newContext['collection_resource'] = $type->getCollectionValueType() ? $type->getCollectionValueType()->getClassName() : null;
177
                    return $this->serializer->denormalize(
178
                        $data[$fieldName],
179
                        $subType->getClassName() . '[]',
180
                        $format,
181
                        $newContext
182
                    );
183
                }
184
                return (array) $data[$fieldName];
185
            default:
186
                throw new CouldNotConvertException('int, float, string, bool, object, array', $type->getBuiltinType());
187
        }
188
    }
189
190
    /**
191
     * Try to get create a new instance of this class from the input $data we retrieve.
192
     *
193
     * @param array $data
194
     * @param string $type
195
     * @param ObjectAccessInterface $objectAccess
196
     * @param string|null $format
197
     * @param array $context
198
     * @return object
199
     */
200
    private function instantiate(array $data, string $type, ObjectAccessInterface $objectAccess, ?string $format = null, array $context = [])
201
    {
202
        $reflectionClass = new ReflectionClass($type);
203
        $argumentTypes = $objectAccess->getConstructorArguments($reflectionClass);
204
        $errors = new ErrorBag($context['key_prefix']);
205
        $parsedArguments = [];
206
        foreach ($argumentTypes as $denormalizedFieldName => $argumentType) {
207
            try {
208
                $fieldName = $this->nameConverter->normalize($denormalizedFieldName, $type, $format, $context);
0 ignored issues
show
Unused Code introduced by
The call to Symfony\Component\Serial...rInterface::normalize() has too many arguments starting with $type. ( Ignorable by Annotation )

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

208
                /** @scrutinizer ignore-call */ 
209
                $fieldName = $this->nameConverter->normalize($denormalizedFieldName, $type, $format, $context);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
209
                if (!array_key_exists($fieldName, $data)) {
210
                    $constructor = $reflectionClass->getConstructor();
211
                    foreach ($constructor->getParameters() as $parameter) {
212
                        if ($parameter->name === $denormalizedFieldName && $parameter->isDefaultValueAvailable()) {
213
                            $parsedArguments[] = $parameter->getDefaultValue();
214
                            continue(2);
215
                        }
216
                    }
217
                    throw new MissingConstructorArgumentsException($fieldName . ' is required');
218
                }
219
                if ($argumentType) {
220
                    $parsedArguments[] = $this->denormalizeType($data, $denormalizedFieldName, $fieldName, $argumentType, $format, $context);
221
                } else {
222
                    $parsedArguments[] = $data[$fieldName];
223
224
                }
225
            } catch (Throwable $throwable) {
226
                $errors->addThrowable($denormalizedFieldName, $throwable);
227
            }
228
        }
229
        if ($errors->hasErrors()) {
230
            throw new ValidationException($errors);
231
        }
232
        return $objectAccess->instantiate($reflectionClass, $parsedArguments);
233
    }
234
235
    /**
236
     * {@inheritDoc}
237
     */
238
    public function supportsDenormalization($data, $type, $format = null)
239
    {
240
        return (is_array($data) || $data instanceof stdClass) && class_exists($type);
241
    }
242
243
    /**
244
     * {@inheritDoc}
245
     */
246
    public function normalize($object, $format = null, array $context = [])
247
    {
248
        $context = $this->sanitizeContext($context);
249
        /** @var ObjectAccessInterface $objectAccess */
250
        $objectAccess = $context['object_access'];
251
        $reflectionClass = new ReflectionClass($object);
252
        if ($this->classMetadataFactory && isset($context['groups'])) {
253
            $objectAccess = $this->filterObjectAccess($objectAccess, $reflectionClass->name, $context['groups']);
254
        }
255
        $result = [];
256
        foreach ($objectAccess->getGetterFields($reflectionClass) as $denormalizedFieldName) {
257
            $fieldName = $this->nameConverter->normalize($denormalizedFieldName, $reflectionClass->name, $format, $context);
0 ignored issues
show
Unused Code introduced by
The call to Symfony\Component\Serial...rInterface::normalize() has too many arguments starting with $reflectionClass->name. ( Ignorable by Annotation )

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

257
            /** @scrutinizer ignore-call */ 
258
            $fieldName = $this->nameConverter->normalize($denormalizedFieldName, $reflectionClass->name, $format, $context);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
258
            $value  = $objectAccess->getValue($object, $denormalizedFieldName);
259
            // circular reference
260
            if (is_object($value) && in_array($value, $context['object_hierarchy'], true)) {
261
                continue;
262
            }
263
            $result[$fieldName] = $this->toPrimitive($value, $fieldName, $format, $context);
264
        }
265
        return $result;
266
    }
267
268
    /**
269
     * Adds FilteredObjectAccess decorator around the Object Access by reading the class metadata needed for the serializer.
270
     */
271
    private function filterObjectAccess(ObjectAccessInterface $objectAccess, string $className, array $groups): ObjectAccessInterface
272
    {
273
        $allowedAttributes = [];
274
        foreach ($this->classMetadataFactory->getMetadataFor($className)->getAttributesMetadata() as $attributeMetadata) {
0 ignored issues
show
Bug introduced by
The method getMetadataFor() does not exist on null. ( Ignorable by Annotation )

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

274
        foreach ($this->classMetadataFactory->/** @scrutinizer ignore-call */ getMetadataFor($className)->getAttributesMetadata() as $attributeMetadata) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
275
            $name = $attributeMetadata->getName();
276
277
            if (array_intersect($attributeMetadata->getGroups(), $groups)) {
278
                $allowedAttributes[] = $name;
279
            }
280
        }
281
282
        return new FilteredObjectAccess($objectAccess, $allowedAttributes);
283
    }
284
285
    /**
286
     * Try to convert any object or array to a native php type by calling the serializer again.
287
     *
288
     * @param $input
289
     * @param string $fieldName
290
     * @param string|null $format
291
     * @param array $context
292
     * @return array
293
     */
294
    private function toPrimitive($input, string $fieldName, ?string $format = null, array $context = [])
295
    {
296
        if (is_array($input)) {
297
            $result = [];
298
            foreach ($input as $key => $item) {
299
                $newContext = $context;
300
                unset($newContext['object_to_populate']);
301
                $newContext['object_hierarchy'][] = $input;
302
                $newContext['key_prefix'] .= '.' . $fieldName . '.' . $key;
303
                $result[$key] = $this->toPrimitive($item, $key, $format, $newContext);
304
            }
305
            return $result;
306
        }
307
        if (is_object($input)) {
308
            $newContext = $context;
309
            unset($newContext['object_to_populate']);
310
            $newContext['object_hierarchy'][] = $input;
311
            $newContext['key_prefix'] .= '.' . $fieldName;
312
            return $this->serializer->normalize($input, $format, $newContext);
313
        }
314
        return $input;
315
    }
316
317
    /**
318
     * {@inheritDoc}
319
     */
320
    public function supportsNormalization($data, $format = null)
321
    {
322
        return is_object($data) && !$data instanceof Traversable;
323
    }
324
325
    /**
326
     * Adds default context array values if they are missing.
327
     *
328
     * @param array $context
329
     * @return array
330
     */
331
    private function sanitizeContext(array $context): array
332
    {
333
        if (empty($context['object_access'])) {
334
            $context['object_access'] = $this->objectAccess;
335
        }
336
        if (empty($context['child_object_groups'])) {
337
            $context['child_object_groups'] = [];
338
        }
339
        if (empty($context['key_prefix'])) {
340
            $context['key_prefix'] = '';
341
        }
342
        if (empty($context['object_hierarchy'])) {
343
            $context['object_hierarchy'] = [];
344
        }
345
        return $context;
346
    }
347
}
348