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

api-platform / core / 14726067612

29 Apr 2025 07:47AM UTC coverage: 23.443% (+15.2%) from 8.252%
14726067612

push

github

web-flow
feat(symfony): Autoconfigure classes using `#[ApiResource]` attribute (#6943)

0 of 12 new or added lines in 4 files covered. (0.0%)

3578 existing lines in 159 files now uncovered.

11517 of 49127 relevant lines covered (23.44%)

54.29 hits per line

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

69.97
/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'])) {
1,561✔
76
            $defaultContext['circular_reference_handler'] = fn ($object): ?string => $this->iriConverter->getIriFromResource($object);
1,561✔
77
        }
78

79
        parent::__construct($classMetadataFactory, $nameConverter, null, null, \Closure::fromCallable($this->getObjectClass(...)), $defaultContext);
1,561✔
80
        $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor();
1,561✔
81
        $this->resourceAccessChecker = $resourceAccessChecker;
1,561✔
82
        $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
1,561✔
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)) {
1,382✔
91
            return false;
470✔
92
        }
93

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

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

102
    public function getSupportedTypes(?string $format): array
103
    {
104
        return [
1,433✔
105
            'object' => true,
1,433✔
106
        ];
1,433✔
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);
1,329✔
117
        if ($outputClass = $this->getOutputClass($context)) {
1,329✔
118
            if (!$this->serializer instanceof NormalizerInterface) {
31✔
UNCOV
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']);
31✔
123
            $context['resource_class'] = $outputClass;
31✔
124
            $context['api_sub_level'] = true;
31✔
125
            $context[self::ALLOW_EXTRA_ATTRIBUTES] = false;
31✔
126

127
            return $this->serializer->normalize($object, $format, $context);
31✔
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) {
1,329✔
133
            unset($context['operation_name'], $context['operation'], $context['iri']);
12✔
134
        }
135

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

140
        $context['api_normalize'] = true;
1,329✔
141
        $iri = $context['iri'] ??= $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL, $context['operation'] ?? null, $context);
1,329✔
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;
1,329✔
154
        unset($context['api_empty_resource_as_iri']);
1,329✔
155

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

160
        $context['object'] = $object;
1,329✔
161
        $context['format'] = $format;
1,329✔
162

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

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

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

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

UNCOV
175
            return $iri;
×
176
        }
177

178
        if ($this->tagCollector) {
1,329✔
179
            $this->tagCollector->collect($context);
1,140✔
180
        }
181

182
        return $data;
1,329✔
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) {
476✔
UNCOV
191
            return true;
×
192
        }
193

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

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

204
        if ($inputClass = $this->getInputClass($context)) {
470✔
205
            if (!$this->serializer instanceof DenormalizerInterface) {
23✔
UNCOV
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']);
23✔
210
            $context['resource_class'] = $inputClass;
23✔
211

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

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

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

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

231
        if (\is_string($data)) {
450✔
232
            try {
233
                return $this->iriConverter->getResourceFromIri($data, $context + ['fetch_data' => true]);
3✔
UNCOV
234
            } catch (ItemNotFoundException $e) {
×
UNCOV
235
                throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
×
UNCOV
236
            } catch (InvalidArgumentException $e) {
×
UNCOV
237
                throw new UnexpectedValueException(\sprintf('Invalid IRI "%s".', $data), $e->getCode(), $e);
×
238
            }
239
        }
240

241
        if (!\is_array($data)) {
448✔
242
            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✔
243
        }
244

245
        $previousObject = $this->clone($objectToPopulate);
448✔
246
        $object = parent::denormalize($data, $class, $format, $context);
448✔
247

248
        if (!$this->resourceClassResolver->isResourceClass($class)) {
418✔
UNCOV
249
            return $object;
×
250
        }
251

252
        // Bypass the post-denormalize attribute revert logic if the object could not be
253
        // cloned since we cannot possibly revert any changes made to it.
254
        if (null !== $objectToPopulate && null === $previousObject) {
418✔
UNCOV
255
            return $object;
×
256
        }
257

258
        $options = $this->getFactoryOptions($context);
418✔
259
        $propertyNames = iterator_to_array($this->propertyNameCollectionFactory->create($resourceClass, $options));
418✔
260

261
        // Revert attributes that aren't allowed to be changed after a post-denormalize check
262
        foreach (array_keys($data) as $attribute) {
418✔
263
            $attribute = $this->nameConverter ? $this->nameConverter->denormalize((string) $attribute) : $attribute;
402✔
264
            if (!\in_array($attribute, $propertyNames, true)) {
402✔
265
                continue;
90✔
266
            }
267

268
            if (!$this->canAccessAttributePostDenormalize($object, $previousObject, $attribute, $context)) {
381✔
269
                if (null !== $previousObject) {
4✔
UNCOV
270
                    $this->setValue($object, $attribute, $this->propertyAccessor->getValue($previousObject, $attribute));
1✔
271
                } else {
272
                    $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $attribute, $options);
3✔
273
                    $this->setValue($object, $attribute, $propertyMetadata->getDefault());
3✔
274
                }
275
            }
276
        }
277

278
        return $object;
418✔
279
    }
280

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

294
            return $object;
97✔
295
        }
296

297
        $class = $this->getClassDiscriminatorResolvedClass($data, $class, $context);
365✔
298
        $reflectionClass = new \ReflectionClass($class);
365✔
299

300
        $constructor = $this->getConstructor($data, $class, $context, $reflectionClass, $allowedAttributes);
365✔
301
        if ($constructor) {
365✔
302
            $constructorParameters = $constructor->getParameters();
164✔
303

304
            $params = [];
164✔
305
            $missingConstructorArguments = [];
164✔
306
            foreach ($constructorParameters as $constructorParameter) {
164✔
307
                $paramName = $constructorParameter->name;
70✔
308
                $key = $this->nameConverter ? $this->nameConverter->normalize($paramName, $class, $format, $context) : $paramName;
70✔
309
                $attributeContext = $this->getAttributeDenormalizationContext($class, $paramName, $context);
70✔
310
                $attributeContext['deserialization_path'] = $attributeContext['deserialization_path'] ?? $key;
70✔
311

312
                $allowed = false === $allowedAttributes || (\is_array($allowedAttributes) && \in_array($paramName, $allowedAttributes, true));
70✔
313
                $ignored = !$this->isAllowedAttribute($class, $paramName, $format, $context);
70✔
314
                if ($constructorParameter->isVariadic()) {
70✔
UNCOV
315
                    if ($allowed && !$ignored && (isset($data[$key]) || \array_key_exists($key, $data))) {
×
UNCOV
316
                        if (!\is_array($data[$paramName])) {
×
UNCOV
317
                            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));
×
318
                        }
319

UNCOV
320
                        $params[] = $data[$paramName];
×
321
                    }
322
                } elseif ($allowed && !$ignored && (isset($data[$key]) || \array_key_exists($key, $data))) {
70✔
323
                    try {
324
                        $params[] = $this->createConstructorArgument($data[$key], $key, $constructorParameter, $attributeContext, $format);
22✔
325
                    } catch (NotNormalizableValueException $exception) {
3✔
326
                        if (!isset($context['not_normalizable_value_exceptions'])) {
3✔
327
                            throw $exception;
3✔
328
                        }
UNCOV
329
                        $context['not_normalizable_value_exceptions'][] = $exception;
×
330
                    }
331

332
                    // Don't run set for a parameter passed to the constructor
333
                    unset($data[$key]);
19✔
334
                } elseif (isset($context[static::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class][$key])) {
61✔
UNCOV
335
                    $params[] = $context[static::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class][$key];
×
336
                } elseif ($constructorParameter->isDefaultValueAvailable()) {
61✔
337
                    $params[] = $constructorParameter->getDefaultValue();
61✔
338
                } else {
339
                    if (!isset($context['not_normalizable_value_exceptions'])) {
2✔
340
                        $missingConstructorArguments[] = $constructorParameter->name;
2✔
341
                    }
342

343
                    $constructorParameterType = 'unknown';
2✔
344
                    $reflectionType = $constructorParameter->getType();
2✔
345
                    if ($reflectionType instanceof \ReflectionNamedType) {
2✔
346
                        $constructorParameterType = $reflectionType->getName();
2✔
347
                    }
348

349
                    $exception = NotNormalizableValueException::createForUnexpectedDataType(
2✔
350
                        \sprintf('Failed to create object because the class misses the "%s" property.', $constructorParameter->name),
2✔
351
                        null,
2✔
352
                        [$constructorParameterType],
2✔
353
                        $attributeContext['deserialization_path'],
2✔
354
                        true
2✔
355
                    );
2✔
356
                    $context['not_normalizable_value_exceptions'][] = $exception;
2✔
357
                }
358
            }
359

360
            if ($missingConstructorArguments) {
161✔
361
                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✔
362
            }
363

364
            if (\count($context['not_normalizable_value_exceptions'] ?? []) > 0) {
161✔
UNCOV
365
                return $reflectionClass->newInstanceWithoutConstructor();
×
366
            }
367

368
            if ($constructor->isConstructor()) {
161✔
369
                return $reflectionClass->newInstanceArgs($params);
161✔
370
            }
371

UNCOV
372
            return $constructor->invokeArgs(null, $params);
×
373
        }
374

375
        return new $class();
212✔
376
    }
377

378
    protected function getClassDiscriminatorResolvedClass(array $data, string $class, array $context = []): string
379
    {
380
        if (null === $this->classDiscriminatorResolver || (null === $mapping = $this->classDiscriminatorResolver->getMappingForClass($class))) {
367✔
381
            return $class;
367✔
382
        }
383

384
        if (!isset($data[$mapping->getTypeProperty()])) {
2✔
UNCOV
385
            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());
×
386
        }
387

388
        $type = $data[$mapping->getTypeProperty()];
2✔
389
        if (null === ($mappedClass = $mapping->getClassForType($type))) {
2✔
UNCOV
390
            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);
×
391
        }
392

393
        return $mappedClass;
2✔
394
    }
395

396
    protected function createConstructorArgument($parameterData, string $key, \ReflectionParameter $constructorParameter, array $context, ?string $format = null): mixed
397
    {
398
        return $this->createAndValidateAttributeValue($constructorParameter->name, $parameterData, $format, $context);
22✔
399
    }
400

401
    /**
402
     * {@inheritdoc}
403
     *
404
     * Unused in this context.
405
     *
406
     * @return string[]
407
     */
408
    protected function extractAttributes($object, $format = null, array $context = []): array
409
    {
UNCOV
410
        return [];
×
411
    }
412

413
    /**
414
     * {@inheritdoc}
415
     */
416
    protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool
417
    {
418
        if (!$this->resourceClassResolver->isResourceClass($context['resource_class'])) {
1,343✔
419
            return parent::getAllowedAttributes($classOrObject, $context, $attributesAsString);
31✔
420
        }
421

422
        $resourceClass = $this->resourceClassResolver->getResourceClass(null, $context['resource_class']); // fix for abstract classes and interfaces
1,317✔
423
        $options = $this->getFactoryOptions($context);
1,317✔
424
        $propertyNames = $this->propertyNameCollectionFactory->create($resourceClass, $options);
1,317✔
425

426
        $allowedAttributes = [];
1,317✔
427
        foreach ($propertyNames as $propertyName) {
1,317✔
428
            $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName, $options);
1,311✔
429

430
            if (
431
                $this->isAllowedAttribute($classOrObject, $propertyName, null, $context)
1,311✔
432
                && (isset($context['api_normalize']) && $propertyMetadata->isReadable()
1,311✔
433
                    || isset($context['api_denormalize']) && ($propertyMetadata->isWritable() || !\is_object($classOrObject) && $propertyMetadata->isInitializable())
1,311✔
434
                )
435
            ) {
436
                $allowedAttributes[] = $propertyName;
1,303✔
437
            }
438
        }
439

440
        return $allowedAttributes;
1,317✔
441
    }
442

443
    /**
444
     * {@inheritdoc}
445
     */
446
    protected function isAllowedAttribute(object|string $classOrObject, string $attribute, ?string $format = null, array $context = []): bool
447
    {
448
        if (!parent::isAllowedAttribute($classOrObject, $attribute, $format, $context)) {
1,337✔
449
            return false;
424✔
450
        }
451

452
        return $this->canAccessAttribute(\is_object($classOrObject) ? $classOrObject : null, $attribute, $context);
1,335✔
453
    }
454

455
    /**
456
     * Check if access to the attribute is granted.
457
     */
458
    protected function canAccessAttribute(?object $object, string $attribute, array $context = []): bool
459
    {
460
        if (!$this->resourceClassResolver->isResourceClass($context['resource_class'])) {
1,335✔
461
            return true;
31✔
462
        }
463

464
        $options = $this->getFactoryOptions($context);
1,309✔
465
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options);
1,309✔
466
        $security = $propertyMetadata->getSecurity() ?? $propertyMetadata->getPolicy();
1,309✔
467
        if (null !== $this->resourceAccessChecker && $security) {
1,309✔
468
            return $this->resourceAccessChecker->isGranted($context['resource_class'], $security, [
62✔
469
                'object' => $object,
62✔
470
                'property' => $attribute,
62✔
471
            ]);
62✔
472
        }
473

474
        return true;
1,301✔
475
    }
476

477
    /**
478
     * Check if access to the attribute is granted.
479
     */
480
    protected function canAccessAttributePostDenormalize(?object $object, ?object $previousObject, string $attribute, array $context = []): bool
481
    {
482
        $options = $this->getFactoryOptions($context);
381✔
483
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options);
381✔
484
        $security = $propertyMetadata->getSecurityPostDenormalize();
381✔
485
        if ($this->resourceAccessChecker && $security) {
381✔
486
            return $this->resourceAccessChecker->isGranted($context['resource_class'], $security, [
10✔
487
                'object' => $object,
10✔
488
                'previous_object' => $previousObject,
10✔
489
                'property' => $attribute,
10✔
490
            ]);
10✔
491
        }
492

493
        return true;
379✔
494
    }
495

496
    /**
497
     * {@inheritdoc}
498
     */
499
    protected function setAttributeValue(object $object, string $attribute, mixed $value, ?string $format = null, array $context = []): void
500
    {
501
        try {
502
            $this->setValue($object, $attribute, $this->createAttributeValue($attribute, $value, $format, $context));
382✔
503
        } catch (NotNormalizableValueException $exception) {
27✔
504
            // Only throw if collecting denormalization errors is disabled.
505
            if (!isset($context['not_normalizable_value_exceptions'])) {
19✔
506
                throw $exception;
19✔
507
            }
508
        }
509
    }
510

511
    /**
512
     * @deprecated since 4.1, use "validateAttributeType" instead
513
     *
514
     * Validates the type of the value. Allows using integers as floats for JSON formats.
515
     *
516
     * @throws NotNormalizableValueException
517
     */
518
    protected function validateType(string $attribute, LegacyType $type, mixed $value, ?string $format = null, array $context = []): void
519
    {
UNCOV
520
        trigger_deprecation('api-platform/serializer', '4.1', 'The "%s()" method is deprecated, use "%s::validateAttributeType()" instead.', __METHOD__, self::class);
×
521

UNCOV
522
        $builtinType = $type->getBuiltinType();
×
UNCOV
523
        if (LegacyType::BUILTIN_TYPE_FLOAT === $builtinType && null !== $format && str_contains($format, 'json')) {
×
UNCOV
524
            $isValid = \is_float($value) || \is_int($value);
×
525
        } else {
UNCOV
526
            $isValid = \call_user_func('is_'.$builtinType, $value);
×
527
        }
528

UNCOV
529
        if (!$isValid) {
×
UNCOV
530
            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);
×
531
        }
532
    }
533

534
    /**
535
     * Validates the type of the value. Allows using integers as floats for JSON formats.
536
     *
537
     * @throws NotNormalizableValueException
538
     */
539
    protected function validateAttributeType(string $attribute, Type $type, mixed $value, ?string $format = null, array $context = []): void
540
    {
541
        if ($type->isIdentifiedBy(TypeIdentifier::FLOAT) && null !== $format && str_contains($format, 'json')) {
334✔
542
            $isValid = \is_float($value) || \is_int($value);
2✔
543
        } else {
544
            $isValid = $type->accepts($value);
334✔
545
        }
546

547
        if (!$isValid) {
334✔
548
            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);
74✔
549
        }
550
    }
551

552
    /**
553
     * @deprecated since 4.1, use "denormalizeObjectCollection" instead.
554
     *
555
     * Denormalizes a collection of objects.
556
     *
557
     * @throws NotNormalizableValueException
558
     */
559
    protected function denormalizeCollection(string $attribute, ApiProperty $propertyMetadata, LegacyType $type, string $className, mixed $value, ?string $format, array $context): array
560
    {
UNCOV
561
        trigger_deprecation('api-platform/serializer', '4.1', 'The "%s()" method is deprecated, use "%s::denormalizeObjectCollection()" instead.', __METHOD__, self::class);
×
562

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

UNCOV
567
        $values = [];
×
UNCOV
568
        $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
×
UNCOV
569
        $collectionKeyTypes = $type->getCollectionKeyTypes();
×
UNCOV
570
        foreach ($value as $index => $obj) {
×
UNCOV
571
            $currentChildContext = $childContext;
×
UNCOV
572
            if (isset($childContext['deserialization_path'])) {
×
UNCOV
573
                $currentChildContext['deserialization_path'] = "{$childContext['deserialization_path']}[{$index}]";
×
574
            }
575

576
            // no typehint provided on collection key
UNCOV
577
            if (!$collectionKeyTypes) {
×
UNCOV
578
                $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $currentChildContext);
×
UNCOV
579
                continue;
×
580
            }
581

582
            // validate collection key typehint
583
            foreach ($collectionKeyTypes as $collectionKeyType) {
×
584
                $collectionKeyBuiltinType = $collectionKeyType->getBuiltinType();
×
585
                if (!\call_user_func('is_'.$collectionKeyBuiltinType, $index)) {
×
586
                    continue;
×
587
                }
588

UNCOV
589
                $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $currentChildContext);
×
590
                continue 2;
×
591
            }
UNCOV
592
            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);
×
593
        }
594

595
        return $values;
×
596
    }
597

598
    /**
599
     * Denormalizes a collection of objects.
600
     *
601
     * @throws NotNormalizableValueException
602
     */
603
    protected function denormalizeObjectCollection(string $attribute, ApiProperty $propertyMetadata, Type $type, string $className, mixed $value, ?string $format, array $context): array
604
    {
605
        if (!\is_array($value)) {
30✔
606
            throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" attribute must be "array", "%s" given.', $attribute, \gettype($value)), $value, ['array'], $context['deserialization_path'] ?? null);
2✔
607
        }
608

609
        $values = [];
28✔
610
        $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
28✔
611

612
        foreach ($value as $index => $obj) {
28✔
613
            $currentChildContext = $childContext;
28✔
614
            if (isset($childContext['deserialization_path'])) {
28✔
615
                $currentChildContext['deserialization_path'] = "{$childContext['deserialization_path']}[{$index}]";
28✔
616
            }
617

618
            if ($type instanceof CollectionType) {
28✔
619
                $collectionKeyType = $type->getCollectionKeyType();
28✔
620

621
                while ($collectionKeyType instanceof WrappingTypeInterface) {
28✔
UNCOV
622
                    $collectionKeyType = $type->getWrappedType();
×
623
                }
624

625
                if (!$collectionKeyType->accepts($index)) {
28✔
626
                    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✔
627
                }
628
            }
629

630
            $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $currentChildContext);
26✔
631
        }
632

633
        return $values;
26✔
634
    }
635

636
    /**
637
     * Denormalizes a relation.
638
     *
639
     * @throws LogicException
640
     * @throws UnexpectedValueException
641
     * @throws NotNormalizableValueException
642
     */
643
    protected function denormalizeRelation(string $attributeName, ApiProperty $propertyMetadata, string $className, mixed $value, ?string $format, array $context): ?object
644
    {
645
        if (\is_string($value)) {
106✔
646
            try {
647
                return $this->iriConverter->getResourceFromIri($value, $context + ['fetch_data' => true]);
58✔
648
            } catch (ItemNotFoundException $e) {
6✔
649
                if (!isset($context['not_normalizable_value_exceptions'])) {
2✔
650
                    throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
2✔
651
                }
UNCOV
652
                $context['not_normalizable_value_exceptions'][] = NotNormalizableValueException::createForUnexpectedDataType(
×
UNCOV
653
                    $e->getMessage(),
×
UNCOV
654
                    $value,
×
UNCOV
655
                    [$className],
×
UNCOV
656
                    $context['deserialization_path'] ?? null,
×
UNCOV
657
                    true,
×
UNCOV
658
                    $e->getCode(),
×
UNCOV
659
                    $e
×
UNCOV
660
                );
×
661

UNCOV
662
                return null;
×
663
            } catch (InvalidArgumentException $e) {
4✔
664
                if (!isset($context['not_normalizable_value_exceptions'])) {
4✔
665
                    throw new UnexpectedValueException(\sprintf('Invalid IRI "%s".', $value), $e->getCode(), $e);
4✔
666
                }
UNCOV
667
                $context['not_normalizable_value_exceptions'][] = NotNormalizableValueException::createForUnexpectedDataType(
×
UNCOV
668
                    $e->getMessage(),
×
UNCOV
669
                    $value,
×
UNCOV
670
                    [$className],
×
UNCOV
671
                    $context['deserialization_path'] ?? null,
×
UNCOV
672
                    true,
×
UNCOV
673
                    $e->getCode(),
×
UNCOV
674
                    $e
×
UNCOV
675
                );
×
676

UNCOV
677
                return null;
×
678
            }
679
        }
680

681
        if ($propertyMetadata->isWritableLink()) {
50✔
682
            $context['api_allow_update'] = true;
49✔
683

684
            if (!$this->serializer instanceof DenormalizerInterface) {
49✔
UNCOV
685
                throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class));
×
686
            }
687

688
            $item = $this->serializer->denormalize($value, $className, $format, $context);
49✔
689
            if (!\is_object($item) && null !== $item) {
45✔
UNCOV
690
                throw new \UnexpectedValueException('Expected item to be an object or null.');
×
691
            }
692

693
            return $item;
45✔
694
        }
695

UNCOV
696
        if (!\is_array($value)) {
1✔
UNCOV
697
            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);
×
698
        }
699

UNCOV
700
        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✔
701
    }
702

703
    /**
704
     * Gets the options for the property name collection / property metadata factories.
705
     */
706
    protected function getFactoryOptions(array $context): array
707
    {
708
        $options = [];
1,343✔
709
        if (isset($context[self::GROUPS])) {
1,343✔
710
            /* @see https://github.com/symfony/symfony/blob/v4.2.6/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php */
711
            $options['serializer_groups'] = (array) $context[self::GROUPS];
462✔
712
        }
713

714
        $operationCacheKey = ($context['resource_class'] ?? '').($context['operation_name'] ?? '').($context['root_operation_name'] ?? '');
1,343✔
715
        $suffix = ($context['api_normalize'] ?? '') ? 'n' : '';
1,343✔
716
        if ($operationCacheKey && isset($this->localFactoryOptionsCache[$operationCacheKey.$suffix])) {
1,343✔
717
            return $options + $this->localFactoryOptionsCache[$operationCacheKey.$suffix];
1,339✔
718
        }
719

720
        // This is a hot spot
721
        if (isset($context['resource_class'])) {
1,343✔
722
            // Note that the groups need to be read on the root operation
723
            if ($operation = ($context['root_operation'] ?? null)) {
1,343✔
724
                $options['normalization_groups'] = $operation->getNormalizationContext()['groups'] ?? null;
515✔
725
                $options['denormalization_groups'] = $operation->getDenormalizationContext()['groups'] ?? null;
515✔
726
                $options['operation_name'] = $operation->getName();
515✔
727
            }
728
        }
729

730
        return $options + $this->localFactoryOptionsCache[$operationCacheKey.$suffix] = $options;
1,343✔
731
    }
732

733
    /**
734
     * {@inheritdoc}
735
     *
736
     * @throws UnexpectedValueException
737
     */
738
    protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed
739
    {
740
        $context['api_attribute'] = $attribute;
1,315✔
741
        $context['property_metadata'] = $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context));
1,315✔
742

743
        if ($context['api_denormalize'] ?? false) {
1,315✔
744
            return $this->propertyAccessor->getValue($object, $attribute);
21✔
745
        }
746

747
        if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
1,315✔
UNCOV
748
            $types = $propertyMetadata->getBuiltinTypes() ?? [];
×
749

UNCOV
750
            foreach ($types as $type) {
×
751
                if (
752
                    $type->isCollection()
×
UNCOV
753
                    && ($collectionValueType = $type->getCollectionValueTypes()[0] ?? null)
×
UNCOV
754
                    && ($className = $collectionValueType->getClassName())
×
UNCOV
755
                    && $this->resourceClassResolver->isResourceClass($className)
×
756
                ) {
UNCOV
757
                    $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
×
758

759
                    // @see ApiPlatform\Hal\Serializer\ItemNormalizer:getComponents logic for intentional duplicate content
760
                    // @see ApiPlatform\JsonApi\Serializer\ItemNormalizer:getComponents logic for intentional duplicate content
UNCOV
761
                    if ('jsonld' === $format && $itemUriTemplate = $propertyMetadata->getUriTemplate()) {
×
UNCOV
762
                        $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(
×
UNCOV
763
                            operationName: $itemUriTemplate,
×
UNCOV
764
                            forceCollection: true,
×
UNCOV
765
                            httpOperation: true
×
UNCOV
766
                        );
×
767

UNCOV
768
                        return $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
×
769
                    }
770

UNCOV
771
                    $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
×
772

UNCOV
773
                    if (!is_iterable($attributeValue)) {
×
UNCOV
774
                        throw new UnexpectedValueException('Unexpected non-iterable value for to-many relation.');
×
775
                    }
776

UNCOV
777
                    $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
×
778

UNCOV
779
                    $data = $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
×
UNCOV
780
                    $context['data'] = $data;
×
UNCOV
781
                    $context['type'] = $type;
×
782

UNCOV
783
                    if ($this->tagCollector) {
×
UNCOV
784
                        $this->tagCollector->collect($context);
×
785
                    }
786

UNCOV
787
                    return $data;
×
788
                }
789

790
                if (
UNCOV
791
                    ($className = $type->getClassName())
×
UNCOV
792
                    && $this->resourceClassResolver->isResourceClass($className)
×
793
                ) {
UNCOV
794
                    $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
×
UNCOV
795
                    unset($childContext['iri'], $childContext['uri_variables'], $childContext['item_uri_template']);
×
UNCOV
796
                    if ('jsonld' === $format && $uriTemplate = $propertyMetadata->getUriTemplate()) {
×
UNCOV
797
                        $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(
×
UNCOV
798
                            operationName: $uriTemplate,
×
UNCOV
799
                            httpOperation: true
×
UNCOV
800
                        );
×
801

UNCOV
802
                        return $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
×
803
                    }
804

UNCOV
805
                    $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
×
806

UNCOV
807
                    if (!\is_object($attributeValue) && null !== $attributeValue) {
×
UNCOV
808
                        throw new UnexpectedValueException('Unexpected non-object value for to-one relation.');
×
809
                    }
810

UNCOV
811
                    $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
×
812

UNCOV
813
                    $data = $this->normalizeRelation($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
×
UNCOV
814
                    $context['data'] = $data;
×
UNCOV
815
                    $context['type'] = $type;
×
816

UNCOV
817
                    if ($this->tagCollector) {
×
UNCOV
818
                        $this->tagCollector->collect($context);
×
819
                    }
820

UNCOV
821
                    return $data;
×
822
                }
823

UNCOV
824
                if (!$this->serializer instanceof NormalizerInterface) {
×
UNCOV
825
                    throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class));
×
826
                }
827

UNCOV
828
                unset(
×
UNCOV
829
                    $context['resource_class'],
×
UNCOV
830
                    $context['force_resource_class'],
×
UNCOV
831
                    $context['uri_variables'],
×
UNCOV
832
                );
×
833

834
                // Anonymous resources
UNCOV
835
                if ($className) {
×
836
                    $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
×
UNCOV
837
                    $childContext['output']['gen_id'] = $propertyMetadata->getGenId() ?? true;
×
838

UNCOV
839
                    $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
×
840

UNCOV
841
                    return $this->serializer->normalize($attributeValue, $format, $childContext);
×
842
                }
843

UNCOV
844
                if ('array' === $type->getBuiltinType()) {
×
UNCOV
845
                    if ($className = ($type->getCollectionValueTypes()[0] ?? null)?->getClassName()) {
×
UNCOV
846
                        $context = $this->createOperationContext($context, $className);
×
847
                    }
848

UNCOV
849
                    $childContext = $this->createChildContext($context, $attribute, $format);
×
UNCOV
850
                    $childContext['output']['gen_id'] = $propertyMetadata->getGenId() ?? true;
×
851

UNCOV
852
                    $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
×
853

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

UNCOV
858
            if (!$this->serializer instanceof NormalizerInterface) {
×
UNCOV
859
                throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class));
×
860
            }
861

UNCOV
862
            unset(
×
UNCOV
863
                $context['resource_class'],
×
UNCOV
864
                $context['force_resource_class'],
×
UNCOV
865
                $context['uri_variables']
×
UNCOV
866
            );
×
867

UNCOV
868
            $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
×
869

UNCOV
870
            return $this->serializer->normalize($attributeValue, $format, $context);
×
871
        }
872

873
        $type = $propertyMetadata->getNativeType();
1,315✔
874

875
        $nullable = false;
1,315✔
876
        if ($type instanceof NullableType) {
1,315✔
877
            $type = $type->getWrappedType();
864✔
878
            $nullable = true;
864✔
879
        }
880

881
        // TODO check every foreach composite to see if null is an issue
882
        $types = $type instanceof CompositeTypeInterface ? $type->getTypes() : (null === $type ? [] : [$type]);
1,315✔
883
        $className = null;
1,315✔
884
        $typeIsResourceClass = function (Type $type) use (&$className): bool {
1,315✔
885
            return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName());
1,285✔
886
        };
1,315✔
887

888
        foreach ($types as $type) {
1,315✔
889
            if ($type instanceof CollectionType && $type->getCollectionValueType()->isSatisfiedBy($typeIsResourceClass)) {
1,285✔
890
                $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
419✔
891

892
                // @see ApiPlatform\Hal\Serializer\ItemNormalizer:getComponents logic for intentional duplicate content
893
                // @see ApiPlatform\JsonApi\Serializer\ItemNormalizer:getComponents logic for intentional duplicate content
894
                if ('jsonld' === $format && $itemUriTemplate = $propertyMetadata->getUriTemplate()) {
419✔
895
                    $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(
2✔
896
                        operationName: $itemUriTemplate,
2✔
897
                        forceCollection: true,
2✔
898
                        httpOperation: true
2✔
899
                    );
2✔
900

901
                    return $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
2✔
902
                }
903

904
                $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
417✔
905

906
                if (!is_iterable($attributeValue)) {
417✔
UNCOV
907
                    throw new UnexpectedValueException('Unexpected non-iterable value for to-many relation.');
×
908
                }
909

910
                $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
417✔
911

912
                $data = $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
417✔
913
                $context['data'] = $data;
417✔
914
                $context['type'] = ($nullable && $type instanceof Type) ? Type::nullable($type) : $type;
417✔
915

916
                if ($this->tagCollector) {
417✔
917
                    $this->tagCollector->collect($context);
378✔
918
                }
919

920
                return $data;
417✔
921
            }
922

923
            if ($type->isSatisfiedBy($typeIsResourceClass)) {
1,285✔
924
                $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
591✔
925
                unset($childContext['iri'], $childContext['uri_variables'], $childContext['item_uri_template']);
591✔
926
                if ('jsonld' === $format && $uriTemplate = $propertyMetadata->getUriTemplate()) {
591✔
927
                    $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(
2✔
928
                        operationName: $uriTemplate,
2✔
929
                        httpOperation: true
2✔
930
                    );
2✔
931

932
                    return $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
2✔
933
                }
934

935
                $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
589✔
936

937
                if (!\is_object($attributeValue) && null !== $attributeValue) {
585✔
938
                    throw new UnexpectedValueException('Unexpected non-object value for to-one relation.');
×
939
                }
940

941
                $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
585✔
942

943
                $data = $this->normalizeRelation($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
585✔
944
                $context['data'] = $data;
585✔
945
                $context['type'] = $nullable ? Type::nullable($type) : $type;
585✔
946

947
                if ($this->tagCollector) {
585✔
948
                    $this->tagCollector->collect($context);
552✔
949
                }
950

951
                return $data;
585✔
952
            }
953

954
            if (!$this->serializer instanceof NormalizerInterface) {
1,271✔
UNCOV
955
                throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class));
×
956
            }
957

958
            unset(
1,271✔
959
                $context['resource_class'],
1,271✔
960
                $context['force_resource_class'],
1,271✔
961
                $context['uri_variables'],
1,271✔
962
            );
1,271✔
963

964
            // Anonymous resources
965
            if ($className) {
1,271✔
966
                $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format);
391✔
967
                $childContext['output']['gen_id'] = $propertyMetadata->getGenId() ?? true;
391✔
968

969
                $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
391✔
970

971
                return $this->serializer->normalize($attributeValue, $format, $childContext);
385✔
972
            }
973

974
            if ($type instanceof CollectionType) {
1,256✔
975
                if (($subType = $type->getCollectionValueType()) instanceof ObjectType) {
365✔
UNCOV
976
                    $context = $this->createOperationContext($context, $subType->getClassName());
×
977
                }
978

979
                $childContext = $this->createChildContext($context, $attribute, $format);
365✔
980
                $childContext['output']['gen_id'] = $propertyMetadata->getGenId() ?? true;
365✔
981

982
                $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
365✔
983

984
                return $this->serializer->normalize($attributeValue, $format, $childContext);
365✔
985
            }
986
        }
987

988
        if (!$this->serializer instanceof NormalizerInterface) {
1,286✔
UNCOV
989
            throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class));
×
990
        }
991

992
        unset(
1,286✔
993
            $context['resource_class'],
1,286✔
994
            $context['force_resource_class'],
1,286✔
995
            $context['uri_variables']
1,286✔
996
        );
1,286✔
997

998
        $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
1,286✔
999

1000
        return $this->serializer->normalize($attributeValue, $format, $context);
1,285✔
1001
    }
1002

1003
    /**
1004
     * Normalizes a collection of relations (to-many).
1005
     *
1006
     * @throws UnexpectedValueException
1007
     */
1008
    protected function normalizeCollectionOfRelations(ApiProperty $propertyMetadata, iterable $attributeValue, string $resourceClass, ?string $format, array $context): array
1009
    {
1010
        $value = [];
378✔
1011
        foreach ($attributeValue as $index => $obj) {
378✔
1012
            if (!\is_object($obj) && null !== $obj) {
103✔
1013
                throw new UnexpectedValueException('Unexpected non-object element in to-many relation.');
×
1014
            }
1015

1016
            // update context, if concrete object class deviates from general relation class (e.g. in case of polymorphic resources)
1017
            $objResourceClass = $this->resourceClassResolver->getResourceClass($obj, $resourceClass);
103✔
1018
            $context['resource_class'] = $objResourceClass;
103✔
1019
            if ($this->resourceMetadataCollectionFactory) {
103✔
1020
                $context['operation'] = $this->resourceMetadataCollectionFactory->create($objResourceClass)->getOperation();
103✔
1021
            }
1022

1023
            $value[$index] = $this->normalizeRelation($propertyMetadata, $obj, $resourceClass, $format, $context);
103✔
1024
        }
1025

1026
        return $value;
378✔
1027
    }
1028

1029
    /**
1030
     * Normalizes a relation.
1031
     *
1032
     * @throws LogicException
1033
     * @throws UnexpectedValueException
1034
     */
1035
    protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $relatedObject, string $resourceClass, ?string $format, array $context): \ArrayObject|array|string|null
1036
    {
1037
        if (null === $relatedObject || !empty($context['attributes']) || $propertyMetadata->isReadableLink()) {
521✔
1038
            if (!$this->serializer instanceof NormalizerInterface) {
409✔
UNCOV
1039
                throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class));
×
1040
            }
1041

1042
            $relatedContext = $this->createOperationContext($context, $resourceClass);
409✔
1043
            $normalizedRelatedObject = $this->serializer->normalize($relatedObject, $format, $relatedContext);
409✔
1044
            if (!\is_string($normalizedRelatedObject) && !\is_array($normalizedRelatedObject) && !$normalizedRelatedObject instanceof \ArrayObject && null !== $normalizedRelatedObject) {
409✔
UNCOV
1045
                throw new UnexpectedValueException('Expected normalized relation to be an IRI, array, \ArrayObject or null');
×
1046
            }
1047

1048
            return $normalizedRelatedObject;
409✔
1049
        }
1050

1051
        $context['iri'] = $iri = $this->iriConverter->getIriFromResource(resource: $relatedObject, context: $context);
170✔
1052
        $context['data'] = $iri;
170✔
1053
        $context['object'] = $relatedObject;
170✔
1054
        unset($context['property_metadata'], $context['api_attribute']);
170✔
1055

1056
        if ($this->tagCollector) {
170✔
1057
            $this->tagCollector->collect($context);
170✔
UNCOV
1058
        } elseif (isset($context['resources'])) {
×
UNCOV
1059
            $context['resources'][$iri] = $iri;
×
1060
        }
1061

1062
        $push = $propertyMetadata->getPush() ?? false;
170✔
1063
        if (isset($context['resources_to_push']) && $push) {
170✔
UNCOV
1064
            $context['resources_to_push'][$iri] = $iri;
17✔
1065
        }
1066

1067
        return $iri;
170✔
1068
    }
1069

1070
    private function createAttributeValue(string $attribute, mixed $value, ?string $format = null, array &$context = []): mixed
1071
    {
1072
        try {
1073
            return $this->createAndValidateAttributeValue($attribute, $value, $format, $context);
382✔
1074
        } catch (NotNormalizableValueException $exception) {
27✔
1075
            if (!isset($context['not_normalizable_value_exceptions'])) {
19✔
1076
                throw $exception;
19✔
1077
            }
UNCOV
1078
            $context['not_normalizable_value_exceptions'][] = $exception;
×
UNCOV
1079
            throw $exception;
×
1080
        }
1081
    }
1082

1083
    private function createAndValidateAttributeValue(string $attribute, mixed $value, ?string $format = null, array $context = []): mixed
1084
    {
1085
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context));
404✔
1086

1087
        $types = [];
404✔
1088
        $type = null;
404✔
1089
        if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
404✔
UNCOV
1090
            $types = $propertyMetadata->getBuiltinTypes() ?? [];
×
1091
        } else {
1092
            $type = $propertyMetadata->getNativeType();
404✔
1093
            $types = $type instanceof CompositeTypeInterface ? $type->getTypes() : (null === $type ? [] : [$type]);
404✔
1094
        }
1095

1096
        $className = null;
404✔
1097
        $typeIsResourceClass = function (Type $type) use (&$className): bool {
404✔
1098
            return $type instanceof ObjectType ? $this->resourceClassResolver->isResourceClass($className = $type->getClassName()) : false;
395✔
1099
        };
404✔
1100

1101
        $isMultipleTypes = \count($types) > 1;
404✔
1102
        $denormalizationException = null;
404✔
1103

1104
        foreach ($types as $t) {
404✔
1105
            if ($type instanceof Type) {
396✔
1106
                $isNullable = $type->isNullable();
396✔
1107
            } else {
UNCOV
1108
                $isNullable = $t->isNullable();
×
1109
            }
1110

1111
            if (null === $value && ($isNullable || ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false))) {
396✔
1112
                return $value;
5✔
1113
            }
1114

1115
            $collectionValueType = null;
395✔
1116

1117
            if ($t instanceof CollectionType) {
395✔
1118
                $collectionValueType = $t->getCollectionValueType();
46✔
1119
            } elseif ($t instanceof LegacyType) {
385✔
UNCOV
1120
                $collectionValueType = $t->getCollectionValueTypes()[0] ?? null;
×
1121
            }
1122

1123
            /* From @see AbstractObjectNormalizer::validateAndDenormalize() */
1124
            // Fix a collection that contains the only one element
1125
            // This is special to xml format only
1126
            if ('xml' === $format && null !== $collectionValueType && (!\is_array($value) || !\is_int(key($value)))) {
395✔
1127
                $isMixedType = $collectionValueType instanceof Type && $collectionValueType->isIdentifiedBy(TypeIdentifier::MIXED);
2✔
1128
                if (!$isMixedType) {
2✔
1129
                    $value = [$value];
2✔
1130
                }
1131
            }
1132

1133
            if (($collectionValueType instanceof Type && $collectionValueType->isSatisfiedBy($typeIsResourceClass))
395✔
1134
                || ($t instanceof LegacyType && $t->isCollection() && null !== $collectionValueType && null !== ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className))
395✔
1135
            ) {
1136
                $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className);
30✔
1137
                $context['resource_class'] = $resourceClass;
30✔
1138
                unset($context['uri_variables']);
30✔
1139

1140
                try {
1141
                    return $t instanceof Type
30✔
1142
                        ? $this->denormalizeObjectCollection($attribute, $propertyMetadata, $t, $resourceClass, $value, $format, $context)
30✔
1143
                        : $this->denormalizeCollection($attribute, $propertyMetadata, $t, $resourceClass, $value, $format, $context);
26✔
1144
                } catch (NotNormalizableValueException $e) {
4✔
1145
                    // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
1146
                    if ($isMultipleTypes) {
4✔
UNCOV
1147
                        $denormalizationException ??= $e;
×
1148

UNCOV
1149
                        continue;
×
1150
                    }
1151

1152
                    throw $e;
4✔
1153
                }
1154
            }
1155

1156
            if (
1157
                ($t instanceof Type && $t->isSatisfiedBy($typeIsResourceClass))
386✔
1158
                || ($t instanceof LegacyType && null !== ($className = $t->getClassName()) && $this->resourceClassResolver->isResourceClass($className))
386✔
1159
            ) {
1160
                $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className);
102✔
1161
                $childContext = $this->createChildContext($this->createOperationContext($context, $resourceClass), $attribute, $format);
102✔
1162

1163
                try {
1164
                    return $this->denormalizeRelation($attribute, $propertyMetadata, $resourceClass, $value, $format, $childContext);
102✔
1165
                } catch (NotNormalizableValueException $e) {
11✔
1166
                    // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
1167
                    if ($isMultipleTypes) {
3✔
UNCOV
1168
                        $denormalizationException ??= $e;
2✔
1169

UNCOV
1170
                        continue;
2✔
1171
                    }
1172

1173
                    throw $e;
1✔
1174
                }
1175
            }
1176

1177
            if (
1178
                ($t instanceof CollectionType && $collectionValueType instanceof ObjectType && (null !== ($className = $collectionValueType->getClassName())))
354✔
1179
                || ($t instanceof LegacyType && $t->isCollection() && null !== $collectionValueType && null !== ($className = $collectionValueType->getClassName()))
354✔
1180
            ) {
1181
                if (!$this->serializer instanceof DenormalizerInterface) {
6✔
UNCOV
1182
                    throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class));
×
1183
                }
1184

1185
                unset($context['resource_class'], $context['uri_variables']);
6✔
1186

1187
                try {
1188
                    return $this->serializer->denormalize($value, $className.'[]', $format, $context);
6✔
UNCOV
1189
                } catch (NotNormalizableValueException $e) {
×
1190
                    // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
UNCOV
1191
                    if ($isMultipleTypes) {
×
UNCOV
1192
                        $denormalizationException ??= $e;
×
1193

UNCOV
1194
                        continue;
×
1195
                    }
1196

UNCOV
1197
                    throw $e;
×
1198
                }
1199
            }
1200

1201
            while ($t instanceof WrappingTypeInterface) {
354✔
1202
                $t = $t->getWrappedType();
11✔
1203
            }
1204

1205
            if (
1206
                $t instanceof ObjectType
354✔
1207
                || ($t instanceof LegacyType && null !== $t->getClassName())
354✔
1208
            ) {
1209
                if (!$this->serializer instanceof DenormalizerInterface) {
34✔
UNCOV
1210
                    throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class));
×
1211
                }
1212

1213
                unset($context['resource_class'], $context['uri_variables']);
34✔
1214

1215
                try {
1216
                    return $this->serializer->denormalize($value, $t->getClassName(), $format, $context);
34✔
1217
                } catch (NotNormalizableValueException $e) {
9✔
1218
                    // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
1219
                    if ($isMultipleTypes) {
9✔
1220
                        $denormalizationException ??= $e;
7✔
1221

1222
                        continue;
7✔
1223
                    }
1224

UNCOV
1225
                    throw $e;
2✔
1226
                }
1227
            }
1228

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

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

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

1274
                        switch ($value) {
1275
                            case 'NaN':
6✔
1276
                                return \NAN;
2✔
1277
                            case 'INF':
4✔
1278
                                return \INF;
2✔
1279
                            case '-INF':
2✔
1280
                                return -\INF;
2✔
1281
                            default:
1282
                                // union/intersect types: try the next type, if not valid, an exception will be thrown at the end
UNCOV
1283
                                if ($isMultipleTypes) {
×
UNCOV
1284
                                    break 3;
×
1285
                                }
UNCOV
1286
                                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);
×
1287
                        }
1288
                }
1289
            }
1290

1291
            if ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false) {
334✔
UNCOV
1292
                return $value;
×
1293
            }
1294

1295
            try {
1296
                $t instanceof Type
334✔
1297
                    ? $this->validateAttributeType($attribute, $t, $value, $format, $context)
334✔
UNCOV
1298
                    : $this->validateType($attribute, $t, $value, $format, $context);
×
1299

1300
                $denormalizationException = null;
324✔
1301
                break;
324✔
1302
            } catch (NotNormalizableValueException $e) {
74✔
1303
                // union/intersect types: try the next type
1304
                if (!$isMultipleTypes) {
74✔
1305
                    throw $e;
7✔
1306
                }
1307

1308
                $denormalizationException ??= $e;
67✔
1309
            }
1310
        }
1311

1312
        if ($denormalizationException) {
335✔
1313
            if ($type instanceof Type && $type->isSatisfiedBy(static fn ($type) => $type instanceof BuiltinType) && !$type->isSatisfiedBy($typeIsResourceClass)) {
8✔
1314
                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);
4✔
1315
            }
1316

1317
            throw $denormalizationException;
4✔
1318
        }
1319

1320
        return $value;
332✔
1321
    }
1322

1323
    /**
1324
     * Sets a value of the object using the PropertyAccess component.
1325
     */
1326
    private function setValue(object $object, string $attributeName, mixed $value): void
1327
    {
1328
        try {
1329
            $this->propertyAccessor->setValue($object, $attributeName, $value);
366✔
1330
        } catch (NoSuchPropertyException) {
17✔
1331
            // Properties not found are ignored
1332
        }
1333
    }
1334
}
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