supportsDenormalization()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 3
eloc 1
c 1
b 0
f 1
nc 3
nop 3
dl 0
loc 3
rs 10
1
<?php
2
3
namespace Apie\ObjectAccessNormalizer\Normalizers;
4
5
use Apie\ObjectAccessNormalizer\Errors\ErrorBag;
6
use Apie\ObjectAccessNormalizer\Exceptions\CouldNotConvertException;
7
use Apie\ObjectAccessNormalizer\Exceptions\ValidationException;
8
use Apie\ObjectAccessNormalizer\NameConverters\NullNameConverter;
9
use Apie\ObjectAccessNormalizer\ObjectAccess\FilteredObjectAccess;
10
use Apie\ObjectAccessNormalizer\ObjectAccess\ObjectAccess;
11
use Apie\ObjectAccessNormalizer\ObjectAccess\ObjectAccessInterface;
12
use Apie\ObjectAccessNormalizer\Utils;
13
use ReflectionClass;
14
use stdClass;
15
use Symfony\Component\PropertyInfo\Type;
16
use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException;
17
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
18
use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface;
19
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
20
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
21
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
22
use Symfony\Component\Serializer\SerializerAwareInterface;
23
use Symfony\Component\Serializer\SerializerAwareTrait;
24
use Throwable;
25
use Traversable;
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
            } catch (Throwable $throwable) {
227
                $errors->addThrowable($denormalizedFieldName, $throwable);
228
            }
229
        }
230
        if ($errors->hasErrors()) {
231
            throw new ValidationException($errors);
232
        }
233
        return $objectAccess->instantiate($reflectionClass, $parsedArguments);
234
    }
235
236
    /**
237
     * {@inheritDoc}
238
     */
239
    public function supportsDenormalization($data, $type, $format = null)
240
    {
241
        return (is_array($data) || $data instanceof stdClass) && class_exists($type);
242
    }
243
244
    /**
245
     * {@inheritDoc}
246
     */
247
    public function normalize($object, $format = null, array $context = [])
248
    {
249
        $context = $this->sanitizeContext($context);
250
        /** @var ObjectAccessInterface $objectAccess */
251
        $objectAccess = $context['object_access'];
252
        $reflectionClass = new ReflectionClass($object);
253
        if ($this->classMetadataFactory && isset($context['groups'])) {
254
            $objectAccess = $this->filterObjectAccess($objectAccess, $reflectionClass->name, $context['groups']);
255
        }
256
        $result = [];
257
        foreach ($objectAccess->getGetterFields($reflectionClass) as $denormalizedFieldName) {
258
            $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

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

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