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

api-platform / core / 15040977736

15 May 2025 09:02AM UTC coverage: 21.754% (+13.3%) from 8.423%
15040977736

Pull #6960

github

web-flow
Merge 7a7a13526 into 1862d03b7
Pull Request #6960: feat(json-schema): mutualize json schema between formats

320 of 460 new or added lines in 24 files covered. (69.57%)

1863 existing lines in 109 files now uncovered.

11069 of 50882 relevant lines covered (21.75%)

29.49 hits per line

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

71.52
/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\InvalidArgumentException;
19
use ApiPlatform\Metadata\Exception\ItemNotFoundException;
20
use ApiPlatform\Metadata\IriConverterInterface;
21
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
22
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
23
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
24
use ApiPlatform\Metadata\ResourceAccessCheckerInterface;
25
use ApiPlatform\Metadata\ResourceClassResolverInterface;
26
use ApiPlatform\Metadata\UrlGeneratorInterface;
27
use ApiPlatform\Metadata\Util\ClassInfoTrait;
28
use ApiPlatform\Metadata\Util\CloneTrait;
29
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
30
use Symfony\Component\PropertyAccess\PropertyAccess;
31
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
32
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
33
use Symfony\Component\PropertyInfo\Type as LegacyType;
34
use Symfony\Component\Serializer\Encoder\CsvEncoder;
35
use Symfony\Component\Serializer\Encoder\XmlEncoder;
36
use Symfony\Component\Serializer\Exception\LogicException;
37
use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException;
38
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
39
use Symfony\Component\Serializer\Exception\RuntimeException;
40
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
41
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
42
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
43
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
44
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
45
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
46
use Symfony\Component\TypeInfo\Type;
47
use Symfony\Component\TypeInfo\Type\BuiltinType;
48
use Symfony\Component\TypeInfo\Type\CollectionType;
49
use Symfony\Component\TypeInfo\Type\CompositeTypeInterface;
50
use Symfony\Component\TypeInfo\Type\NullableType;
51
use Symfony\Component\TypeInfo\Type\ObjectType;
52
use Symfony\Component\TypeInfo\Type\WrappingTypeInterface;
53
use Symfony\Component\TypeInfo\TypeIdentifier;
54

55
/**
56
 * Base item normalizer.
57
 *
58
 * @author Kévin Dunglas <dunglas@gmail.com>
59
 */
60
abstract class AbstractItemNormalizer extends AbstractObjectNormalizer
61
{
62
    use ClassInfoTrait;
63
    use CloneTrait;
64
    use ContextTrait;
65
    use InputOutputMetadataTrait;
66
    use OperationContextTrait;
67

68
    protected PropertyAccessorInterface $propertyAccessor;
69
    protected array $localCache = [];
70
    protected array $localFactoryOptionsCache = [];
71
    protected ?ResourceAccessCheckerInterface $resourceAccessChecker;
72

73
    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)
74
    {
75
        if (!isset($defaultContext['circular_reference_handler'])) {
855✔
76
            $defaultContext['circular_reference_handler'] = fn ($object): ?string => $this->iriConverter->getIriFromResource($object);
855✔
77
        }
78

79
        parent::__construct($classMetadataFactory, $nameConverter, null, null, \Closure::fromCallable($this->getObjectClass(...)), $defaultContext);
855✔
80
        $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor();
855✔
81
        $this->resourceAccessChecker = $resourceAccessChecker;
855✔
82
        $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
855✔
83
    }
84

85
    /**
86
     * {@inheritdoc}
87
     */
88
    public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
89
    {
90
        if (!\is_object($data) || is_iterable($data)) {
758✔
91
            return false;
249✔
92
        }
93

94
        $class = $context['force_resource_class'] ?? $this->getObjectClass($data);
733✔
95
        if (($context['output']['class'] ?? null) === $class) {
733✔
96
            return true;
16✔
97
        }
98

99
        return $this->resourceClassResolver->isResourceClass($class);
727✔
100
    }
101

102
    public function getSupportedTypes(?string $format): array
103
    {
104
        return [
789✔
105
            'object' => true,
789✔
106
        ];
789✔
107
    }
108

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

122
            unset($context['output'], $context['operation'], $context['operation_name']);
16✔
123
            $context['resource_class'] = $outputClass;
16✔
124
            $context['api_sub_level'] = true;
16✔
125
            $context[self::ALLOW_EXTRA_ATTRIBUTES] = false;
16✔
126

127
            return $this->serializer->normalize($object, $format, $context);
16✔
128
        }
129

130
        // Never remove this, with `application/json` we don't use our AbstractCollectionNormalizer and we need
131
        // to remove the collection operation from our context or we'll introduce security issues
132
        if (isset($context['operation']) && $context['operation'] instanceof CollectionOperationInterface) {
732✔
133
            unset($context['operation_name'], $context['operation'], $context['iri']);
6✔
134
        }
135

136
        if ($this->resourceClassResolver->isResourceClass($resourceClass)) {
732✔
137
            $context = $this->initContext($resourceClass, $context);
718✔
138
        }
139

140
        $context['api_normalize'] = true;
732✔
141
        $iri = $context['iri'] ??= $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL, $context['operation'] ?? null, $context);
732✔
142

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

156
        if (!$this->tagCollector && isset($context['resources'])) {
732✔
157
            $context['resources'][$iri] = $iri;
×
158
        }
159

160
        $context['object'] = $object;
732✔
161
        $context['format'] = $format;
732✔
162

163
        $data = parent::normalize($object, $format, $context);
732✔
164

165
        $context['data'] = $data;
732✔
166
        unset($context['property_metadata'], $context['api_attribute']);
732✔
167

168
        if ($emptyResourceAsIri && \is_array($data) && 0 === \count($data)) {
732✔
169
            $context['data'] = $iri;
×
170

171
            if ($this->tagCollector) {
×
172
                $this->tagCollector->collect($context);
×
173
            }
174

175
            return $iri;
×
176
        }
177

178
        if ($this->tagCollector) {
732✔
179
            $this->tagCollector->collect($context);
633✔
180
        }
181

182
        return $data;
732✔
183
    }
184

185
    /**
186
     * {@inheritdoc}
187
     */
188
    public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
189
    {
190
        if (($context['input']['class'] ?? null) === $type) {
270✔
191
            return true;
×
192
        }
193

194
        return $this->localCache[$type] ?? $this->localCache[$type] = $this->resourceClassResolver->isResourceClass($type);
270✔
195
    }
196

197
    /**
198
     * {@inheritdoc}
199
     */
200
    public function denormalize(mixed $data, string $class, ?string $format = null, array $context = []): mixed
201
    {
202
        $resourceClass = $class;
267✔
203

204
        if ($inputClass = $this->getInputClass($context)) {
267✔
205
            if (!$this->serializer instanceof DenormalizerInterface) {
14✔
206
                throw new LogicException('Cannot denormalize the input because the injected serializer is not a denormalizer');
×
207
            }
208

209
            unset($context['input'], $context['operation'], $context['operation_name'], $context['uri_variables']);
14✔
210
            $context['resource_class'] = $inputClass;
14✔
211

212
            try {
213
                return $this->serializer->denormalize($data, $inputClass, $format, $context);
14✔
214
            } catch (NotNormalizableValueException $e) {
1✔
215
                throw new UnexpectedValueException('The input data is misformatted.', $e->getCode(), $e);
1✔
216
            }
217
        }
218

219
        if (null === $objectToPopulate = $this->extractObjectToPopulate($resourceClass, $context, static::OBJECT_TO_POPULATE)) {
255✔
220
            $normalizedData = \is_scalar($data) ? [$data] : $this->prepareForDenormalization($data);
207✔
221
            $class = $this->getClassDiscriminatorResolvedClass($normalizedData, $class, $context);
207✔
222
        }
223

224
        $context['api_denormalize'] = true;
255✔
225

226
        if ($this->resourceClassResolver->isResourceClass($class)) {
255✔
227
            $resourceClass = $this->resourceClassResolver->getResourceClass($objectToPopulate, $class);
255✔
228
            $context['resource_class'] = $resourceClass;
255✔
229
        }
230

231
        if (\is_string($data)) {
255✔
232
            try {
233
                return $this->iriConverter->getResourceFromIri($data, $context + ['fetch_data' => true]);
2✔
234
            } catch (ItemNotFoundException $e) {
×
235
                if (!isset($context['not_normalizable_value_exceptions'])) {
×
236
                    throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
×
237
                }
238

239
                throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" resource "string" (IRI), "%s" given.', $resourceClass, \gettype($data)), $data, [$resourceClass], $context['deserialization_path'] ?? null, true, $e->getCode(), $e);
×
240
            } catch (InvalidArgumentException $e) {
×
241
                if (!isset($context['not_normalizable_value_exceptions'])) {
×
242
                    throw new UnexpectedValueException(\sprintf('Invalid IRI "%s".', $data), $e->getCode(), $e);
×
243
                }
244

245
                throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" resource "string" (IRI), "%s" given.', $resourceClass, \gettype($data)), $data, [$resourceClass], $context['deserialization_path'] ?? null, true, $e->getCode(), $e);
×
246
            }
247
        }
248

249
        if (!\is_array($data)) {
254✔
250
            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);
1✔
251
        }
252

253
        $previousObject = $this->clone($objectToPopulate);
254✔
254
        $object = parent::denormalize($data, $class, $format, $context);
254✔
255

256
        if (!$this->resourceClassResolver->isResourceClass($class)) {
238✔
257
            return $object;
×
258
        }
259

260
        // Bypass the post-denormalize attribute revert logic if the object could not be
261
        // cloned since we cannot possibly revert any changes made to it.
262
        if (null !== $objectToPopulate && null === $previousObject) {
238✔
263
            return $object;
×
264
        }
265

266
        $options = $this->getFactoryOptions($context);
238✔
267
        $propertyNames = iterator_to_array($this->propertyNameCollectionFactory->create($resourceClass, $options));
238✔
268

269
        // Revert attributes that aren't allowed to be changed after a post-denormalize check
270
        foreach (array_keys($data) as $attribute) {
238✔
271
            $attribute = $this->nameConverter ? $this->nameConverter->denormalize((string) $attribute) : $attribute;
223✔
272
            if (!\in_array($attribute, $propertyNames, true)) {
223✔
273
                continue;
50✔
274
            }
275

276
            if (!$this->canAccessAttributePostDenormalize($object, $previousObject, $attribute, $context)) {
212✔
277
                if (null !== $previousObject) {
3✔
278
                    $this->setValue($object, $attribute, $this->propertyAccessor->getValue($previousObject, $attribute));
1✔
279
                } else {
280
                    $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $attribute, $options);
2✔
281
                    $this->setValue($object, $attribute, $propertyMetadata->getDefault());
2✔
282
                }
283
            }
284
        }
285

286
        return $object;
238✔
287
    }
288

289
    /**
290
     * Method copy-pasted from symfony/serializer.
291
     * Remove it after symfony/serializer version update @see https://github.com/symfony/symfony/pull/28263.
292
     *
293
     * {@inheritdoc}
294
     *
295
     * @internal
296
     */
297
    protected function instantiateObject(array &$data, string $class, array &$context, \ReflectionClass $reflectionClass, array|bool $allowedAttributes, ?string $format = null): object
298
    {
299
        if (null !== $object = $this->extractObjectToPopulate($class, $context, static::OBJECT_TO_POPULATE)) {
254✔
300
            unset($context[static::OBJECT_TO_POPULATE]);
57✔
301

302
            return $object;
57✔
303
        }
304

305
        $class = $this->getClassDiscriminatorResolvedClass($data, $class, $context);
206✔
306
        $reflectionClass = new \ReflectionClass($class);
206✔
307

308
        $constructor = $this->getConstructor($data, $class, $context, $reflectionClass, $allowedAttributes);
206✔
309
        if ($constructor) {
206✔
310
            $constructorParameters = $constructor->getParameters();
95✔
311

312
            $params = [];
95✔
313
            $missingConstructorArguments = [];
95✔
314
            foreach ($constructorParameters as $constructorParameter) {
95✔
315
                $paramName = $constructorParameter->name;
40✔
316
                $key = $this->nameConverter ? $this->nameConverter->normalize($paramName, $class, $format, $context) : $paramName;
40✔
317
                $attributeContext = $this->getAttributeDenormalizationContext($class, $paramName, $context);
40✔
318
                $attributeContext['deserialization_path'] = $attributeContext['deserialization_path'] ?? $key;
40✔
319

320
                $allowed = false === $allowedAttributes || (\is_array($allowedAttributes) && \in_array($paramName, $allowedAttributes, true));
40✔
321
                $ignored = !$this->isAllowedAttribute($class, $paramName, $format, $context);
40✔
322
                if ($constructorParameter->isVariadic()) {
40✔
323
                    if ($allowed && !$ignored && (isset($data[$key]) || \array_key_exists($key, $data))) {
×
324
                        if (!\is_array($data[$paramName])) {
×
325
                            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));
×
326
                        }
327

328
                        $params[] = $data[$paramName];
×
329
                    }
330
                } elseif ($allowed && !$ignored && (isset($data[$key]) || \array_key_exists($key, $data))) {
40✔
331
                    try {
332
                        $params[] = $this->createConstructorArgument($data[$key], $key, $constructorParameter, $attributeContext, $format);
14✔
333
                    } catch (NotNormalizableValueException $exception) {
2✔
334
                        if (!isset($context['not_normalizable_value_exceptions'])) {
2✔
335
                            throw $exception;
2✔
336
                        }
UNCOV
337
                        $context['not_normalizable_value_exceptions'][] = $exception;
×
338
                    }
339

340
                    // Don't run set for a parameter passed to the constructor
341
                    unset($data[$key]);
12✔
342
                } elseif (isset($context[static::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class][$key])) {
34✔
343
                    $params[] = $context[static::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class][$key];
×
344
                } elseif ($constructorParameter->isDefaultValueAvailable()) {
34✔
345
                    $params[] = $constructorParameter->getDefaultValue();
34✔
346
                } else {
347
                    if (!isset($context['not_normalizable_value_exceptions'])) {
1✔
348
                        $missingConstructorArguments[] = $constructorParameter->name;
1✔
349
                    }
350

351
                    $constructorParameterType = 'unknown';
1✔
352
                    $reflectionType = $constructorParameter->getType();
1✔
353
                    if ($reflectionType instanceof \ReflectionNamedType) {
1✔
354
                        $constructorParameterType = $reflectionType->getName();
1✔
355
                    }
356

357
                    $exception = NotNormalizableValueException::createForUnexpectedDataType(
1✔
358
                        \sprintf('Failed to create object because the class misses the "%s" property.', $constructorParameter->name),
1✔
359
                        null,
1✔
360
                        [$constructorParameterType],
1✔
361
                        $attributeContext['deserialization_path'],
1✔
362
                        true
1✔
363
                    );
1✔
364
                    $context['not_normalizable_value_exceptions'][] = $exception;
1✔
365
                }
366
            }
367

368
            if ($missingConstructorArguments) {
93✔
369
                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);
1✔
370
            }
371

372
            if (\count($context['not_normalizable_value_exceptions'] ?? []) > 0) {
93✔
UNCOV
373
                return $reflectionClass->newInstanceWithoutConstructor();
×
374
            }
375

376
            if ($constructor->isConstructor()) {
93✔
377
                return $reflectionClass->newInstanceArgs($params);
93✔
378
            }
379

380
            return $constructor->invokeArgs(null, $params);
×
381
        }
382

383
        return new $class();
119✔
384
    }
385

386
    protected function getClassDiscriminatorResolvedClass(array $data, string $class, array $context = []): string
387
    {
388
        if (null === $this->classDiscriminatorResolver || (null === $mapping = $this->classDiscriminatorResolver->getMappingForClass($class))) {
207✔
389
            return $class;
207✔
390
        }
391

392
        if (!isset($data[$mapping->getTypeProperty()])) {
1✔
393
            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());
×
394
        }
395

396
        $type = $data[$mapping->getTypeProperty()];
1✔
397
        if (null === ($mappedClass = $mapping->getClassForType($type))) {
1✔
398
            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);
×
399
        }
400

401
        return $mappedClass;
1✔
402
    }
403

404
    protected function createConstructorArgument($parameterData, string $key, \ReflectionParameter $constructorParameter, array $context, ?string $format = null): mixed
405
    {
406
        return $this->createAndValidateAttributeValue($constructorParameter->name, $parameterData, $format, $context);
14✔
407
    }
408

409
    /**
410
     * {@inheritdoc}
411
     *
412
     * Unused in this context.
413
     *
414
     * @return string[]
415
     */
416
    protected function extractAttributes($object, $format = null, array $context = []): array
417
    {
418
        return [];
×
419
    }
420

421
    /**
422
     * {@inheritdoc}
423
     */
424
    protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool
425
    {
426
        if (!$this->resourceClassResolver->isResourceClass($context['resource_class'])) {
741✔
427
            return parent::getAllowedAttributes($classOrObject, $context, $attributesAsString);
16✔
428
        }
429

430
        $resourceClass = $this->resourceClassResolver->getResourceClass(null, $context['resource_class']); // fix for abstract classes and interfaces
728✔
431
        $options = $this->getFactoryOptions($context);
728✔
432
        $propertyNames = $this->propertyNameCollectionFactory->create($resourceClass, $options);
728✔
433

434
        $allowedAttributes = [];
728✔
435
        foreach ($propertyNames as $propertyName) {
728✔
436
            $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName, $options);
725✔
437

438
            if (
439
                $this->isAllowedAttribute($classOrObject, $propertyName, null, $context)
725✔
440
                && (isset($context['api_normalize']) && $propertyMetadata->isReadable()
725✔
441
                    || isset($context['api_denormalize']) && ($propertyMetadata->isWritable() || !\is_object($classOrObject) && $propertyMetadata->isInitializable())
725✔
442
                )
443
            ) {
444
                $allowedAttributes[] = $propertyName;
721✔
445
            }
446
        }
447

448
        return $allowedAttributes;
728✔
449
    }
450

451
    /**
452
     * {@inheritdoc}
453
     */
454
    protected function isAllowedAttribute(object|string $classOrObject, string $attribute, ?string $format = null, array $context = []): bool
455
    {
456
        if (!parent::isAllowedAttribute($classOrObject, $attribute, $format, $context)) {
738✔
457
            return false;
220✔
458
        }
459

460
        return $this->canAccessAttribute(\is_object($classOrObject) ? $classOrObject : null, $attribute, $context);
737✔
461
    }
462

463
    /**
464
     * Check if access to the attribute is granted.
465
     */
466
    protected function canAccessAttribute(?object $object, string $attribute, array $context = []): bool
467
    {
468
        if (!$this->resourceClassResolver->isResourceClass($context['resource_class'])) {
737✔
469
            return true;
16✔
470
        }
471

472
        $options = $this->getFactoryOptions($context);
724✔
473
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options);
724✔
474
        $security = $propertyMetadata->getSecurity() ?? $propertyMetadata->getPolicy();
724✔
475
        if (null !== $this->resourceAccessChecker && $security) {
724✔
476
            return $this->resourceAccessChecker->isGranted($context['resource_class'], $security, [
31✔
477
                'object' => $object,
31✔
478
                'property' => $attribute,
31✔
479
            ]);
31✔
480
        }
481

482
        return true;
720✔
483
    }
484

485
    /**
486
     * Check if access to the attribute is granted.
487
     */
488
    protected function canAccessAttributePostDenormalize(?object $object, ?object $previousObject, string $attribute, array $context = []): bool
489
    {
490
        $options = $this->getFactoryOptions($context);
212✔
491
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options);
212✔
492
        $security = $propertyMetadata->getSecurityPostDenormalize();
212✔
493
        if ($this->resourceAccessChecker && $security) {
212✔
494
            return $this->resourceAccessChecker->isGranted($context['resource_class'], $security, [
6✔
495
                'object' => $object,
6✔
496
                'previous_object' => $previousObject,
6✔
497
                'property' => $attribute,
6✔
498
            ]);
6✔
499
        }
500

501
        return true;
211✔
502
    }
503

504
    /**
505
     * {@inheritdoc}
506
     */
507
    protected function setAttributeValue(object $object, string $attribute, mixed $value, ?string $format = null, array $context = []): void
508
    {
509
        try {
510
            $this->setValue($object, $attribute, $this->createAttributeValue($attribute, $value, $format, $context));
210✔
511
        } catch (NotNormalizableValueException $exception) {
14✔
512
            // Only throw if collecting denormalization errors is disabled.
513
            if (!isset($context['not_normalizable_value_exceptions'])) {
10✔
514
                throw $exception;
10✔
515
            }
516
        }
517
    }
518

519
    /**
520
     * @deprecated since 4.1, use "validateAttributeType" instead
521
     *
522
     * Validates the type of the value. Allows using integers as floats for JSON formats.
523
     *
524
     * @throws NotNormalizableValueException
525
     */
526
    protected function validateType(string $attribute, LegacyType $type, mixed $value, ?string $format = null, array $context = []): void
527
    {
528
        trigger_deprecation('api-platform/serializer', '4.1', 'The "%s()" method is deprecated, use "%s::validateAttributeType()" instead.', __METHOD__, self::class);
×
529

530
        $builtinType = $type->getBuiltinType();
×
531
        if (LegacyType::BUILTIN_TYPE_FLOAT === $builtinType && null !== $format && str_contains($format, 'json')) {
×
532
            $isValid = \is_float($value) || \is_int($value);
×
533
        } else {
534
            $isValid = \call_user_func('is_'.$builtinType, $value);
×
535
        }
536

537
        if (!$isValid) {
×
538
            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);
×
539
        }
540
    }
541

542
    /**
543
     * Validates the type of the value. Allows using integers as floats for JSON formats.
544
     *
545
     * @throws NotNormalizableValueException
546
     */
547
    protected function validateAttributeType(string $attribute, Type $type, mixed $value, ?string $format = null, array $context = []): void
548
    {
549
        if ($type->isIdentifiedBy(TypeIdentifier::FLOAT) && null !== $format && str_contains($format, 'json')) {
180✔
550
            $isValid = \is_float($value) || \is_int($value);
1✔
551
        } else {
552
            $isValid = $type->accepts($value);
180✔
553
        }
554

555
        if (!$isValid) {
180✔
556
            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);
43✔
557
        }
558
    }
559

560
    /**
561
     * @deprecated since 4.1, use "denormalizeObjectCollection" instead.
562
     *
563
     * Denormalizes a collection of objects.
564
     *
565
     * @throws NotNormalizableValueException
566
     */
567
    protected function denormalizeCollection(string $attribute, ApiProperty $propertyMetadata, LegacyType $type, string $className, mixed $value, ?string $format, array $context): array
568
    {
569
        trigger_deprecation('api-platform/serializer', '4.1', 'The "%s()" method is deprecated, use "%s::denormalizeObjectCollection()" instead.', __METHOD__, self::class);
×
570

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

575
        $values = [];
×
576
        $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
×
577
        $collectionKeyTypes = $type->getCollectionKeyTypes();
×
578
        foreach ($value as $index => $obj) {
×
579
            $currentChildContext = $childContext;
×
580
            if (isset($childContext['deserialization_path'])) {
×
581
                $currentChildContext['deserialization_path'] = "{$childContext['deserialization_path']}[{$index}]";
×
582
            }
583

584
            // no typehint provided on collection key
585
            if (!$collectionKeyTypes) {
×
586
                $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $currentChildContext);
×
587
                continue;
×
588
            }
589

590
            // validate collection key typehint
591
            foreach ($collectionKeyTypes as $collectionKeyType) {
×
592
                $collectionKeyBuiltinType = $collectionKeyType->getBuiltinType();
×
593
                if (!\call_user_func('is_'.$collectionKeyBuiltinType, $index)) {
×
594
                    continue;
×
595
                }
596

597
                $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $currentChildContext);
×
598
                continue 2;
×
599
            }
600
            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);
×
601
        }
602

603
        return $values;
×
604
    }
605

606
    /**
607
     * Denormalizes a collection of objects.
608
     *
609
     * @throws NotNormalizableValueException
610
     */
611
    protected function denormalizeObjectCollection(string $attribute, ApiProperty $propertyMetadata, Type $type, string $className, mixed $value, ?string $format, array $context): array
612
    {
613
        if (!\is_array($value)) {
17✔
614
            throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" attribute must be "array", "%s" given.', $attribute, \gettype($value)), $value, ['array'], $context['deserialization_path'] ?? null);
1✔
615
        }
616

617
        $values = [];
16✔
618
        $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
16✔
619

620
        foreach ($value as $index => $obj) {
16✔
621
            $currentChildContext = $childContext;
16✔
622
            if (isset($childContext['deserialization_path'])) {
16✔
623
                $currentChildContext['deserialization_path'] = "{$childContext['deserialization_path']}[{$index}]";
16✔
624
            }
625

626
            if ($type instanceof CollectionType) {
16✔
627
                $collectionKeyType = $type->getCollectionKeyType();
16✔
628

629
                while ($collectionKeyType instanceof WrappingTypeInterface) {
16✔
630
                    $collectionKeyType = $type->getWrappedType();
×
631
                }
632

633
                if (!$collectionKeyType->accepts($index)) {
16✔
634
                    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);
1✔
635
                }
636
            }
637

638
            $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $currentChildContext);
15✔
639
        }
640

641
        return $values;
15✔
642
    }
643

644
    /**
645
     * Denormalizes a relation.
646
     *
647
     * @throws LogicException
648
     * @throws UnexpectedValueException
649
     * @throws NotNormalizableValueException
650
     */
651
    protected function denormalizeRelation(string $attributeName, ApiProperty $propertyMetadata, string $className, mixed $value, ?string $format, array $context): ?object
652
    {
653
        if (\is_string($value)) {
59✔
654
            try {
655
                return $this->iriConverter->getResourceFromIri($value, $context + ['fetch_data' => true]);
32✔
656
            } catch (ItemNotFoundException $e) {
3✔
657
                if (!isset($context['not_normalizable_value_exceptions'])) {
1✔
658
                    throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
1✔
659
                }
660

661
                throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $value, [$className], $context['deserialization_path'] ?? null, true, $e->getCode(), $e);
×
662
            } catch (InvalidArgumentException $e) {
2✔
663
                if (!isset($context['not_normalizable_value_exceptions'])) {
2✔
664
                    throw new UnexpectedValueException(\sprintf('Invalid IRI "%s".', $value), $e->getCode(), $e);
2✔
665
                }
666

667
                throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $value, [$className], $context['deserialization_path'] ?? null, true, $e->getCode(), $e);
×
668
            }
669
        }
670

671
        if ($propertyMetadata->isWritableLink()) {
28✔
672
            $context['api_allow_update'] = true;
27✔
673

674
            if (!$this->serializer instanceof DenormalizerInterface) {
27✔
675
                throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class));
×
676
            }
677

678
            $item = $this->serializer->denormalize($value, $className, $format, $context);
27✔
679
            if (!\is_object($item) && null !== $item) {
25✔
680
                throw new \UnexpectedValueException('Expected item to be an object or null.');
×
681
            }
682

683
            return $item;
25✔
684
        }
685

686
        if (!\is_array($value)) {
1✔
UNCOV
687
            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);
×
688
        }
689

690
        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✔
691
    }
692

693
    /**
694
     * Gets the options for the property name collection / property metadata factories.
695
     */
696
    protected function getFactoryOptions(array $context): array
697
    {
698
        $options = [];
741✔
699
        if (isset($context[self::GROUPS])) {
741✔
700
            /* @see https://github.com/symfony/symfony/blob/v4.2.6/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php */
701
            $options['serializer_groups'] = (array) $context[self::GROUPS];
253✔
702
        }
703

704
        $operationCacheKey = ($context['resource_class'] ?? '').($context['operation_name'] ?? '').($context['root_operation_name'] ?? '');
741✔
705
        $suffix = ($context['api_normalize'] ?? '') ? 'n' : '';
741✔
706
        if ($operationCacheKey && isset($this->localFactoryOptionsCache[$operationCacheKey.$suffix])) {
741✔
707
            return $options + $this->localFactoryOptionsCache[$operationCacheKey.$suffix];
739✔
708
        }
709

710
        // This is a hot spot
711
        if (isset($context['resource_class'])) {
741✔
712
            // Note that the groups need to be read on the root operation
713
            if ($operation = ($context['root_operation'] ?? null)) {
741✔
714
                $options['normalization_groups'] = $operation->getNormalizationContext()['groups'] ?? null;
284✔
715
                $options['denormalization_groups'] = $operation->getDenormalizationContext()['groups'] ?? null;
284✔
716
                $options['operation_name'] = $operation->getName();
284✔
717
            }
718
        }
719

720
        return $options + $this->localFactoryOptionsCache[$operationCacheKey.$suffix] = $options;
741✔
721
    }
722

723
    /**
724
     * {@inheritdoc}
725
     *
726
     * @throws UnexpectedValueException
727
     */
728
    protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed
729
    {
730
        $context['api_attribute'] = $attribute;
725✔
731
        $context['property_metadata'] = $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context));
725✔
732

733
        if ($context['api_denormalize'] ?? false) {
725✔
734
            return $this->propertyAccessor->getValue($object, $attribute);
12✔
735
        }
736

737
        if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
725✔
738
            $types = $propertyMetadata->getBuiltinTypes() ?? [];
×
739

740
            foreach ($types as $type) {
×
741
                if (
742
                    $type->isCollection()
×
743
                    && ($collectionValueType = $type->getCollectionValueTypes()[0] ?? null)
×
744
                    && ($className = $collectionValueType->getClassName())
×
745
                    && $this->resourceClassResolver->isResourceClass($className)
×
746
                ) {
747
                    $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
×
748

749
                    // @see ApiPlatform\Hal\Serializer\ItemNormalizer:getComponents logic for intentional duplicate content
750
                    // @see ApiPlatform\JsonApi\Serializer\ItemNormalizer:getComponents logic for intentional duplicate content
751
                    if ('jsonld' === $format && $itemUriTemplate = $propertyMetadata->getUriTemplate()) {
×
752
                        $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(
×
753
                            operationName: $itemUriTemplate,
×
754
                            forceCollection: true,
×
755
                            httpOperation: true
×
756
                        );
×
757

758
                        return $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
×
759
                    }
760

761
                    $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
×
762

763
                    if (!is_iterable($attributeValue)) {
×
764
                        throw new UnexpectedValueException('Unexpected non-iterable value for to-many relation.');
×
765
                    }
766

767
                    $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
×
768

769
                    $data = $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
×
770
                    $context['data'] = $data;
×
771
                    $context['type'] = $type;
×
772

773
                    if ($this->tagCollector) {
×
774
                        $this->tagCollector->collect($context);
×
775
                    }
776

777
                    return $data;
×
778
                }
779

780
                if (
781
                    ($className = $type->getClassName())
×
782
                    && $this->resourceClassResolver->isResourceClass($className)
×
783
                ) {
784
                    $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
×
785
                    unset($childContext['iri'], $childContext['uri_variables'], $childContext['item_uri_template']);
×
786
                    if ('jsonld' === $format && $uriTemplate = $propertyMetadata->getUriTemplate()) {
×
787
                        $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(
×
788
                            operationName: $uriTemplate,
×
789
                            httpOperation: true
×
790
                        );
×
791

792
                        return $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
×
793
                    }
794

795
                    $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
×
796

797
                    if (!\is_object($attributeValue) && null !== $attributeValue) {
×
798
                        throw new UnexpectedValueException('Unexpected non-object value for to-one relation.');
×
799
                    }
800

801
                    $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
×
802

803
                    $data = $this->normalizeRelation($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
×
804
                    $context['data'] = $data;
×
805
                    $context['type'] = $type;
×
806

807
                    if ($this->tagCollector) {
×
808
                        $this->tagCollector->collect($context);
×
809
                    }
810

811
                    return $data;
×
812
                }
813

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

818
                unset(
×
819
                    $context['resource_class'],
×
820
                    $context['force_resource_class'],
×
821
                    $context['uri_variables'],
×
822
                );
×
823

824
                // Anonymous resources
825
                if ($className) {
×
826
                    $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
×
827
                    $childContext['output']['gen_id'] = $propertyMetadata->getGenId() ?? true;
×
828

829
                    $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
×
830

831
                    return $this->serializer->normalize($attributeValue, $format, $childContext);
×
832
                }
833

834
                if ('array' === $type->getBuiltinType()) {
×
835
                    if ($className = ($type->getCollectionValueTypes()[0] ?? null)?->getClassName()) {
×
836
                        $context = $this->createOperationContext($context, $className);
×
837
                    }
838

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

842
                    $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
×
843

844
                    return $this->serializer->normalize($attributeValue, $format, $childContext);
×
845
                }
846
            }
847

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

852
            unset(
×
853
                $context['resource_class'],
×
854
                $context['force_resource_class'],
×
855
                $context['uri_variables']
×
856
            );
×
857

858
            $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
×
859

860
            return $this->serializer->normalize($attributeValue, $format, $context);
×
861
        }
862

863
        $type = $propertyMetadata->getNativeType();
725✔
864

865
        $nullable = false;
725✔
866
        if ($type instanceof NullableType) {
725✔
867
            $type = $type->getWrappedType();
527✔
868
            $nullable = true;
527✔
869
        }
870

871
        // TODO check every foreach composite to see if null is an issue
872
        $types = $type instanceof CompositeTypeInterface ? $type->getTypes() : (null === $type ? [] : [$type]);
725✔
873
        $className = null;
725✔
874
        $typeIsResourceClass = function (Type $type) use (&$className): bool {
725✔
875
            return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName());
708✔
876
        };
725✔
877

878
        foreach ($types as $type) {
725✔
879
            if ($type instanceof CollectionType && $type->getCollectionValueType()->isSatisfiedBy($typeIsResourceClass)) {
708✔
880
                $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
223✔
881

882
                // @see ApiPlatform\Hal\Serializer\ItemNormalizer:getComponents logic for intentional duplicate content
883
                // @see ApiPlatform\JsonApi\Serializer\ItemNormalizer:getComponents logic for intentional duplicate content
884
                if ('jsonld' === $format && $itemUriTemplate = $propertyMetadata->getUriTemplate()) {
223✔
885
                    $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(
1✔
886
                        operationName: $itemUriTemplate,
1✔
887
                        forceCollection: true,
1✔
888
                        httpOperation: true
1✔
889
                    );
1✔
890

891
                    return $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
1✔
892
                }
893

894
                $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
222✔
895

896
                if (!is_iterable($attributeValue)) {
222✔
897
                    throw new UnexpectedValueException('Unexpected non-iterable value for to-many relation.');
×
898
                }
899

900
                $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
222✔
901

902
                $data = $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
222✔
903
                $context['data'] = $data;
222✔
904
                $context['type'] = ($nullable && $type instanceof Type) ? Type::nullable($type) : $type;
222✔
905

906
                if ($this->tagCollector) {
222✔
907
                    $this->tagCollector->collect($context);
202✔
908
                }
909

910
                return $data;
222✔
911
            }
912

913
            if ($type->isSatisfiedBy($typeIsResourceClass)) {
708✔
914
                $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
321✔
915
                unset($childContext['iri'], $childContext['uri_variables'], $childContext['item_uri_template']);
321✔
916
                if ('jsonld' === $format && $uriTemplate = $propertyMetadata->getUriTemplate()) {
321✔
917
                    $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(
1✔
918
                        operationName: $uriTemplate,
1✔
919
                        httpOperation: true
1✔
920
                    );
1✔
921

922
                    return $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
1✔
923
                }
924

925
                $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
320✔
926

927
                if (!\is_object($attributeValue) && null !== $attributeValue) {
318✔
928
                    throw new UnexpectedValueException('Unexpected non-object value for to-one relation.');
×
929
                }
930

931
                $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
318✔
932

933
                $data = $this->normalizeRelation($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
318✔
934
                $context['data'] = $data;
318✔
935
                $context['type'] = $nullable ? Type::nullable($type) : $type;
318✔
936

937
                if ($this->tagCollector) {
318✔
938
                    $this->tagCollector->collect($context);
301✔
939
                }
940

941
                return $data;
318✔
942
            }
943

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

948
            unset(
701✔
949
                $context['resource_class'],
701✔
950
                $context['force_resource_class'],
701✔
951
                $context['uri_variables'],
701✔
952
            );
701✔
953

954
            // Anonymous resources
955
            if ($className) {
701✔
956
                $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
211✔
957
                $childContext['output']['gen_id'] = $propertyMetadata->getGenId() ?? true;
211✔
958

959
                $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
211✔
960

961
                return $this->serializer->normalize($attributeValue, $format, $childContext);
208✔
962
            }
963

964
            if ($type instanceof CollectionType) {
691✔
965
                if (($subType = $type->getCollectionValueType()) instanceof ObjectType) {
195✔
966
                    $context = $this->createOperationContext($context, $subType->getClassName());
×
967
                }
968

969
                $childContext = $this->createChildContext($context, $attribute, $format);
195✔
970
                $childContext['output']['gen_id'] = $propertyMetadata->getGenId() ?? true;
195✔
971

972
                $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
195✔
973

974
                return $this->serializer->normalize($attributeValue, $format, $childContext);
195✔
975
            }
976
        }
977

978
        if (!$this->serializer instanceof NormalizerInterface) {
708✔
979
            throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class));
×
980
        }
981

982
        unset(
708✔
983
            $context['resource_class'],
708✔
984
            $context['force_resource_class'],
708✔
985
            $context['uri_variables']
708✔
986
        );
708✔
987

988
        $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
708✔
989

990
        return $this->serializer->normalize($attributeValue, $format, $context);
707✔
991
    }
992

993
    /**
994
     * Normalizes a collection of relations (to-many).
995
     *
996
     * @throws UnexpectedValueException
997
     */
998
    protected function normalizeCollectionOfRelations(ApiProperty $propertyMetadata, iterable $attributeValue, string $resourceClass, ?string $format, array $context): array
999
    {
1000
        $value = [];
202✔
1001
        foreach ($attributeValue as $index => $obj) {
202✔
1002
            if (!\is_object($obj) && null !== $obj) {
59✔
1003
                throw new UnexpectedValueException('Unexpected non-object element in to-many relation.');
×
1004
            }
1005

1006
            // update context, if concrete object class deviates from general relation class (e.g. in case of polymorphic resources)
1007
            $objResourceClass = $this->resourceClassResolver->getResourceClass($obj, $resourceClass);
59✔
1008
            $context['resource_class'] = $objResourceClass;
59✔
1009
            if ($this->resourceMetadataCollectionFactory) {
59✔
1010
                $context['operation'] = $this->resourceMetadataCollectionFactory->create($objResourceClass)->getOperation();
59✔
1011
            }
1012

1013
            $value[$index] = $this->normalizeRelation($propertyMetadata, $obj, $resourceClass, $format, $context);
59✔
1014
        }
1015

1016
        return $value;
202✔
1017
    }
1018

1019
    /**
1020
     * Normalizes a relation.
1021
     *
1022
     * @throws LogicException
1023
     * @throws UnexpectedValueException
1024
     */
1025
    protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $relatedObject, string $resourceClass, ?string $format, array $context): \ArrayObject|array|string|null
1026
    {
1027
        if (null === $relatedObject || !empty($context['attributes']) || $propertyMetadata->isReadableLink()) {
291✔
1028
            if (!$this->serializer instanceof NormalizerInterface) {
220✔
1029
                throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class));
×
1030
            }
1031

1032
            $relatedContext = $this->createOperationContext($context, $resourceClass);
220✔
1033
            $normalizedRelatedObject = $this->serializer->normalize($relatedObject, $format, $relatedContext);
220✔
1034
            if (!\is_string($normalizedRelatedObject) && !\is_array($normalizedRelatedObject) && !$normalizedRelatedObject instanceof \ArrayObject && null !== $normalizedRelatedObject) {
220✔
1035
                throw new UnexpectedValueException('Expected normalized relation to be an IRI, array, \ArrayObject or null');
×
1036
            }
1037

1038
            return $normalizedRelatedObject;
220✔
1039
        }
1040

1041
        $context['iri'] = $iri = $this->iriConverter->getIriFromResource(resource: $relatedObject, context: $context);
102✔
1042
        $context['data'] = $iri;
102✔
1043
        $context['object'] = $relatedObject;
102✔
1044
        unset($context['property_metadata'], $context['api_attribute']);
102✔
1045

1046
        if ($this->tagCollector) {
102✔
1047
            $this->tagCollector->collect($context);
102✔
UNCOV
1048
        } elseif (isset($context['resources'])) {
×
1049
            $context['resources'][$iri] = $iri;
×
1050
        }
1051

1052
        $push = $propertyMetadata->getPush() ?? false;
102✔
1053
        if (isset($context['resources_to_push']) && $push) {
102✔
1054
            $context['resources_to_push'][$iri] = $iri;
17✔
1055
        }
1056

1057
        return $iri;
102✔
1058
    }
1059

1060
    private function createAttributeValue(string $attribute, mixed $value, ?string $format = null, array &$context = []): mixed
1061
    {
1062
        try {
1063
            return $this->createAndValidateAttributeValue($attribute, $value, $format, $context);
210✔
1064
        } catch (NotNormalizableValueException $exception) {
14✔
1065
            if (!isset($context['not_normalizable_value_exceptions'])) {
10✔
1066
                throw $exception;
10✔
1067
            }
UNCOV
1068
            $context['not_normalizable_value_exceptions'][] = $exception;
×
UNCOV
1069
            throw $exception;
×
1070
        }
1071
    }
1072

1073
    private function createAndValidateAttributeValue(string $attribute, mixed $value, ?string $format = null, array $context = []): mixed
1074
    {
1075
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context));
224✔
1076
        $denormalizationException = null;
224✔
1077

1078
        $types = [];
224✔
1079
        $type = null;
224✔
1080
        if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
224✔
1081
            $types = $propertyMetadata->getBuiltinTypes() ?? [];
×
1082
        } else {
1083
            $type = $propertyMetadata->getNativeType();
224✔
1084
            $types = $type instanceof CompositeTypeInterface ? $type->getTypes() : (null === $type ? [] : [$type]);
224✔
1085
        }
1086

1087
        $isMultipleTypes = \count($types) > 1;
224✔
1088
        $className = null;
224✔
1089
        $typeIsResourceClass = function (Type $type) use (&$className): bool {
224✔
1090
            return $type instanceof ObjectType ? $this->resourceClassResolver->isResourceClass($className = $type->getClassName()) : false;
217✔
1091
        };
224✔
1092

1093
        $isMultipleTypes = \count($types) > 1;
224✔
1094
        $denormalizationException = null;
224✔
1095

1096
        foreach ($types as $t) {
224✔
1097
            if ($type instanceof Type) {
218✔
1098
                $isNullable = $type->isNullable();
218✔
1099
            } else {
1100
                $isNullable = $t->isNullable();
×
1101
            }
1102

1103
            if (null === $value && ($isNullable || ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false))) {
218✔
1104
                return $value;
3✔
1105
            }
1106

1107
            $collectionValueType = null;
217✔
1108

1109
            if ($t instanceof CollectionType) {
217✔
1110
                $collectionValueType = $t->getCollectionValueType();
25✔
1111
            } elseif ($t instanceof LegacyType) {
211✔
1112
                $collectionValueType = $t->getCollectionValueTypes()[0] ?? null;
×
1113
            }
1114

1115
            /* From @see AbstractObjectNormalizer::validateAndDenormalize() */
1116
            // Fix a collection that contains the only one element
1117
            // This is special to xml format only
1118
            if ('xml' === $format && null !== $collectionValueType && (!\is_array($value) || !\is_int(key($value)))) {
217✔
1119
                $isMixedType = $collectionValueType instanceof Type && $collectionValueType->isIdentifiedBy(TypeIdentifier::MIXED);
1✔
1120
                if (!$isMixedType) {
1✔
1121
                    $value = [$value];
1✔
1122
                }
1123
            }
1124

1125
            if (($collectionValueType instanceof Type && $collectionValueType->isSatisfiedBy($typeIsResourceClass))
217✔
1126
                || ($t instanceof LegacyType && $t->isCollection() && null !== $collectionValueType && null !== ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className))
217✔
1127
            ) {
1128
                $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className);
17✔
1129
                $context['resource_class'] = $resourceClass;
17✔
1130
                unset($context['uri_variables']);
17✔
1131

1132
                try {
1133
                    return $t instanceof Type
17✔
1134
                        ? $this->denormalizeObjectCollection($attribute, $propertyMetadata, $t, $resourceClass, $value, $format, $context)
17✔
1135
                        : $this->denormalizeCollection($attribute, $propertyMetadata, $t, $resourceClass, $value, $format, $context);
15✔
1136
                } catch (NotNormalizableValueException $e) {
2✔
1137
                    // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
1138
                    if ($isMultipleTypes) {
2✔
1139
                        $denormalizationException ??= $e;
×
1140

1141
                        continue;
×
1142
                    }
1143

1144
                    throw $e;
2✔
1145
                }
1146
            }
1147

1148
            if (
1149
                ($t instanceof Type && $t->isSatisfiedBy($typeIsResourceClass))
211✔
1150
                || ($t instanceof LegacyType && null !== ($className = $t->getClassName()) && $this->resourceClassResolver->isResourceClass($className))
211✔
1151
            ) {
1152
                $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className);
55✔
1153
                $childContext = $this->createChildContext($this->createOperationContext($context, $resourceClass), $attribute, $format);
55✔
1154

1155
                try {
1156
                    return $this->denormalizeRelation($attribute, $propertyMetadata, $resourceClass, $value, $format, $childContext);
55✔
1157
                } catch (NotNormalizableValueException $e) {
6✔
1158
                    // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
1159
                    if ($isMultipleTypes) {
2✔
1160
                        $denormalizationException ??= $e;
2✔
1161

1162
                        continue;
2✔
1163
                    }
1164

1165
                    throw $e;
×
1166
                }
1167
            }
1168

1169
            if (
1170
                ($t instanceof CollectionType && $collectionValueType instanceof ObjectType && (null !== ($className = $collectionValueType->getClassName())))
193✔
1171
                || ($t instanceof LegacyType && $t->isCollection() && null !== $collectionValueType && null !== ($className = $collectionValueType->getClassName()))
193✔
1172
            ) {
1173
                if (!$this->serializer instanceof DenormalizerInterface) {
3✔
1174
                    throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class));
×
1175
                }
1176

1177
                unset($context['resource_class'], $context['uri_variables']);
3✔
1178

1179
                try {
1180
                    return $this->serializer->denormalize($value, $className.'[]', $format, $context);
3✔
1181
                } catch (NotNormalizableValueException $e) {
×
1182
                    // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
1183
                    if ($isMultipleTypes) {
×
1184
                        $denormalizationException ??= $e;
×
1185

1186
                        continue;
×
1187
                    }
1188

1189
                    throw $e;
×
1190
                }
1191
            }
1192

1193
            while ($t instanceof WrappingTypeInterface) {
193✔
1194
                $t = $t->getWrappedType();
5✔
1195
            }
1196

1197
            if (
1198
                $t instanceof ObjectType
193✔
1199
                || ($t instanceof LegacyType && null !== $t->getClassName())
193✔
1200
            ) {
1201
                if (!$this->serializer instanceof DenormalizerInterface) {
20✔
1202
                    throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class));
×
1203
                }
1204

1205
                unset($context['resource_class'], $context['uri_variables']);
20✔
1206

1207
                try {
1208
                    return $this->serializer->denormalize($value, $t->getClassName(), $format, $context);
20✔
1209
                } catch (NotNormalizableValueException $e) {
5✔
1210
                    // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
1211
                    if ($isMultipleTypes) {
5✔
1212
                        $denormalizationException ??= $e;
3✔
1213

1214
                        continue;
3✔
1215
                    }
1216

1217
                    throw $e;
2✔
1218
                }
1219
            }
1220

1221
            /* From @see AbstractObjectNormalizer::validateAndDenormalize() */
1222
            // In XML and CSV all basic datatypes are represented as strings, it is e.g. not possible to determine,
1223
            // if a value is meant to be a string, float, int or a boolean value from the serialized representation.
1224
            // That's why we have to transform the values, if one of these non-string basic datatypes is expected.
1225
            if (\is_string($value) && (XmlEncoder::FORMAT === $format || CsvEncoder::FORMAT === $format)) {
184✔
1226
                if ('' === $value && $isNullable && (
15✔
1227
                    ($t instanceof Type && $t->isIdentifiedBy(TypeIdentifier::BOOL, TypeIdentifier::INT, TypeIdentifier::FLOAT))
15✔
1228
                    || ($t instanceof LegacyType && \in_array($t->getBuiltinType(), [LegacyType::BUILTIN_TYPE_BOOL, LegacyType::BUILTIN_TYPE_INT, LegacyType::BUILTIN_TYPE_FLOAT], true))
15✔
1229
                )) {
1230
                    return null;
×
1231
                }
1232

1233
                $typeIdentifier = $t instanceof BuiltinType ? $t->getTypeIdentifier() : TypeIdentifier::tryFrom($t->getBuiltinType());
15✔
1234

1235
                switch ($typeIdentifier) {
1236
                    case TypeIdentifier::BOOL:
15✔
1237
                        // according to http://www.w3.org/TR/xmlschema-2/#boolean, valid representations are "false", "true", "0" and "1"
1238
                        if ('false' === $value || '0' === $value) {
4✔
1239
                            $value = false;
2✔
1240
                        } elseif ('true' === $value || '1' === $value) {
2✔
1241
                            $value = true;
2✔
1242
                        } else {
1243
                            // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
1244
                            if ($isMultipleTypes) {
×
1245
                                break 2;
×
1246
                            }
1247
                            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);
×
1248
                        }
1249
                        break;
4✔
1250
                    case TypeIdentifier::INT:
11✔
1251
                        if (ctype_digit($value) || ('-' === $value[0] && ctype_digit(substr($value, 1)))) {
4✔
1252
                            $value = (int) $value;
4✔
1253
                        } else {
1254
                            // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
1255
                            if ($isMultipleTypes) {
×
1256
                                break 2;
×
1257
                            }
1258
                            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);
×
1259
                        }
1260
                        break;
4✔
1261
                    case TypeIdentifier::FLOAT:
7✔
1262
                        if (is_numeric($value)) {
4✔
1263
                            return (float) $value;
1✔
1264
                        }
1265

1266
                        switch ($value) {
1267
                            case 'NaN':
3✔
1268
                                return \NAN;
1✔
1269
                            case 'INF':
2✔
1270
                                return \INF;
1✔
1271
                            case '-INF':
1✔
1272
                                return -\INF;
1✔
1273
                            default:
1274
                                // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
1275
                                if ($isMultipleTypes) {
×
1276
                                    break 3;
×
1277
                                }
1278
                                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);
×
1279
                        }
1280
                }
1281
            }
1282

1283
            if ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false) {
180✔
1284
                return $value;
×
1285
            }
1286

1287
            try {
1288
                $t instanceof Type
180✔
1289
                    ? $this->validateAttributeType($attribute, $t, $value, $format, $context)
180✔
1290
                    : $this->validateType($attribute, $t, $value, $format, $context);
×
1291

1292
                $denormalizationException = null;
175✔
1293
                break;
175✔
1294
            } catch (NotNormalizableValueException $e) {
43✔
1295
                // union/intersect types: try the next type
1296
                if (!$isMultipleTypes) {
43✔
1297
                    throw $e;
3✔
1298
                }
1299

1300
                $denormalizationException ??= $e;
40✔
1301
            }
1302
        }
1303

1304
        if ($denormalizationException) {
183✔
1305
            if ($type instanceof Type && $type->isSatisfiedBy(static fn ($type) => $type instanceof BuiltinType) && !$type->isSatisfiedBy($typeIsResourceClass)) {
5✔
1306
                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);
2✔
1307
            }
1308

1309
            throw $denormalizationException;
3✔
1310
        }
1311

1312
        return $value;
181✔
1313
    }
1314

1315
    /**
1316
     * Sets a value of the object using the PropertyAccess component.
1317
     */
1318
    private function setValue(object $object, string $attributeName, mixed $value): void
1319
    {
1320
        try {
1321
            $this->propertyAccessor->setValue($object, $attributeName, $value);
202✔
1322
        } catch (NoSuchPropertyException) {
9✔
1323
            // Properties not found are ignored
1324
        }
1325
    }
1326
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc