ApieObjectAccessNormalizer   F
last analyzed

Complexity

Total Complexity 64

Size/Duplication

Total Lines 318
Duplicated Lines 0 %

Importance

Changes 4
Bugs 1 Features 0
Metric Value
eloc 158
c 4
b 1
f 0
dl 0
loc 318
rs 3.28
wmc 64

10 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 8 1
F denormalize() 0 70 16
A filterObjectAccess() 0 12 3
A supportsDenormalization() 0 3 3
A normalize() 0 20 6
A toPrimitive() 0 21 4
C denormalizeType() 0 42 15
B instantiate() 0 33 9
A sanitizeContext() 0 15 5
A supportsNormalization() 0 3 2

How to fix   Complexity   

Complex Class

Complex classes like ApieObjectAccessNormalizer often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use ApieObjectAccessNormalizer, and based on these observations, apply Extract Interface, too.

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
            if (empty('keep_setter_calls')) {
0 ignored issues
show
introduced by
The condition empty('keep_setter_calls') is always false.
Loading history...
74
                foreach (array_keys($context['object_access']->getConstructorArguments(new ReflectionClass($type))) as $skippedField) {
75
                    unset($data[$skippedField]);
76
                }
77
            }
78
        } else {
79
            $object = $context['object_to_populate'];
80
        }
81
        $context['object_hierarchy'][] = $object;
82
        /** @var ObjectAccessInterface $objectAccess */
83
        $objectAccess = $context['object_access'];
84
        if ($this->classMetadataFactory && isset($context['groups'])) {
85
            $objectAccess = $this->filterObjectAccess($objectAccess, $type, $context['groups']);
86
        }
87
        $reflClass = new ReflectionClass($object);
88
        $setterFields = $objectAccess->getSetterFields($reflClass);
89
        $errors = new ErrorBag($context['key_prefix']);
90
        // iterate over all fields that can be set and try to call them.
91
        foreach ($setterFields as $denormalizedFieldName) {
92
            try {
93
                $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

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

210
                /** @scrutinizer ignore-call */ 
211
                $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...
211
                if (!array_key_exists($fieldName, $data)) {
212
                    $constructor = $reflectionClass->getConstructor();
213
                    foreach ($constructor->getParameters() as $parameter) {
214
                        if ($parameter->name === $denormalizedFieldName && $parameter->isDefaultValueAvailable()) {
215
                            $parsedArguments[] = $parameter->getDefaultValue();
216
                            continue(2);
217
                        }
218
                    }
219
                    throw new MissingConstructorArgumentsException($fieldName . ' is required');
220
                }
221
                if ($argumentType) {
222
                    $parsedArguments[] = $this->denormalizeType($data, $denormalizedFieldName, $fieldName, $argumentType, $format, $context);
223
                } else {
224
                    $parsedArguments[] = $data[$fieldName];
225
226
                }
227
            } catch (Throwable $throwable) {
228
                $errors->addThrowable($denormalizedFieldName, $throwable);
229
            }
230
        }
231
        if ($errors->hasErrors()) {
232
            throw new ValidationException($errors);
233
        }
234
        return $objectAccess->instantiate($reflectionClass, $parsedArguments);
235
    }
236
237
    /**
238
     * {@inheritDoc}
239
     */
240
    public function supportsDenormalization($data, $type, $format = null)
241
    {
242
        return (is_array($data) || $data instanceof stdClass) && class_exists($type);
243
    }
244
245
    /**
246
     * {@inheritDoc}
247
     */
248
    public function normalize($object, $format = null, array $context = [])
249
    {
250
        $context = $this->sanitizeContext($context);
251
        /** @var ObjectAccessInterface $objectAccess */
252
        $objectAccess = $context['object_access'];
253
        $reflectionClass = new ReflectionClass($object);
254
        if ($this->classMetadataFactory && isset($context['groups'])) {
255
            $objectAccess = $this->filterObjectAccess($objectAccess, $reflectionClass->name, $context['groups']);
256
        }
257
        $result = [];
258
        foreach ($objectAccess->getGetterFields($reflectionClass) as $denormalizedFieldName) {
259
            $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

259
            /** @scrutinizer ignore-call */ 
260
            $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...
260
            $value  = $objectAccess->getValue($object, $denormalizedFieldName);
261
            // circular reference
262
            if (is_object($value) && in_array($value, $context['object_hierarchy'], true)) {
263
                continue;
264
            }
265
            $result[$fieldName] = $this->toPrimitive($value, $fieldName, $format, $context);
266
        }
267
        return $result;
268
    }
269
270
    /**
271
     * Adds FilteredObjectAccess decorator around the Object Access by reading the class metadata needed for the serializer.
272
     */
273
    private function filterObjectAccess(ObjectAccessInterface $objectAccess, string $className, array $groups): ObjectAccessInterface
274
    {
275
        $allowedAttributes = [];
276
        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

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