Completed
Push — master ( 04aca3...4b3d1a )
by
unknown
03:17
created

AbstractItemNormalizer::normalizeRelation()   D

Complexity

Conditions 9
Paths 5

Size

Total Lines 25
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 25
rs 4.909
c 0
b 0
f 0
cc 9
eloc 13
nc 5
nop 5
1
<?php
2
3
/*
4
 * This file is part of the API Platform project.
5
 *
6
 * (c) Kévin Dunglas <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
declare(strict_types=1);
13
14
namespace ApiPlatform\Core\Serializer;
15
16
use ApiPlatform\Core\Api\IriConverterInterface;
17
use ApiPlatform\Core\Api\OperationType;
18
use ApiPlatform\Core\Api\ResourceClassResolverInterface;
19
use ApiPlatform\Core\DataProvider\ItemDataProviderInterface;
20
use ApiPlatform\Core\Exception\InvalidArgumentException;
21
use ApiPlatform\Core\Exception\ItemNotFoundException;
22
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
23
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
24
use ApiPlatform\Core\Metadata\Property\PropertyMetadata;
25
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
26
use Symfony\Component\PropertyAccess\PropertyAccess;
27
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
28
use Symfony\Component\PropertyInfo\Type;
29
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
30
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
31
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
32
33
/**
34
 * Base item normalizer.
35
 *
36
 * @author Kévin Dunglas <[email protected]>
37
 */
38
abstract class AbstractItemNormalizer extends AbstractObjectNormalizer
39
{
40
    use ContextTrait;
41
42
    protected $propertyNameCollectionFactory;
43
    protected $propertyMetadataFactory;
44
    protected $iriConverter;
45
    protected $resourceClassResolver;
46
    protected $propertyAccessor;
47
    protected $localCache = [];
48
    protected $itemDataProvider;
49
    protected $allowPlainIdentifiers;
50
51
    public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, ItemDataProviderInterface $itemDataProvider = null, bool $allowPlainIdentifiers = false)
52
    {
53
        parent::__construct($classMetadataFactory, $nameConverter);
54
55
        $this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
56
        $this->propertyMetadataFactory = $propertyMetadataFactory;
57
        $this->iriConverter = $iriConverter;
58
        $this->resourceClassResolver = $resourceClassResolver;
59
        $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor();
60
        $this->itemDataProvider = $itemDataProvider;
61
        $this->allowPlainIdentifiers = $allowPlainIdentifiers;
62
63
        $this->setCircularReferenceHandler(function ($object) {
64
            return $this->iriConverter->getIriFromItem($object);
65
        });
66
    }
67
68
    /**
69
     * {@inheritdoc}
70
     */
71
    public function supportsNormalization($data, $format = null)
72
    {
73
        if (!is_object($data)) {
74
            return false;
75
        }
76
77
        try {
78
            $this->resourceClassResolver->getResourceClass($data);
79
        } catch (InvalidArgumentException $e) {
80
            return false;
81
        }
82
83
        return true;
84
    }
85
86
    /**
87
     * {@inheritdoc}
88
     */
89
    public function normalize($object, $format = null, array $context = [])
90
    {
91
        $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true);
92
        $context = $this->initContext($resourceClass, $context);
93
        $context['api_normalize'] = true;
94
95
        if (isset($context['resources'])) {
96
            $resource = $context['iri'] ?? $this->iriConverter->getIriFromItem($object);
97
            $context['resources'][$resource] = $resource;
98
        }
99
100
        return parent::normalize($object, $format, $context);
101
    }
102
103
    /**
104
     * {@inheritdoc}
105
     */
106
    public function supportsDenormalization($data, $type, $format = null)
107
    {
108
        return $this->localCache[$type] ?? $this->localCache[$type] = $this->resourceClassResolver->isResourceClass($type);
109
    }
110
111
    /**
112
     * {@inheritdoc}
113
     */
114
    public function denormalize($data, $class, $format = null, array $context = [])
115
    {
116
        $context['api_denormalize'] = true;
117
        if (!isset($context['resource_class'])) {
118
            $context['resource_class'] = $class;
119
        }
120
121
        return parent::denormalize($data, $class, $format, $context);
122
    }
123
124
    /**
125
     * {@inheritdoc}
126
     *
127
     * Unused in this context.
128
     */
129
    protected function extractAttributes($object, $format = null, array $context = [])
130
    {
131
        return [];
132
    }
133
134
    /**
135
     * {@inheritdoc}
136
     */
137
    protected function getAllowedAttributes($classOrObject, array $context, $attributesAsString = false)
138
    {
139
        $options = $this->getFactoryOptions($context);
140
        $propertyNames = $this->propertyNameCollectionFactory->create($context['resource_class'], $options);
141
142
        $allowedAttributes = [];
143
        foreach ($propertyNames as $propertyName) {
144
            $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $propertyName, $options);
145
146
            if (
147
                $this->isAllowedAttribute($classOrObject, $propertyName, null, $context) &&
148
                ((isset($context['api_normalize']) && $propertyMetadata->isReadable()) ||
149
                (isset($context['api_denormalize']) && $propertyMetadata->isWritable()))
150
            ) {
151
                $allowedAttributes[] = $propertyName;
152
            }
153
        }
154
155
        return $allowedAttributes;
156
    }
157
158
    /**
159
     * {@inheritdoc}
160
     */
161
    protected function setAttributeValue($object, $attribute, $value, $format = null, array $context = [])
162
    {
163
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context));
164
        $type = $propertyMetadata->getType();
165
166
        if (null === $type) {
167
            // No type provided, blindly set the value
168
            $this->setValue($object, $attribute, $value);
169
170
            return;
171
        }
172
173
        if (null === $value && $type->isNullable()) {
174
            $this->setValue($object, $attribute, $value);
175
176
            return;
177
        }
178
179
        if (
180
            $type->isCollection() &&
181
            null !== ($collectionValueType = $type->getCollectionValueType()) &&
182
            null !== $className = $collectionValueType->getClassName()
183
        ) {
184
            $this->setValue(
185
                $object,
186
                $attribute,
187
                $this->denormalizeCollection($attribute, $propertyMetadata, $type, $className, $value, $format, $context)
188
            );
189
190
            return;
191
        }
192
193
        if (null !== $className = $type->getClassName()) {
194
            $this->setValue(
195
                $object,
196
                $attribute,
197
                $this->denormalizeRelation($attribute, $propertyMetadata, $className, $value, $format, $this->createChildContext($context, $attribute))
198
            );
199
200
            return;
201
        }
202
203
        $this->validateType($attribute, $type, $value, $format);
204
        $this->setValue($object, $attribute, $value);
205
    }
206
207
    /**
208
     * Validates the type of the value. Allows using integers as floats for JSON formats.
209
     *
210
     * @param string      $attribute
211
     * @param Type        $type
212
     * @param mixed       $value
213
     * @param string|null $format
214
     *
215
     * @throws InvalidArgumentException
216
     */
217
    protected function validateType(string $attribute, Type $type, $value, string $format = null)
218
    {
219
        $builtinType = $type->getBuiltinType();
220
        if (Type::BUILTIN_TYPE_FLOAT === $builtinType && null !== $format && false !== strpos($format, 'json')) {
221
            $isValid = is_float($value) || is_int($value);
222
        } else {
223
            $isValid = call_user_func('is_'.$builtinType, $value);
224
        }
225
226
        if (!$isValid) {
227
            throw new InvalidArgumentException(sprintf(
228
                'The type of the "%s" attribute must be "%s", "%s" given.', $attribute, $builtinType, gettype($value)
229
            ));
230
        }
231
    }
232
233
    /**
234
     * Denormalizes a collection of objects.
235
     *
236
     * @param string           $attribute
237
     * @param PropertyMetadata $propertyMetadata
238
     * @param Type             $type
239
     * @param string           $className
240
     * @param mixed            $value
241
     * @param string|null      $format
242
     * @param array            $context
243
     *
244
     * @throws InvalidArgumentException
245
     *
246
     * @return array
247
     */
248
    protected function denormalizeCollection(string $attribute, PropertyMetadata $propertyMetadata, Type $type, string $className, $value, string $format = null, array $context): array
249
    {
250
        if (!is_array($value)) {
251
            throw new InvalidArgumentException(sprintf(
252
                'The type of the "%s" attribute must be "array", "%s" given.', $attribute, gettype($value)
253
            ));
254
        }
255
256
        $collectionKeyType = $type->getCollectionKeyType();
257
        $collectionKeyBuiltinType = null === $collectionKeyType ? null : $collectionKeyType->getBuiltinType();
258
259
        $values = [];
260
        foreach ($value as $index => $obj) {
261
            if (null !== $collectionKeyBuiltinType && !call_user_func('is_'.$collectionKeyBuiltinType, $index)) {
262
                throw new InvalidArgumentException(sprintf(
263
                        'The type of the key "%s" must be "%s", "%s" given.',
264
                        $index, $collectionKeyBuiltinType, gettype($index))
265
                );
266
            }
267
268
            $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $this->createChildContext($context, $attribute));
269
        }
270
271
        return $values;
272
    }
273
274
    /**
275
     * Denormalizes a relation.
276
     *
277
     * @param string           $attributeName
278
     * @param PropertyMetadata $propertyMetadata
279
     * @param string           $className
280
     * @param mixed            $value
281
     * @param string|null      $format
282
     * @param array            $context
283
     *
284
     * @throws InvalidArgumentException
285
     *
286
     * @return object|null
287
     */
288
    protected function denormalizeRelation(string $attributeName, PropertyMetadata $propertyMetadata, string $className, $value, string $format = null, array $context)
289
    {
290
        if (is_string($value)) {
291
            try {
292
                return $this->iriConverter->getItemFromIri($value, $context + ['fetch_data' => true]);
293
            } catch (ItemNotFoundException $e) {
294
                throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e);
295
            } catch (InvalidArgumentException $e) {
296
                // Give a chance to other normalizers (e.g.: DateTimeNormalizer)
297
            }
298
        }
299
300 View Code Duplication
        if (!$this->resourceClassResolver->isResourceClass($className) || $propertyMetadata->isWritableLink()) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
301
            $context['resource_class'] = $className;
302
303
            return $this->serializer->denormalize($value, $className, $format, $context);
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Symfony\Component\Serializer\SerializerInterface as the method denormalize() does only exist in the following implementations of said interface: Symfony\Component\Serializer\Serializer.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
304
        }
305
306
        if (!is_array($value)) {
307
            // repeat the code so that IRIs keep working with the json format
308
            if (true === $this->allowPlainIdentifiers && $this->itemDataProvider) {
309
                try {
310
                    return $this->itemDataProvider->getItem($className, $value, null, $context + ['fetch_data' => true]);
311
                } catch (ItemNotFoundException $e) {
312
                    throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e);
313
                } catch (InvalidArgumentException $e) {
314
                    // Give a chance to other normalizers (e.g.: DateTimeNormalizer)
315
                }
316
            }
317
318
            throw new InvalidArgumentException(sprintf(
319
                'Expected IRI or nested document for attribute "%s", "%s" given.', $attributeName, gettype($value)
320
            ));
321
        }
322
323
        throw new InvalidArgumentException(sprintf('Nested documents for attribute "%s" are not allowed. Use IRIs instead.', $attributeName));
324
    }
325
326
    /**
327
     * Sets a value of the object using the PropertyAccess component.
328
     *
329
     * @param object $object
330
     * @param string $attributeName
331
     * @param mixed  $value
332
     */
333
    private function setValue($object, string $attributeName, $value)
334
    {
335
        try {
336
            $this->propertyAccessor->setValue($object, $attributeName, $value);
337
        } catch (NoSuchPropertyException $exception) {
338
            // Properties not found are ignored
339
        }
340
    }
341
342
    /**
343
     * Gets a valid context for property metadata factories.
344
     *
345
     * @see https://github.com/symfony/symfony/blob/master/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php
346
     *
347
     * @param array $context
348
     *
349
     * @return array
350
     */
351
    protected function getFactoryOptions(array $context): array
352
    {
353
        $options = [];
354
355
        if (isset($context['groups'])) {
356
            $options['serializer_groups'] = $context['groups'];
357
        }
358
359
        if (isset($context['collection_operation_name'])) {
360
            $options['collection_operation_name'] = $context['collection_operation_name'];
361
        }
362
363
        if (isset($context['item_operation_name'])) {
364
            $options['item_operation_name'] = $context['item_operation_name'];
365
        }
366
367
        return $options;
368
    }
369
370
    /**
371
     * Creates the context to use when serializing a relation.
372
     *
373
     * @param string $resourceClass
374
     * @param array  $context
375
     *
376
     * @return array
377
     *
378
     * @deprecated since version 2.1, to be removed in 3.0.
379
     */
380
    protected function createRelationSerializationContext(string $resourceClass, array $context): array
0 ignored issues
show
Unused Code introduced by
The parameter $resourceClass is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
381
    {
382
        @trigger_error(sprintf('The method %s() is deprecated since 2.1 and will be removed in 3.0.', __METHOD__), E_USER_DEPRECATED);
383
384
        return $context;
385
    }
386
387
    /**
388
     * {@inheritdoc}
389
     *
390
     * @throws NoSuchPropertyException
391
     */
392
    protected function getAttributeValue($object, $attribute, $format = null, array $context = [])
393
    {
394
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context));
395
396
        try {
397
            $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
398
        } catch (NoSuchPropertyException $e) {
399
            if (null === $propertyMetadata->isChildInherited()) {
400
                throw $e;
401
            }
402
403
            $attributeValue = null;
404
        }
405
406
        $type = $propertyMetadata->getType();
407
408
        if (
409
            (is_array($attributeValue) || $attributeValue instanceof \Traversable) &&
410
            $type &&
411
            $type->isCollection() &&
412
            ($collectionValueType = $type->getCollectionValueType()) &&
413
            ($className = $collectionValueType->getClassName()) &&
414
            $this->resourceClassResolver->isResourceClass($className)
415
        ) {
416
            if (isset($context['graphql'])) {
417
                return [];
418
            }
419
420
            $value = [];
421
            foreach ($attributeValue as $index => $obj) {
422
                $value[$index] = $this->normalizeRelation($propertyMetadata, $obj, $className, $format, $this->createChildContext($context, $attribute));
423
            }
424
425
            return $value;
426
        }
427
428
        if (
429
            $type &&
430
            ($className = $type->getClassName()) &&
431
            $this->resourceClassResolver->isResourceClass($className)
432
        ) {
433
            if (isset($context['graphql'])) {
434
                return false;
435
            }
436
437
            return $this->normalizeRelation($propertyMetadata, $attributeValue, $className, $format, $this->createChildContext($context, $attribute));
438
        }
439
440
        unset($context['resource_class']);
441
442
        return $this->serializer->normalize($attributeValue, $format, $context);
443
    }
444
445
    /**
446
     * Normalizes a relation as an object if is a Link or as an URI.
447
     *
448
     * @param PropertyMetadata $propertyMetadata
449
     * @param mixed            $relatedObject
450
     * @param string           $resourceClass
451
     * @param string|null      $format
452
     * @param array            $context
453
     *
454
     * @return string|array
455
     */
456
    protected function normalizeRelation(PropertyMetadata $propertyMetadata, $relatedObject, string $resourceClass, string $format = null, array $context)
457
    {
458
        // On a subresource, we know the value of the identifiers.
459
        // If attributeValue is null, meaning that it hasn't been returned by the DataProvider, get the item Iri
460
        if (null === $relatedObject && isset($context['operation_type']) && OperationType::SUBRESOURCE === $context['operation_type'] && isset($context['subresource_resources'][$resourceClass])) {
461
            return $this->iriConverter->getItemIriFromResourceClass($resourceClass, $context['subresource_resources'][$resourceClass]);
462
        }
463
464
        if (null === $relatedObject || $propertyMetadata->isReadableLink()) {
465
            if (null === $relatedObject) {
466
                unset($context['resource_class']);
467
            } else {
468
                $context['resource_class'] = $resourceClass;
469
            }
470
471
            return $this->serializer->normalize($relatedObject, $format, $context);
472
        }
473
474
        $iri = $this->iriConverter->getIriFromItem($relatedObject);
475
        if (isset($context['resources'])) {
476
            $context['resources'][$iri] = $iri;
477
        }
478
479
        return $iri;
480
    }
481
}
482