Passed
Push — master ( b678cc...5a9a7b )
by Pieter
03:14
created

ApieObjectAccessNormalizer   D

Complexity

Total Complexity 58

Size/Duplication

Total Lines 300
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 145
c 1
b 0
f 0
dl 0
loc 300
rs 4.5599
wmc 58

10 Methods

Rating   Name   Duplication   Size   Complexity  
A filterObjectAccess() 0 12 3
A supportsDenormalization() 0 3 3
A normalize() 0 15 4
A toPrimitive() 0 19 4
A __construct() 0 8 1
A supportsNormalization() 0 3 2
D denormalize() 0 63 14
C denormalizeType() 0 38 13
B instantiate() 0 33 9
A sanitizeContext() 0 15 5

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
        } else {
73
            $object = $context['object_to_populate'];
74
        }
75
        /** @var ObjectAccessInterface $objectAccess */
76
        $objectAccess = $context['object_access'];
77
        if ($this->classMetadataFactory && isset($context['groups'])) {
78
            $objectAccess = $this->filterObjectAccess($objectAccess, $type, $context['groups']);
79
        }
80
        $reflClass = new ReflectionClass($object);
81
        $setterFields = $objectAccess->getSetterFields($reflClass);
82
        $errors = new ErrorBag($context['key_prefix']);
83
        // iterate over all fields that can be set and try to call them.
84
        foreach ($setterFields as $denormalizedFieldName) {
85
            try {
86
                $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

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

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

248
            /** @scrutinizer ignore-call */ 
249
            $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...
249
            $result[$fieldName] = $this->toPrimitive($objectAccess->getValue($object, $denormalizedFieldName), $fieldName, $format, $context);
250
        }
251
        return $result;
252
    }
253
254
    /**
255
     * Adds FilteredObjectAccess decorator around the Object Access by reading the class metadata needed for the serializer.
256
     */
257
    private function filterObjectAccess(ObjectAccessInterface $objectAccess, string $className, array $groups): ObjectAccessInterface
258
    {
259
        $allowedAttributes = [];
260
        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

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