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

api-platform / core / 5833581798

pending completion
5833581798

push

github

web-flow
fix(serializer): use data if no uri_variables provided (#5743)

fixes #5736

18 of 18 new or added lines in 2 files covered. (100.0%)

10855 of 18447 relevant lines covered (58.84%)

19.81 hits per line

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

74.6
/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\Exception\OperationNotFoundException;
24
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
25
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
26
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
27
use ApiPlatform\Metadata\Util\ClassInfoTrait;
28
use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface;
29
use ApiPlatform\Util\CloneTrait;
30
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
31
use Symfony\Component\PropertyAccess\PropertyAccess;
32
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
33
use Symfony\Component\PropertyInfo\Type;
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\Serializer\Serializer;
47

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

60
    protected PropertyAccessorInterface $propertyAccessor;
61
    protected array $localCache = [];
62
    protected array $localFactoryOptionsCache = [];
63

64
    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)
65
    {
66
        if (!isset($defaultContext['circular_reference_handler'])) {
152✔
67
            $defaultContext['circular_reference_handler'] = fn ($object): ?string => $this->iriConverter->getIriFromResource($object);
152✔
68
        }
69

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

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

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

89
        return $this->resourceClassResolver->isResourceClass($class);
24✔
90
    }
91

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

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

113
        return true;
×
114
    }
115

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

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

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

137
        if (isset($context['operation']) && $context['operation'] instanceof CollectionOperationInterface) {
46✔
138
            unset($context['operation_name']);
×
139
            unset($context['operation']);
×
140
            unset($context['iri']);
×
141
        }
142

143
        if ($this->resourceClassResolver->isResourceClass($resourceClass)) {
46✔
144
            $context = $this->initContext($resourceClass, $context);
44✔
145
        }
146

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

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

163
        if (isset($context['resources'])) {
46✔
164
            $context['resources'][$iri] = $iri;
32✔
165
        }
166

167
        $data = parent::normalize($object, $format, $context);
46✔
168

169
        if ($emptyResourceAsIri && \is_array($data) && 0 === \count($data)) {
44✔
170
            return $iri;
×
171
        }
172

173
        return $data;
44✔
174
    }
175

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

185
        return $this->localCache[$type] ?? $this->localCache[$type] = $this->resourceClassResolver->isResourceClass($type);
12✔
186
    }
187

188
    /**
189
     * {@inheritdoc}
190
     */
191
    public function denormalize(mixed $data, string $class, string $format = null, array $context = []): mixed
192
    {
193
        $resourceClass = $class;
60✔
194

195
        if ($inputClass = $this->getInputClass($context)) {
60✔
196
            if (!$this->serializer instanceof DenormalizerInterface) {
×
197
                throw new LogicException('Cannot denormalize the input because the injected serializer is not a denormalizer');
×
198
            }
199

200
            unset($context['input'], $context['operation'], $context['operation_name']);
×
201
            $context['resource_class'] = $inputClass;
×
202

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

210
        if (null === $objectToPopulate = $this->extractObjectToPopulate($resourceClass, $context, static::OBJECT_TO_POPULATE)) {
60✔
211
            $normalizedData = \is_scalar($data) ? [$data] : $this->prepareForDenormalization($data);
52✔
212
            $class = $this->getClassDiscriminatorResolvedClass($normalizedData, $class, $context);
52✔
213
        }
214

215
        $context['api_denormalize'] = true;
60✔
216

217
        if ($this->resourceClassResolver->isResourceClass($class)) {
60✔
218
            $resourceClass = $this->resourceClassResolver->getResourceClass($objectToPopulate, $class);
60✔
219
            $context['resource_class'] = $resourceClass;
60✔
220
        }
221

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

232
        if (!\is_array($data)) {
60✔
233
            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);
×
234
        }
235

236
        $previousObject = $this->clone($objectToPopulate);
60✔
237
        $object = parent::denormalize($data, $class, $format, $context);
60✔
238

239
        if (!$this->resourceClassResolver->isResourceClass($class)) {
46✔
240
            return $object;
×
241
        }
242

243
        // Bypass the post-denormalize attribute revert logic if the object could not be
244
        // cloned since we cannot possibly revert any changes made to it.
245
        if (null !== $objectToPopulate && null === $previousObject) {
46✔
246
            return $object;
2✔
247
        }
248

249
        $options = $this->getFactoryOptions($context);
44✔
250
        $propertyNames = iterator_to_array($this->propertyNameCollectionFactory->create($resourceClass, $options));
44✔
251

252
        // Revert attributes that aren't allowed to be changed after a post-denormalize check
253
        foreach (array_keys($data) as $attribute) {
44✔
254
            $attribute = $this->nameConverter ? $this->nameConverter->denormalize((string) $attribute) : $attribute;
44✔
255
            if (!\in_array($attribute, $propertyNames, true)) {
44✔
256
                continue;
2✔
257
            }
258

259
            if (!$this->canAccessAttributePostDenormalize($object, $previousObject, $attribute, $context)) {
44✔
260
                if (null !== $previousObject) {
4✔
261
                    $this->setValue($object, $attribute, $this->propertyAccessor->getValue($previousObject, $attribute));
2✔
262
                } else {
263
                    $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $attribute, $options);
2✔
264
                    $this->setValue($object, $attribute, $propertyMetadata->getDefault());
2✔
265
                }
266
            }
267
        }
268

269
        return $object;
44✔
270
    }
271

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

285
            return $object;
8✔
286
        }
287

288
        $class = $this->getClassDiscriminatorResolvedClass($data, $class, $context);
52✔
289
        $reflectionClass = new \ReflectionClass($class);
52✔
290

291
        $constructor = $this->getConstructor($data, $class, $context, $reflectionClass, $allowedAttributes);
52✔
292
        if ($constructor) {
52✔
293
            $constructorParameters = $constructor->getParameters();
48✔
294

295
            $params = [];
48✔
296
            foreach ($constructorParameters as $constructorParameter) {
48✔
297
                $paramName = $constructorParameter->name;
×
298
                $key = $this->nameConverter ? $this->nameConverter->normalize($paramName, $class, $format, $context) : $paramName;
×
299

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

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

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

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

338
            if (\count($context['not_normalizable_value_exceptions'] ?? []) > 0) {
48✔
339
                return $reflectionClass->newInstanceWithoutConstructor();
×
340
            }
341

342
            if ($constructor->isConstructor()) {
48✔
343
                return $reflectionClass->newInstanceArgs($params);
48✔
344
            }
345

346
            return $constructor->invokeArgs(null, $params);
×
347
        }
348

349
        return new $class();
4✔
350
    }
351

352
    protected function getClassDiscriminatorResolvedClass(array $data, string $class, array $context = []): string
353
    {
354
        if (null === $this->classDiscriminatorResolver || (null === $mapping = $this->classDiscriminatorResolver->getMappingForClass($class))) {
52✔
355
            return $class;
52✔
356
        }
357

358
        if (!isset($data[$mapping->getTypeProperty()])) {
×
359
            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());
×
360
        }
361

362
        $type = $data[$mapping->getTypeProperty()];
×
363
        if (null === ($mappedClass = $mapping->getClassForType($type))) {
×
364
            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);
×
365
        }
366

367
        return $mappedClass;
×
368
    }
369

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

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

387
    /**
388
     * {@inheritdoc}
389
     */
390
    protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool
391
    {
392
        if (!$this->resourceClassResolver->isResourceClass($context['resource_class'])) {
98✔
393
            return parent::getAllowedAttributes($classOrObject, $context, $attributesAsString);
2✔
394
        }
395

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

400
        $allowedAttributes = [];
96✔
401
        foreach ($propertyNames as $propertyName) {
96✔
402
            $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName, $options);
96✔
403

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

415
        return $allowedAttributes;
96✔
416
    }
417

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

427
        return $this->canAccessAttribute(\is_object($classOrObject) ? $classOrObject : null, $attribute, $context);
98✔
428
    }
429

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

439
        $options = $this->getFactoryOptions($context);
96✔
440
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options);
96✔
441
        $security = $propertyMetadata->getSecurity();
96✔
442
        if (null !== $this->resourceAccessChecker && $security) {
96✔
443
            return $this->resourceAccessChecker->isGranted($context['resource_class'], $security, [
8✔
444
                'object' => $object,
8✔
445
            ]);
8✔
446
        }
447

448
        return true;
96✔
449
    }
450

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

466
        return true;
44✔
467
    }
468

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

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

498
        if (!$isValid) {
30✔
499
            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✔
500
        }
501
    }
502

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

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

523
            $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $childContext);
8✔
524
        }
525

526
        return $values;
8✔
527
    }
528

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

548
        if ($propertyMetadata->isWritableLink()) {
8✔
549
            $context['api_allow_update'] = true;
4✔
550

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

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

560
            return $item;
4✔
561
        }
562

563
        if (!\is_array($value)) {
4✔
564
            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✔
565
        }
566

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

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

581
        $operationCacheKey = ($context['resource_class'] ?? '').($context['operation_name'] ?? '').($context['api_normalize'] ?? '');
98✔
582
        if ($operationCacheKey && isset($this->localFactoryOptionsCache[$operationCacheKey])) {
98✔
583
            return $options + $this->localFactoryOptionsCache[$operationCacheKey];
98✔
584
        }
585

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

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

596
            if ($operation) {
98✔
597
                $options['normalization_groups'] = $operation->getNormalizationContext()['groups'] ?? null;
24✔
598
                $options['denormalization_groups'] = $operation->getDenormalizationContext()['groups'] ?? null;
24✔
599
                $options['operation_name'] = $operation->getName();
24✔
600
            }
601
        }
602

603
        return $options + $this->localFactoryOptionsCache[$operationCacheKey] = $options;
98✔
604
    }
605

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

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

618
        if ($context['api_denormalize'] ?? false) {
42✔
619
            return $attributeValue;
×
620
        }
621

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

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

635
            $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
14✔
636
            $childContext = $this->createChildContext($this->createOperationContext($context, $resourceClass), $attribute, $format);
14✔
637

638
            return $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
14✔
639
        }
640

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

650
            $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
16✔
651
            $childContext = $this->createChildContext($this->createOperationContext($context, $resourceClass), $attribute, $format);
16✔
652

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

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

660
        unset($context['resource_class']);
38✔
661
        unset($context['force_resource_class']);
38✔
662

663
        // Anonymous resources
664
        if ($type && $type->getClassName()) {
38✔
665
            $childContext = $this->createChildContext($context, $attribute, $format);
6✔
666
            $childContext['output']['gen_id'] = $propertyMetadata->getGenId() ?? true;
6✔
667

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

671
        if ($type && 'array' === $type->getBuiltinType()) {
38✔
672
            $childContext = $this->createChildContext($context, $attribute, $format);
8✔
673

674
            return $this->serializer->normalize($attributeValue, $format, $childContext);
8✔
675
        }
676

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

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

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

700
            $value[$index] = $this->normalizeRelation($propertyMetadata, $obj, $resourceClass, $format, $context);
6✔
701
        }
702

703
        return $value;
14✔
704
    }
705

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

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

726
            return $normalizedRelatedObject;
12✔
727
        }
728

729
        $iri = $this->iriConverter->getIriFromResource($relatedObject);
6✔
730

731
        if (isset($context['resources'])) {
6✔
732
            $context['resources'][$iri] = $iri;
2✔
733
        }
734

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

740
        return $iri;
6✔
741
    }
742

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

753
            throw $exception;
×
754
        }
755
    }
756

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

762
        if (null === $type) {
60✔
763
            // No type provided, blindly return the value
764
            return $value;
10✔
765
        }
766

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

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

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

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

788
            return $this->denormalizeCollection($attribute, $propertyMetadata, $type, $resourceClass, $value, $format, $context);
14✔
789
        }
790

791
        if (
792
            null !== ($className = $type->getClassName())
36✔
793
            && $this->resourceClassResolver->isResourceClass($className)
36✔
794
        ) {
795
            $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className);
12✔
796
            $childContext = $this->createChildContext($this->createOperationContext($context, $resourceClass), $attribute, $format);
12✔
797

798
            return $this->denormalizeRelation($attribute, $propertyMetadata, $resourceClass, $value, $format, $childContext);
12✔
799
        }
800

801
        if (
802
            $type->isCollection()
30✔
803
            && null !== $collectionValueType
30✔
804
            && null !== ($className = $collectionValueType->getClassName())
30✔
805
        ) {
806
            if (!$this->serializer instanceof DenormalizerInterface) {
×
807
                throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class));
×
808
            }
809

810
            unset($context['resource_class']);
×
811

812
            return $this->serializer->denormalize($value, $className.'[]', $format, $context);
×
813
        }
814

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

820
            unset($context['resource_class']);
×
821

822
            return $this->serializer->denormalize($value, $className, $format, $context);
×
823
        }
824

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

834
            switch ($type->getBuiltinType()) {
2✔
835
                case Type::BUILTIN_TYPE_BOOL:
836
                    // according to http://www.w3.org/TR/xmlschema-2/#boolean, valid representations are "false", "true", "0" and "1"
837
                    if ('false' === $value || '0' === $value) {
2✔
838
                        $value = false;
2✔
839
                    } elseif ('true' === $value || '1' === $value) {
2✔
840
                        $value = true;
2✔
841
                    } else {
842
                        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);
×
843
                    }
844
                    break;
2✔
845
                case Type::BUILTIN_TYPE_INT:
846
                    if (ctype_digit($value) || ('-' === $value[0] && ctype_digit(substr($value, 1)))) {
2✔
847
                        $value = (int) $value;
2✔
848
                    } else {
849
                        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);
×
850
                    }
851
                    break;
2✔
852
                case Type::BUILTIN_TYPE_FLOAT:
853
                    if (is_numeric($value)) {
2✔
854
                        return (float) $value;
2✔
855
                    }
856

857
                    return match ($value) {
2✔
858
                        'NaN' => \NAN,
2✔
859
                        'INF' => \INF,
2✔
860
                        '-INF' => -\INF,
2✔
861
                        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✔
862
                    };
2✔
863
            }
864
        }
865

866
        if ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false) { // @phpstan-ignore-line
30✔
867
            return $value;
×
868
        }
869

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

872
        return $value;
28✔
873
    }
874

875
    /**
876
     * Sets a value of the object using the PropertyAccess component.
877
     */
878
    private function setValue(object $object, string $attributeName, mixed $value): void
879
    {
880
        try {
881
            $this->propertyAccessor->setValue($object, $attributeName, $value);
46✔
882
        } catch (NoSuchPropertyException) {
2✔
883
            // Properties not found are ignored
884
        }
885
    }
886

887
    private function createOperationContext(array $context, string $resourceClass = null): array
888
    {
889
        if (isset($context['operation']) && !isset($context['root_operation'])) {
38✔
890
            $context['root_operation'] = $context['operation'];
6✔
891
            $context['root_operation_name'] = $context['operation_name'];
6✔
892
        }
893

894
        unset($context['iri'], $context['uri_variables']);
38✔
895
        if (!$resourceClass) {
38✔
896
            return $context;
×
897
        }
898

899
        unset($context['operation'], $context['operation_name']);
38✔
900
        $context['resource_class'] = $resourceClass;
38✔
901
        if ($this->resourceMetadataCollectionFactory) {
38✔
902
            try {
903
                $context['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation();
10✔
904
                $context['operation_name'] = $context['operation']->getName();
10✔
905
            } catch (OperationNotFoundException) {
×
906
            }
907
        }
908

909
        return $context;
38✔
910
    }
911
}
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