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

api-platform / core / 7196499749

13 Dec 2023 02:17PM UTC coverage: 37.359% (+1.4%) from 36.003%
7196499749

push

github

web-flow
ci: conflict sebastian/comparator (#6032)

* ci: conflict sebastian/comparator

* for lowest

10295 of 27557 relevant lines covered (37.36%)

28.14 hits per line

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

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

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

62
    protected PropertyAccessorInterface $propertyAccessor;
63
    protected array $localCache = [];
64
    protected array $localFactoryOptionsCache = [];
65

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

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

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

86
        $class = $context['force_resource_class'] ?? $this->getObjectClass($data);
40✔
87
        if (($context['output']['class'] ?? null) === $class) {
40✔
88
            return true;
4✔
89
        }
90

91
        return $this->resourceClassResolver->isResourceClass($class);
36✔
92
    }
93

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

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

115
        return true;
×
116
    }
117

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

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

136
            return $this->serializer->normalize($object, $format, $context);
4✔
137
        }
138

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

147
        if ($this->resourceClassResolver->isResourceClass($resourceClass)) {
64✔
148
            $context = $this->initContext($resourceClass, $context);
60✔
149
        }
150

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

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

167
        if (isset($context['resources'])) {
64✔
168
            $context['resources'][$iri] = $iri;
32✔
169
        }
170

171
        $data = parent::normalize($object, $format, $context);
64✔
172

173
        if ($emptyResourceAsIri && \is_array($data) && 0 === \count($data)) {
60✔
174
            return $iri;
×
175
        }
176

177
        return $data;
60✔
178
    }
179

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

189
        return $this->localCache[$type] ?? $this->localCache[$type] = $this->resourceClassResolver->isResourceClass($type);
12✔
190
    }
191

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

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

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

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

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

219
        $context['api_denormalize'] = true;
28✔
220

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

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

236
        if (!\is_array($data)) {
28✔
237
            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);
×
238
        }
239

240
        $previousObject = $this->clone($objectToPopulate);
28✔
241
        $object = parent::denormalize($data, $class, $format, $context);
28✔
242

243
        if (!$this->resourceClassResolver->isResourceClass($class)) {
16✔
244
            return $object;
×
245
        }
246

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

253
        $options = $this->getFactoryOptions($context);
16✔
254
        $propertyNames = iterator_to_array($this->propertyNameCollectionFactory->create($resourceClass, $options));
16✔
255

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

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

273
        return $object;
16✔
274
    }
275

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

289
            return $object;
×
290
        }
291

292
        $class = $this->getClassDiscriminatorResolvedClass($data, $class, $context);
28✔
293
        $reflectionClass = new \ReflectionClass($class);
28✔
294

295
        $constructor = $this->getConstructor($data, $class, $context, $reflectionClass, $allowedAttributes);
28✔
296
        if ($constructor) {
28✔
297
            $constructorParameters = $constructor->getParameters();
28✔
298

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

304
                $allowed = false === $allowedAttributes || (\is_array($allowedAttributes) && \in_array($paramName, $allowedAttributes, true));
×
305
                $ignored = !$this->isAllowedAttribute($class, $paramName, $format, $context);
×
306
                if ($constructorParameter->isVariadic()) {
×
307
                    if ($allowed && !$ignored && (isset($data[$key]) || \array_key_exists($key, $data))) {
×
308
                        if (!\is_array($data[$paramName])) {
×
309
                            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));
×
310
                        }
311

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

326
                    // Don't run set for a parameter passed to the constructor
327
                    unset($data[$key]);
×
328
                } elseif (isset($context[static::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class][$key])) {
×
329
                    $params[] = $context[static::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class][$key];
×
330
                } elseif ($constructorParameter->isDefaultValueAvailable()) {
×
331
                    $params[] = $constructorParameter->getDefaultValue();
×
332
                } else {
333
                    if (!isset($context['not_normalizable_value_exceptions'])) {
×
334
                        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]);
×
335
                    }
336

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

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

346
            if ($constructor->isConstructor()) {
28✔
347
                return $reflectionClass->newInstanceArgs($params);
28✔
348
            }
349

350
            return $constructor->invokeArgs(null, $params);
×
351
        }
352

353
        return new $class();
×
354
    }
355

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

362
        if (!isset($data[$mapping->getTypeProperty()])) {
×
363
            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());
×
364
        }
365

366
        $type = $data[$mapping->getTypeProperty()];
×
367
        if (null === ($mappedClass = $mapping->getClassForType($type))) {
×
368
            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);
×
369
        }
370

371
        return $mappedClass;
×
372
    }
373

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

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

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

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

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

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

418
        return $allowedAttributes;
72✔
419
    }
420

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

430
        return $this->canAccessAttribute(\is_object($classOrObject) ? $classOrObject : null, $attribute, $context);
76✔
431
    }
432

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

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

451
        return true;
72✔
452
    }
453

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

469
        return true;
16✔
470
    }
471

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

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

501
        if (!$isValid) {
16✔
502
            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);
×
503
        }
504
    }
505

506
    /**
507
     * Denormalizes a collection of objects.
508
     *
509
     * @throws NotNormalizableValueException
510
     */
511
    protected function denormalizeCollection(string $attribute, ApiProperty $propertyMetadata, Type $type, string $className, mixed $value, ?string $format, array $context): array
512
    {
513
        if (!\is_array($value)) {
12✔
514
            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);
4✔
515
        }
516

517
        $collectionKeyType = $type->getCollectionKeyTypes()[0] ?? null;
8✔
518
        $collectionKeyBuiltinType = $collectionKeyType?->getBuiltinType();
8✔
519
        $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
8✔
520
        $values = [];
8✔
521
        foreach ($value as $index => $obj) {
8✔
522
            if (null !== $collectionKeyBuiltinType && !\call_user_func('is_'.$collectionKeyBuiltinType, $index)) {
8✔
523
                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);
4✔
524
            }
525

526
            $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $childContext);
4✔
527
        }
528

529
        return $values;
4✔
530
    }
531

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

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

573
                return null;
×
574
            }
575
        }
576

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

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

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

589
            return $item;
×
590
        }
591

592
        if (!\is_array($value)) {
×
593
            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);
×
594
        }
595

596
        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);
×
597
    }
598

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

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

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

625
        return $options + $this->localFactoryOptionsCache[$operationCacheKey] = $options;
76✔
626
    }
627

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

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

642
        $types = $propertyMetadata->getBuiltinTypes() ?? [];
60✔
643

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

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

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

665
                $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
16✔
666

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

671
                $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
16✔
672

673
                return $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
16✔
674
            }
675

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

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

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

692
                $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
24✔
693

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

698
                $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
24✔
699

700
                return $this->normalizeRelation($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
24✔
701
            }
702

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

707
            unset(
48✔
708
                $context['resource_class'],
48✔
709
                $context['force_resource_class'],
48✔
710
            );
48✔
711

712
            // Anonymous resources
713
            if ($className) {
48✔
714
                $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
16✔
715
                $childContext['output']['gen_id'] = $propertyMetadata->getGenId() ?? true;
16✔
716

717
                $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
16✔
718

719
                return $this->serializer->normalize($attributeValue, $format, $childContext);
16✔
720
            }
721

722
            if ('array' === $type->getBuiltinType()) {
44✔
723
                $childContext = $this->createChildContext($context, $attribute, $format);
16✔
724
                $childContext['output']['gen_id'] = $propertyMetadata->getGenId() ?? true;
16✔
725

726
                $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
16✔
727

728
                return $this->serializer->normalize($attributeValue, $format, $childContext);
16✔
729
            }
730
        }
731

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

736
        unset($context['resource_class']);
56✔
737
        unset($context['force_resource_class']);
56✔
738

739
        $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
56✔
740

741
        return $this->serializer->normalize($attributeValue, $format, $context);
52✔
742
    }
743

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

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

764
            $value[$index] = $this->normalizeRelation($propertyMetadata, $obj, $resourceClass, $format, $context);
×
765
        }
766

767
        return $value;
16✔
768
    }
769

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

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

789
            return $normalizedRelatedObject;
16✔
790
        }
791

792
        $iri = $this->iriConverter->getIriFromResource(resource: $relatedObject, context: $context);
8✔
793

794
        if (isset($context['resources'])) {
8✔
795
            $context['resources'][$iri] = $iri;
×
796
        }
797

798
        $push = $propertyMetadata->getPush() ?? false;
8✔
799
        if (isset($context['resources_to_push']) && $push) {
8✔
800
            $context['resources_to_push'][$iri] = $iri;
×
801
        }
802

803
        return $iri;
8✔
804
    }
805

806
    private function createAttributeValue(string $attribute, mixed $value, string $format = null, array &$context = []): mixed
807
    {
808
        try {
809
            return $this->createAndValidateAttributeValue($attribute, $value, $format, $context);
28✔
810
        } catch (NotNormalizableValueException $exception) {
12✔
811
            if (!isset($context['not_normalizable_value_exceptions'])) {
8✔
812
                throw $exception;
8✔
813
            }
814
            $context['not_normalizable_value_exceptions'][] = $exception;
×
815

816
            throw $exception;
×
817
        }
818
    }
819

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

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

831
            $collectionValueType = $type->getCollectionValueTypes()[0] ?? null;
28✔
832

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

840
            if (
841
                $type->isCollection()
28✔
842
                && null !== $collectionValueType
28✔
843
                && null !== ($className = $collectionValueType->getClassName())
28✔
844
                && $this->resourceClassResolver->isResourceClass($className)
28✔
845
            ) {
846
                $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className);
12✔
847
                $context['resource_class'] = $resourceClass;
12✔
848

849
                return $this->denormalizeCollection($attribute, $propertyMetadata, $type, $resourceClass, $value, $format, $context);
12✔
850
            }
851

852
            if (
853
                null !== ($className = $type->getClassName())
20✔
854
                && $this->resourceClassResolver->isResourceClass($className)
20✔
855
            ) {
856
                $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className);
8✔
857
                $childContext = $this->createChildContext($this->createOperationContext($context, $resourceClass), $attribute, $format);
8✔
858

859
                return $this->denormalizeRelation($attribute, $propertyMetadata, $resourceClass, $value, $format, $childContext);
8✔
860
            }
861

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

872
                unset($context['resource_class']);
×
873

874
                return $this->serializer->denormalize($value, $className.'[]', $format, $context);
×
875
            }
876

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

882
                unset($context['resource_class']);
×
883

884
                return $this->serializer->denormalize($value, $className, $format, $context);
×
885
            }
886

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

896
                switch ($type->getBuiltinType()) {
×
897
                    case Type::BUILTIN_TYPE_BOOL:
898
                        // according to http://www.w3.org/TR/xmlschema-2/#boolean, valid representations are "false", "true", "0" and "1"
899
                        if ('false' === $value || '0' === $value) {
×
900
                            $value = false;
×
901
                        } elseif ('true' === $value || '1' === $value) {
×
902
                            $value = true;
×
903
                        } else {
904
                            // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
905
                            if ($isMultipleTypes) {
×
906
                                break 2;
×
907
                            }
908
                            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);
×
909
                        }
910
                        break;
×
911
                    case Type::BUILTIN_TYPE_INT:
912
                        if (ctype_digit($value) || ('-' === $value[0] && ctype_digit(substr($value, 1)))) {
×
913
                            $value = (int) $value;
×
914
                        } else {
915
                            // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
916
                            if ($isMultipleTypes) {
×
917
                                break 2;
×
918
                            }
919
                            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);
×
920
                        }
921
                        break;
×
922
                    case Type::BUILTIN_TYPE_FLOAT:
923
                        if (is_numeric($value)) {
×
924
                            return (float) $value;
×
925
                        }
926

927
                        switch ($value) {
928
                            case 'NaN':
×
929
                                return \NAN;
×
930
                            case 'INF':
×
931
                                return \INF;
×
932
                            case '-INF':
×
933
                                return -\INF;
×
934
                            default:
935
                                // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
936
                                if ($isMultipleTypes) {
×
937
                                    break 3;
×
938
                                }
939
                                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);
×
940
                        }
941
                }
942
            }
943

944
            if ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false) {
16✔
945
                return $value;
×
946
            }
947

948
            try {
949
                $this->validateType($attribute, $type, $value, $format, $context);
16✔
950

951
                break;
16✔
952
            } catch (NotNormalizableValueException $e) {
×
953
                // union/intersect types: try the next type
954
                if (!$isMultipleTypes) {
×
955
                    throw $e;
×
956
                }
957
            }
958
        }
959

960
        return $value;
16✔
961
    }
962

963
    /**
964
     * Sets a value of the object using the PropertyAccess component.
965
     */
966
    private function setValue(object $object, string $attributeName, mixed $value): void
967
    {
968
        try {
969
            $this->propertyAccessor->setValue($object, $attributeName, $value);
16✔
970
        } catch (NoSuchPropertyException) {
4✔
971
            // Properties not found are ignored
972
        }
973
    }
974
}
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

© 2026 Coveralls, Inc