Completed
Pull Request — master (#1036)
by Hector
03:13
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
namespace ApiPlatform\Core\Serializer;
13
14
use ApiPlatform\Core\Api\IriConverterInterface;
15
use ApiPlatform\Core\Api\ResourceClassResolverInterface;
16
use ApiPlatform\Core\Exception\InvalidArgumentException;
17
use ApiPlatform\Core\Exception\ItemNotFoundException;
18
use ApiPlatform\Core\Exception\RuntimeException;
19
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
20
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
21
use ApiPlatform\Core\Metadata\Property\PropertyMetadata;
22
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
23
use Symfony\Component\PropertyAccess\PropertyAccess;
24
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
25
use Symfony\Component\PropertyInfo\Type;
26
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
27
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
28
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
29
30
/**
31
 * Base item normalizer.
32
 *
33
 * @author Kévin Dunglas <[email protected]>
34
 */
35
abstract class AbstractItemNormalizer extends AbstractObjectNormalizer
36
{
37
    use ContextTrait;
38
39
    protected $propertyNameCollectionFactory;
40
    protected $propertyMetadataFactory;
41
    protected $iriConverter;
42
    protected $resourceClassResolver;
43
    protected $propertyAccessor;
44
45
    public function __construct(
46
        PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory,
47
        PropertyMetadataFactoryInterface $propertyMetadataFactory,
48
        IriConverterInterface $iriConverter,
49
        ResourceClassResolverInterface $resourceClassResolver,
50
        PropertyAccessorInterface $propertyAccessor = null,
51
        NameConverterInterface $nameConverter = null,
52
        ClassMetadataFactoryInterface $classMetadataFactory = null
53
    ) {
54
        parent::__construct($classMetadataFactory, $nameConverter);
55
56
        $this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
57
        $this->propertyMetadataFactory = $propertyMetadataFactory;
58
        $this->iriConverter = $iriConverter;
59
        $this->resourceClassResolver = $resourceClassResolver;
60
        $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor();
61
62
        $this->setCircularReferenceHandler(function ($object) {
63
            return $this->iriConverter->getIriFromItem($object);
64
        });
65
    }
66
67
    /**
68
     * {@inheritdoc}
69
     */
70
    public function supportsNormalization($data, $format = null)
71
    {
72
        if (!is_object($data)) {
73
            return false;
74
        }
75
76
        try {
77
            $this->resourceClassResolver->getResourceClass($data);
78
        } catch (InvalidArgumentException $e) {
79
            return false;
80
        }
81
82
        return true;
83
    }
84
85
    /**
86
     * {@inheritdoc}
87
     */
88
    public function normalize($object, $format = null, array $context = [])
89
    {
90
        $resourceClass = $this->resourceClassResolver->getResourceClass(
91
            $object,
92
            $context['resource_class'] ?? null,
93
            true
94
        );
95
        $context = $this->initContext($resourceClass, $context);
96
        $context['api_normalize'] = true;
97
98
        return parent::normalize($object, $format, $context);
99
    }
100
101
    /**
102
     * {@inheritdoc}
103
     */
104
    public function supportsDenormalization($data, $type, $format = null)
105
    {
106
        return $this->resourceClassResolver->isResourceClass($type);
107
    }
108
109
    /**
110
     * {@inheritdoc}
111
     */
112
    public function denormalize($data, $class, $format = null, array $context = [])
113
    {
114
        $context['api_denormalize'] = true;
115
        if (!isset($context['resource_class'])) {
116
            $context['resource_class'] = $class;
117
        }
118
119
        return parent::denormalize($data, $class, $format, $context);
120
    }
121
122
    /**
123
     * {@inheritdoc}
124
     *
125
     * Unused in this context.
126
     */
127
    protected function extractAttributes($object, $format = null, array $context = [])
128
    {
129
        return [];
130
    }
131
132
    /**
133
     * {@inheritdoc}
134
     */
135
    protected function getAllowedAttributes(
136
        $classOrObject,
137
        array $context,
138
        $attributesAsString = false
139
    ) {
140
        $options = $this->getFactoryOptions($context);
141
        $propertyNames = $this
142
            ->propertyNameCollectionFactory
143
            ->create($context['resource_class'], $options);
144
145
        $allowedAttributes = [];
146
        foreach ($propertyNames as $propertyName) {
147
            $propertyMetadata = $this
148
                ->propertyMetadataFactory
149
                ->create($context['resource_class'], $propertyName, $options);
150
151
            if (
152
                isset($context['api_denormalize'])
153
                    && !$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...
154
                    && !$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...
155
            ) {
156
                throw new RuntimeException(sprintf(
157
                    'Property \'%s.%s\' is not writeable',
158
                    $context['resource_class'],
159
                    $propertyName
160
                ));
161
            }
162
163
            if (
164
                (isset($context['api_normalize']) && $propertyMetadata->isReadable()) ||
165
                (isset($context['api_denormalize']) && $propertyMetadata->isWritable())
166
            ) {
167
                $allowedAttributes[] = $propertyName;
168
            }
169
        }
170
171
        return $allowedAttributes;
172
    }
173
174
    /**
175
     * {@inheritdoc}
176
     */
177
    protected function setAttributeValue(
178
        $object,
179
        $attribute,
180
        $value,
181
        $format = null,
182
        array $context = []
183
    ) {
184
        $propertyMetadata = $this->propertyMetadataFactory->create(
185
            $context['resource_class'],
186
            $attribute,
187
            $this->getFactoryOptions($context)
188
        );
189
        $type = $propertyMetadata->getType();
190
191
        if (null === $type) {
192
            // No type provided, blindly set the value
193
            $this->setValue($object, $attribute, $value);
194
195
            return;
196
        }
197
198
        if (null === $value && $type->isNullable()) {
199
            $this->setValue($object, $attribute, $value);
200
201
            return;
202
        }
203
204
        if (
205
            $type->isCollection() &&
206
            null !== ($collectionValueType = $type->getCollectionValueType()) &&
207
            null !== $className = $collectionValueType->getClassName()
208
        ) {
209
            $this->setValue(
210
                $object,
211
                $attribute,
212
                $this->denormalizeCollection(
213
                    $attribute,
214
                    $propertyMetadata,
215
                    $type,
216
                    $className,
217
                    $value,
218
                    $format,
219
                    $context
220
                )
221
            );
222
223
            return;
224
        }
225
226
        if (null !== $className = $type->getClassName()) {
227
            $this->setValue(
228
                $object,
229
                $attribute,
230
                $this->denormalizeRelation(
231
                    $attribute,
232
                    $propertyMetadata,
233
                    $className,
234
                    $value,
235
                    $format,
236
                    $context
237
                )
238
            );
239
240
            return;
241
        }
242
243
        $this->validateType($attribute, $type, $value, $format);
244
        $this->setValue($object, $attribute, $value);
245
    }
246
247
    /**
248
     * Validates the type of the value. Allows using integers as floats for JSON formats.
249
     *
250
     * @param string      $attribute
251
     * @param Type        $type
252
     * @param mixed       $value
253
     * @param string|null $format
254
     *
255
     * @throws InvalidArgumentException
256
     */
257
    protected function validateType(string $attribute, Type $type, $value, string $format = null)
258
    {
259
        $builtinType = $type->getBuiltinType();
260
        if (Type::BUILTIN_TYPE_FLOAT === $builtinType && false !== strpos($format, 'json')) {
261
            $isValid = is_float($value) || is_int($value);
262
        } else {
263
            $isValid = call_user_func('is_'.$builtinType, $value);
264
        }
265
266
        if (!$isValid) {
267
            throw new InvalidArgumentException(sprintf(
268
                'The type of the "%s" attribute must be "%s", "%s" given.', $attribute, $builtinType, gettype($value)
269
            ));
270
        }
271
    }
272
273
    /**
274
     * Denormalizes a collection of objects.
275
     *
276
     * @param string           $attribute
277
     * @param PropertyMetadata $propertyMetadata
278
     * @param Type             $type
279
     * @param string           $className
280
     * @param mixed            $value
281
     * @param string|null      $format
282
     * @param array            $context
283
     *
284
     * @throws InvalidArgumentException
285
     *
286
     * @return array
287
     */
288
    private function denormalizeCollection(
289
        string $attribute,
290
        PropertyMetadata $propertyMetadata,
291
        Type $type,
292
        string $className,
293
        $value,
294
        string $format = null,
295
        array $context
296
    ): array {
297
        if (!is_array($value)) {
298
            throw new InvalidArgumentException(sprintf(
299
                'The type of the "%s" attribute must be "array", "%s" given.', $attribute, gettype($value)
300
            ));
301
        }
302
303
        $collectionKeyType = $type->getCollectionKeyType();
304
        $collectionKeyBuiltinType = null === $collectionKeyType ? null : $collectionKeyType->getBuiltinType();
305
306
        $values = [];
307
        foreach ($value as $index => $obj) {
308
            if (null !== $collectionKeyBuiltinType && !call_user_func('is_'.$collectionKeyBuiltinType, $index)) {
309
                throw new InvalidArgumentException(sprintf(
310
                        'The type of the key "%s" must be "%s", "%s" given.',
311
                        $index, $collectionKeyBuiltinType, gettype($index))
312
                );
313
            }
314
315
            $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $context);
316
        }
317
318
        return $values;
319
    }
320
321
    /**
322
     * Denormalizes a relation.
323
     *
324
     * @param string           $attributeName
325
     * @param PropertyMetadata $propertyMetadata
326
     * @param string           $className
327
     * @param mixed            $value
328
     * @param string|null      $format
329
     * @param array            $context
330
     *
331
     * @throws InvalidArgumentException
332
     *
333
     * @return object|null
334
     */
335
    protected function denormalizeRelation(
336
        string $attributeName,
337
        PropertyMetadata $propertyMetadata,
338
        string $className,
339
        $value,
340
        string $format = null,
341
        array $context
342
    ) {
343
        if (is_string($value)) {
344
            try {
345
                return $this->iriConverter->getItemFromIri($value, $context + ['fetch_data' => true]);
346
            } catch (ItemNotFoundException $e) {
347
                throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e);
348
            } catch (InvalidArgumentException $e) {
349
                // Give a chance to other normalizers (e.g.: DateTimeNormalizer)
350
            }
351
        }
352
353
        if (!$this->resourceClassResolver->isResourceClass($className) || $propertyMetadata->isWritableLink()) {
354
            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...
355
        }
356
357
        if (!is_array($value)) {
358
            throw new InvalidArgumentException(sprintf(
359
                'Expected IRI or nested document for attribute "%s", "%s" given.', $attributeName, gettype($value)
360
            ));
361
        }
362
363
        throw new InvalidArgumentException(sprintf('Nested documents for attribute "%s" are not allowed. Use IRIs instead.', $attributeName));
364
    }
365
366
    /**
367
     * Sets a value of the object using the PropertyAccess component.
368
     *
369
     * @param object $object
370
     * @param string $attributeName
371
     * @param mixed  $value
372
     */
373
    private function setValue($object, string $attributeName, $value)
374
    {
375
        try {
376
            $this->propertyAccessor->setValue($object, $attributeName, $value);
377
        } catch (NoSuchPropertyException $exception) {
378
            // Properties not found are ignored
379
        }
380
    }
381
382
    /**
383
     * Gets a valid context for property metadata factories.
384
     *
385
     * @see https://github.com/symfony/symfony/blob/master/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php
386
     *
387
     * @param array $context
388
     *
389
     * @return array
390
     */
391
    protected function getFactoryOptions(array $context): array
392
    {
393
        $options = [];
394
395
        if (isset($context['groups'])) {
396
            $options['serializer_groups'] = $context['groups'];
397
        }
398
399
        if (isset($context['collection_operation_name'])) {
400
            $options['collection_operation_name'] = $context['collection_operation_name'];
401
        }
402
403
        if (isset($context['item_operation_name'])) {
404
            $options['item_operation_name'] = $context['item_operation_name'];
405
        }
406
407
        return $options;
408
    }
409
410
    /**
411
     * Creates the context to use when serializing a relation.
412
     *
413
     * @param string $resourceClass
414
     * @param array  $context
415
     *
416
     * @return array
417
     */
418
    protected function createRelationSerializationContext(string $resourceClass, array $context): array
419
    {
420
        $context['resource_class'] = $resourceClass;
421
        unset($context['item_operation_name'], $context['collection_operation_name']);
422
423
        return $context;
424
    }
425
426
    /**
427
     * {@inheritdoc}
428
     *
429
     * @throws NoSuchPropertyException
430
     */
431
    protected function getAttributeValue($object, $attribute, $format = null, array $context = [])
432
    {
433
        $propertyMetadata = $this->propertyMetadataFactory->create(
434
            $context['resource_class'],
435
            $attribute,
436
            $this->getFactoryOptions($context)
437
        );
438
439
        try {
440
            $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
441
        } catch (NoSuchPropertyException $e) {
442
            if (null === $propertyMetadata->isChildInherited()) {
443
                throw $e;
444
            }
445
446
            $attributeValue = null;
447
        }
448
449
        $type = $propertyMetadata->getType();
450
451
        if (
452
            (is_array($attributeValue) || $attributeValue instanceof \Traversable) &&
453
            $type &&
454
            $type->isCollection() &&
455
            ($collectionValueType = $type->getCollectionValueType()) &&
456
            ($className = $collectionValueType->getClassName()) &&
457
            $this->resourceClassResolver->isResourceClass($className)
458
        ) {
459
            $value = [];
460
            foreach ($attributeValue as $index => $obj) {
461
                $value[$index] = $this->normalizeRelation($propertyMetadata, $obj, $className, $format, $context);
462
            }
463
464
            return $value;
465
        }
466
467
        if (
468
            $attributeValue &&
469
            $type &&
470
            ($className = $type->getClassName()) &&
471
            $this->resourceClassResolver->isResourceClass($className)
472
        ) {
473
            return $this->normalizeRelation($propertyMetadata, $attributeValue, $className, $format, $context);
474
        }
475
476
        return $this->serializer->normalize($attributeValue, $format, $context);
477
    }
478
479
    /**
480
     * Normalizes a relation as an URI if is a Link or as a JSON-LD object.
481
     *
482
     * @param PropertyMetadata $propertyMetadata
483
     * @param mixed            $relatedObject
484
     * @param string           $resourceClass
485
     * @param string|null      $format
486
     * @param array            $context
487
     *
488
     * @return string|array
489
     */
490
    protected function normalizeRelation(PropertyMetadata $propertyMetadata, $relatedObject, string $resourceClass, string $format = null, array $context)
491
    {
492
        if ($propertyMetadata->isReadableLink()) {
493
            return $this->serializer->normalize($relatedObject, $format, $this->createRelationSerializationContext($resourceClass, $context));
494
        }
495
496
        return $this->iriConverter->getIriFromItem($relatedObject);
497
    }
498
}
499