Completed
Pull Request — master (#1036)
by Hector
02:56
created

AbstractItemNormalizer::getAllowedAttributes()   D

Complexity

Conditions 9
Paths 4

Size

Total Lines 38
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 38
rs 4.909
c 0
b 0
f 0
cc 9
eloc 26
nc 4
nop 3
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\ResourceClassResolverInterface;
18
use ApiPlatform\Core\Exception\InvalidArgumentException;
19
use ApiPlatform\Core\Exception\ItemNotFoundException;
20
use ApiPlatform\Core\Exception\RuntimeException;
21
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
22
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
23
use ApiPlatform\Core\Metadata\Property\PropertyMetadata;
24
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
25
use Symfony\Component\PropertyAccess\PropertyAccess;
26
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
27
use Symfony\Component\PropertyInfo\Type;
28
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
29
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
30
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
31
32
/**
33
 * Base item normalizer.
34
 *
35
 * @author Kévin Dunglas <[email protected]>
36
 */
37
abstract class AbstractItemNormalizer extends AbstractObjectNormalizer
38
{
39
    use ContextTrait;
40
41
    protected $propertyNameCollectionFactory;
42
    protected $propertyMetadataFactory;
43
    protected $iriConverter;
44
    protected $resourceClassResolver;
45
    protected $propertyAccessor;
46
47
    public function __construct(
48
        PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory,
49
        PropertyMetadataFactoryInterface $propertyMetadataFactory,
50
        IriConverterInterface $iriConverter,
51
        ResourceClassResolverInterface $resourceClassResolver,
52
        PropertyAccessorInterface $propertyAccessor = null,
53
        NameConverterInterface $nameConverter = null,
54
        ClassMetadataFactoryInterface $classMetadataFactory = null
55
    ) {
56
        parent::__construct($classMetadataFactory, $nameConverter);
57
58
        $this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
59
        $this->propertyMetadataFactory = $propertyMetadataFactory;
60
        $this->iriConverter = $iriConverter;
61
        $this->resourceClassResolver = $resourceClassResolver;
62
        $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor();
63
64
        $this->setCircularReferenceHandler(function ($object) {
65
            return $this->iriConverter->getIriFromItem($object);
66
        });
67
    }
68
69
    /**
70
     * {@inheritdoc}
71
     */
72
    public function supportsNormalization($data, $format = null)
73
    {
74
        if (!is_object($data)) {
75
            return false;
76
        }
77
78
        try {
79
            $this->resourceClassResolver->getResourceClass($data);
80
        } catch (InvalidArgumentException $e) {
81
            return false;
82
        }
83
84
        return true;
85
    }
86
87
    /**
88
     * {@inheritdoc}
89
     */
90
    public function normalize($object, $format = null, array $context = [])
91
    {
92
        $resourceClass = $this->resourceClassResolver->getResourceClass(
93
            $object,
94
            $context['resource_class'] ?? null,
95
            true
96
        );
97
        $context = $this->initContext($resourceClass, $context);
98
        $context['api_normalize'] = true;
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->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(
138
        $classOrObject,
139
        array $context,
140
        $attributesAsString = false
141
    ) {
142
        $options = $this->getFactoryOptions($context);
143
        $propertyNames = $this
144
            ->propertyNameCollectionFactory
145
            ->create($context['resource_class'], $options);
146
147
        $allowedAttributes = [];
148
        foreach ($propertyNames as $propertyName) {
149
            $propertyMetadata = $this
150
                ->propertyMetadataFactory
151
                ->create($context['resource_class'], $propertyName, $options);
152
153
            if (
154
                isset($context['api_denormalize'])
155
                    && !$propertyMetadata->isWritable()
0 ignored issues
show
Bug Best Practice introduced by
The expression $propertyMetadata->isWritable() of type null|boolean is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
156
                    && !$propertyMetadata->isIdentifier()
0 ignored issues
show
Bug Best Practice introduced by
The expression $propertyMetadata->isIdentifier() of type null|boolean is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
157
            ) {
158
                throw new RuntimeException(sprintf(
159
                    'Property \'%s.%s\' is not writeable',
160
                    $context['resource_class'],
161
                    $propertyName
162
                ));
163
            }
164
165
            if (
166
                (isset($context['api_normalize']) && $propertyMetadata->isReadable()) ||
167
                (isset($context['api_denormalize']) && $propertyMetadata->isWritable())
168
            ) {
169
                $allowedAttributes[] = $propertyName;
170
            }
171
        }
172
173
        return $allowedAttributes;
174
    }
175
176
    /**
177
     * {@inheritdoc}
178
     */
179
    protected function setAttributeValue(
180
        $object,
181
        $attribute,
182
        $value,
183
        $format = null,
184
        array $context = []
185
    ) {
186
        $propertyMetadata = $this->propertyMetadataFactory->create(
187
            $context['resource_class'],
188
            $attribute,
189
            $this->getFactoryOptions($context)
190
        );
191
        $type = $propertyMetadata->getType();
192
193
        if (null === $type) {
194
            // No type provided, blindly set the value
195
            $this->setValue($object, $attribute, $value);
196
197
            return;
198
        }
199
200
        if (null === $value && $type->isNullable()) {
201
            $this->setValue($object, $attribute, $value);
202
203
            return;
204
        }
205
206
        if (
207
            $type->isCollection() &&
208
            null !== ($collectionValueType = $type->getCollectionValueType()) &&
209
            null !== $className = $collectionValueType->getClassName()
210
        ) {
211
            $this->setValue(
212
                $object,
213
                $attribute,
214
                $this->denormalizeCollection(
215
                    $attribute,
216
                    $propertyMetadata,
217
                    $type,
218
                    $className,
219
                    $value,
220
                    $format,
221
                    $context
222
                )
223
            );
224
225
            return;
226
        }
227
228
        if (null !== $className = $type->getClassName()) {
229
            $this->setValue(
230
                $object,
231
                $attribute,
232
                $this->denormalizeRelation(
233
                    $attribute,
234
                    $propertyMetadata,
235
                    $className,
236
                    $value,
237
                    $format,
238
                    $context
239
                )
240
            );
241
242
            return;
243
        }
244
245
        $this->validateType($attribute, $type, $value, $format);
246
        $this->setValue($object, $attribute, $value);
247
    }
248
249
    /**
250
     * Validates the type of the value. Allows using integers as floats for JSON formats.
251
     *
252
     * @param string      $attribute
253
     * @param Type        $type
254
     * @param mixed       $value
255
     * @param string|null $format
256
     *
257
     * @throws InvalidArgumentException
258
     */
259
    protected function validateType(string $attribute, Type $type, $value, string $format = null)
260
    {
261
        $builtinType = $type->getBuiltinType();
262
        if (Type::BUILTIN_TYPE_FLOAT === $builtinType && null !== $format && false !== strpos($format, 'json')) {
263
            $isValid = is_float($value) || is_int($value);
264
        } else {
265
            $isValid = call_user_func('is_'.$builtinType, $value);
266
        }
267
268
        if (!$isValid) {
269
            throw new InvalidArgumentException(sprintf(
270
                'The type of the "%s" attribute must be "%s", "%s" given.', $attribute, $builtinType, gettype($value)
271
            ));
272
        }
273
    }
274
275
    /**
276
     * Denormalizes a collection of objects.
277
     *
278
     * @param string           $attribute
279
     * @param PropertyMetadata $propertyMetadata
280
     * @param Type             $type
281
     * @param string           $className
282
     * @param mixed            $value
283
     * @param string|null      $format
284
     * @param array            $context
285
     *
286
     * @throws InvalidArgumentException
287
     *
288
     * @return array
289
     */
290
    private function denormalizeCollection(
291
        string $attribute,
292
        PropertyMetadata $propertyMetadata,
293
        Type $type,
294
        string $className,
295
        $value,
296
        string $format = null,
297
        array $context
298
    ): array {
299
        if (!is_array($value)) {
300
            throw new InvalidArgumentException(sprintf(
301
                'The type of the "%s" attribute must be "array", "%s" given.', $attribute, gettype($value)
302
            ));
303
        }
304
305
        $collectionKeyType = $type->getCollectionKeyType();
306
        $collectionKeyBuiltinType = null === $collectionKeyType ? null : $collectionKeyType->getBuiltinType();
307
308
        $values = [];
309
        foreach ($value as $index => $obj) {
310
            if (null !== $collectionKeyBuiltinType && !call_user_func('is_'.$collectionKeyBuiltinType, $index)) {
311
                throw new InvalidArgumentException(sprintf(
312
                        'The type of the key "%s" must be "%s", "%s" given.',
313
                        $index, $collectionKeyBuiltinType, gettype($index))
314
                );
315
            }
316
317
            $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $context);
318
        }
319
320
        return $values;
321
    }
322
323
    /**
324
     * Denormalizes a relation.
325
     *
326
     * @param string           $attributeName
327
     * @param PropertyMetadata $propertyMetadata
328
     * @param string           $className
329
     * @param mixed            $value
330
     * @param string|null      $format
331
     * @param array            $context
332
     *
333
     * @throws InvalidArgumentException
334
     *
335
     * @return object|null
336
     */
337
    protected function denormalizeRelation(
338
        string $attributeName,
339
        PropertyMetadata $propertyMetadata,
340
        string $className,
341
        $value,
342
        string $format = null,
343
        array $context
344
    ) {
345
        if (is_string($value)) {
346
            try {
347
                return $this->iriConverter->getItemFromIri($value, $context + ['fetch_data' => true]);
348
            } catch (ItemNotFoundException $e) {
349
                throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e);
350
            } catch (InvalidArgumentException $e) {
351
                // Give a chance to other normalizers (e.g.: DateTimeNormalizer)
352
            }
353
        }
354
355
        if (!$this->resourceClassResolver->isResourceClass($className) || $propertyMetadata->isWritableLink()) {
356
            return $this->serializer->denormalize($value, $className, $format, $this->createRelationSerializationContext($className, $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...
357
        }
358
359
        if (!is_array($value)) {
360
            throw new InvalidArgumentException(sprintf(
361
                'Expected IRI or nested document for attribute "%s", "%s" given.', $attributeName, gettype($value)
362
            ));
363
        }
364
365
        throw new InvalidArgumentException(sprintf('Nested documents for attribute "%s" are not allowed. Use IRIs instead.', $attributeName));
366
    }
367
368
    /**
369
     * Sets a value of the object using the PropertyAccess component.
370
     *
371
     * @param object $object
372
     * @param string $attributeName
373
     * @param mixed  $value
374
     */
375
    private function setValue($object, string $attributeName, $value)
376
    {
377
        try {
378
            $this->propertyAccessor->setValue($object, $attributeName, $value);
379
        } catch (NoSuchPropertyException $exception) {
380
            // Properties not found are ignored
381
        }
382
    }
383
384
    /**
385
     * Gets a valid context for property metadata factories.
386
     *
387
     * @see https://github.com/symfony/symfony/blob/master/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php
388
     *
389
     * @param array $context
390
     *
391
     * @return array
392
     */
393
    protected function getFactoryOptions(array $context): array
394
    {
395
        $options = [];
396
397
        if (isset($context['groups'])) {
398
            $options['serializer_groups'] = $context['groups'];
399
        }
400
401
        if (isset($context['collection_operation_name'])) {
402
            $options['collection_operation_name'] = $context['collection_operation_name'];
403
        }
404
405
        if (isset($context['item_operation_name'])) {
406
            $options['item_operation_name'] = $context['item_operation_name'];
407
        }
408
409
        return $options;
410
    }
411
412
    /**
413
     * Creates the context to use when serializing a relation.
414
     *
415
     * @param string $resourceClass
416
     * @param array  $context
417
     *
418
     * @return array
419
     */
420
    protected function createRelationSerializationContext(string $resourceClass, array $context): array
421
    {
422
        $context['resource_class'] = $resourceClass;
423
        unset($context['item_operation_name'], $context['collection_operation_name']);
424
425
        return $context;
426
    }
427
428
    /**
429
     * {@inheritdoc}
430
     *
431
     * @throws NoSuchPropertyException
432
     */
433
    protected function getAttributeValue($object, $attribute, $format = null, array $context = [])
434
    {
435
        $propertyMetadata = $this->propertyMetadataFactory->create(
436
            $context['resource_class'],
437
            $attribute,
438
            $this->getFactoryOptions($context)
439
        );
440
441
        try {
442
            $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
443
        } catch (NoSuchPropertyException $e) {
444
            if (null === $propertyMetadata->isChildInherited()) {
445
                throw $e;
446
            }
447
448
            $attributeValue = null;
449
        }
450
451
        $type = $propertyMetadata->getType();
452
453
        if (
454
            (is_array($attributeValue) || $attributeValue instanceof \Traversable) &&
455
            $type &&
456
            $type->isCollection() &&
457
            ($collectionValueType = $type->getCollectionValueType()) &&
458
            ($className = $collectionValueType->getClassName()) &&
459
            $this->resourceClassResolver->isResourceClass($className)
460
        ) {
461
            $value = [];
462
            foreach ($attributeValue as $index => $obj) {
463
                $value[$index] = $this->normalizeRelation($propertyMetadata, $obj, $className, $format, $context);
464
            }
465
466
            return $value;
467
        }
468
469
        if (
470
            $attributeValue &&
471
            $type &&
472
            ($className = $type->getClassName()) &&
473
            $this->resourceClassResolver->isResourceClass($className)
474
        ) {
475
            return $this->normalizeRelation($propertyMetadata, $attributeValue, $className, $format, $context);
476
        }
477
478
        return $this->serializer->normalize($attributeValue, $format, $context);
479
    }
480
481
    /**
482
     * Normalizes a relation as an URI if is a Link or as a JSON-LD object.
483
     *
484
     * @param PropertyMetadata $propertyMetadata
485
     * @param mixed            $relatedObject
486
     * @param string           $resourceClass
487
     * @param string|null      $format
488
     * @param array            $context
489
     *
490
     * @return string|array
491
     */
492
    protected function normalizeRelation(PropertyMetadata $propertyMetadata, $relatedObject, string $resourceClass, string $format = null, array $context)
493
    {
494
        if ($propertyMetadata->isReadableLink()) {
495
            return $this->serializer->normalize($relatedObject, $format, $this->createRelationSerializationContext($resourceClass, $context));
496
        }
497
498
        return $this->iriConverter->getIriFromItem($relatedObject);
499
    }
500
}
501