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

api-platform / core / 5645455322

pending completion
5645455322

push

github

web-flow
test: fix a bug in DummyDtoInputOutputProvider (#5694)

10875 of 18245 relevant lines covered (59.61%)

20.04 hits per line

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

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

3
/*
4
 * This file is part of the API Platform project.
5
 *
6
 * (c) Kévin Dunglas <dunglas@gmail.com>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11

12
declare(strict_types=1);
13

14
namespace ApiPlatform\Serializer;
15

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

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

58
    protected PropertyAccessorInterface $propertyAccessor;
59
    protected array $localCache = [];
60
    protected array $localFactoryOptionsCache = [];
61

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

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

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

82
        $class = $context['force_resource_class'] ?? $this->getObjectClass($data);
26✔
83
        if (($context['output']['class'] ?? null) === $class) {
26✔
84
            return true;
2✔
85
        }
86

87
        return $this->resourceClassResolver->isResourceClass($class);
24✔
88
    }
89

90
    public function getSupportedTypes(?string $format): array
91
    {
92
        return [
30✔
93
            '*' => true,
30✔
94
        ];
30✔
95
    }
96

97
    /**
98
     * {@inheritdoc}
99
     */
100
    public function hasCacheableSupportsMethod(): bool
101
    {
102
        trigger_deprecation('api-platform/core', '3.1', 'The "%s()" method is deprecated, use "getSupportedTypes()" instead.', __METHOD__);
6✔
103

104
        return true;
6✔
105
    }
106

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

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

125
            return $this->serializer->normalize($object, $format, $context);
2✔
126
        }
127

128
        if (isset($context['operation']) && $context['operation'] instanceof CollectionOperationInterface) {
46✔
129
            unset($context['operation_name']);
×
130
            unset($context['operation']);
×
131
            unset($context['iri']);
×
132
        }
133

134
        if ($this->resourceClassResolver->isResourceClass($resourceClass)) {
46✔
135
            $context = $this->initContext($resourceClass, $context);
44✔
136
        }
137

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

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

154
        if (isset($context['resources'])) {
46✔
155
            $context['resources'][$iri] = $iri;
32✔
156
        }
157

158
        $data = parent::normalize($object, $format, $context);
46✔
159

160
        if ($emptyResourceAsIri && \is_array($data) && 0 === \count($data)) {
44✔
161
            return $iri;
×
162
        }
163

164
        return $data;
44✔
165
    }
166

167
    /**
168
     * {@inheritdoc}
169
     */
170
    public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool
171
    {
172
        if (($context['input']['class'] ?? null) === $type) {
12✔
173
            return true;
×
174
        }
175

176
        return $this->localCache[$type] ?? $this->localCache[$type] = $this->resourceClassResolver->isResourceClass($type);
12✔
177
    }
178

179
    /**
180
     * {@inheritdoc}
181
     */
182
    public function denormalize(mixed $data, string $class, string $format = null, array $context = []): mixed
183
    {
184
        $resourceClass = $class;
60✔
185

186
        if ($inputClass = $this->getInputClass($context)) {
60✔
187
            if (!$this->serializer instanceof DenormalizerInterface) {
×
188
                throw new LogicException('Cannot denormalize the input because the injected serializer is not a denormalizer');
×
189
            }
190

191
            unset($context['input'], $context['operation'], $context['operation_name']);
×
192
            $context['resource_class'] = $inputClass;
×
193

194
            try {
195
                return $this->serializer->denormalize($data, $inputClass, $format, $context);
×
196
            } catch (NotNormalizableValueException $e) {
×
197
                throw new UnexpectedValueException('The input data is misformatted.', $e->getCode(), $e);
×
198
            }
199
        }
200

201
        if (null === $objectToPopulate = $this->extractObjectToPopulate($resourceClass, $context, static::OBJECT_TO_POPULATE)) {
60✔
202
            $normalizedData = \is_scalar($data) ? [$data] : $this->prepareForDenormalization($data);
52✔
203
            $class = $this->getClassDiscriminatorResolvedClass($normalizedData, $class, $context);
52✔
204
        }
205

206
        $context['api_denormalize'] = true;
60✔
207

208
        if ($this->resourceClassResolver->isResourceClass($class)) {
60✔
209
            $resourceClass = $this->resourceClassResolver->getResourceClass($objectToPopulate, $class);
60✔
210
            $context['resource_class'] = $resourceClass;
60✔
211
        }
212

213
        if (\is_string($data)) {
60✔
214
            try {
215
                return $this->iriConverter->getResourceFromIri($data, $context + ['fetch_data' => true]);
×
216
            } catch (ItemNotFoundException $e) {
×
217
                throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
×
218
            } catch (InvalidArgumentException $e) {
×
219
                throw new UnexpectedValueException(sprintf('Invalid IRI "%s".', $data), $e->getCode(), $e);
×
220
            }
221
        }
222

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

227
        $previousObject = $this->clone($objectToPopulate);
60✔
228
        $object = parent::denormalize($data, $class, $format, $context);
60✔
229

230
        if (!$this->resourceClassResolver->isResourceClass($class)) {
46✔
231
            return $object;
×
232
        }
233

234
        // Bypass the post-denormalize attribute revert logic if the object could not be
235
        // cloned since we cannot possibly revert any changes made to it.
236
        if (null !== $objectToPopulate && null === $previousObject) {
46✔
237
            return $object;
2✔
238
        }
239

240
        $options = $this->getFactoryOptions($context);
44✔
241
        $propertyNames = iterator_to_array($this->propertyNameCollectionFactory->create($resourceClass, $options));
44✔
242

243
        // Revert attributes that aren't allowed to be changed after a post-denormalize check
244
        foreach (array_keys($data) as $attribute) {
44✔
245
            $attribute = $this->nameConverter ? $this->nameConverter->denormalize((string) $attribute) : $attribute;
44✔
246
            if (!\in_array($attribute, $propertyNames, true)) {
44✔
247
                continue;
2✔
248
            }
249

250
            if (!$this->canAccessAttributePostDenormalize($object, $previousObject, $attribute, $context)) {
44✔
251
                if (null !== $previousObject) {
4✔
252
                    $this->setValue($object, $attribute, $this->propertyAccessor->getValue($previousObject, $attribute));
2✔
253
                } else {
254
                    $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $attribute, $options);
2✔
255
                    $this->setValue($object, $attribute, $propertyMetadata->getDefault());
2✔
256
                }
257
            }
258
        }
259

260
        return $object;
44✔
261
    }
262

263
    /**
264
     * Method copy-pasted from symfony/serializer.
265
     * Remove it after symfony/serializer version update @see https://github.com/symfony/symfony/pull/28263.
266
     *
267
     * {@inheritdoc}
268
     *
269
     * @internal
270
     */
271
    protected function instantiateObject(array &$data, string $class, array &$context, \ReflectionClass $reflectionClass, array|bool $allowedAttributes, string $format = null): object
272
    {
273
        if (null !== $object = $this->extractObjectToPopulate($class, $context, static::OBJECT_TO_POPULATE)) {
60✔
274
            unset($context[static::OBJECT_TO_POPULATE]);
8✔
275

276
            return $object;
8✔
277
        }
278

279
        $class = $this->getClassDiscriminatorResolvedClass($data, $class, $context);
52✔
280
        $reflectionClass = new \ReflectionClass($class);
52✔
281

282
        $constructor = $this->getConstructor($data, $class, $context, $reflectionClass, $allowedAttributes);
52✔
283
        if ($constructor) {
52✔
284
            $constructorParameters = $constructor->getParameters();
48✔
285

286
            $params = [];
48✔
287
            foreach ($constructorParameters as $constructorParameter) {
48✔
288
                $paramName = $constructorParameter->name;
×
289
                $key = $this->nameConverter ? $this->nameConverter->normalize($paramName, $class, $format, $context) : $paramName;
×
290

291
                $allowed = false === $allowedAttributes || (\is_array($allowedAttributes) && \in_array($paramName, $allowedAttributes, true));
×
292
                $ignored = !$this->isAllowedAttribute($class, $paramName, $format, $context);
×
293
                if ($constructorParameter->isVariadic()) {
×
294
                    if ($allowed && !$ignored && (isset($data[$key]) || \array_key_exists($key, $data))) {
×
295
                        if (!\is_array($data[$paramName])) {
×
296
                            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));
×
297
                        }
298

299
                        $params[] = $data[$paramName];
×
300
                    }
301
                } elseif ($allowed && !$ignored && (isset($data[$key]) || \array_key_exists($key, $data))) {
×
302
                    $constructorContext = $context;
×
303
                    $constructorContext['deserialization_path'] = $context['deserialization_path'] ?? $key;
×
304
                    try {
305
                        $params[] = $this->createConstructorArgument($data[$key], $key, $constructorParameter, $constructorContext, $format);
×
306
                    } catch (NotNormalizableValueException $exception) {
×
307
                        if (!isset($context['not_normalizable_value_exceptions'])) {
×
308
                            throw $exception;
×
309
                        }
310
                        $context['not_normalizable_value_exceptions'][] = $exception;
×
311
                    }
312

313
                    // Don't run set for a parameter passed to the constructor
314
                    unset($data[$key]);
×
315
                } elseif (isset($context[static::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class][$key])) {
×
316
                    $params[] = $context[static::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class][$key];
×
317
                } elseif ($constructorParameter->isDefaultValueAvailable()) {
×
318
                    $params[] = $constructorParameter->getDefaultValue();
×
319
                } else {
320
                    if (!isset($context['not_normalizable_value_exceptions'])) {
×
321
                        throw new MissingConstructorArgumentsException(sprintf('Cannot create an instance of "%s" from serialized data because its constructor requires parameter "%s" to be present.', $class, $constructorParameter->name), 0, null, [$constructorParameter->name]);
×
322
                    }
323

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

329
            if (\count($context['not_normalizable_value_exceptions'] ?? []) > 0) {
48✔
330
                return $reflectionClass->newInstanceWithoutConstructor();
×
331
            }
332

333
            if ($constructor->isConstructor()) {
48✔
334
                return $reflectionClass->newInstanceArgs($params);
48✔
335
            }
336

337
            return $constructor->invokeArgs(null, $params);
×
338
        }
339

340
        return new $class();
4✔
341
    }
342

343
    protected function getClassDiscriminatorResolvedClass(array $data, string $class, array $context = []): string
344
    {
345
        if (null === $this->classDiscriminatorResolver || (null === $mapping = $this->classDiscriminatorResolver->getMappingForClass($class))) {
52✔
346
            return $class;
52✔
347
        }
348

349
        if (!isset($data[$mapping->getTypeProperty()])) {
×
350
            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());
×
351
        }
352

353
        $type = $data[$mapping->getTypeProperty()];
×
354
        if (null === ($mappedClass = $mapping->getClassForType($type))) {
×
355
            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);
×
356
        }
357

358
        return $mappedClass;
×
359
    }
360

361
    protected function createConstructorArgument($parameterData, string $key, \ReflectionParameter $constructorParameter, array &$context, string $format = null): mixed
362
    {
363
        return $this->createAndValidateAttributeValue($constructorParameter->name, $parameterData, $format, $context);
×
364
    }
365

366
    /**
367
     * {@inheritdoc}
368
     *
369
     * Unused in this context.
370
     *
371
     * @return string[]
372
     */
373
    protected function extractAttributes($object, $format = null, array $context = []): array
374
    {
375
        return [];
×
376
    }
377

378
    /**
379
     * {@inheritdoc}
380
     */
381
    protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool
382
    {
383
        if (!$this->resourceClassResolver->isResourceClass($context['resource_class'])) {
98✔
384
            return parent::getAllowedAttributes($classOrObject, $context, $attributesAsString);
2✔
385
        }
386

387
        $resourceClass = $this->resourceClassResolver->getResourceClass(null, $context['resource_class']); // fix for abstract classes and interfaces
96✔
388
        $options = $this->getFactoryOptions($context);
96✔
389
        $propertyNames = $this->propertyNameCollectionFactory->create($resourceClass, $options);
96✔
390

391
        $allowedAttributes = [];
96✔
392
        foreach ($propertyNames as $propertyName) {
96✔
393
            $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName, $options);
96✔
394

395
            if (
396
                $this->isAllowedAttribute($classOrObject, $propertyName, null, $context)
96✔
397
                && (
398
                    isset($context['api_normalize']) && $propertyMetadata->isReadable()
96✔
399
                    || isset($context['api_denormalize']) && ($propertyMetadata->isWritable() || !\is_object($classOrObject) && $propertyMetadata->isInitializable())
96✔
400
                )
401
            ) {
402
                $allowedAttributes[] = $propertyName;
96✔
403
            }
404
        }
405

406
        return $allowedAttributes;
96✔
407
    }
408

409
    /**
410
     * {@inheritdoc}
411
     */
412
    protected function isAllowedAttribute(object|string $classOrObject, string $attribute, string $format = null, array $context = []): bool
413
    {
414
        if (!parent::isAllowedAttribute($classOrObject, $attribute, $format, $context)) {
98✔
415
            return false;
2✔
416
        }
417

418
        return $this->canAccessAttribute(\is_object($classOrObject) ? $classOrObject : null, $attribute, $context);
98✔
419
    }
420

421
    /**
422
     * Check if access to the attribute is granted.
423
     */
424
    protected function canAccessAttribute(?object $object, string $attribute, array $context = []): bool
425
    {
426
        if (!$this->resourceClassResolver->isResourceClass($context['resource_class'])) {
98✔
427
            return true;
2✔
428
        }
429

430
        $options = $this->getFactoryOptions($context);
96✔
431
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options);
96✔
432
        $security = $propertyMetadata->getSecurity();
96✔
433
        if (null !== $this->resourceAccessChecker && $security) {
96✔
434
            return $this->resourceAccessChecker->isGranted($context['resource_class'], $security, [
8✔
435
                'object' => $object,
8✔
436
            ]);
8✔
437
        }
438

439
        return true;
96✔
440
    }
441

442
    /**
443
     * Check if access to the attribute is granted.
444
     */
445
    protected function canAccessAttributePostDenormalize(?object $object, ?object $previousObject, string $attribute, array $context = []): bool
446
    {
447
        $options = $this->getFactoryOptions($context);
44✔
448
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options);
44✔
449
        $security = $propertyMetadata->getSecurityPostDenormalize();
44✔
450
        if ($this->resourceAccessChecker && $security) {
44✔
451
            return $this->resourceAccessChecker->isGranted($context['resource_class'], $security, [
4✔
452
                'object' => $object,
4✔
453
                'previous_object' => $previousObject,
4✔
454
            ]);
4✔
455
        }
456

457
        return true;
44✔
458
    }
459

460
    /**
461
     * {@inheritdoc}
462
     */
463
    protected function setAttributeValue(object $object, string $attribute, mixed $value, string $format = null, array $context = []): void
464
    {
465
        try {
466
            $this->setValue($object, $attribute, $this->createAttributeValue($attribute, $value, $format, $context));
60✔
467
        } catch (NotNormalizableValueException $exception) {
14✔
468
            // Only throw if collecting denormalization errors is disabled.
469
            if (!isset($context['not_normalizable_value_exceptions'])) {
10✔
470
                throw $exception;
10✔
471
            }
472
        }
473
    }
474

475
    /**
476
     * Validates the type of the value. Allows using integers as floats for JSON formats.
477
     *
478
     * @throws NotNormalizableValueException
479
     */
480
    protected function validateType(string $attribute, Type $type, mixed $value, string $format = null, array $context = []): void
481
    {
482
        $builtinType = $type->getBuiltinType();
30✔
483
        if (Type::BUILTIN_TYPE_FLOAT === $builtinType && null !== $format && str_contains($format, 'json')) {
30✔
484
            $isValid = \is_float($value) || \is_int($value);
2✔
485
        } else {
486
            $isValid = \call_user_func('is_'.$builtinType, $value);
28✔
487
        }
488

489
        if (!$isValid) {
30✔
490
            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);
2✔
491
        }
492
    }
493

494
    /**
495
     * Denormalizes a collection of objects.
496
     *
497
     * @throws NotNormalizableValueException
498
     */
499
    protected function denormalizeCollection(string $attribute, ApiProperty $propertyMetadata, Type $type, string $className, mixed $value, ?string $format, array $context): array
500
    {
501
        if (!\is_array($value)) {
14✔
502
            throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute must be "array", "%s" given.', $attribute, \gettype($value)), $value, [Type::BUILTIN_TYPE_ARRAY], $context['deserialization_path'] ?? null);
2✔
503
        }
504

505
        $collectionKeyType = $type->getCollectionKeyTypes()[0] ?? null;
12✔
506
        $collectionKeyBuiltinType = $collectionKeyType?->getBuiltinType();
12✔
507
        $childContext = $this->createChildContext(['resource_class' => $className] + $context, $attribute, $format);
12✔
508
        unset($childContext['uri_variables']);
12✔
509
        if ($this->resourceMetadataCollectionFactory) {
12✔
510
            $childContext['operation'] = $this->resourceMetadataCollectionFactory->create($className)->getOperation();
2✔
511
        }
512

513
        $values = [];
12✔
514
        foreach ($value as $index => $obj) {
12✔
515
            if (null !== $collectionKeyBuiltinType && !\call_user_func('is_'.$collectionKeyBuiltinType, $index)) {
12✔
516
                throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the key "%s" must be "%s", "%s" given.', $index, $collectionKeyBuiltinType, \gettype($index)), $index, [$collectionKeyBuiltinType], ($context['deserialization_path'] ?? false) ? sprintf('key(%s)', $context['deserialization_path']) : null, true);
4✔
517
            }
518

519
            $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $childContext);
8✔
520
        }
521

522
        return $values;
8✔
523
    }
524

525
    /**
526
     * Denormalizes a relation.
527
     *
528
     * @throws LogicException
529
     * @throws UnexpectedValueException
530
     * @throws NotNormalizableValueException
531
     */
532
    protected function denormalizeRelation(string $attributeName, ApiProperty $propertyMetadata, string $className, mixed $value, ?string $format, array $context): ?object
533
    {
534
        if (\is_string($value)) {
10✔
535
            try {
536
                return $this->iriConverter->getResourceFromIri($value, $context + ['fetch_data' => true]);
2✔
537
            } catch (ItemNotFoundException $e) {
×
538
                throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
×
539
            } catch (InvalidArgumentException $e) {
×
540
                throw new UnexpectedValueException(sprintf('Invalid IRI "%s".', $value), $e->getCode(), $e);
×
541
            }
542
        }
543

544
        if ($propertyMetadata->isWritableLink()) {
8✔
545
            $context['api_allow_update'] = true;
4✔
546

547
            if (!$this->serializer instanceof DenormalizerInterface) {
4✔
548
                throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class));
×
549
            }
550

551
            $item = $this->serializer->denormalize($value, $className, $format, $context);
4✔
552
            if (!\is_object($item) && null !== $item) {
4✔
553
                throw new \UnexpectedValueException('Expected item to be an object or null.');
×
554
            }
555

556
            return $item;
4✔
557
        }
558

559
        if (!\is_array($value)) {
4✔
560
            throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute must be "array" (nested document) or "string" (IRI), "%s" given.', $attributeName, \gettype($value)), $value, [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null);
2✔
561
        }
562

563
        throw new UnexpectedValueException(sprintf('Nested documents for attribute "%s" are not allowed. Use IRIs instead.', $attributeName));
2✔
564
    }
565

566
    /**
567
     * Gets the options for the property name collection / property metadata factories.
568
     */
569
    protected function getFactoryOptions(array $context): array
570
    {
571
        $options = [];
98✔
572
        if (isset($context[self::GROUPS])) {
98✔
573
            /* @see https://github.com/symfony/symfony/blob/v4.2.6/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php */
574
            $options['serializer_groups'] = (array) $context[self::GROUPS];
2✔
575
        }
576

577
        $operationCacheKey = ($context['resource_class'] ?? '').($context['operation_name'] ?? '').($context['api_normalize'] ?? '');
98✔
578
        if ($operationCacheKey && isset($this->localFactoryOptionsCache[$operationCacheKey])) {
98✔
579
            return $options + $this->localFactoryOptionsCache[$operationCacheKey];
98✔
580
        }
581

582
        // This is a hot spot
583
        if (isset($context['resource_class'])) {
98✔
584
            // Note that the groups need to be read on the root operation
585
            $operation = $context['root_operation'] ?? $context['operation'] ?? null;
98✔
586

587
            if (!$operation && $this->resourceMetadataCollectionFactory && $this->resourceClassResolver->isResourceClass($context['resource_class'])) {
98✔
588
                $resourceClass = $this->resourceClassResolver->getResourceClass(null, $context['resource_class']); // fix for abstract classes and interfaces
10✔
589
                $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation($context['root_operation_name'] ?? $context['operation_name'] ?? null);
10✔
590
            }
591

592
            if ($operation) {
98✔
593
                $options['normalization_groups'] = $operation->getNormalizationContext()['groups'] ?? null;
24✔
594
                $options['denormalization_groups'] = $operation->getDenormalizationContext()['groups'] ?? null;
24✔
595
                $options['operation_name'] = $operation->getName();
24✔
596
            }
597
        }
598

599
        return $options + $this->localFactoryOptionsCache[$operationCacheKey] = $options;
98✔
600
    }
601

602
    /**
603
     * {@inheritdoc}
604
     *
605
     * @throws UnexpectedValueException
606
     */
607
    protected function getAttributeValue(object $object, string $attribute, string $format = null, array $context = []): mixed
608
    {
609
        $context['api_attribute'] = $attribute;
44✔
610
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context));
44✔
611

612
        $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
44✔
613

614
        if ($context['api_denormalize'] ?? false) {
42✔
615
            return $attributeValue;
×
616
        }
617

618
        $type = $propertyMetadata->getBuiltinTypes()[0] ?? null;
42✔
619

620
        if (
621
            $type
42✔
622
            && $type->isCollection()
42✔
623
            && ($collectionValueType = $type->getCollectionValueTypes()[0] ?? null)
42✔
624
            && ($className = $collectionValueType->getClassName())
42✔
625
            && $this->resourceClassResolver->isResourceClass($className)
42✔
626
        ) {
627
            if (!is_iterable($attributeValue)) {
14✔
628
                throw new UnexpectedValueException('Unexpected non-iterable value for to-many relation.');
×
629
            }
630

631
            $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
14✔
632
            $childContext = $this->createChildContext($context, $attribute, $format);
14✔
633
            unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']);
14✔
634

635
            return $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
14✔
636
        }
637

638
        if (
639
            $type
40✔
640
            && ($className = $type->getClassName())
40✔
641
            && $this->resourceClassResolver->isResourceClass($className)
40✔
642
        ) {
643
            if (!\is_object($attributeValue) && null !== $attributeValue) {
16✔
644
                throw new UnexpectedValueException('Unexpected non-object value for to-one relation.');
×
645
            }
646

647
            $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
16✔
648
            $childContext = $this->createChildContext($context, $attribute, $format);
16✔
649
            $childContext['resource_class'] = $resourceClass;
16✔
650
            if ($this->resourceMetadataCollectionFactory) {
16✔
651
                $childContext['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation();
6✔
652
            }
653
            unset($childContext['iri'], $childContext['uri_variables']);
16✔
654

655
            return $this->normalizeRelation($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
16✔
656
        }
657

658
        if (!$this->serializer instanceof NormalizerInterface) {
38✔
659
            throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class));
×
660
        }
661

662
        unset($context['resource_class']);
38✔
663
        unset($context['force_resource_class']);
38✔
664

665
        if ($type && $type->getClassName()) {
38✔
666
            $childContext = $this->createChildContext($context, $attribute, $format);
6✔
667
            unset($childContext['iri'], $childContext['uri_variables']);
6✔
668
            $childContext['output']['gen_id'] = $propertyMetadata->getGenId() ?? true;
6✔
669

670
            return $this->serializer->normalize($attributeValue, $format, $childContext);
6✔
671
        }
672

673
        if ($type && 'array' === $type->getBuiltinType()) {
38✔
674
            $childContext = $this->createChildContext($context, $attribute, $format);
8✔
675
            unset($childContext['iri'], $childContext['uri_variables']);
8✔
676

677
            return $this->serializer->normalize($attributeValue, $format, $childContext);
8✔
678
        }
679

680
        return $this->serializer->normalize($attributeValue, $format, $context);
38✔
681
    }
682

683
    /**
684
     * Normalizes a collection of relations (to-many).
685
     *
686
     * @throws UnexpectedValueException
687
     */
688
    protected function normalizeCollectionOfRelations(ApiProperty $propertyMetadata, iterable $attributeValue, string $resourceClass, ?string $format, array $context): array
689
    {
690
        $value = [];
14✔
691
        foreach ($attributeValue as $index => $obj) {
14✔
692
            if (!\is_object($obj) && null !== $obj) {
6✔
693
                throw new UnexpectedValueException('Unexpected non-object element in to-many relation.');
×
694
            }
695

696
            // update context, if concrete object class deviates from general relation class (e.g. in case of polymorphic resources)
697
            $objResourceClass = $this->resourceClassResolver->getResourceClass($obj, $resourceClass);
6✔
698
            $context['resource_class'] = $objResourceClass;
6✔
699
            if ($this->resourceMetadataCollectionFactory) {
6✔
700
                $context['operation'] = $this->resourceMetadataCollectionFactory->create($objResourceClass)->getOperation();
×
701
            }
702

703
            $value[$index] = $this->normalizeRelation($propertyMetadata, $obj, $resourceClass, $format, $context);
6✔
704
        }
705

706
        return $value;
14✔
707
    }
708

709
    /**
710
     * Normalizes a relation.
711
     *
712
     * @throws LogicException
713
     * @throws UnexpectedValueException
714
     */
715
    protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $relatedObject, string $resourceClass, ?string $format, array $context): \ArrayObject|array|string|null
716
    {
717
        if (null === $relatedObject || !empty($context['attributes']) || $propertyMetadata->isReadableLink()) {
18✔
718
            if (!$this->serializer instanceof NormalizerInterface) {
12✔
719
                throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class));
×
720
            }
721

722
            $relatedContext = $context;
12✔
723
            unset($relatedContext['force_resource_class']);
12✔
724
            $normalizedRelatedObject = $this->serializer->normalize($relatedObject, $format, $relatedContext);
12✔
725
            if (!\is_string($normalizedRelatedObject) && !\is_array($normalizedRelatedObject) && !$normalizedRelatedObject instanceof \ArrayObject && null !== $normalizedRelatedObject) {
12✔
726
                throw new UnexpectedValueException('Expected normalized relation to be an IRI, array, \ArrayObject or null');
×
727
            }
728

729
            return $normalizedRelatedObject;
12✔
730
        }
731

732
        $iri = $this->iriConverter->getIriFromResource($relatedObject);
6✔
733

734
        if (isset($context['resources'])) {
6✔
735
            $context['resources'][$iri] = $iri;
2✔
736
        }
737

738
        $push = $propertyMetadata->getPush() ?? false;
6✔
739
        if (isset($context['resources_to_push']) && $push) {
6✔
740
            $context['resources_to_push'][$iri] = $iri;
×
741
        }
742

743
        return $iri;
6✔
744
    }
745

746
    private function createAttributeValue(string $attribute, mixed $value, string $format = null, array &$context = []): mixed
747
    {
748
        try {
749
            return $this->createAndValidateAttributeValue($attribute, $value, $format, $context);
60✔
750
        } catch (NotNormalizableValueException $exception) {
14✔
751
            if (!isset($context['not_normalizable_value_exceptions'])) {
10✔
752
                throw $exception;
10✔
753
            }
754
            $context['not_normalizable_value_exceptions'][] = $exception;
×
755

756
            throw $exception;
×
757
        }
758
    }
759

760
    private function createAndValidateAttributeValue(string $attribute, mixed $value, string $format = null, array $context = []): mixed
761
    {
762
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context));
60✔
763
        $type = $propertyMetadata->getBuiltinTypes()[0] ?? null;
60✔
764

765
        if (null === $type) {
60✔
766
            // No type provided, blindly return the value
767
            return $value;
10✔
768
        }
769

770
        if (null === $value && $type->isNullable() || ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false)) {
50✔
771
            return $value;
6✔
772
        }
773

774
        $collectionValueType = $type->getCollectionValueTypes()[0] ?? null;
44✔
775

776
        /* From @see AbstractObjectNormalizer::validateAndDenormalize() */
777
        // Fix a collection that contains the only one element
778
        // This is special to xml format only
779
        if ('xml' === $format && null !== $collectionValueType && (!\is_array($value) || !\is_int(key($value)))) {
44✔
780
            $value = [$value];
2✔
781
        }
782

783
        if (
784
            $type->isCollection()
44✔
785
            && null !== $collectionValueType
44✔
786
            && null !== ($className = $collectionValueType->getClassName())
44✔
787
            && $this->resourceClassResolver->isResourceClass($className)
44✔
788
        ) {
789
            $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className);
14✔
790

791
            return $this->denormalizeCollection($attribute, $propertyMetadata, $type, $resourceClass, $value, $format, $context);
14✔
792
        }
793

794
        if (
795
            null !== ($className = $type->getClassName())
36✔
796
            && $this->resourceClassResolver->isResourceClass($className)
36✔
797
        ) {
798
            $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className);
12✔
799
            $childContext = $this->createChildContext($context, $attribute, $format);
12✔
800
            $childContext['resource_class'] = $resourceClass;
12✔
801
            unset($childContext['uri_variables']);
12✔
802
            if ($this->resourceMetadataCollectionFactory) {
12✔
803
                $childContext['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation();
2✔
804
            }
805

806
            return $this->denormalizeRelation($attribute, $propertyMetadata, $resourceClass, $value, $format, $childContext);
12✔
807
        }
808

809
        if (
810
            $type->isCollection()
30✔
811
            && null !== $collectionValueType
30✔
812
            && null !== ($className = $collectionValueType->getClassName())
30✔
813
        ) {
814
            if (!$this->serializer instanceof DenormalizerInterface) {
×
815
                throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class));
×
816
            }
817

818
            unset($context['resource_class']);
×
819

820
            return $this->serializer->denormalize($value, $className.'[]', $format, $context);
×
821
        }
822

823
        if (null !== $className = $type->getClassName()) {
30✔
824
            if (!$this->serializer instanceof DenormalizerInterface) {
×
825
                throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class));
×
826
            }
827

828
            unset($context['resource_class']);
×
829

830
            return $this->serializer->denormalize($value, $className, $format, $context);
×
831
        }
832

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

842
            switch ($type->getBuiltinType()) {
2✔
843
                case Type::BUILTIN_TYPE_BOOL:
844
                    // according to http://www.w3.org/TR/xmlschema-2/#boolean, valid representations are "false", "true", "0" and "1"
845
                    if ('false' === $value || '0' === $value) {
2✔
846
                        $value = false;
2✔
847
                    } elseif ('true' === $value || '1' === $value) {
2✔
848
                        $value = true;
2✔
849
                    } else {
850
                        throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be bool ("%s" given).', $attribute, $className, $value), $value, [Type::BUILTIN_TYPE_BOOL], $context['deserialization_path'] ?? null);
×
851
                    }
852
                    break;
2✔
853
                case Type::BUILTIN_TYPE_INT:
854
                    if (ctype_digit($value) || ('-' === $value[0] && ctype_digit(substr($value, 1)))) {
2✔
855
                        $value = (int) $value;
2✔
856
                    } else {
857
                        throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be int ("%s" given).', $attribute, $className, $value), $value, [Type::BUILTIN_TYPE_INT], $context['deserialization_path'] ?? null);
×
858
                    }
859
                    break;
2✔
860
                case Type::BUILTIN_TYPE_FLOAT:
861
                    if (is_numeric($value)) {
2✔
862
                        return (float) $value;
2✔
863
                    }
864

865
                    return match ($value) {
2✔
866
                        'NaN' => \NAN,
2✔
867
                        'INF' => \INF,
2✔
868
                        '-INF' => -\INF,
2✔
869
                        default => throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be float ("%s" given).', $attribute, $className, $value), $value, [Type::BUILTIN_TYPE_FLOAT], $context['deserialization_path'] ?? null),
2✔
870
                    };
2✔
871
            }
872
        }
873

874
        if ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false) { // @phpstan-ignore-line
30✔
875
            return $value;
×
876
        }
877

878
        $this->validateType($attribute, $type, $value, $format, $context);
30✔
879

880
        return $value;
28✔
881
    }
882

883
    /**
884
     * Sets a value of the object using the PropertyAccess component.
885
     */
886
    private function setValue(object $object, string $attributeName, mixed $value): void
887
    {
888
        try {
889
            $this->propertyAccessor->setValue($object, $attributeName, $value);
46✔
890
        } catch (NoSuchPropertyException) {
2✔
891
            // Properties not found are ignored
892
        }
893
    }
894
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc