• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

api-platform / core / 6417369461

05 Oct 2023 09:44AM UTC coverage: 36.79% (-0.3%) from 37.131%
6417369461

push

github

web-flow
feat: improve 'not_normalizable_value_exception' (#5844)

71 of 71 new or added lines in 3 files covered. (100.0%)

10087 of 27418 relevant lines covered (36.79%)

6.67 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

53.38
/src/Serializer/AbstractItemNormalizer.php
1
<?php
2

3
/*
4
 * This file is part of the API Platform project.
5
 *
6
 * (c) Kévin Dunglas <dunglas@gmail.com>
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\Serializer;
15

16
use ApiPlatform\Exception\InvalidArgumentException;
17
use ApiPlatform\Exception\ItemNotFoundException;
18
use ApiPlatform\Metadata\ApiProperty;
19
use ApiPlatform\Metadata\CollectionOperationInterface;
20
use ApiPlatform\Metadata\IriConverterInterface;
21
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
22
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
23
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
24
use ApiPlatform\Metadata\ResourceClassResolverInterface;
25
use ApiPlatform\Metadata\UrlGeneratorInterface;
26
use ApiPlatform\Metadata\Util\ClassInfoTrait;
27
use ApiPlatform\Metadata\Util\CloneTrait;
28
use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface;
29
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
30
use Symfony\Component\PropertyAccess\PropertyAccess;
31
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
32
use Symfony\Component\PropertyInfo\Type;
33
use Symfony\Component\Serializer\Encoder\CsvEncoder;
34
use Symfony\Component\Serializer\Encoder\XmlEncoder;
35
use Symfony\Component\Serializer\Exception\LogicException;
36
use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException;
37
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
38
use Symfony\Component\Serializer\Exception\RuntimeException;
39
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
40
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
41
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
42
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
43
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
44
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
45
use Symfony\Component\Serializer\Serializer;
46

47
/**
48
 * Base item normalizer.
49
 *
50
 * @author Kévin Dunglas <dunglas@gmail.com>
51
 */
52
abstract class AbstractItemNormalizer extends AbstractObjectNormalizer
53
{
54
    use ClassInfoTrait;
55
    use CloneTrait;
56
    use ContextTrait;
57
    use InputOutputMetadataTrait;
58
    use OperationContextTrait;
59

60
    protected PropertyAccessorInterface $propertyAccessor;
61
    protected array $localCache = [];
62
    protected array $localFactoryOptionsCache = [];
63

64
    public function __construct(protected PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, protected PropertyMetadataFactoryInterface $propertyMetadataFactory, protected IriConverterInterface $iriConverter, protected ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, protected ?ResourceAccessCheckerInterface $resourceAccessChecker = null)
65
    {
66
        if (!isset($defaultContext['circular_reference_handler'])) {
126✔
67
            $defaultContext['circular_reference_handler'] = fn ($object): ?string => $this->iriConverter->getIriFromResource($object);
126✔
68
        }
69

70
        parent::__construct($classMetadataFactory, $nameConverter, null, null, \Closure::fromCallable($this->getObjectClass(...)), $defaultContext);
126✔
71
        $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor();
126✔
72
        $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
126✔
73
    }
74

75
    /**
76
     * {@inheritdoc}
77
     */
78
    public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
79
    {
80
        if (!\is_object($data) || is_iterable($data)) {
48✔
81
            return false;
24✔
82
        }
83

84
        $class = $context['force_resource_class'] ?? $this->getObjectClass($data);
30✔
85
        if (($context['output']['class'] ?? null) === $class) {
30✔
86
            return true;
3✔
87
        }
88

89
        return $this->resourceClassResolver->isResourceClass($class);
27✔
90
    }
91

92
    public function getSupportedTypes(?string $format): array
93
    {
94
        return [
57✔
95
            'object' => true,
57✔
96
        ];
57✔
97
    }
98

99
    /**
100
     * {@inheritdoc}
101
     */
102
    public function hasCacheableSupportsMethod(): bool
103
    {
104
        if (method_exists(Serializer::class, 'getSupportedTypes')) {
×
105
            trigger_deprecation(
×
106
                'api-platform/core',
×
107
                '3.1',
×
108
                'The "%s()" method is deprecated, use "getSupportedTypes()" instead.',
×
109
                __METHOD__
×
110
            );
×
111
        }
112

113
        return true;
×
114
    }
115

116
    /**
117
     * {@inheritdoc}
118
     *
119
     * @throws LogicException
120
     */
121
    public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
122
    {
123
        $resourceClass = $context['force_resource_class'] ?? $this->getObjectClass($object);
48✔
124
        if ($outputClass = $this->getOutputClass($context)) {
48✔
125
            if (!$this->serializer instanceof NormalizerInterface) {
3✔
126
                throw new LogicException('Cannot normalize the output because the injected serializer is not a normalizer');
×
127
            }
128

129
            unset($context['output'], $context['operation'], $context['operation_name']);
3✔
130
            $context['resource_class'] = $outputClass;
3✔
131
            $context['api_sub_level'] = true;
3✔
132
            $context[self::ALLOW_EXTRA_ATTRIBUTES] = false;
3✔
133

134
            return $this->serializer->normalize($object, $format, $context);
3✔
135
        }
136

137
        // Never remove this, with `application/json` we don't use our AbstractCollectionNormalizer and we need
138
        // to remove the collection operation from our context or we'll introduce security issues
139
        if (isset($context['operation']) && $context['operation'] instanceof CollectionOperationInterface) {
48✔
140
            unset($context['operation_name']);
×
141
            unset($context['operation']);
×
142
            unset($context['iri']);
×
143
        }
144

145
        if ($this->resourceClassResolver->isResourceClass($resourceClass)) {
48✔
146
            $context = $this->initContext($resourceClass, $context);
45✔
147
        }
148

149
        $context['api_normalize'] = true;
48✔
150
        $iri = $context['iri'] ??= $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL, $context['operation'] ?? null, $context);
48✔
151

152
        /*
153
         * When true, converts the normalized data array of a resource into an
154
         * IRI, if the normalized data array is empty.
155
         *
156
         * This is useful when traversing from a non-resource towards an attribute
157
         * which is a resource, as we do not have the benefit of {@see ApiProperty::isReadableLink}.
158
         *
159
         * It must not be propagated to resources, as {@see ApiProperty::isReadableLink}
160
         * should take effect.
161
         */
162
        $emptyResourceAsIri = $context['api_empty_resource_as_iri'] ?? false;
48✔
163
        unset($context['api_empty_resource_as_iri']);
48✔
164

165
        if (isset($context['resources'])) {
48✔
166
            $context['resources'][$iri] = $iri;
24✔
167
        }
168

169
        $data = parent::normalize($object, $format, $context);
48✔
170

171
        if ($emptyResourceAsIri && \is_array($data) && 0 === \count($data)) {
45✔
172
            return $iri;
×
173
        }
174

175
        return $data;
45✔
176
    }
177

178
    /**
179
     * {@inheritdoc}
180
     */
181
    public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool
182
    {
183
        if (($context['input']['class'] ?? null) === $type) {
9✔
184
            return true;
×
185
        }
186

187
        return $this->localCache[$type] ?? $this->localCache[$type] = $this->resourceClassResolver->isResourceClass($type);
9✔
188
    }
189

190
    /**
191
     * {@inheritdoc}
192
     */
193
    public function denormalize(mixed $data, string $class, string $format = null, array $context = []): mixed
194
    {
195
        $resourceClass = $class;
21✔
196

197
        if ($inputClass = $this->getInputClass($context)) {
21✔
198
            if (!$this->serializer instanceof DenormalizerInterface) {
×
199
                throw new LogicException('Cannot denormalize the input because the injected serializer is not a denormalizer');
×
200
            }
201

202
            unset($context['input'], $context['operation'], $context['operation_name']);
×
203
            $context['resource_class'] = $inputClass;
×
204

205
            try {
206
                return $this->serializer->denormalize($data, $inputClass, $format, $context);
×
207
            } catch (NotNormalizableValueException $e) {
×
208
                throw new UnexpectedValueException('The input data is misformatted.', $e->getCode(), $e);
×
209
            }
210
        }
211

212
        if (null === $objectToPopulate = $this->extractObjectToPopulate($resourceClass, $context, static::OBJECT_TO_POPULATE)) {
21✔
213
            $normalizedData = \is_scalar($data) ? [$data] : $this->prepareForDenormalization($data);
21✔
214
            $class = $this->getClassDiscriminatorResolvedClass($normalizedData, $class, $context);
21✔
215
        }
216

217
        $context['api_denormalize'] = true;
21✔
218

219
        if ($this->resourceClassResolver->isResourceClass($class)) {
21✔
220
            $resourceClass = $this->resourceClassResolver->getResourceClass($objectToPopulate, $class);
21✔
221
            $context['resource_class'] = $resourceClass;
21✔
222
        }
223

224
        if (\is_string($data)) {
21✔
225
            try {
226
                return $this->iriConverter->getResourceFromIri($data, $context + ['fetch_data' => true]);
×
227
            } catch (ItemNotFoundException $e) {
×
228
                throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
×
229
            } catch (InvalidArgumentException $e) {
×
230
                throw new UnexpectedValueException(sprintf('Invalid IRI "%s".', $data), $e->getCode(), $e);
×
231
            }
232
        }
233

234
        if (!\is_array($data)) {
21✔
235
            throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" resource must be "array" (nested document) or "string" (IRI), "%s" given.', $resourceClass, \gettype($data)), $data, [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null);
×
236
        }
237

238
        $previousObject = $this->clone($objectToPopulate);
21✔
239
        $object = parent::denormalize($data, $class, $format, $context);
21✔
240

241
        if (!$this->resourceClassResolver->isResourceClass($class)) {
12✔
242
            return $object;
×
243
        }
244

245
        // Bypass the post-denormalize attribute revert logic if the object could not be
246
        // cloned since we cannot possibly revert any changes made to it.
247
        if (null !== $objectToPopulate && null === $previousObject) {
12✔
248
            return $object;
×
249
        }
250

251
        $options = $this->getFactoryOptions($context);
12✔
252
        $propertyNames = iterator_to_array($this->propertyNameCollectionFactory->create($resourceClass, $options));
12✔
253

254
        // Revert attributes that aren't allowed to be changed after a post-denormalize check
255
        foreach (array_keys($data) as $attribute) {
12✔
256
            $attribute = $this->nameConverter ? $this->nameConverter->denormalize((string) $attribute) : $attribute;
12✔
257
            if (!\in_array($attribute, $propertyNames, true)) {
12✔
258
                continue;
×
259
            }
260

261
            if (!$this->canAccessAttributePostDenormalize($object, $previousObject, $attribute, $context)) {
12✔
262
                if (null !== $previousObject) {
×
263
                    $this->setValue($object, $attribute, $this->propertyAccessor->getValue($previousObject, $attribute));
×
264
                } else {
265
                    $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $attribute, $options);
×
266
                    $this->setValue($object, $attribute, $propertyMetadata->getDefault());
×
267
                }
268
            }
269
        }
270

271
        return $object;
12✔
272
    }
273

274
    /**
275
     * Method copy-pasted from symfony/serializer.
276
     * Remove it after symfony/serializer version update @see https://github.com/symfony/symfony/pull/28263.
277
     *
278
     * {@inheritdoc}
279
     *
280
     * @internal
281
     */
282
    protected function instantiateObject(array &$data, string $class, array &$context, \ReflectionClass $reflectionClass, array|bool $allowedAttributes, string $format = null): object
283
    {
284
        if (null !== $object = $this->extractObjectToPopulate($class, $context, static::OBJECT_TO_POPULATE)) {
21✔
285
            unset($context[static::OBJECT_TO_POPULATE]);
×
286

287
            return $object;
×
288
        }
289

290
        $class = $this->getClassDiscriminatorResolvedClass($data, $class, $context);
21✔
291
        $reflectionClass = new \ReflectionClass($class);
21✔
292

293
        $constructor = $this->getConstructor($data, $class, $context, $reflectionClass, $allowedAttributes);
21✔
294
        if ($constructor) {
21✔
295
            $constructorParameters = $constructor->getParameters();
21✔
296

297
            $params = [];
21✔
298
            foreach ($constructorParameters as $constructorParameter) {
21✔
299
                $paramName = $constructorParameter->name;
×
300
                $key = $this->nameConverter ? $this->nameConverter->normalize($paramName, $class, $format, $context) : $paramName;
×
301

302
                $allowed = false === $allowedAttributes || (\is_array($allowedAttributes) && \in_array($paramName, $allowedAttributes, true));
×
303
                $ignored = !$this->isAllowedAttribute($class, $paramName, $format, $context);
×
304
                if ($constructorParameter->isVariadic()) {
×
305
                    if ($allowed && !$ignored && (isset($data[$key]) || \array_key_exists($key, $data))) {
×
306
                        if (!\is_array($data[$paramName])) {
×
307
                            throw new RuntimeException(sprintf('Cannot create an instance of %s from serialized data because the variadic parameter %s can only accept an array.', $class, $constructorParameter->name));
×
308
                        }
309

310
                        $params[] = $data[$paramName];
×
311
                    }
312
                } elseif ($allowed && !$ignored && (isset($data[$key]) || \array_key_exists($key, $data))) {
×
313
                    $constructorContext = $context;
×
314
                    $constructorContext['deserialization_path'] = $context['deserialization_path'] ?? $key;
×
315
                    try {
316
                        $params[] = $this->createConstructorArgument($data[$key], $key, $constructorParameter, $constructorContext, $format);
×
317
                    } catch (NotNormalizableValueException $exception) {
×
318
                        if (!isset($context['not_normalizable_value_exceptions'])) {
×
319
                            throw $exception;
×
320
                        }
321
                        $context['not_normalizable_value_exceptions'][] = $exception;
×
322
                    }
323

324
                    // Don't run set for a parameter passed to the constructor
325
                    unset($data[$key]);
×
326
                } elseif (isset($context[static::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class][$key])) {
×
327
                    $params[] = $context[static::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class][$key];
×
328
                } elseif ($constructorParameter->isDefaultValueAvailable()) {
×
329
                    $params[] = $constructorParameter->getDefaultValue();
×
330
                } else {
331
                    if (!isset($context['not_normalizable_value_exceptions'])) {
×
332
                        throw new MissingConstructorArgumentsException(sprintf('Cannot create an instance of "%s" from serialized data because its constructor requires parameter "%s" to be present.', $class, $constructorParameter->name), 0, null, [$constructorParameter->name]);
×
333
                    }
334

335
                    $exception = NotNormalizableValueException::createForUnexpectedDataType(sprintf('Failed to create object because the class misses the "%s" property.', $constructorParameter->name), $data, ['unknown'], $context['deserialization_path'] ?? null, true);
×
336
                    $context['not_normalizable_value_exceptions'][] = $exception;
×
337
                }
338
            }
339

340
            if (\count($context['not_normalizable_value_exceptions'] ?? []) > 0) {
21✔
341
                return $reflectionClass->newInstanceWithoutConstructor();
×
342
            }
343

344
            if ($constructor->isConstructor()) {
21✔
345
                return $reflectionClass->newInstanceArgs($params);
21✔
346
            }
347

348
            return $constructor->invokeArgs(null, $params);
×
349
        }
350

351
        return new $class();
×
352
    }
353

354
    protected function getClassDiscriminatorResolvedClass(array $data, string $class, array $context = []): string
355
    {
356
        if (null === $this->classDiscriminatorResolver || (null === $mapping = $this->classDiscriminatorResolver->getMappingForClass($class))) {
21✔
357
            return $class;
21✔
358
        }
359

360
        if (!isset($data[$mapping->getTypeProperty()])) {
×
361
            throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Type property "%s" not found for the abstract object "%s".', $mapping->getTypeProperty(), $class), null, ['string'], isset($context['deserialization_path']) ? $context['deserialization_path'].'.'.$mapping->getTypeProperty() : $mapping->getTypeProperty());
×
362
        }
363

364
        $type = $data[$mapping->getTypeProperty()];
×
365
        if (null === ($mappedClass = $mapping->getClassForType($type))) {
×
366
            throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type "%s" is not a valid value.', $type), $type, ['string'], isset($context['deserialization_path']) ? $context['deserialization_path'].'.'.$mapping->getTypeProperty() : $mapping->getTypeProperty(), true);
×
367
        }
368

369
        return $mappedClass;
×
370
    }
371

372
    protected function createConstructorArgument($parameterData, string $key, \ReflectionParameter $constructorParameter, array &$context, string $format = null): mixed
373
    {
374
        return $this->createAndValidateAttributeValue($constructorParameter->name, $parameterData, $format, $context);
×
375
    }
376

377
    /**
378
     * {@inheritdoc}
379
     *
380
     * Unused in this context.
381
     *
382
     * @return string[]
383
     */
384
    protected function extractAttributes($object, $format = null, array $context = []): array
385
    {
386
        return [];
×
387
    }
388

389
    /**
390
     * {@inheritdoc}
391
     */
392
    protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool
393
    {
394
        if (!$this->resourceClassResolver->isResourceClass($context['resource_class'])) {
57✔
395
            return parent::getAllowedAttributes($classOrObject, $context, $attributesAsString);
3✔
396
        }
397

398
        $resourceClass = $this->resourceClassResolver->getResourceClass(null, $context['resource_class']); // fix for abstract classes and interfaces
54✔
399
        $options = $this->getFactoryOptions($context);
54✔
400
        $propertyNames = $this->propertyNameCollectionFactory->create($resourceClass, $options);
54✔
401

402
        $allowedAttributes = [];
54✔
403
        foreach ($propertyNames as $propertyName) {
54✔
404
            $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName, $options);
54✔
405

406
            if (
407
                $this->isAllowedAttribute($classOrObject, $propertyName, null, $context)
54✔
408
                && (isset($context['api_normalize']) && $propertyMetadata->isReadable()
54✔
409
                    || isset($context['api_denormalize']) && ($propertyMetadata->isWritable() || !\is_object($classOrObject) && $propertyMetadata->isInitializable())
54✔
410
                )
411
            ) {
412
                $allowedAttributes[] = $propertyName;
54✔
413
            }
414
        }
415

416
        return $allowedAttributes;
54✔
417
    }
418

419
    /**
420
     * {@inheritdoc}
421
     */
422
    protected function isAllowedAttribute(object|string $classOrObject, string $attribute, string $format = null, array $context = []): bool
423
    {
424
        if (!parent::isAllowedAttribute($classOrObject, $attribute, $format, $context)) {
57✔
425
            return false;
×
426
        }
427

428
        return $this->canAccessAttribute(\is_object($classOrObject) ? $classOrObject : null, $attribute, $context);
57✔
429
    }
430

431
    /**
432
     * Check if access to the attribute is granted.
433
     */
434
    protected function canAccessAttribute(?object $object, string $attribute, array $context = []): bool
435
    {
436
        if (!$this->resourceClassResolver->isResourceClass($context['resource_class'])) {
57✔
437
            return true;
3✔
438
        }
439

440
        $options = $this->getFactoryOptions($context);
54✔
441
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options);
54✔
442
        $security = $propertyMetadata->getSecurity();
54✔
443
        if (null !== $this->resourceAccessChecker && $security) {
54✔
444
            return $this->resourceAccessChecker->isGranted($context['resource_class'], $security, [
×
445
                'object' => $object,
×
446
            ]);
×
447
        }
448

449
        return true;
54✔
450
    }
451

452
    /**
453
     * Check if access to the attribute is granted.
454
     */
455
    protected function canAccessAttributePostDenormalize(?object $object, ?object $previousObject, string $attribute, array $context = []): bool
456
    {
457
        $options = $this->getFactoryOptions($context);
12✔
458
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options);
12✔
459
        $security = $propertyMetadata->getSecurityPostDenormalize();
12✔
460
        if ($this->resourceAccessChecker && $security) {
12✔
461
            return $this->resourceAccessChecker->isGranted($context['resource_class'], $security, [
×
462
                'object' => $object,
×
463
                'previous_object' => $previousObject,
×
464
            ]);
×
465
        }
466

467
        return true;
12✔
468
    }
469

470
    /**
471
     * {@inheritdoc}
472
     */
473
    protected function setAttributeValue(object $object, string $attribute, mixed $value, string $format = null, array $context = []): void
474
    {
475
        try {
476
            $this->setValue($object, $attribute, $this->createAttributeValue($attribute, $value, $format, $context));
21✔
477
        } catch (NotNormalizableValueException $exception) {
9✔
478
            // Only throw if collecting denormalization errors is disabled.
479
            if (!isset($context['not_normalizable_value_exceptions'])) {
6✔
480
                throw $exception;
6✔
481
            }
482
        }
483
    }
484

485
    /**
486
     * Validates the type of the value. Allows using integers as floats for JSON formats.
487
     *
488
     * @throws NotNormalizableValueException
489
     */
490
    protected function validateType(string $attribute, Type $type, mixed $value, string $format = null, array $context = []): void
491
    {
492
        $builtinType = $type->getBuiltinType();
12✔
493
        if (Type::BUILTIN_TYPE_FLOAT === $builtinType && null !== $format && str_contains($format, 'json')) {
12✔
494
            $isValid = \is_float($value) || \is_int($value);
×
495
        } else {
496
            $isValid = \call_user_func('is_'.$builtinType, $value);
12✔
497
        }
498

499
        if (!$isValid) {
12✔
500
            throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute must be "%s", "%s" given.', $attribute, $builtinType, \gettype($value)), $value, [$builtinType], $context['deserialization_path'] ?? null);
×
501
        }
502
    }
503

504
    /**
505
     * Denormalizes a collection of objects.
506
     *
507
     * @throws NotNormalizableValueException
508
     */
509
    protected function denormalizeCollection(string $attribute, ApiProperty $propertyMetadata, Type $type, string $className, mixed $value, ?string $format, array $context): array
510
    {
511
        if (!\is_array($value)) {
9✔
512
            throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute must be "array", "%s" given.', $attribute, \gettype($value)), $value, [Type::BUILTIN_TYPE_ARRAY], $context['deserialization_path'] ?? null);
3✔
513
        }
514

515
        $collectionKeyType = $type->getCollectionKeyTypes()[0] ?? null;
6✔
516
        $collectionKeyBuiltinType = $collectionKeyType?->getBuiltinType();
6✔
517
        $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
6✔
518
        $values = [];
6✔
519
        foreach ($value as $index => $obj) {
6✔
520
            if (null !== $collectionKeyBuiltinType && !\call_user_func('is_'.$collectionKeyBuiltinType, $index)) {
6✔
521
                throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the key "%s" must be "%s", "%s" given.', $index, $collectionKeyBuiltinType, \gettype($index)), $index, [$collectionKeyBuiltinType], ($context['deserialization_path'] ?? false) ? sprintf('key(%s)', $context['deserialization_path']) : null, true);
3✔
522
            }
523

524
            $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $childContext);
3✔
525
        }
526

527
        return $values;
3✔
528
    }
529

530
    /**
531
     * Denormalizes a relation.
532
     *
533
     * @throws LogicException
534
     * @throws UnexpectedValueException
535
     * @throws NotNormalizableValueException
536
     */
537
    protected function denormalizeRelation(string $attributeName, ApiProperty $propertyMetadata, string $className, mixed $value, ?string $format, array $context): ?object
538
    {
539
        if (\is_string($value)) {
×
540
            try {
541
                return $this->iriConverter->getResourceFromIri($value, $context + ['fetch_data' => true]);
×
542
            } catch (ItemNotFoundException $e) {
×
543
                if (!isset($context['not_normalizable_value_exceptions'])) {
×
544
                    throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
×
545
                }
546
                $context['not_normalizable_value_exceptions'][] = NotNormalizableValueException::createForUnexpectedDataType(
×
547
                    $e->getMessage(),
×
548
                    $value,
×
549
                    [$className],
×
550
                    $context['deserialization_path'] ?? null,
×
551
                    true,
×
552
                    $e->getCode(),
×
553
                    $e
×
554
                );
×
555

556
                return null;
×
557
            } catch (InvalidArgumentException $e) {
×
558
                if (!isset($context['not_normalizable_value_exceptions'])) {
×
559
                    throw new UnexpectedValueException(sprintf('Invalid IRI "%s".', $value), $e->getCode(), $e);
×
560
                }
561
                $context['not_normalizable_value_exceptions'][] = NotNormalizableValueException::createForUnexpectedDataType(
×
562
                    $e->getMessage(),
×
563
                    $value,
×
564
                    [$className],
×
565
                    $context['deserialization_path'] ?? null,
×
566
                    true,
×
567
                    $e->getCode(),
×
568
                    $e
×
569
                );
×
570

571
                return null;
×
572
            }
573
        }
574

575
        if ($propertyMetadata->isWritableLink()) {
×
576
            $context['api_allow_update'] = true;
×
577

578
            if (!$this->serializer instanceof DenormalizerInterface) {
×
579
                throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class));
×
580
            }
581

582
            $item = $this->serializer->denormalize($value, $className, $format, $context);
×
583
            if (!\is_object($item) && null !== $item) {
×
584
                throw new \UnexpectedValueException('Expected item to be an object or null.');
×
585
            }
586

587
            return $item;
×
588
        }
589

590
        if (!\is_array($value)) {
×
591
            throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute must be "array" (nested document) or "string" (IRI), "%s" given.', $attributeName, \gettype($value)), $value, [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true);
×
592
        }
593

594
        throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Nested documents for attribute "%s" are not allowed. Use IRIs instead.', $attributeName), $value, [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true);
×
595
    }
596

597
    /**
598
     * Gets the options for the property name collection / property metadata factories.
599
     */
600
    protected function getFactoryOptions(array $context): array
601
    {
602
        $options = [];
57✔
603
        if (isset($context[self::GROUPS])) {
57✔
604
            /* @see https://github.com/symfony/symfony/blob/v4.2.6/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php */
605
            $options['serializer_groups'] = (array) $context[self::GROUPS];
3✔
606
        }
607

608
        $operationCacheKey = ($context['resource_class'] ?? '').($context['operation_name'] ?? '').($context['api_normalize'] ?? '');
57✔
609
        if ($operationCacheKey && isset($this->localFactoryOptionsCache[$operationCacheKey])) {
57✔
610
            return $options + $this->localFactoryOptionsCache[$operationCacheKey];
57✔
611
        }
612

613
        // This is a hot spot
614
        if (isset($context['resource_class'])) {
57✔
615
            // Note that the groups need to be read on the root operation
616
            if ($operation = ($context['root_operation'] ?? null)) {
57✔
617
                $options['normalization_groups'] = $operation->getNormalizationContext()['groups'] ?? null;
6✔
618
                $options['denormalization_groups'] = $operation->getDenormalizationContext()['groups'] ?? null;
6✔
619
                $options['operation_name'] = $operation->getName();
6✔
620
            }
621
        }
622

623
        return $options + $this->localFactoryOptionsCache[$operationCacheKey] = $options;
57✔
624
    }
625

626
    /**
627
     * {@inheritdoc}
628
     *
629
     * @throws UnexpectedValueException
630
     */
631
    protected function getAttributeValue(object $object, string $attribute, string $format = null, array $context = []): mixed
632
    {
633
        $context['api_attribute'] = $attribute;
45✔
634
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context));
45✔
635

636
        if ($context['api_denormalize'] ?? false) {
45✔
637
            return $this->propertyAccessor->getValue($object, $attribute);
×
638
        }
639

640
        $types = $propertyMetadata->getBuiltinTypes() ?? [];
45✔
641

642
        foreach ($types as $type) {
45✔
643
            if (
644
                $type->isCollection()
36✔
645
                && ($collectionValueType = $type->getCollectionValueTypes()[0] ?? null)
36✔
646
                && ($className = $collectionValueType->getClassName())
36✔
647
                && $this->resourceClassResolver->isResourceClass($className)
36✔
648
            ) {
649
                $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
12✔
650

651
                // @see ApiPlatform\Hal\Serializer\ItemNormalizer:getComponents logic for intentional duplicate content
652
                // @see ApiPlatform\JsonApi\Serializer\ItemNormalizer:getComponents logic for intentional duplicate content
653
                if ('jsonld' === $format && $itemUriTemplate = $propertyMetadata->getUriTemplate()) {
12✔
654
                    $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(
×
655
                        operationName: $itemUriTemplate,
×
656
                        forceCollection: true,
×
657
                        httpOperation: true
×
658
                    );
×
659

660
                    return $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
×
661
                }
662

663
                $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
12✔
664

665
                if (!is_iterable($attributeValue)) {
12✔
666
                    throw new UnexpectedValueException('Unexpected non-iterable value for to-many relation.');
×
667
                }
668

669
                $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
12✔
670

671
                return $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
12✔
672
            }
673

674
            if (
675
                ($className = $type->getClassName())
36✔
676
                && $this->resourceClassResolver->isResourceClass($className)
36✔
677
            ) {
678
                $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
18✔
679
                unset($childContext['iri'], $childContext['uri_variables']);
18✔
680

681
                if ('jsonld' === $format && $uriTemplate = $propertyMetadata->getUriTemplate()) {
18✔
682
                    $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(
×
683
                        operationName: $uriTemplate,
×
684
                        httpOperation: true
×
685
                    );
×
686

687
                    return $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
×
688
                }
689

690
                $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
18✔
691

692
                if (!\is_object($attributeValue) && null !== $attributeValue) {
18✔
693
                    throw new UnexpectedValueException('Unexpected non-object value for to-one relation.');
×
694
                }
695

696
                $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
18✔
697

698
                return $this->normalizeRelation($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
18✔
699
            }
700

701
            if (!$this->serializer instanceof NormalizerInterface) {
36✔
702
                throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class));
×
703
            }
704

705
            unset(
36✔
706
                $context['resource_class'],
36✔
707
                $context['force_resource_class'],
36✔
708
            );
36✔
709

710
            // Anonymous resources
711
            if ($type->getClassName()) {
36✔
712
                $childContext = $this->createChildContext($this->createOperationContext($context, null), $attribute, $format);
12✔
713
                $childContext['output']['gen_id'] = $propertyMetadata->getGenId() ?? true;
12✔
714

715
                $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
12✔
716

717
                return $this->serializer->normalize($attributeValue, $format, $childContext);
12✔
718
            }
719

720
            if ('array' === $type->getBuiltinType()) {
33✔
721
                $childContext = $this->createChildContext($this->createOperationContext($context, null), $attribute, $format);
12✔
722

723
                $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
12✔
724

725
                return $this->serializer->normalize($attributeValue, $format, $childContext);
12✔
726
            }
727
        }
728

729
        if (!$this->serializer instanceof NormalizerInterface) {
42✔
730
            throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class));
×
731
        }
732

733
        unset($context['resource_class']);
42✔
734
        unset($context['force_resource_class']);
42✔
735

736
        $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
42✔
737

738
        return $this->serializer->normalize($attributeValue, $format, $context);
39✔
739
    }
740

741
    /**
742
     * Normalizes a collection of relations (to-many).
743
     *
744
     * @throws UnexpectedValueException
745
     */
746
    protected function normalizeCollectionOfRelations(ApiProperty $propertyMetadata, iterable $attributeValue, string $resourceClass, ?string $format, array $context): array
747
    {
748
        $value = [];
12✔
749
        foreach ($attributeValue as $index => $obj) {
12✔
750
            if (!\is_object($obj) && null !== $obj) {
×
751
                throw new UnexpectedValueException('Unexpected non-object element in to-many relation.');
×
752
            }
753

754
            // update context, if concrete object class deviates from general relation class (e.g. in case of polymorphic resources)
755
            $objResourceClass = $this->resourceClassResolver->getResourceClass($obj, $resourceClass);
×
756
            $context['resource_class'] = $objResourceClass;
×
757
            if ($this->resourceMetadataCollectionFactory) {
×
758
                $context['operation'] = $this->resourceMetadataCollectionFactory->create($objResourceClass)->getOperation();
×
759
            }
760

761
            $value[$index] = $this->normalizeRelation($propertyMetadata, $obj, $resourceClass, $format, $context);
×
762
        }
763

764
        return $value;
12✔
765
    }
766

767
    /**
768
     * Normalizes a relation.
769
     *
770
     * @throws LogicException
771
     * @throws UnexpectedValueException
772
     */
773
    protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $relatedObject, string $resourceClass, ?string $format, array $context): \ArrayObject|array|string|null
774
    {
775
        if (null === $relatedObject || !empty($context['attributes']) || $propertyMetadata->isReadableLink()) {
18✔
776
            if (!$this->serializer instanceof NormalizerInterface) {
12✔
777
                throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class));
×
778
            }
779

780
            $relatedContext = $this->createOperationContext($context, $resourceClass);
12✔
781
            $normalizedRelatedObject = $this->serializer->normalize($relatedObject, $format, $relatedContext);
12✔
782
            if (!\is_string($normalizedRelatedObject) && !\is_array($normalizedRelatedObject) && !$normalizedRelatedObject instanceof \ArrayObject && null !== $normalizedRelatedObject) {
12✔
783
                throw new UnexpectedValueException('Expected normalized relation to be an IRI, array, \ArrayObject or null');
×
784
            }
785

786
            return $normalizedRelatedObject;
12✔
787
        }
788

789
        $iri = $this->iriConverter->getIriFromResource($relatedObject);
6✔
790

791
        if (isset($context['resources'])) {
6✔
792
            $context['resources'][$iri] = $iri;
×
793
        }
794

795
        $push = $propertyMetadata->getPush() ?? false;
6✔
796
        if (isset($context['resources_to_push']) && $push) {
6✔
797
            $context['resources_to_push'][$iri] = $iri;
×
798
        }
799

800
        return $iri;
6✔
801
    }
802

803
    private function createAttributeValue(string $attribute, mixed $value, string $format = null, array &$context = []): mixed
804
    {
805
        try {
806
            return $this->createAndValidateAttributeValue($attribute, $value, $format, $context);
21✔
807
        } catch (NotNormalizableValueException $exception) {
9✔
808
            if (!isset($context['not_normalizable_value_exceptions'])) {
6✔
809
                throw $exception;
6✔
810
            }
811
            $context['not_normalizable_value_exceptions'][] = $exception;
×
812

813
            throw $exception;
×
814
        }
815
    }
816

817
    private function createAndValidateAttributeValue(string $attribute, mixed $value, string $format = null, array $context = []): mixed
818
    {
819
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context));
21✔
820
        $types = $propertyMetadata->getBuiltinTypes() ?? [];
21✔
821
        $isMultipleTypes = \count($types) > 1;
21✔
822

823
        foreach ($types as $type) {
21✔
824
            if (null === $value && ($type->isNullable() || ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false))) {
21✔
825
                return $value;
×
826
            }
827

828
            $collectionValueType = $type->getCollectionValueTypes()[0] ?? null;
21✔
829

830
            /* From @see AbstractObjectNormalizer::validateAndDenormalize() */
831
            // Fix a collection that contains the only one element
832
            // This is special to xml format only
833
            if ('xml' === $format && null !== $collectionValueType && (!\is_array($value) || !\is_int(key($value)))) {
21✔
834
                $value = [$value];
×
835
            }
836

837
            if (
838
                $type->isCollection()
21✔
839
                && null !== $collectionValueType
21✔
840
                && null !== ($className = $collectionValueType->getClassName())
21✔
841
                && $this->resourceClassResolver->isResourceClass($className)
21✔
842
            ) {
843
                $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className);
9✔
844
                $context['resource_class'] = $resourceClass;
9✔
845

846
                return $this->denormalizeCollection($attribute, $propertyMetadata, $type, $resourceClass, $value, $format, $context);
9✔
847
            }
848

849
            if (
850
                null !== ($className = $type->getClassName())
15✔
851
                && $this->resourceClassResolver->isResourceClass($className)
15✔
852
            ) {
853
                $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className);
6✔
854
                $childContext = $this->createChildContext($this->createOperationContext($context, $resourceClass), $attribute, $format);
6✔
855

856
                return $this->denormalizeRelation($attribute, $propertyMetadata, $resourceClass, $value, $format, $childContext);
6✔
857
            }
858

859
            if (
860
                $type->isCollection()
12✔
861
                && null !== $collectionValueType
12✔
862
                && null !== ($className = $collectionValueType->getClassName())
12✔
863
                && \is_array($value)
12✔
864
            ) {
865
                if (!$this->serializer instanceof DenormalizerInterface) {
×
866
                    throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class));
×
867
                }
868

869
                unset($context['resource_class']);
×
870

871
                return $this->serializer->denormalize($value, $className.'[]', $format, $context);
×
872
            }
873

874
            if (null !== $className = $type->getClassName()) {
12✔
875
                if (!$this->serializer instanceof DenormalizerInterface) {
×
876
                    throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class));
×
877
                }
878

879
                unset($context['resource_class']);
×
880

881
                return $this->serializer->denormalize($value, $className, $format, $context);
×
882
            }
883

884
            /* From @see AbstractObjectNormalizer::validateAndDenormalize() */
885
            // In XML and CSV all basic datatypes are represented as strings, it is e.g. not possible to determine,
886
            // if a value is meant to be a string, float, int or a boolean value from the serialized representation.
887
            // That's why we have to transform the values, if one of these non-string basic datatypes is expected.
888
            if (\is_string($value) && (XmlEncoder::FORMAT === $format || CsvEncoder::FORMAT === $format)) {
12✔
889
                if ('' === $value && $type->isNullable() && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_BOOL, Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true)) {
×
890
                    return null;
×
891
                }
892

893
                switch ($type->getBuiltinType()) {
×
894
                    case Type::BUILTIN_TYPE_BOOL:
895
                        // according to http://www.w3.org/TR/xmlschema-2/#boolean, valid representations are "false", "true", "0" and "1"
896
                        if ('false' === $value || '0' === $value) {
×
897
                            $value = false;
×
898
                        } elseif ('true' === $value || '1' === $value) {
×
899
                            $value = true;
×
900
                        } else {
901
                            // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
902
                            if ($isMultipleTypes) {
×
903
                                break 2;
×
904
                            }
905
                            throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be bool ("%s" given).', $attribute, $className, $value), $value, [Type::BUILTIN_TYPE_BOOL], $context['deserialization_path'] ?? null);
×
906
                        }
907
                        break;
×
908
                    case Type::BUILTIN_TYPE_INT:
909
                        if (ctype_digit($value) || ('-' === $value[0] && ctype_digit(substr($value, 1)))) {
×
910
                            $value = (int) $value;
×
911
                        } else {
912
                            // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
913
                            if ($isMultipleTypes) {
×
914
                                break 2;
×
915
                            }
916
                            throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be int ("%s" given).', $attribute, $className, $value), $value, [Type::BUILTIN_TYPE_INT], $context['deserialization_path'] ?? null);
×
917
                        }
918
                        break;
×
919
                    case Type::BUILTIN_TYPE_FLOAT:
920
                        if (is_numeric($value)) {
×
921
                            return (float) $value;
×
922
                        }
923

924
                        switch ($value) {
925
                            case 'NaN':
×
926
                                return \NAN;
×
927
                            case 'INF':
×
928
                                return \INF;
×
929
                            case '-INF':
×
930
                                return -\INF;
×
931
                            default:
932
                                // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
933
                                if ($isMultipleTypes) {
×
934
                                    break 3;
×
935
                                }
936
                                throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be float ("%s" given).', $attribute, $className, $value), $value, [Type::BUILTIN_TYPE_FLOAT], $context['deserialization_path'] ?? null);
×
937
                        }
938
                }
939
            }
940

941
            if ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false) {
12✔
942
                return $value;
×
943
            }
944

945
            try {
946
                $this->validateType($attribute, $type, $value, $format, $context);
12✔
947

948
                break;
12✔
949
            } catch (NotNormalizableValueException $e) {
×
950
                // union/intersect types: try the next type
951
                if (!$isMultipleTypes) {
×
952
                    throw $e;
×
953
                }
954
            }
955
        }
956

957
        return $value;
12✔
958
    }
959

960
    /**
961
     * Sets a value of the object using the PropertyAccess component.
962
     */
963
    private function setValue(object $object, string $attributeName, mixed $value): void
964
    {
965
        try {
966
            $this->propertyAccessor->setValue($object, $attributeName, $value);
12✔
967
        } catch (NoSuchPropertyException) {
3✔
968
            // Properties not found are ignored
969
        }
970
    }
971
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc