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

api-platform / core / 3712739783

pending completion
3712739783

Pull #5254

github

GitHub
Merge 9dfa88fa6 into ac711530f
Pull Request #5254: [OpenApi] Add ApiResource::openapi and deprecate openapiContext

199 of 199 new or added lines in 6 files covered. (100.0%)

7494 of 12363 relevant lines covered (60.62%)

67.55 hits per line

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

88.85
/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\Property\Factory\PropertyMetadataFactoryInterface;
23
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
24
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
25
use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface;
26
use ApiPlatform\Util\ClassInfoTrait;
27
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
28
use Symfony\Component\PropertyAccess\PropertyAccess;
29
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
30
use Symfony\Component\PropertyInfo\Type;
31
use Symfony\Component\Serializer\Encoder\CsvEncoder;
32
use Symfony\Component\Serializer\Encoder\XmlEncoder;
33
use Symfony\Component\Serializer\Exception\LogicException;
34
use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException;
35
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
36
use Symfony\Component\Serializer\Exception\RuntimeException;
37
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
38
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
39
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
40
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
41
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
42
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
43
use Symfony\Component\Serializer\Serializer;
44

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

56
    protected PropertyAccessorInterface $propertyAccessor;
57
    protected array $localCache = [];
58
    protected array $localFactoryOptionsCache = [];
59

60
    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)
61
    {
62
        if (!isset($defaultContext['circular_reference_handler'])) {
649✔
63
            $defaultContext['circular_reference_handler'] = fn ($object): ?string => $this->iriConverter->getIriFromResource($object);
649✔
64
        }
65

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

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

80
        $class = $this->getObjectClass($data);
496✔
81
        if (($context['output']['class'] ?? null) === $class) {
496✔
82
            return true;
16✔
83
        }
84

85
        return $this->resourceClassResolver->isResourceClass($class);
482✔
86
    }
87

88
    /**
89
     * {@inheritdoc}
90
     */
91
    public function hasCacheableSupportsMethod(): bool
92
    {
93
        return true;
573✔
94
    }
95

96
    /**
97
     * {@inheritdoc}
98
     *
99
     * @throws LogicException
100
     */
101
    public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
102
    {
103
        $resourceClass = $this->getObjectClass($object);
479✔
104
        if ($outputClass = $this->getOutputClass($context)) {
479✔
105
            if (!$this->serializer instanceof NormalizerInterface) {
16✔
106
                throw new LogicException('Cannot normalize the output because the injected serializer is not a normalizer');
×
107
            }
108

109
            unset($context['output'], $context['operation'], $context['operation_name']);
16✔
110
            $context['resource_class'] = $outputClass;
16✔
111
            $context['api_sub_level'] = true;
16✔
112
            $context[self::ALLOW_EXTRA_ATTRIBUTES] = false;
16✔
113

114
            return $this->serializer->normalize($object, $format, $context);
16✔
115
        }
116

117
        if ($this->resourceClassResolver->isResourceClass($resourceClass)) {
479✔
118
            $context = $this->initContext($resourceClass, $context);
464✔
119
        }
120

121
        $context['api_normalize'] = true;
479✔
122
        $iri = $context['iri'] ??= $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL, $context['operation'] ?? null, $context);
479✔
123

124
        /*
125
         * When true, converts the normalized data array of a resource into an
126
         * IRI, if the normalized data array is empty.
127
         *
128
         * This is useful when traversing from a non-resource towards an attribute
129
         * which is a resource, as we do not have the benefit of {@see ApiProperty::isReadableLink}.
130
         *
131
         * It must not be propagated to resources, as {@see ApiProperty::isReadableLink}
132
         * should take effect.
133
         */
134
        $emptyResourceAsIri = $context['api_empty_resource_as_iri'] ?? false;
479✔
135
        unset($context['api_empty_resource_as_iri']);
479✔
136

137
        if (isset($context['resources'])) {
479✔
138
            $context['resources'][$iri] = $iri;
392✔
139
        }
140

141
        $data = parent::normalize($object, $format, $context);
479✔
142

143
        if ($emptyResourceAsIri && \is_array($data) && 0 === \count($data)) {
479✔
144
            return $iri;
×
145
        }
146

147
        return $data;
479✔
148
    }
149

150
    /**
151
     * {@inheritdoc}
152
     */
153
    public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool
154
    {
155
        if (($context['input']['class'] ?? null) === $type) {
188✔
156
            return true;
×
157
        }
158

159
        return $this->localCache[$type] ?? $this->localCache[$type] = $this->resourceClassResolver->isResourceClass($type);
188✔
160
    }
161

162
    /**
163
     * {@inheritdoc}
164
     */
165
    public function denormalize(mixed $data, string $class, string $format = null, array $context = []): mixed
166
    {
167
        $resourceClass = $class;
184✔
168

169
        if ($inputClass = $this->getInputClass($context)) {
184✔
170
            if (!$this->serializer instanceof DenormalizerInterface) {
9✔
171
                throw new LogicException('Cannot normalize the output because the injected serializer is not a normalizer');
×
172
            }
173

174
            unset($context['input'], $context['operation'], $context['operation_name']);
9✔
175
            $context['resource_class'] = $inputClass;
9✔
176

177
            try {
178
                return $this->serializer->denormalize($data, $inputClass, $format, $context);
9✔
179
            } catch (NotNormalizableValueException $e) {
1✔
180
                throw new UnexpectedValueException('The input data is misformatted.', $e->getCode(), $e);
1✔
181
            }
182
        }
183

184
        if (null === $objectToPopulate = $this->extractObjectToPopulate($resourceClass, $context, static::OBJECT_TO_POPULATE)) {
176✔
185
            $normalizedData = \is_scalar($data) ? [$data] : $this->prepareForDenormalization($data);
144✔
186
            $class = $this->getClassDiscriminatorResolvedClass($normalizedData, $class);
144✔
187
        }
188

189
        $context['api_denormalize'] = true;
176✔
190

191
        if ($this->resourceClassResolver->isResourceClass($class)) {
176✔
192
            $resourceClass = $this->resourceClassResolver->getResourceClass($objectToPopulate, $class);
176✔
193
            $context['resource_class'] = $resourceClass;
176✔
194
        }
195

196
        if (\is_string($data)) {
176✔
197
            try {
198
                return $this->iriConverter->getResourceFromIri($data, $context + ['fetch_data' => true]);
1✔
199
            } catch (ItemNotFoundException $e) {
×
200
                throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
×
201
            } catch (InvalidArgumentException $e) {
×
202
                throw new UnexpectedValueException(sprintf('Invalid IRI "%s".', $data), $e->getCode(), $e);
×
203
            }
204
        }
205

206
        if (!\is_array($data)) {
175✔
207
            throw new UnexpectedValueException(sprintf('Expected IRI or document for resource "%s", "%s" given.', $resourceClass, \gettype($data)));
1✔
208
        }
209

210
        $previousObject = isset($objectToPopulate) ? clone $objectToPopulate : null;
175✔
211

212
        $object = parent::denormalize($data, $class, $format, $context);
175✔
213

214
        if (!$this->resourceClassResolver->isResourceClass($class)) {
160✔
215
            return $object;
×
216
        }
217

218
        // Revert attributes that aren't allowed to be changed after a post-denormalize check
219
        foreach (array_keys($data) as $attribute) {
160✔
220
            if (!$this->canAccessAttributePostDenormalize($object, $previousObject, $attribute, $context)) {
156✔
221
                if (null !== $previousObject) {
1✔
222
                    $this->setValue($object, $attribute, $this->propertyAccessor->getValue($previousObject, $attribute));
×
223
                } else {
224
                    $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $attribute, $this->getFactoryOptions($context));
1✔
225
                    $this->setValue($object, $attribute, $propertyMetadata->getDefault());
1✔
226
                }
227
            }
228
        }
229

230
        return $object;
160✔
231
    }
232

233
    /**
234
     * Method copy-pasted from symfony/serializer.
235
     * Remove it after symfony/serializer version update @see https://github.com/symfony/symfony/pull/28263.
236
     *
237
     * {@inheritdoc}
238
     *
239
     * @internal
240
     */
241
    protected function instantiateObject(array &$data, string $class, array &$context, \ReflectionClass $reflectionClass, array|bool $allowedAttributes, string $format = null): object
242
    {
243
        if (null !== $object = $this->extractObjectToPopulate($class, $context, static::OBJECT_TO_POPULATE)) {
175✔
244
            unset($context[static::OBJECT_TO_POPULATE]);
42✔
245

246
            return $object;
42✔
247
        }
248

249
        $class = $this->getClassDiscriminatorResolvedClass($data, $class);
143✔
250
        $reflectionClass = new \ReflectionClass($class);
143✔
251

252
        $constructor = $this->getConstructor($data, $class, $context, $reflectionClass, $allowedAttributes);
143✔
253
        if ($constructor) {
143✔
254
            $constructorParameters = $constructor->getParameters();
68✔
255

256
            $params = [];
68✔
257
            foreach ($constructorParameters as $constructorParameter) {
68✔
258
                $paramName = $constructorParameter->name;
6✔
259
                $key = $this->nameConverter ? $this->nameConverter->normalize($paramName, $class, $format, $context) : $paramName;
6✔
260

261
                $allowed = false === $allowedAttributes || (\is_array($allowedAttributes) && \in_array($paramName, $allowedAttributes, true));
6✔
262
                $ignored = !$this->isAllowedAttribute($class, $paramName, $format, $context);
6✔
263
                if ($constructorParameter->isVariadic()) {
6✔
264
                    if ($allowed && !$ignored && (isset($data[$key]) || \array_key_exists($key, $data))) {
×
265
                        if (!\is_array($data[$paramName])) {
×
266
                            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));
×
267
                        }
268

269
                        $params[] = $data[$paramName];
×
270
                    }
271
                } elseif ($allowed && !$ignored && (isset($data[$key]) || \array_key_exists($key, $data))) {
6✔
272
                    $params[] = $this->createConstructorArgument($data[$key], $key, $constructorParameter, $context, $format);
6✔
273

274
                    // Don't run set for a parameter passed to the constructor
275
                    unset($data[$key]);
6✔
276
                } elseif (isset($context[static::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class][$key])) {
3✔
277
                    $params[] = $context[static::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class][$key];
×
278
                } elseif ($constructorParameter->isDefaultValueAvailable()) {
3✔
279
                    $params[] = $constructorParameter->getDefaultValue();
2✔
280
                } else {
281
                    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));
1✔
282
                }
283
            }
284

285
            if ($constructor->isConstructor()) {
68✔
286
                return $reflectionClass->newInstanceArgs($params);
68✔
287
            }
288

289
            return $constructor->invokeArgs(null, $params);
×
290
        }
291

292
        return new $class();
78✔
293
    }
294

295
    protected function getClassDiscriminatorResolvedClass(array $data, string $class): string
296
    {
297
        if (null === $this->classDiscriminatorResolver || (null === $mapping = $this->classDiscriminatorResolver->getMappingForClass($class))) {
144✔
298
            return $class;
144✔
299
        }
300

301
        if (!isset($data[$mapping->getTypeProperty()])) {
1✔
302
            throw new RuntimeException(sprintf('Type property "%s" not found for the abstract object "%s"', $mapping->getTypeProperty(), $class));
×
303
        }
304

305
        $type = $data[$mapping->getTypeProperty()];
1✔
306
        if (null === ($mappedClass = $mapping->getClassForType($type))) {
1✔
307
            throw new RuntimeException(sprintf('The type "%s" has no mapped class for the abstract object "%s"', $type, $class));
×
308
        }
309

310
        return $mappedClass;
1✔
311
    }
312

313
    protected function createConstructorArgument($parameterData, string $key, \ReflectionParameter $constructorParameter, array &$context, string $format = null): mixed
314
    {
315
        return $this->createAttributeValue($constructorParameter->name, $parameterData, $format, $context);
6✔
316
    }
317

318
    /**
319
     * {@inheritdoc}
320
     *
321
     * Unused in this context.
322
     *
323
     * @return string[]
324
     */
325
    protected function extractAttributes($object, $format = null, array $context = []): array
326
    {
327
        return [];
×
328
    }
329

330
    /**
331
     * {@inheritdoc}
332
     */
333
    protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool
334
    {
335
        if (!$this->resourceClassResolver->isResourceClass($context['resource_class'])) {
506✔
336
            return parent::getAllowedAttributes($classOrObject, $context, $attributesAsString);
16✔
337
        }
338

339
        $resourceClass = $this->resourceClassResolver->getResourceClass(null, $context['resource_class']); // fix for abstract classes and interfaces
492✔
340
        $options = $this->getFactoryOptions($context);
492✔
341
        $propertyNames = $this->propertyNameCollectionFactory->create($resourceClass, $options);
492✔
342

343
        $allowedAttributes = [];
492✔
344
        foreach ($propertyNames as $propertyName) {
492✔
345
            $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName, $options);
489✔
346

347
            if (
348
                $this->isAllowedAttribute($classOrObject, $propertyName, null, $context) &&
489✔
349
                (
350
                    isset($context['api_normalize']) && $propertyMetadata->isReadable() ||
489✔
351
                    isset($context['api_denormalize']) && ($propertyMetadata->isWritable() || !\is_object($classOrObject) && $propertyMetadata->isInitializable())
489✔
352
                )
353
            ) {
354
                $allowedAttributes[] = $propertyName;
485✔
355
            }
356
        }
357

358
        return $allowedAttributes;
492✔
359
    }
360

361
    /**
362
     * {@inheritdoc}
363
     */
364
    protected function isAllowedAttribute(object|string $classOrObject, string $attribute, string $format = null, array $context = []): bool
365
    {
366
        if (!parent::isAllowedAttribute($classOrObject, $attribute, $format, $context)) {
503✔
367
            return false;
91✔
368
        }
369

370
        return $this->canAccessAttribute(\is_object($classOrObject) ? $classOrObject : null, $attribute, $context);
502✔
371
    }
372

373
    /**
374
     * Check if access to the attribute is granted.
375
     */
376
    protected function canAccessAttribute(?object $object, string $attribute, array $context = []): bool
377
    {
378
        if (!$this->resourceClassResolver->isResourceClass($context['resource_class'])) {
502✔
379
            return true;
16✔
380
        }
381

382
        $options = $this->getFactoryOptions($context);
488✔
383
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options);
488✔
384
        $security = $propertyMetadata->getSecurity();
488✔
385
        if (null !== $this->resourceAccessChecker && $security) {
488✔
386
            return $this->resourceAccessChecker->isGranted($context['resource_class'], $security, [
29✔
387
                'object' => $object,
29✔
388
            ]);
29✔
389
        }
390

391
        return true;
484✔
392
    }
393

394
    /**
395
     * Check if access to the attribute is granted.
396
     */
397
    protected function canAccessAttributePostDenormalize(?object $object, ?object $previousObject, string $attribute, array $context = []): bool
398
    {
399
        $options = $this->getFactoryOptions($context);
156✔
400
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options);
156✔
401
        $security = $propertyMetadata->getSecurityPostDenormalize();
156✔
402
        if ($this->resourceAccessChecker && $security) {
156✔
403
            return $this->resourceAccessChecker->isGranted($context['resource_class'], $security, [
4✔
404
                'object' => $object,
4✔
405
                'previous_object' => $previousObject,
4✔
406
            ]);
4✔
407
        }
408

409
        return true;
155✔
410
    }
411

412
    /**
413
     * {@inheritdoc}
414
     */
415
    protected function setAttributeValue(object $object, string $attribute, mixed $value, string $format = null, array $context = []): void
416
    {
417
        $this->setValue($object, $attribute, $this->createAttributeValue($attribute, $value, $format, $context));
153✔
418
    }
419

420
    /**
421
     * Validates the type of the value. Allows using integers as floats for JSON formats.
422
     *
423
     * @throws InvalidArgumentException
424
     */
425
    protected function validateType(string $attribute, Type $type, mixed $value, string $format = null): void
426
    {
427
        $builtinType = $type->getBuiltinType();
136✔
428
        if (Type::BUILTIN_TYPE_FLOAT === $builtinType && null !== $format && str_contains($format, 'json')) {
136✔
429
            $isValid = \is_float($value) || \is_int($value);
1✔
430
        } else {
431
            $isValid = \call_user_func('is_'.$builtinType, $value);
136✔
432
        }
433

434
        if (!$isValid) {
136✔
435
            throw new UnexpectedValueException(sprintf('The type of the "%s" attribute must be "%s", "%s" given.', $attribute, $builtinType, \gettype($value)));
4✔
436
        }
437
    }
438

439
    /**
440
     * Denormalizes a collection of objects.
441
     *
442
     * @throws InvalidArgumentException
443
     */
444
    protected function denormalizeCollection(string $attribute, ApiProperty $propertyMetadata, Type $type, string $className, mixed $value, ?string $format, array $context): array
445
    {
446
        if (!\is_array($value)) {
14✔
447
            throw new InvalidArgumentException(sprintf('The type of the "%s" attribute must be "array", "%s" given.', $attribute, \gettype($value)));
1✔
448
        }
449

450
        $collectionKeyType = $type->getCollectionKeyTypes()[0] ?? null;
13✔
451
        $collectionKeyBuiltinType = $collectionKeyType?->getBuiltinType();
13✔
452

453
        $values = [];
13✔
454
        foreach ($value as $index => $obj) {
13✔
455
            if (null !== $collectionKeyBuiltinType && !\call_user_func('is_'.$collectionKeyBuiltinType, $index)) {
13✔
456
                throw new InvalidArgumentException(sprintf('The type of the key "%s" must be "%s", "%s" given.', $index, $collectionKeyBuiltinType, \gettype($index)));
1✔
457
            }
458

459
            $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $this->createChildContext($context, $attribute, $format));
12✔
460
        }
461

462
        return $values;
11✔
463
    }
464

465
    /**
466
     * Denormalizes a relation.
467
     *
468
     * @throws LogicException
469
     * @throws UnexpectedValueException
470
     */
471
    protected function denormalizeRelation(string $attributeName, ApiProperty $propertyMetadata, string $className, mixed $value, ?string $format, array $context): ?object
472
    {
473
        if (\is_string($value)) {
46✔
474
            try {
475
                return $this->iriConverter->getResourceFromIri($value, $context + ['fetch_data' => true]);
21✔
476
            } catch (ItemNotFoundException $e) {
3✔
477
                throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
1✔
478
            } catch (InvalidArgumentException $e) {
2✔
479
                throw new UnexpectedValueException(sprintf('Invalid IRI "%s".', $value), $e->getCode(), $e);
2✔
480
            }
481
        }
482

483
        if ($propertyMetadata->isWritableLink()) {
26✔
484
            $context['api_allow_update'] = true;
23✔
485

486
            if (!$this->serializer instanceof DenormalizerInterface) {
23✔
487
                throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class));
×
488
            }
489

490
            $item = $this->serializer->denormalize($value, $className, $format, $context);
23✔
491
            if (!\is_object($item) && null !== $item) {
21✔
492
                throw new \UnexpectedValueException('Expected item to be an object or null.');
×
493
            }
494

495
            return $item;
21✔
496
        }
497

498
        if (!\is_array($value)) {
3✔
499
            throw new UnexpectedValueException(sprintf('Expected IRI or nested document for attribute "%s", "%s" given.', $attributeName, \gettype($value)));
×
500
        }
501

502
        throw new UnexpectedValueException(sprintf('Nested documents for attribute "%s" are not allowed. Use IRIs instead.', $attributeName));
3✔
503
    }
504

505
    /**
506
     * Gets the options for the property name collection / property metadata factories.
507
     */
508
    protected function getFactoryOptions(array $context): array
509
    {
510
        $options = [];
506✔
511
        if (isset($context[self::GROUPS])) {
506✔
512
            /* @see https://github.com/symfony/symfony/blob/v4.2.6/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php */
513
            $options['serializer_groups'] = (array) $context[self::GROUPS];
140✔
514
        }
515

516
        $operationCacheKey = ($context['resource_class'] ?? '').($context['operation_name'] ?? '').($context['api_normalize'] ?? '');
506✔
517
        if ($operationCacheKey && isset($this->localFactoryOptionsCache[$operationCacheKey])) {
506✔
518
            return $options + $this->localFactoryOptionsCache[$operationCacheKey];
504✔
519
        }
520

521
        // This is a hot spot
522
        if (isset($context['resource_class'])) {
506✔
523
            // Note that the groups need to be read on the root operation
524
            $operation = $context['root_operation'] ?? $context['operation'] ?? null;
506✔
525

526
            if (!$operation && $this->resourceMetadataCollectionFactory && $this->resourceClassResolver->isResourceClass($context['resource_class'])) {
506✔
527
                $resourceClass = $this->resourceClassResolver->getResourceClass(null, $context['resource_class']); // fix for abstract classes and interfaces
91✔
528
                $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation($context['root_operation_name'] ?? $context['operation_name'] ?? null);
91✔
529
            }
530

531
            if ($operation) {
506✔
532
                $options['normalization_groups'] = $operation->getNormalizationContext()['groups'] ?? null;
498✔
533
                $options['denormalization_groups'] = $operation->getDenormalizationContext()['groups'] ?? null;
498✔
534
                $options['operation_name'] = $operation->getName();
498✔
535
            }
536
        }
537

538
        return $options + $this->localFactoryOptionsCache[$operationCacheKey] = $options;
506✔
539
    }
540

541
    /**
542
     * {@inheritdoc}
543
     *
544
     * @throws UnexpectedValueException
545
     */
546
    protected function getAttributeValue(object $object, string $attribute, string $format = null, array $context = []): mixed
547
    {
548
        $context['api_attribute'] = $attribute;
473✔
549
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context));
473✔
550

551
        $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
473✔
552

553
        if ($context['api_denormalize'] ?? false) {
473✔
554
            return $attributeValue;
5✔
555
        }
556

557
        $type = $propertyMetadata->getBuiltinTypes()[0] ?? null;
472✔
558

559
        if (
560
            $type &&
472✔
561
            $type->isCollection() &&
472✔
562
            ($collectionValueType = $type->getCollectionValueTypes()[0] ?? null) &&
472✔
563
            ($className = $collectionValueType->getClassName()) &&
472✔
564
            $this->resourceClassResolver->isResourceClass($className)
472✔
565
        ) {
566
            if (!is_iterable($attributeValue)) {
178✔
567
                throw new UnexpectedValueException('Unexpected non-iterable value for to-many relation.');
×
568
            }
569

570
            $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
178✔
571
            $childContext = $this->createChildContext($context, $attribute, $format);
178✔
572
            unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']);
178✔
573

574
            return $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
178✔
575
        }
576

577
        if (
578
            $type &&
472✔
579
            ($className = $type->getClassName()) &&
472✔
580
            $this->resourceClassResolver->isResourceClass($className)
472✔
581
        ) {
582
            if (!\is_object($attributeValue) && null !== $attributeValue) {
254✔
583
                throw new UnexpectedValueException('Unexpected non-object value for to-one relation.');
×
584
            }
585

586
            $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
254✔
587
            $childContext = $this->createChildContext($context, $attribute, $format);
254✔
588
            $childContext['resource_class'] = $resourceClass;
254✔
589
            if ($this->resourceMetadataCollectionFactory) {
254✔
590
                $childContext['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation();
254✔
591
            }
592
            unset($childContext['iri'], $childContext['uri_variables']);
254✔
593

594
            return $this->normalizeRelation($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
254✔
595
        }
596

597
        if (!$this->serializer instanceof NormalizerInterface) {
467✔
598
            throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class));
×
599
        }
600

601
        unset($context['resource_class']);
467✔
602

603
        if ($type && $type->getClassName()) {
467✔
604
            $childContext = $this->createChildContext($context, $attribute, $format);
155✔
605
            unset($childContext['iri'], $childContext['uri_variables']);
155✔
606
            $childContext['output']['gen_id'] = $propertyMetadata->getGenId() ?? true;
155✔
607

608
            return $this->serializer->normalize($attributeValue, $format, $childContext);
155✔
609
        }
610

611
        return $this->serializer->normalize($attributeValue, $format, $context);
463✔
612
    }
613

614
    /**
615
     * Normalizes a collection of relations (to-many).
616
     *
617
     * @throws UnexpectedValueException
618
     */
619
    protected function normalizeCollectionOfRelations(ApiProperty $propertyMetadata, iterable $attributeValue, string $resourceClass, ?string $format, array $context): array
620
    {
621
        $value = [];
164✔
622
        foreach ($attributeValue as $index => $obj) {
164✔
623
            if (!\is_object($obj) && null !== $obj) {
38✔
624
                throw new UnexpectedValueException('Unexpected non-object element in to-many relation.');
×
625
            }
626

627
            // update context, if concrete object class deviates from general relation class (e.g. in case of polymorphic resources)
628
            $objResourceClass = $this->resourceClassResolver->getResourceClass($obj, $resourceClass);
38✔
629
            $context['resource_class'] = $objResourceClass;
38✔
630
            if ($this->resourceMetadataCollectionFactory) {
38✔
631
                $context['operation'] = $this->resourceMetadataCollectionFactory->create($objResourceClass)->getOperation();
38✔
632
            }
633

634
            $value[$index] = $this->normalizeRelation($propertyMetadata, $obj, $resourceClass, $format, $context);
38✔
635
        }
636

637
        return $value;
164✔
638
    }
639

640
    /**
641
     * Normalizes a relation.
642
     *
643
     * @throws LogicException
644
     * @throws UnexpectedValueException
645
     */
646
    protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $relatedObject, string $resourceClass, ?string $format, array $context): \ArrayObject|array|string|null
647
    {
648
        if (null === $relatedObject || !empty($context['attributes']) || $propertyMetadata->isReadableLink()) {
217✔
649
            if (!$this->serializer instanceof NormalizerInterface) {
186✔
650
                throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class));
×
651
            }
652

653
            $normalizedRelatedObject = $this->serializer->normalize($relatedObject, $format, $context);
186✔
654
            // @phpstan-ignore-next-line throwing an explicit exception helps debugging
655
            if (!\is_string($normalizedRelatedObject) && !\is_array($normalizedRelatedObject) && !$normalizedRelatedObject instanceof \ArrayObject && null !== $normalizedRelatedObject) {
186✔
656
                throw new UnexpectedValueException('Expected normalized relation to be an IRI, array, \ArrayObject or null');
×
657
            }
658

659
            return $normalizedRelatedObject;
186✔
660
        }
661

662
        $iri = $this->iriConverter->getIriFromResource($relatedObject);
57✔
663

664
        if (isset($context['resources'])) {
57✔
665
            $context['resources'][$iri] = $iri;
56✔
666
        }
667

668
        $push = $propertyMetadata->getPush() ?? false;
57✔
669
        if (isset($context['resources_to_push']) && $push) {
57✔
670
            $context['resources_to_push'][$iri] = $iri;
×
671
        }
672

673
        return $iri;
57✔
674
    }
675

676
    private function createAttributeValue(string $attribute, mixed $value, string $format = null, array $context = []): mixed
677
    {
678
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context));
159✔
679
        $type = $propertyMetadata->getBuiltinTypes()[0] ?? null;
159✔
680

681
        if (null === $type) {
159✔
682
            // No type provided, blindly return the value
683
            return $value;
×
684
        }
685

686
        if (null === $value && $type->isNullable()) {
159✔
687
            return $value;
2✔
688
        }
689

690
        $collectionValueType = $type->getCollectionValueTypes()[0] ?? null;
159✔
691

692
        /* From @see AbstractObjectNormalizer::validateAndDenormalize() */
693
        // Fix a collection that contains the only one element
694
        // This is special to xml format only
695
        if ('xml' === $format && null !== $collectionValueType && (!\is_array($value) || !\is_int(key($value)))) {
159✔
696
            $value = [$value];
1✔
697
        }
698

699
        if (
700
            $type->isCollection() &&
159✔
701
            null !== $collectionValueType &&
159✔
702
            null !== ($className = $collectionValueType->getClassName()) &&
159✔
703
            $this->resourceClassResolver->isResourceClass($className)
159✔
704
        ) {
705
            $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className);
14✔
706
            $context['resource_class'] = $resourceClass;
14✔
707

708
            return $this->denormalizeCollection($attribute, $propertyMetadata, $type, $resourceClass, $value, $format, $context);
14✔
709
        }
710

711
        if (
712
            null !== ($className = $type->getClassName()) &&
156✔
713
            $this->resourceClassResolver->isResourceClass($className)
156✔
714
        ) {
715
            $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className);
45✔
716
            $childContext = $this->createChildContext($context, $attribute, $format);
45✔
717
            $childContext['resource_class'] = $resourceClass;
45✔
718
            if ($this->resourceMetadataCollectionFactory) {
45✔
719
                $childContext['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation();
45✔
720
            }
721

722
            return $this->denormalizeRelation($attribute, $propertyMetadata, $resourceClass, $value, $format, $childContext);
45✔
723
        }
724

725
        if (
726
            $type->isCollection() &&
143✔
727
            null !== $collectionValueType &&
143✔
728
            null !== ($className = $collectionValueType->getClassName())
143✔
729
        ) {
730
            if (!$this->serializer instanceof DenormalizerInterface) {
3✔
731
                throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class));
×
732
            }
733

734
            unset($context['resource_class']);
3✔
735

736
            return $this->serializer->denormalize($value, $className.'[]', $format, $context);
3✔
737
        }
738

739
        if (null !== $className = $type->getClassName()) {
142✔
740
            if (!$this->serializer instanceof DenormalizerInterface) {
10✔
741
                throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class));
×
742
            }
743

744
            unset($context['resource_class']);
10✔
745

746
            return $this->serializer->denormalize($value, $className, $format, $context);
10✔
747
        }
748

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

758
            switch ($type->getBuiltinType()) {
15✔
759
                case Type::BUILTIN_TYPE_BOOL:
760
                    // according to http://www.w3.org/TR/xmlschema-2/#boolean, valid representations are "false", "true", "0" and "1"
761
                    if ('false' === $value || '0' === $value) {
4✔
762
                        $value = false;
2✔
763
                    } elseif ('true' === $value || '1' === $value) {
2✔
764
                        $value = true;
2✔
765
                    } else {
766
                        throw new NotNormalizableValueException(sprintf('The type of the "%s" attribute for class "%s" must be bool ("%s" given).', $attribute, $className, $value));
×
767
                    }
768
                    break;
4✔
769
                case Type::BUILTIN_TYPE_INT:
770
                    if (ctype_digit($value) || ('-' === $value[0] && ctype_digit(substr($value, 1)))) {
4✔
771
                        $value = (int) $value;
4✔
772
                    } else {
773
                        throw new NotNormalizableValueException(sprintf('The type of the "%s" attribute for class "%s" must be int ("%s" given).', $attribute, $className, $value));
×
774
                    }
775
                    break;
4✔
776
                case Type::BUILTIN_TYPE_FLOAT:
777
                    if (is_numeric($value)) {
4✔
778
                        return (float) $value;
1✔
779
                    }
780

781
                    return match ($value) {
3✔
782
                        'NaN' => \NAN,
3✔
783
                        'INF' => \INF,
3✔
784
                        '-INF' => -\INF,
3✔
785
                        default => throw new NotNormalizableValueException(sprintf('The type of the "%s" attribute for class "%s" must be float ("%s" given).', $attribute, $className, $value)),
3✔
786
                    };
3✔
787
            }
788
        }
789

790
        if ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false) {
136✔
791
            return $value;
×
792
        }
793

794
        $this->validateType($attribute, $type, $value, $format);
136✔
795

796
        return $value;
132✔
797
    }
798

799
    /**
800
     * Sets a value of the object using the PropertyAccess component.
801
     */
802
    private function setValue(object $object, string $attributeName, mixed $value): void
803
    {
804
        try {
805
            $this->propertyAccessor->setValue($object, $attributeName, $value);
145✔
806
        } catch (NoSuchPropertyException) {
1✔
807
            // Properties not found are ignored
808
        }
809
    }
810
}
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