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

api-platform / core / 20545070147

27 Dec 2025 10:15PM UTC coverage: 28.855% (+3.7%) from 25.192%
20545070147

push

github

soyuka
ci: upgrade to phpunit 12

Remove soyuka/phpunit fork from all composer.json files and upgrade to
PHPUnit 12.2. Update CI workflow to install PHPUnit before other steps
and configure MongoDB conditional execution. Migrate tests from Prophecy
to PHPUnit native mocking in FieldsBuilderTest and Symfony event listener
tests. Remove unused dataprovider and fix warnings.

0 of 84 new or added lines in 8 files covered. (0.0%)

534 existing lines in 34 files now uncovered.

16760 of 58083 relevant lines covered (28.86%)

78.25 hits per line

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

72.58
/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\Metadata\ApiProperty;
17
use ApiPlatform\Metadata\CollectionOperationInterface;
18
use ApiPlatform\Metadata\Exception\AccessDeniedException;
19
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
20
use ApiPlatform\Metadata\Exception\ItemNotFoundException;
21
use ApiPlatform\Metadata\IriConverterInterface;
22
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
23
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
24
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
25
use ApiPlatform\Metadata\ResourceAccessCheckerInterface;
26
use ApiPlatform\Metadata\ResourceClassResolverInterface;
27
use ApiPlatform\Metadata\UrlGeneratorInterface;
28
use ApiPlatform\Metadata\Util\ClassInfoTrait;
29
use ApiPlatform\Metadata\Util\CloneTrait;
30
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
31
use Symfony\Component\PropertyAccess\PropertyAccess;
32
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
33
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
34
use Symfony\Component\PropertyInfo\Type as LegacyType;
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\TypeInfo\Type;
48
use Symfony\Component\TypeInfo\Type\BuiltinType;
49
use Symfony\Component\TypeInfo\Type\CollectionType;
50
use Symfony\Component\TypeInfo\Type\CompositeTypeInterface;
51
use Symfony\Component\TypeInfo\Type\NullableType;
52
use Symfony\Component\TypeInfo\Type\ObjectType;
53
use Symfony\Component\TypeInfo\Type\WrappingTypeInterface;
54
use Symfony\Component\TypeInfo\TypeIdentifier;
55

56
/**
57
 * Base item normalizer.
58
 *
59
 * @author Kévin Dunglas <dunglas@gmail.com>
60
 */
61
abstract class AbstractItemNormalizer extends AbstractObjectNormalizer
62
{
63
    use ClassInfoTrait;
64
    use CloneTrait;
65
    use ContextTrait;
66
    use InputOutputMetadataTrait;
67
    use OperationContextTrait;
68
    /**
69
     * Flag to control whether to one relation with the value `null` should be output
70
     * when normalizing or omitted.
71
     */
72
    public const SKIP_NULL_TO_ONE_RELATIONS = 'skip_null_to_one_relations';
73

74
    protected PropertyAccessorInterface $propertyAccessor;
75
    protected array $localCache = [];
76
    protected array $localFactoryOptionsCache = [];
77
    protected ?ResourceAccessCheckerInterface $resourceAccessChecker;
78

79
    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, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null)
80
    {
81
        if (!isset($defaultContext['circular_reference_handler'])) {
2,303✔
82
            $defaultContext['circular_reference_handler'] = fn ($object): ?string => $this->iriConverter->getIriFromResource($object);
2,303✔
83
        }
84

85
        parent::__construct($classMetadataFactory, $nameConverter, null, null, \Closure::fromCallable($this->getObjectClass(...)), $defaultContext);
2,303✔
86
        $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor();
2,303✔
87
        $this->resourceAccessChecker = $resourceAccessChecker;
2,303✔
88
        $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
2,303✔
89
    }
90

91
    /**
92
     * {@inheritdoc}
93
     */
94
    public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
95
    {
96
        if (!\is_object($data) || is_iterable($data)) {
1,977✔
97
            return false;
747✔
98
        }
99

100
        $class = $context['force_resource_class'] ?? $this->getObjectClass($data);
1,907✔
101
        if (($context['output']['class'] ?? null) === $class) {
1,907✔
102
            return true;
45✔
103
        }
104

105
        return $this->resourceClassResolver->isResourceClass($class);
1,891✔
106
    }
107

108
    public function getSupportedTypes(?string $format): array
109
    {
110
        return [
2,044✔
111
            'object' => true,
2,044✔
112
        ];
2,044✔
113
    }
114

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

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

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

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

142
        if ($this->resourceClassResolver->isResourceClass($resourceClass)) {
1,913✔
143
            $context = $this->initContext($resourceClass, $context);
1,873✔
144
        }
145

146
        $context['api_normalize'] = true;
1,913✔
147
        $iri = $context['iri'] ??= $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL, $context['operation'] ?? null, $context);
1,913✔
148

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

162
        if (!$this->tagCollector && isset($context['resources'])) {
1,913✔
163
            $context['resources'][$iri] = $iri;
×
164
        }
165

166
        $context['object'] = $object;
1,913✔
167
        $context['format'] = $format;
1,913✔
168

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

171
        $context['data'] = $data;
1,913✔
172
        unset($context['property_metadata'], $context['api_attribute']);
1,913✔
173

174
        if ($emptyResourceAsIri && \is_array($data) && 0 === \count($data)) {
1,913✔
175
            $context['data'] = $iri;
×
176

177
            if ($this->tagCollector) {
×
178
                $this->tagCollector->collect($context);
×
179
            }
180

181
            return $iri;
×
182
        }
183

184
        if ($this->tagCollector) {
1,913✔
185
            $this->tagCollector->collect($context);
1,702✔
186
        }
187

188
        return $data;
1,913✔
189
    }
190

191
    /**
192
     * {@inheritdoc}
193
     */
194
    public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
195
    {
196
        if (($context['input']['class'] ?? null) === $type) {
518✔
197
            return true;
×
198
        }
199

200
        return $this->localCache[$type] ?? $this->localCache[$type] = $this->resourceClassResolver->isResourceClass($type);
518✔
201
    }
202

203
    /**
204
     * {@inheritdoc}
205
     */
206
    public function denormalize(mixed $data, string $class, ?string $format = null, array $context = []): mixed
207
    {
208
        $resourceClass = $class;
510✔
209

210
        if ($inputClass = $this->getInputClass($context)) {
510✔
211
            if (!$this->serializer instanceof DenormalizerInterface) {
29✔
212
                throw new LogicException('Cannot denormalize the input because the injected serializer is not a denormalizer');
×
213
            }
214

215
            unset($context['input'], $context['operation'], $context['operation_name'], $context['uri_variables']);
29✔
216
            $context['resource_class'] = $inputClass;
29✔
217

218
            try {
219
                return $this->serializer->denormalize($data, $inputClass, $format, $context);
29✔
220
            } catch (NotNormalizableValueException $e) {
2✔
221
                throw new UnexpectedValueException('The input data is misformatted.', $e->getCode(), $e);
2✔
222
            }
223
        }
224

225
        if (null === $objectToPopulate = $this->extractObjectToPopulate($resourceClass, $context, static::OBJECT_TO_POPULATE)) {
486✔
226
            $normalizedData = \is_scalar($data) ? [$data] : $this->prepareForDenormalization($data);
401✔
227
            $class = $this->getClassDiscriminatorResolvedClass($normalizedData, $class, $context);
401✔
228
        }
229

230
        $context['api_denormalize'] = true;
486✔
231

232
        if ($this->resourceClassResolver->isResourceClass($class)) {
486✔
233
            $resourceClass = $this->resourceClassResolver->getResourceClass($objectToPopulate, $class);
486✔
234
            $context['resource_class'] = $resourceClass;
486✔
235
        }
236

237
        if (\is_string($data)) {
486✔
238
            try {
239
                return $this->iriConverter->getResourceFromIri($data, $context + ['fetch_data' => true]);
5✔
240
            } catch (ItemNotFoundException $e) {
×
241
                if (!isset($context['not_normalizable_value_exceptions'])) {
×
242
                    throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
×
243
                }
244

245
                throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $data, [$resourceClass], $context['deserialization_path'] ?? null, true, $e->getCode(), $e);
×
246
            } catch (InvalidArgumentException $e) {
×
247
                if (!isset($context['not_normalizable_value_exceptions'])) {
×
248
                    throw new UnexpectedValueException(\sprintf('Invalid IRI "%s".', $data), $e->getCode(), $e);
×
249
                }
250

251
                throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Invalid IRI "%s".', $data), $data, [$resourceClass], $context['deserialization_path'] ?? null, true, $e->getCode(), $e);
×
252
            }
253
        }
254

255
        if (!\is_array($data)) {
482✔
256
            throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" resource must be "array" (nested document) or "string" (IRI), "%s" given.', $resourceClass, \gettype($data)), $data, ['array', 'string'], $context['deserialization_path'] ?? null);
2✔
257
        }
258

259
        $previousObject = $this->clone($objectToPopulate);
482✔
260
        $object = parent::denormalize($data, $class, $format, $context);
482✔
261

262
        if (!$this->resourceClassResolver->isResourceClass($class)) {
448✔
263
            return $object;
×
264
        }
265

266
        // Bypass the post-denormalize attribute revert logic if the object could not be
267
        // cloned since we cannot possibly revert any changes made to it.
268
        if (null !== $objectToPopulate && null === $previousObject) {
448✔
269
            return $object;
×
270
        }
271

272
        $options = $this->getFactoryOptions($context);
448✔
273
        $propertyNames = iterator_to_array($this->propertyNameCollectionFactory->create($resourceClass, $options));
448✔
274

275
        $operation = $context['operation'] ?? null;
448✔
276
        $throwOnAccessDenied = $operation?->getExtraProperties()['throw_on_access_denied'] ?? false;
448✔
277
        $securityMessage = $operation?->getSecurityMessage() ?? null;
448✔
278

279
        // Revert attributes that aren't allowed to be changed after a post-denormalize check
280
        foreach (array_keys($data) as $attribute) {
448✔
281
            $attribute = $this->nameConverter ? $this->nameConverter->denormalize((string) $attribute) : $attribute;
430✔
282
            $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $attribute, $options);
430✔
283
            $attributeExtraProperties = $propertyMetadata->getExtraProperties();
430✔
284
            $throwOnPropertyAccessDenied = $attributeExtraProperties['throw_on_access_denied'] ?? $throwOnAccessDenied;
430✔
285
            if (!\in_array($attribute, $propertyNames, true)) {
430✔
286
                continue;
92✔
287
            }
288

289
            if (!$this->canAccessAttributePostDenormalize($object, $previousObject, $attribute, $context)) {
409✔
290
                if ($throwOnPropertyAccessDenied) {
4✔
291
                    throw new AccessDeniedException($securityMessage ?? 'Access denied');
×
292
                }
293
                if (null !== $previousObject) {
4✔
294
                    $this->setValue($object, $attribute, $this->propertyAccessor->getValue($previousObject, $attribute));
1✔
295
                } else {
296
                    $this->setValue($object, $attribute, $propertyMetadata->getDefault());
3✔
297
                }
298
            }
299
        }
300

301
        return $object;
448✔
302
    }
303

304
    /**
305
     * Method copy-pasted from symfony/serializer.
306
     * Remove it after symfony/serializer version update @see https://github.com/symfony/symfony/pull/28263.
307
     *
308
     * {@inheritdoc}
309
     *
310
     * @internal
311
     */
312
    protected function instantiateObject(array &$data, string $class, array &$context, \ReflectionClass $reflectionClass, array|bool $allowedAttributes, ?string $format = null): object
313
    {
314
        if (null !== $object = $this->extractObjectToPopulate($class, $context, static::OBJECT_TO_POPULATE)) {
482✔
315
            unset($context[static::OBJECT_TO_POPULATE]);
105✔
316

317
            return $object;
105✔
318
        }
319

320
        $class = $this->getClassDiscriminatorResolvedClass($data, $class, $context);
397✔
321
        $reflectionClass = new \ReflectionClass($class);
397✔
322

323
        $constructor = $this->getConstructor($data, $class, $context, $reflectionClass, $allowedAttributes);
397✔
324
        if ($constructor) {
397✔
325
            $constructorParameters = $constructor->getParameters();
184✔
326

327
            $params = [];
184✔
328
            $missingConstructorArguments = [];
184✔
329
            foreach ($constructorParameters as $constructorParameter) {
184✔
330
                $paramName = $constructorParameter->name;
84✔
331
                $key = $this->nameConverter ? $this->nameConverter->normalize($paramName, $class, $format, $context) : $paramName;
84✔
332
                $attributeContext = $this->getAttributeDenormalizationContext($class, $paramName, $context);
84✔
333
                $attributeContext['deserialization_path'] = $attributeContext['deserialization_path'] ?? $key;
84✔
334

335
                $allowed = false === $allowedAttributes || (\is_array($allowedAttributes) && \in_array($paramName, $allowedAttributes, true));
84✔
336
                $ignored = !$this->isAllowedAttribute($class, $paramName, $format, $context);
84✔
337
                if ($constructorParameter->isVariadic()) {
84✔
338
                    if ($allowed && !$ignored && (isset($data[$key]) || \array_key_exists($key, $data))) {
×
339
                        if (!\is_array($data[$paramName])) {
×
340
                            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));
×
341
                        }
342

343
                        $params[] = $data[$paramName];
×
344
                    }
345
                } elseif ($allowed && !$ignored && (isset($data[$key]) || \array_key_exists($key, $data))) {
84✔
346
                    try {
347
                        $params[] = $this->createConstructorArgument($data[$key], $key, $constructorParameter, $attributeContext, $format);
28✔
348
                    } catch (NotNormalizableValueException $exception) {
5✔
349
                        if (!isset($context['not_normalizable_value_exceptions'])) {
5✔
350
                            throw $exception;
3✔
351
                        }
352
                        $context['not_normalizable_value_exceptions'][] = $exception;
2✔
353
                    }
354

355
                    // Don't run set for a parameter passed to the constructor
356
                    unset($data[$key]);
25✔
357
                } elseif (isset($context[static::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class][$key])) {
71✔
358
                    $params[] = $context[static::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class][$key];
×
359
                } elseif ($constructorParameter->isDefaultValueAvailable()) {
71✔
360
                    $params[] = $constructorParameter->getDefaultValue();
69✔
361
                } else {
362
                    if (!isset($context['not_normalizable_value_exceptions'])) {
4✔
363
                        $missingConstructorArguments[] = $constructorParameter->name;
2✔
364
                    }
365

366
                    $constructorParameterType = 'unknown';
4✔
367
                    $reflectionType = $constructorParameter->getType();
4✔
368
                    if ($reflectionType instanceof \ReflectionNamedType) {
4✔
369
                        $constructorParameterType = $reflectionType->getName();
4✔
370
                    }
371

372
                    $exception = NotNormalizableValueException::createForUnexpectedDataType(
4✔
373
                        \sprintf('Failed to create object because the class misses the "%s" property.', $constructorParameter->name),
4✔
374
                        null,
4✔
375
                        [$constructorParameterType],
4✔
376
                        $attributeContext['deserialization_path'],
4✔
377
                        true
4✔
378
                    );
4✔
379
                    $context['not_normalizable_value_exceptions'][] = $exception;
4✔
380
                }
381
            }
382

383
            if ($missingConstructorArguments) {
181✔
384
                throw new MissingConstructorArgumentsException(\sprintf('Cannot create an instance of "%s" from serialized data because its constructor requires the following parameters to be present : "$%s".', $class, implode('", "$', $missingConstructorArguments)), 0, null, $missingConstructorArguments, $class);
2✔
385
            }
386

387
            if (\count($context['not_normalizable_value_exceptions'] ?? []) > 0) {
181✔
388
                return $reflectionClass->newInstanceWithoutConstructor();
2✔
389
            }
390

391
            if ($constructor->isConstructor()) {
179✔
392
                return $reflectionClass->newInstanceArgs($params);
179✔
393
            }
394

395
            return $constructor->invokeArgs(null, $params);
×
396
        }
397

398
        return new $class();
224✔
399
    }
400

401
    protected function getClassDiscriminatorResolvedClass(array $data, string $class, array $context = []): string
402
    {
403
        if (null === $this->classDiscriminatorResolver || (null === $mapping = $this->classDiscriminatorResolver->getMappingForClass($class))) {
401✔
404
            return $class;
401✔
405
        }
406

407
        // @phpstan-ignore-next-line function.alreadyNarrowedType
408
        $defaultType = method_exists($mapping, 'getDefaultType') ? $mapping->getDefaultType() : null;
2✔
409
        if (!isset($data[$mapping->getTypeProperty()]) && null === $defaultType) {
2✔
410
            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());
×
411
        }
412

413
        $type = $data[$mapping->getTypeProperty()] ?? $defaultType;
2✔
414
        if (null === ($mappedClass = $mapping->getClassForType($type))) {
2✔
415
            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);
×
416
        }
417

418
        return $mappedClass;
2✔
419
    }
420

421
    protected function createConstructorArgument(mixed $parameterData, string $key, \ReflectionParameter $constructorParameter, array $context, ?string $format = null): mixed
422
    {
423
        return $this->createAndValidateAttributeValue($constructorParameter->name, $parameterData, $format, $context);
28✔
424
    }
425

426
    /**
427
     * {@inheritdoc}
428
     *
429
     * Unused in this context.
430
     *
431
     * @param object      $object
432
     * @param string|null $format
433
     *
434
     * @return string[]
435
     */
436
    protected function extractAttributes($object, $format = null, array $context = []): array
437
    {
438
        return [];
×
439
    }
440

441
    /**
442
     * {@inheritdoc}
443
     */
444
    protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool
445
    {
446
        if (!$this->resourceClassResolver->isResourceClass($context['resource_class'])) {
1,929✔
447
            return parent::getAllowedAttributes($classOrObject, $context, $attributesAsString);
45✔
448
        }
449

450
        $resourceClass = $this->resourceClassResolver->getResourceClass(null, $context['resource_class']); // fix for abstract classes and interfaces
1,891✔
451
        $options = $this->getFactoryOptions($context);
1,891✔
452
        $propertyNames = $this->propertyNameCollectionFactory->create($resourceClass, $options);
1,891✔
453

454
        $allowedAttributes = [];
1,891✔
455
        foreach ($propertyNames as $propertyName) {
1,891✔
456
            $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName, $options);
1,877✔
457

458
            if (
459
                $this->isAllowedAttribute($classOrObject, $propertyName, null, $context)
1,877✔
460
                && (isset($context['api_normalize']) && $propertyMetadata->isReadable()
1,877✔
461
                    || isset($context['api_denormalize']) && ($propertyMetadata->isWritable() || !\is_object($classOrObject) && $propertyMetadata->isInitializable())
1,877✔
462
                )
463
            ) {
464
                $allowedAttributes[] = $propertyName;
1,863✔
465
            }
466
        }
467

468
        return $allowedAttributes;
1,891✔
469
    }
470

471
    /**
472
     * {@inheritdoc}
473
     */
474
    protected function isAllowedAttribute(object|string $classOrObject, string $attribute, ?string $format = null, array $context = []): bool
475
    {
476
        if (!parent::isAllowedAttribute($classOrObject, $attribute, $format, $context)) {
1,915✔
477
            return false;
438✔
478
        }
479

480
        return $this->canAccessAttribute(\is_object($classOrObject) ? $classOrObject : null, $attribute, $context);
1,913✔
481
    }
482

483
    /**
484
     * Check if access to the attribute is granted.
485
     */
486
    protected function canAccessAttribute(?object $object, string $attribute, array $context = []): bool
487
    {
488
        if (!$this->resourceClassResolver->isResourceClass($context['resource_class'])) {
1,913✔
489
            return true;
45✔
490
        }
491

492
        $options = $this->getFactoryOptions($context);
1,875✔
493
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options);
1,875✔
494
        $security = $propertyMetadata->getSecurity() ?? $propertyMetadata->getPolicy();
1,875✔
495
        if (null !== $this->resourceAccessChecker && $security) {
1,875✔
496
            return $this->resourceAccessChecker->isGranted($context['resource_class'], $security, [
64✔
497
                'object' => $object,
64✔
498
                'property' => $attribute,
64✔
499
            ]);
64✔
500
        }
501

502
        return true;
1,867✔
503
    }
504

505
    /**
506
     * Check if access to the attribute is granted.
507
     */
508
    protected function canAccessAttributePostDenormalize(?object $object, ?object $previousObject, string $attribute, array $context = []): bool
509
    {
510
        $options = $this->getFactoryOptions($context);
409✔
511
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options);
409✔
512
        $security = $propertyMetadata->getSecurityPostDenormalize();
409✔
513
        if ($this->resourceAccessChecker && $security) {
409✔
514
            return $this->resourceAccessChecker->isGranted($context['resource_class'], $security, [
10✔
515
                'object' => $object,
10✔
516
                'previous_object' => $previousObject,
10✔
517
                'property' => $attribute,
10✔
518
            ]);
10✔
519
        }
520

521
        return true;
407✔
522
    }
523

524
    /**
525
     * {@inheritdoc}
526
     */
527
    protected function setAttributeValue(object $object, string $attribute, mixed $value, ?string $format = null, array $context = []): void
528
    {
529
        try {
530
            $this->setValue($object, $attribute, $this->createAttributeValue($attribute, $value, $format, $context));
408✔
531
        } catch (NotNormalizableValueException $exception) {
31✔
532
            // Only throw if collecting denormalization errors is disabled.
533
            if (!isset($context['not_normalizable_value_exceptions'])) {
21✔
534
                throw $exception;
19✔
535
            }
536
        }
537
    }
538

539
    /**
540
     * @deprecated since 4.1, use "validateAttributeType" instead
541
     *
542
     * Validates the type of the value. Allows using integers as floats for JSON formats.
543
     *
544
     * @throws NotNormalizableValueException
545
     */
546
    protected function validateType(string $attribute, LegacyType $type, mixed $value, ?string $format = null, array $context = []): void
547
    {
548
        trigger_deprecation('api-platform/serializer', '4.1', 'The "%s()" method is deprecated, use "%s::validateAttributeType()" instead.', __METHOD__, self::class);
×
549

550
        $builtinType = $type->getBuiltinType();
×
551
        if (LegacyType::BUILTIN_TYPE_FLOAT === $builtinType && null !== $format && str_contains($format, 'json')) {
×
552
            $isValid = \is_float($value) || \is_int($value);
×
553
        } else {
554
            $isValid = \call_user_func('is_'.$builtinType, $value);
×
555
        }
556

557
        if (!$isValid) {
×
558
            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);
×
559
        }
560
    }
561

562
    /**
563
     * Validates the type of the value. Allows using integers as floats for JSON formats.
564
     *
565
     * @throws NotNormalizableValueException
566
     */
567
    protected function validateAttributeType(string $attribute, Type $type, mixed $value, ?string $format = null, array $context = []): void
568
    {
569
        if ($type->isIdentifiedBy(TypeIdentifier::FLOAT) && null !== $format && str_contains($format, 'json')) {
359✔
UNCOV
570
            $isValid = \is_float($value) || \is_int($value);
4✔
571
        } else {
572
            $isValid = $type->accepts($value);
359✔
573
        }
574

575
        if (!$isValid) {
359✔
576
            throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" attribute must be "%s", "%s" given.', $attribute, $type, \gettype($value)), $value, [(string) $type], $context['deserialization_path'] ?? null);
79✔
577
        }
578
    }
579

580
    /**
581
     * @deprecated since 4.1, use "denormalizeObjectCollection" instead.
582
     *
583
     * Denormalizes a collection of objects.
584
     *
585
     * @throws NotNormalizableValueException
586
     */
587
    protected function denormalizeCollection(string $attribute, ApiProperty $propertyMetadata, LegacyType $type, string $className, mixed $value, ?string $format, array $context): array
588
    {
589
        trigger_deprecation('api-platform/serializer', '4.1', 'The "%s()" method is deprecated, use "%s::denormalizeObjectCollection()" instead.', __METHOD__, self::class);
×
590

591
        if (!\is_array($value)) {
×
592
            throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" attribute must be "array", "%s" given.', $attribute, \gettype($value)), $value, ['array'], $context['deserialization_path'] ?? null);
×
593
        }
594

595
        $values = [];
×
596
        $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
×
597
        $collectionKeyTypes = $type->getCollectionKeyTypes();
×
598
        foreach ($value as $index => $obj) {
×
599
            $currentChildContext = $childContext;
×
600
            if (isset($childContext['deserialization_path'])) {
×
601
                $currentChildContext['deserialization_path'] = "{$childContext['deserialization_path']}[{$index}]";
×
602
            }
603

604
            // no typehint provided on collection key
605
            if (!$collectionKeyTypes) {
×
606
                $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $currentChildContext);
×
607
                continue;
×
608
            }
609

610
            // validate collection key typehint
611
            foreach ($collectionKeyTypes as $collectionKeyType) {
×
612
                $collectionKeyBuiltinType = $collectionKeyType->getBuiltinType();
×
613
                if (!\call_user_func('is_'.$collectionKeyBuiltinType, $index)) {
×
614
                    continue;
×
615
                }
616

617
                $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $currentChildContext);
×
618
                continue 2;
×
619
            }
620
            throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the key "%s" must be "%s", "%s" given.', $index, $collectionKeyTypes[0]->getBuiltinType(), \gettype($index)), $index, [$collectionKeyTypes[0]->getBuiltinType()], ($context['deserialization_path'] ?? false) ? \sprintf('key(%s)', $context['deserialization_path']) : null, true);
×
621
        }
622

623
        return $values;
×
624
    }
625

626
    /**
627
     * Denormalizes a collection of objects.
628
     *
629
     * @throws NotNormalizableValueException
630
     */
631
    protected function denormalizeObjectCollection(string $attribute, ApiProperty $propertyMetadata, Type $type, string $className, mixed $value, ?string $format, array $context): array
632
    {
633
        if (!\is_array($value)) {
32✔
634
            throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" attribute must be "array", "%s" given.', $attribute, \gettype($value)), $value, ['array'], $context['deserialization_path'] ?? null);
4✔
635
        }
636

637
        $values = [];
28✔
638
        $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
28✔
639

640
        foreach ($value as $index => $obj) {
28✔
641
            $currentChildContext = $childContext;
28✔
642
            if (isset($childContext['deserialization_path'])) {
28✔
643
                $currentChildContext['deserialization_path'] = "{$childContext['deserialization_path']}[{$index}]";
28✔
644
            }
645

646
            if ($type instanceof CollectionType) {
28✔
647
                $collectionKeyType = $type->getCollectionKeyType();
28✔
648

649
                while ($collectionKeyType instanceof WrappingTypeInterface) {
28✔
650
                    $collectionKeyType = $type->getWrappedType();
×
651
                }
652

653
                if (!$collectionKeyType->accepts($index)) {
28✔
654
                    throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the key "%s" must be "%s", "%s" given.', $index, $type->getCollectionKeyType(), \gettype($index)), $index, [(string) $collectionKeyType], ($context['deserialization_path'] ?? false) ? \sprintf('key(%s)', $context['deserialization_path']) : null, true);
2✔
655
                }
656
            }
657

658
            $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $currentChildContext);
26✔
659
        }
660

661
        return $values;
26✔
662
    }
663

664
    /**
665
     * Denormalizes a relation.
666
     *
667
     * @throws LogicException
668
     * @throws UnexpectedValueException
669
     * @throws NotNormalizableValueException
670
     */
671
    protected function denormalizeRelation(string $attributeName, ApiProperty $propertyMetadata, string $className, mixed $value, ?string $format, array $context): ?object
672
    {
673
        if (\is_string($value)) {
116✔
674
            try {
675
                return $this->iriConverter->getResourceFromIri($value, $context + ['fetch_data' => true]);
64✔
676
            } catch (ItemNotFoundException $e) {
8✔
677
                if (false === ($context['denormalize_throw_on_relation_not_found'] ?? true)) {
2✔
678
                    return null;
×
679
                }
680

681
                if (!isset($context['not_normalizable_value_exceptions'])) {
2✔
682
                    throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
2✔
683
                }
684

685
                throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $value, [$className], $context['deserialization_path'] ?? null, true, $e->getCode(), $e);
×
686
            } catch (InvalidArgumentException $e) {
6✔
687
                if (!isset($context['not_normalizable_value_exceptions'])) {
6✔
688
                    throw new UnexpectedValueException(\sprintf('Invalid IRI "%s".', $value), $e->getCode(), $e);
6✔
689
                }
690

691
                throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Invalid IRI "%s".', $value), $value, [$className], $context['deserialization_path'] ?? null, true, $e->getCode(), $e);
×
692
            }
693
        }
694

695
        if ($propertyMetadata->isWritableLink()) {
54✔
696
            $context['api_allow_update'] = true;
51✔
697

698
            if (!$this->serializer instanceof DenormalizerInterface) {
51✔
699
                throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class));
×
700
            }
701

702
            $item = $this->serializer->denormalize($value, $className, $format, $context);
51✔
703
            if (!\is_object($item) && null !== $item) {
47✔
704
                throw new \UnexpectedValueException('Expected item to be an object or null.');
×
705
            }
706

707
            return $item;
47✔
708
        }
709

710
        if (!\is_array($value)) {
3✔
711
            throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" attribute must be "array" (nested document) or "string" (IRI), "%s" given.', $attributeName, \gettype($value)), $value, ['array', 'string'], $context['deserialization_path'] ?? null, true);
2✔
712
        }
713

714
        throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Nested documents for attribute "%s" are not allowed. Use IRIs instead.', $attributeName), $value, ['array', 'string'], $context['deserialization_path'] ?? null, true);
1✔
715
    }
716

717
    /**
718
     * Gets the options for the property name collection / property metadata factories.
719
     */
720
    protected function getFactoryOptions(array $context): array
721
    {
722
        $options = ['api_allow_update' => $context['api_allow_update'] ?? false];
1,929✔
723
        if (isset($context[self::GROUPS])) {
1,929✔
724
            /* @see https://github.com/symfony/symfony/blob/v4.2.6/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php */
725
            $options['serializer_groups'] = (array) $context[self::GROUPS];
672✔
726
        }
727

728
        $operationCacheKey = ($context['resource_class'] ?? '').($context['operation_name'] ?? '').($context['root_operation_name'] ?? '');
1,929✔
729
        $suffix = ($context['api_normalize'] ?? '') ? 'n' : '';
1,929✔
730
        if ($operationCacheKey && isset($this->localFactoryOptionsCache[$operationCacheKey.$suffix])) {
1,929✔
731
            return $options + $this->localFactoryOptionsCache[$operationCacheKey.$suffix];
1,913✔
732
        }
733

734
        // This is a hot spot
735
        if (isset($context['resource_class'])) {
1,929✔
736
            // Note that the groups need to be read on the root operation
737
            if ($operation = ($context['root_operation'] ?? null)) {
1,929✔
738
                $options['normalization_groups'] = $operation->getNormalizationContext()['groups'] ?? null;
824✔
739
                $options['denormalization_groups'] = $operation->getDenormalizationContext()['groups'] ?? null;
824✔
740
                $options['operation_name'] = $operation->getName();
824✔
741
            }
742
        }
743

744
        return $options + $this->localFactoryOptionsCache[$operationCacheKey.$suffix] = $options;
1,929✔
745
    }
746

747
    /**
748
     * {@inheritdoc}
749
     *
750
     * @throws UnexpectedValueException
751
     */
752
    protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed
753
    {
754
        $context['api_attribute'] = $attribute;
1,885✔
755
        $context['property_metadata'] = $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context));
1,885✔
756

757
        if ($context['api_denormalize'] ?? false) {
1,885✔
758
            return $this->propertyAccessor->getValue($object, $attribute);
27✔
759
        }
760

761
        if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
1,885✔
762
            $types = $propertyMetadata->getBuiltinTypes() ?? [];
×
763

764
            foreach ($types as $type) {
×
765
                if (
766
                    $type->isCollection()
×
767
                    && ($collectionValueType = $type->getCollectionValueTypes()[0] ?? null)
×
768
                    && ($className = $collectionValueType->getClassName())
×
769
                    && $this->resourceClassResolver->isResourceClass($className)
×
770
                ) {
771
                    $childContext = $this->createChildContext($this->createOperationContext($context, $className, $propertyMetadata), $attribute, $format);
×
772

773
                    // @see ApiPlatform\Hal\Serializer\ItemNormalizer:getComponents logic for intentional duplicate content
774
                    // @see ApiPlatform\JsonApi\Serializer\ItemNormalizer:getComponents logic for intentional duplicate content
775
                    if ('jsonld' === $format && $itemUriTemplate = $propertyMetadata->getUriTemplate()) {
×
776
                        $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(
×
777
                            operationName: $itemUriTemplate,
×
778
                            forceCollection: true,
×
779
                            httpOperation: true
×
780
                        );
×
781

782
                        return $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
×
783
                    }
784

785
                    $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
×
786

787
                    if (!is_iterable($attributeValue)) {
×
788
                        throw new UnexpectedValueException('Unexpected non-iterable value for to-many relation.');
×
789
                    }
790

791
                    $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
×
792

793
                    $data = $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
×
794
                    $context['data'] = $data;
×
795
                    $context['type'] = $type;
×
796

797
                    if ($this->tagCollector) {
×
798
                        $this->tagCollector->collect($context);
×
799
                    }
800

801
                    return $data;
×
802
                }
803

804
                if (
805
                    ($className = $type->getClassName())
×
806
                    && $this->resourceClassResolver->isResourceClass($className)
×
807
                ) {
808
                    $childContext = $this->createChildContext($this->createOperationContext($context, $className, $propertyMetadata), $attribute, $format);
×
809
                    unset($childContext['iri'], $childContext['uri_variables'], $childContext['item_uri_template']);
×
810
                    if ('jsonld' === $format && $uriTemplate = $propertyMetadata->getUriTemplate()) {
×
811
                        $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(
×
812
                            operationName: $uriTemplate,
×
813
                            httpOperation: true
×
814
                        );
×
815

816
                        return $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
×
817
                    }
818

819
                    $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
×
820

821
                    if (!\is_object($attributeValue) && null !== $attributeValue) {
×
822
                        throw new UnexpectedValueException('Unexpected non-object value for to-one relation.');
×
823
                    }
824

825
                    $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
×
826

827
                    $data = $this->normalizeRelation($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
×
828
                    $context['data'] = $data;
×
829
                    $context['type'] = $type;
×
830

831
                    if ($this->tagCollector) {
×
832
                        $this->tagCollector->collect($context);
×
833
                    }
834

835
                    return $data;
×
836
                }
837

838
                if (!$this->serializer instanceof NormalizerInterface) {
×
839
                    throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class));
×
840
                }
841

842
                unset(
×
843
                    $context['resource_class'],
×
844
                    $context['force_resource_class'],
×
845
                    $context['uri_variables'],
×
846
                );
×
847

848
                // Anonymous resources
849
                if ($className) {
×
850
                    $childContext = $this->createChildContext($this->createOperationContext($context, $className, $propertyMetadata), $attribute, $format);
×
851
                    $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
×
852

853
                    return $this->serializer->normalize($attributeValue, $format, $childContext);
×
854
                }
855

856
                if ('array' === $type->getBuiltinType()) {
×
857
                    if ($className = ($type->getCollectionValueTypes()[0] ?? null)?->getClassName()) {
×
858
                        $context = $this->createOperationContext($context, $className, $propertyMetadata);
×
859
                    }
860

861
                    $childContext = $this->createChildContext($context, $attribute, $format);
×
862
                    $childContext['output']['gen_id'] ??= $propertyMetadata->getGenId() ?? true;
×
863

864
                    $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
×
865

866
                    return $this->serializer->normalize($attributeValue, $format, $childContext);
×
867
                }
868
            }
869

870
            if (!$this->serializer instanceof NormalizerInterface) {
×
871
                throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class));
×
872
            }
873

874
            unset(
×
875
                $context['resource_class'],
×
876
                $context['force_resource_class'],
×
877
                $context['uri_variables']
×
878
            );
×
879

880
            $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
×
881

882
            return $this->serializer->normalize($attributeValue, $format, $context);
×
883
        }
884

885
        $type = $propertyMetadata->getNativeType();
1,885✔
886

887
        $nullable = false;
1,885✔
888
        if ($type instanceof NullableType) {
1,885✔
889
            $type = $type->getWrappedType();
1,263✔
890
            $nullable = true;
1,263✔
891
        }
892

893
        // TODO check every foreach composite to see if null is an issue
894
        $types = $type instanceof CompositeTypeInterface ? $type->getTypes() : (null === $type ? [] : [$type]);
1,885✔
895
        $className = null;
1,885✔
896
        $typeIsResourceClass = function (Type $type) use (&$className): bool {
1,885✔
897
            return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName());
1,853✔
898
        };
1,885✔
899

900
        foreach ($types as $type) {
1,885✔
901
            if ($type instanceof CollectionType && $type->getCollectionValueType()->isSatisfiedBy($typeIsResourceClass)) {
1,853✔
902
                $childContext = $this->createChildContext($this->createOperationContext($context, $className, $propertyMetadata), $attribute, $format);
431✔
903

904
                // @see ApiPlatform\Hal\Serializer\ItemNormalizer:getComponents logic for intentional duplicate content
905
                // @see ApiPlatform\JsonApi\Serializer\ItemNormalizer:getComponents logic for intentional duplicate content
906
                if ('jsonld' === $format && $itemUriTemplate = $propertyMetadata->getUriTemplate()) {
431✔
907
                    $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(
2✔
908
                        operationName: $itemUriTemplate,
2✔
909
                        forceCollection: true,
2✔
910
                        httpOperation: true
2✔
911
                    );
2✔
912

913
                    return $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
2✔
914
                }
915

916
                $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
429✔
917

918
                if (!is_iterable($attributeValue)) {
429✔
919
                    throw new UnexpectedValueException('Unexpected non-iterable value for to-many relation.');
×
920
                }
921

922
                $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
429✔
923

924
                $data = $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
429✔
925
                $context['data'] = $data;
429✔
926
                $context['type'] = $nullable ? Type::nullable($type) : $type;
429✔
927

928
                if ($this->tagCollector) {
429✔
929
                    $this->tagCollector->collect($context);
390✔
930
                }
931

932
                return $data;
429✔
933
            }
934

935
            if ($type->isSatisfiedBy($typeIsResourceClass)) {
1,853✔
936
                $childContext = $this->createChildContext($this->createOperationContext($context, $className, $propertyMetadata), $attribute, $format);
639✔
937
                unset($childContext['iri'], $childContext['uri_variables'], $childContext['item_uri_template']);
639✔
938
                if ('jsonld' === $format && $uriTemplate = $propertyMetadata->getUriTemplate()) {
639✔
939
                    $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(
2✔
940
                        operationName: $uriTemplate,
2✔
941
                        httpOperation: true
2✔
942
                    );
2✔
943

944
                    return $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
2✔
945
                }
946

947
                $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
637✔
948

949
                if (!\is_object($attributeValue) && null !== $attributeValue) {
633✔
950
                    throw new UnexpectedValueException('Unexpected non-object value for to-one relation.');
×
951
                }
952

953
                $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
633✔
954

955
                $data = $this->normalizeRelation($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
633✔
956
                $context['data'] = $data;
633✔
957
                $context['type'] = $nullable ? Type::nullable($type) : $type;
633✔
958

959
                if ($this->tagCollector) {
633✔
960
                    $this->tagCollector->collect($context);
598✔
961
                }
962

963
                return $data;
633✔
964
            }
965

966
            if (!$this->serializer instanceof NormalizerInterface) {
1,839✔
967
                throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class));
×
968
            }
969

970
            unset(
1,839✔
971
                $context['resource_class'],
1,839✔
972
                $context['force_resource_class'],
1,839✔
973
                $context['uri_variables'],
1,839✔
974
            );
1,839✔
975

976
            // Anonymous resources
977
            if ($className) {
1,839✔
978
                $childContext = $this->createChildContext($this->createOperationContext($context, $className, $propertyMetadata), $attribute, $format);
595✔
979
                $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
595✔
980

981
                return $this->serializer->normalize($attributeValue, $format, $childContext);
589✔
982
            }
983

984
            if ($type instanceof CollectionType) {
1,822✔
985
                if (($subType = $type->getCollectionValueType()) instanceof ObjectType) {
435✔
986
                    $context = $this->createOperationContext($context, $subType->getClassName(), $propertyMetadata);
×
987
                }
988

989
                $childContext = $this->createChildContext($context, $attribute, $format);
435✔
990
                $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
435✔
991

992
                return $this->serializer->normalize($attributeValue, $format, $childContext);
435✔
993
            }
994
        }
995

996
        if (!$this->serializer instanceof NormalizerInterface) {
1,852✔
997
            throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class));
×
998
        }
999

1000
        unset(
1,852✔
1001
            $context['resource_class'],
1,852✔
1002
            $context['force_resource_class'],
1,852✔
1003
            $context['uri_variables']
1,852✔
1004
        );
1,852✔
1005

1006
        $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
1,852✔
1007

1008
        return $this->serializer->normalize($attributeValue, $format, $context);
1,849✔
1009
    }
1010

1011
    /**
1012
     * Normalizes a collection of relations (to-many).
1013
     *
1014
     * @throws UnexpectedValueException
1015
     */
1016
    protected function normalizeCollectionOfRelations(ApiProperty $propertyMetadata, iterable $attributeValue, string $resourceClass, ?string $format, array $context): array
1017
    {
1018
        $value = [];
390✔
1019
        foreach ($attributeValue as $index => $obj) {
390✔
1020
            if (!\is_object($obj) && null !== $obj) {
105✔
1021
                throw new UnexpectedValueException('Unexpected non-object element in to-many relation.');
×
1022
            }
1023

1024
            // update context, if concrete object class deviates from general relation class (e.g. in case of polymorphic resources)
1025
            $objResourceClass = $this->resourceClassResolver->getResourceClass($obj, $resourceClass);
105✔
1026
            $context['resource_class'] = $objResourceClass;
105✔
1027
            if ($this->resourceMetadataCollectionFactory) {
105✔
1028
                $context['operation'] = $this->resourceMetadataCollectionFactory->create($objResourceClass)->getOperation();
105✔
1029
            }
1030

1031
            $value[$index] = $this->normalizeRelation($propertyMetadata, $obj, $resourceClass, $format, $context);
105✔
1032
        }
1033

1034
        return $value;
390✔
1035
    }
1036

1037
    /**
1038
     * Normalizes a relation.
1039
     *
1040
     * @throws LogicException
1041
     * @throws UnexpectedValueException
1042
     */
1043
    protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $relatedObject, string $resourceClass, ?string $format, array $context): \ArrayObject|array|string|null
1044
    {
1045
        if (null === $relatedObject || !empty($context['attributes']) || $propertyMetadata->isReadableLink() || false === ($context['output']['gen_id'] ?? true)) {
571✔
1046
            if (!$this->serializer instanceof NormalizerInterface) {
423✔
1047
                throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class));
×
1048
            }
1049

1050
            $relatedContext = $this->createOperationContext($context, $resourceClass, $propertyMetadata);
423✔
1051
            $normalizedRelatedObject = $this->serializer->normalize($relatedObject, $format, $relatedContext);
423✔
1052
            if (!\is_string($normalizedRelatedObject) && !\is_array($normalizedRelatedObject) && !$normalizedRelatedObject instanceof \ArrayObject && null !== $normalizedRelatedObject) {
423✔
1053
                throw new UnexpectedValueException('Expected normalized relation to be an IRI, array, \ArrayObject or null');
×
1054
            }
1055

1056
            return $normalizedRelatedObject;
423✔
1057
        }
1058

1059
        $context['iri'] = $iri = $this->iriConverter->getIriFromResource(resource: $relatedObject, context: $context);
206✔
1060
        $context['data'] = $iri;
206✔
1061
        $context['object'] = $relatedObject;
206✔
1062
        unset($context['property_metadata'], $context['api_attribute']);
206✔
1063

1064
        if ($this->tagCollector) {
206✔
1065
            $this->tagCollector->collect($context);
206✔
1066
        } elseif (isset($context['resources'])) {
×
1067
            $context['resources'][$iri] = $iri;
×
1068
        }
1069

1070
        $push = $propertyMetadata->getPush() ?? false;
206✔
1071
        if (isset($context['resources_to_push']) && $push) {
206✔
1072
            $context['resources_to_push'][$iri] = $iri;
17✔
1073
        }
1074

1075
        return $iri;
206✔
1076
    }
1077

1078
    private function createAttributeValue(string $attribute, mixed $value, ?string $format = null, array &$context = []): mixed
1079
    {
1080
        try {
1081
            return $this->createAndValidateAttributeValue($attribute, $value, $format, $context);
408✔
1082
        } catch (NotNormalizableValueException $exception) {
31✔
1083
            if (!isset($context['not_normalizable_value_exceptions'])) {
21✔
1084
                throw $exception;
19✔
1085
            }
1086
            $context['not_normalizable_value_exceptions'][] = $exception;
2✔
1087
            throw $exception;
2✔
1088
        }
1089
    }
1090

1091
    private function createAndValidateAttributeValue(string $attribute, mixed $value, ?string $format = null, array $context = []): mixed
1092
    {
1093
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context));
434✔
1094

1095
        $type = null;
434✔
1096
        if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
434✔
1097
            $types = $propertyMetadata->getBuiltinTypes() ?? [];
×
1098
        } else {
1099
            $type = $propertyMetadata->getNativeType();
434✔
1100
            $types = $type instanceof CompositeTypeInterface ? $type->getTypes() : (null === $type ? [] : [$type]);
434✔
1101
        }
1102

1103
        $className = null;
434✔
1104
        $typeIsResourceClass = function (Type $type) use (&$className): bool {
434✔
1105
            return $type instanceof ObjectType ? $this->resourceClassResolver->isResourceClass($className = $type->getClassName()) : false;
425✔
1106
        };
434✔
1107

1108
        $isMultipleTypes = \count($types) > 1;
434✔
1109
        $denormalizationException = null;
434✔
1110

1111
        foreach ($types as $t) {
434✔
1112
            if ($type instanceof Type) {
426✔
1113
                $isNullable = $type->isNullable();
426✔
1114
            } else {
1115
                $isNullable = $t->isNullable();
×
1116
            }
1117

1118
            if (null === $value && ($isNullable || ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false))) {
426✔
1119
                return $value;
5✔
1120
            }
1121

1122
            $collectionValueType = null;
425✔
1123

1124
            if ($t instanceof CollectionType) {
425✔
1125
                $collectionValueType = $t->getCollectionValueType();
48✔
1126
            } elseif ($t instanceof LegacyType) {
415✔
1127
                $collectionValueType = $t->getCollectionValueTypes()[0] ?? null;
×
1128
            }
1129

1130
            /* From @see AbstractObjectNormalizer::validateAndDenormalize() */
1131
            // Fix a collection that contains the only one element
1132
            // This is special to xml format only
1133
            if ('xml' === $format && null !== $collectionValueType && (!\is_array($value) || !\is_int(key($value)))) {
425✔
1134
                $isMixedType = $collectionValueType instanceof Type && $collectionValueType->isIdentifiedBy(TypeIdentifier::MIXED);
2✔
1135
                if (!$isMixedType) {
2✔
1136
                    $value = [$value];
2✔
1137
                }
1138
            }
1139

1140
            if (($collectionValueType instanceof Type && $collectionValueType->isSatisfiedBy($typeIsResourceClass))
425✔
1141
                || ($t instanceof LegacyType && $t->isCollection() && null !== $collectionValueType && null !== ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className))
425✔
1142
            ) {
1143
                $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className);
32✔
1144
                $context['resource_class'] = $resourceClass;
32✔
1145
                unset($context['uri_variables']);
32✔
1146

1147
                try {
1148
                    return $t instanceof Type
32✔
1149
                        ? $this->denormalizeObjectCollection($attribute, $propertyMetadata, $t, $resourceClass, $value, $format, $context)
32✔
1150
                        : $this->denormalizeCollection($attribute, $propertyMetadata, $t, $resourceClass, $value, $format, $context);
26✔
1151
                } catch (NotNormalizableValueException $e) {
6✔
1152
                    // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
1153
                    if ($isMultipleTypes) {
6✔
1154
                        $denormalizationException ??= $e;
×
1155

1156
                        continue;
×
1157
                    }
1158

1159
                    throw $e;
6✔
1160
                }
1161
            }
1162

1163
            if (
1164
                ($t instanceof Type && $t->isSatisfiedBy($typeIsResourceClass))
416✔
1165
                || ($t instanceof LegacyType && null !== ($className = $t->getClassName()) && $this->resourceClassResolver->isResourceClass($className))
416✔
1166
            ) {
1167
                $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className);
112✔
1168
                $childContext = $this->createChildContext($this->createOperationContext($context, $resourceClass, $propertyMetadata), $attribute, $format);
112✔
1169

1170
                try {
1171
                    return $this->denormalizeRelation($attribute, $propertyMetadata, $resourceClass, $value, $format, $childContext);
112✔
1172
                } catch (NotNormalizableValueException $e) {
15✔
1173
                    // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
1174
                    if ($isMultipleTypes) {
5✔
1175
                        $denormalizationException ??= $e;
4✔
1176

1177
                        continue;
4✔
1178
                    }
1179

1180
                    throw $e;
1✔
1181
                }
1182
            }
1183

1184
            if (
1185
                ($t instanceof CollectionType && $collectionValueType instanceof ObjectType)
378✔
1186
                || ($t instanceof LegacyType && $t->isCollection() && null !== $collectionValueType && null !== $collectionValueType->getClassName())
378✔
1187
            ) {
1188
                $className = $collectionValueType->getClassName();
6✔
1189
                if (!$this->serializer instanceof DenormalizerInterface) {
6✔
1190
                    throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class));
×
1191
                }
1192

1193
                unset($context['resource_class'], $context['uri_variables']);
6✔
1194

1195
                try {
1196
                    return $this->serializer->denormalize($value, $className.'[]', $format, $context);
6✔
1197
                } catch (NotNormalizableValueException $e) {
×
1198
                    // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
1199
                    if ($isMultipleTypes) {
×
1200
                        $denormalizationException ??= $e;
×
1201

1202
                        continue;
×
1203
                    }
1204

1205
                    throw $e;
×
1206
                }
1207
            }
1208

1209
            while ($t instanceof WrappingTypeInterface) {
378✔
1210
                $t = $t->getWrappedType();
11✔
1211
            }
1212

1213
            if (
1214
                $t instanceof ObjectType
378✔
1215
                || ($t instanceof LegacyType && null !== $t->getClassName())
378✔
1216
            ) {
1217
                if (!$this->serializer instanceof DenormalizerInterface) {
42✔
1218
                    throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class));
×
1219
                }
1220

1221
                unset($context['resource_class'], $context['uri_variables']);
42✔
1222

1223
                try {
1224
                    return $this->serializer->denormalize($value, $t->getClassName(), $format, $context);
42✔
1225
                } catch (NotNormalizableValueException $e) {
11✔
1226
                    // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
1227
                    if ($isMultipleTypes) {
11✔
1228
                        $denormalizationException ??= $e;
10✔
1229

1230
                        continue;
10✔
1231
                    }
1232

1233
                    throw $e;
1✔
1234
                }
1235
            }
1236

1237
            /* From @see AbstractObjectNormalizer::validateAndDenormalize() */
1238
            // In XML and CSV all basic datatypes are represented as strings, it is e.g. not possible to determine,
1239
            // if a value is meant to be a string, float, int or a boolean value from the serialized representation.
1240
            // That's why we have to transform the values, if one of these non-string basic datatypes is expected.
1241
            if (\is_string($value) && (XmlEncoder::FORMAT === $format || CsvEncoder::FORMAT === $format)) {
367✔
1242
                if ('' === $value && $isNullable && (
30✔
1243
                    ($t instanceof Type && $t->isIdentifiedBy(TypeIdentifier::BOOL, TypeIdentifier::INT, TypeIdentifier::FLOAT))
30✔
1244
                    || ($t instanceof LegacyType && \in_array($t->getBuiltinType(), [LegacyType::BUILTIN_TYPE_BOOL, LegacyType::BUILTIN_TYPE_INT, LegacyType::BUILTIN_TYPE_FLOAT], true))
30✔
1245
                )) {
1246
                    return null;
×
1247
                }
1248

1249
                $typeIdentifier = $t instanceof BuiltinType ? $t->getTypeIdentifier() : TypeIdentifier::tryFrom($t->getBuiltinType());
30✔
1250

1251
                switch ($typeIdentifier) {
1252
                    case TypeIdentifier::BOOL:
30✔
1253
                        // according to http://www.w3.org/TR/xmlschema-2/#boolean, valid representations are "false", "true", "0" and "1"
1254
                        if ('false' === $value || '0' === $value) {
8✔
1255
                            $value = false;
4✔
1256
                        } elseif ('true' === $value || '1' === $value) {
4✔
1257
                            $value = true;
4✔
1258
                        } else {
1259
                            // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
1260
                            if ($isMultipleTypes) {
×
1261
                                break 2;
×
1262
                            }
1263
                            throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" attribute for class "%s" must be bool ("%s" given).', $attribute, $className, $value), $value, ['bool'], $context['deserialization_path'] ?? null);
×
1264
                        }
1265
                        break;
8✔
1266
                    case TypeIdentifier::INT:
22✔
1267
                        if (ctype_digit($value) || ('-' === $value[0] && ctype_digit(substr($value, 1)))) {
8✔
1268
                            $value = (int) $value;
8✔
1269
                        } else {
1270
                            // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
1271
                            if ($isMultipleTypes) {
×
1272
                                break 2;
×
1273
                            }
1274
                            throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" attribute for class "%s" must be int ("%s" given).', $attribute, $className, $value), $value, ['int'], $context['deserialization_path'] ?? null);
×
1275
                        }
1276
                        break;
8✔
1277
                    case TypeIdentifier::FLOAT:
14✔
1278
                        if (is_numeric($value)) {
8✔
1279
                            return (float) $value;
2✔
1280
                        }
1281

1282
                        switch ($value) {
1283
                            case 'NaN':
6✔
1284
                                return \NAN;
2✔
1285
                            case 'INF':
4✔
1286
                                return \INF;
2✔
1287
                            case '-INF':
2✔
1288
                                return -\INF;
2✔
1289
                            default:
1290
                                // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
1291
                                if ($isMultipleTypes) {
×
1292
                                    break 3;
×
1293
                                }
1294
                                throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" attribute for class "%s" must be float ("%s" given).', $attribute, $className, $value), $value, ['float'], $context['deserialization_path'] ?? null);
×
1295
                        }
1296
                }
1297
            }
1298

1299
            if ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false) {
359✔
1300
                return $value;
×
1301
            }
1302

1303
            try {
1304
                $t instanceof Type
359✔
1305
                    ? $this->validateAttributeType($attribute, $t, $value, $format, $context)
359✔
1306
                    : $this->validateType($attribute, $t, $value, $format, $context);
×
1307

1308
                $denormalizationException = null;
346✔
1309
                break;
346✔
1310
            } catch (NotNormalizableValueException $e) {
79✔
1311
                // union/intersect types: try the next type
1312
                if (!$isMultipleTypes) {
79✔
1313
                    throw $e;
7✔
1314
                }
1315

1316
                $denormalizationException ??= $e;
72✔
1317
            }
1318
        }
1319

1320
        if ($denormalizationException) {
362✔
1321
            if ($type instanceof Type && $type->isSatisfiedBy(static fn ($type) => $type instanceof BuiltinType) && !$type->isSatisfiedBy($typeIsResourceClass)) {
11✔
1322
                throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" attribute must be "%s", "%s" given.', $attribute, $type, \gettype($value)), $value, array_map(strval(...), $types), $context['deserialization_path'] ?? null);
7✔
1323
            }
1324

1325
            throw $denormalizationException;
6✔
1326
        }
1327

1328
        return $value;
356✔
1329
    }
1330

1331
    /**
1332
     * Sets a value of the object using the PropertyAccess component.
1333
     */
1334
    private function setValue(object $object, string $attributeName, mixed $value): void
1335
    {
1336
        try {
1337
            $this->propertyAccessor->setValue($object, $attributeName, $value);
388✔
1338
        } catch (NoSuchPropertyException) {
23✔
1339
            // Properties not found are ignored
1340
        }
1341
    }
1342
}
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