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

api-platform / core / 3713134090

pending completion
3713134090

Pull #5254

github

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

197 of 197 new or added lines in 5 files covered. (100.0%)

10372 of 12438 relevant lines covered (83.39%)

11.97 hits per line

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

78.95
/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'])) {
72✔
63
            $defaultContext['circular_reference_handler'] = fn ($object): ?string => $this->iriConverter->getIriFromResource($object);
72✔
64
        }
65

66
        parent::__construct($classMetadataFactory, $nameConverter, null, null, \Closure::fromCallable($this->getObjectClass(...)), $defaultContext);
72✔
67
        $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor();
72✔
68
        $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
72✔
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)) {
18✔
77
            return false;
15✔
78
        }
79

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

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

88
    /**
89
     * {@inheritdoc}
90
     */
91
    public function hasCacheableSupportsMethod(): bool
92
    {
93
        return true;
17✔
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);
22✔
104
        if ($outputClass = $this->getOutputClass($context)) {
22✔
105
            if (!$this->serializer instanceof NormalizerInterface) {
1✔
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']);
1✔
110
            $context['resource_class'] = $outputClass;
1✔
111
            $context['api_sub_level'] = true;
1✔
112
            $context[self::ALLOW_EXTRA_ATTRIBUTES] = false;
1✔
113

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

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

121
        $context['api_normalize'] = true;
22✔
122
        $iri = $context['iri'] ??= $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL, $context['operation'] ?? null, $context);
22✔
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;
22✔
135
        unset($context['api_empty_resource_as_iri']);
22✔
136

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

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

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

147
        return $data;
20✔
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) {
6✔
156
            return true;
×
157
        }
158

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

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

169
        if ($inputClass = $this->getInputClass($context)) {
27✔
170
            if (!$this->serializer instanceof DenormalizerInterface) {
×
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']);
×
175
            $context['resource_class'] = $inputClass;
×
176

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

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

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

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

196
        if (\is_string($data)) {
27✔
197
            try {
198
                return $this->iriConverter->getResourceFromIri($data, $context + ['fetch_data' => true]);
×
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)) {
27✔
207
            throw new UnexpectedValueException(sprintf('Expected IRI or document for resource "%s", "%s" given.', $resourceClass, \gettype($data)));
×
208
        }
209

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

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

214
        if (!$this->resourceClassResolver->isResourceClass($class)) {
20✔
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) {
20✔
220
            if (!$this->canAccessAttributePostDenormalize($object, $previousObject, $attribute, $context)) {
20✔
221
                if (null !== $previousObject) {
2✔
222
                    $this->setValue($object, $attribute, $this->propertyAccessor->getValue($previousObject, $attribute));
1✔
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;
20✔
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)) {
27✔
244
            unset($context[static::OBJECT_TO_POPULATE]);
3✔
245

246
            return $object;
3✔
247
        }
248

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

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

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

261
                $allowed = false === $allowedAttributes || (\is_array($allowedAttributes) && \in_array($paramName, $allowedAttributes, true));
×
262
                $ignored = !$this->isAllowedAttribute($class, $paramName, $format, $context);
×
263
                if ($constructorParameter->isVariadic()) {
×
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))) {
×
272
                    $params[] = $this->createConstructorArgument($data[$key], $key, $constructorParameter, $context, $format);
×
273

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

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

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

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

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

301
        if (!isset($data[$mapping->getTypeProperty()])) {
×
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()];
×
306
        if (null === ($mappedClass = $mapping->getClassForType($type))) {
×
307
            throw new RuntimeException(sprintf('The type "%s" has no mapped class for the abstract object "%s"', $type, $class));
×
308
        }
309

310
        return $mappedClass;
×
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);
×
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'])) {
45✔
336
            return parent::getAllowedAttributes($classOrObject, $context, $attributesAsString);
1✔
337
        }
338

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

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

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

358
        return $allowedAttributes;
44✔
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)) {
45✔
367
            return false;
1✔
368
        }
369

370
        return $this->canAccessAttribute(\is_object($classOrObject) ? $classOrObject : null, $attribute, $context);
45✔
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'])) {
45✔
379
            return true;
1✔
380
        }
381

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

391
        return true;
44✔
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);
20✔
400
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options);
20✔
401
        $security = $propertyMetadata->getSecurityPostDenormalize();
20✔
402
        if ($this->resourceAccessChecker && $security) {
20✔
403
            return $this->resourceAccessChecker->isGranted($context['resource_class'], $security, [
2✔
404
                'object' => $object,
2✔
405
                'previous_object' => $previousObject,
2✔
406
            ]);
2✔
407
        }
408

409
        return true;
20✔
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));
27✔
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();
14✔
428
        if (Type::BUILTIN_TYPE_FLOAT === $builtinType && null !== $format && str_contains($format, 'json')) {
14✔
429
            $isValid = \is_float($value) || \is_int($value);
1✔
430
        } else {
431
            $isValid = \call_user_func('is_'.$builtinType, $value);
13✔
432
        }
433

434
        if (!$isValid) {
14✔
435
            throw new UnexpectedValueException(sprintf('The type of the "%s" attribute must be "%s", "%s" given.', $attribute, $builtinType, \gettype($value)));
1✔
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)) {
7✔
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;
6✔
451
        $collectionKeyBuiltinType = $collectionKeyType?->getBuiltinType();
6✔
452

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

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

462
        return $values;
4✔
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)) {
5✔
474
            try {
475
                return $this->iriConverter->getResourceFromIri($value, $context + ['fetch_data' => true]);
1✔
476
            } catch (ItemNotFoundException $e) {
×
477
                throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
×
478
            } catch (InvalidArgumentException $e) {
×
479
                throw new UnexpectedValueException(sprintf('Invalid IRI "%s".', $value), $e->getCode(), $e);
×
480
            }
481
        }
482

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

486
            if (!$this->serializer instanceof DenormalizerInterface) {
2✔
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);
2✔
491
            if (!\is_object($item) && null !== $item) {
2✔
492
                throw new \UnexpectedValueException('Expected item to be an object or null.');
×
493
            }
494

495
            return $item;
2✔
496
        }
497

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

502
        throw new UnexpectedValueException(sprintf('Nested documents for attribute "%s" are not allowed. Use IRIs instead.', $attributeName));
1✔
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 = [];
45✔
511
        if (isset($context[self::GROUPS])) {
45✔
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];
×
514
        }
515

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

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

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

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

538
        return $options + $this->localFactoryOptionsCache[$operationCacheKey] = $options;
45✔
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;
21✔
549
        $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context));
21✔
550

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

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

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

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

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

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

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

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

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

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

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

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

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

611
        return $this->serializer->normalize($attributeValue, $format, $context);
18✔
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 = [];
7✔
622
        foreach ($attributeValue as $index => $obj) {
7✔
623
            if (!\is_object($obj) && null !== $obj) {
4✔
624
                throw new UnexpectedValueException('Unexpected non-object element in to-many relation.');
1✔
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);
4✔
629
            $context['resource_class'] = $objResourceClass;
4✔
630
            if ($this->resourceMetadataCollectionFactory) {
4✔
631
                $context['operation'] = $this->resourceMetadataCollectionFactory->create($objResourceClass)->getOperation();
1✔
632
            }
633

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

637
        return $value;
6✔
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()) {
10✔
649
            if (!$this->serializer instanceof NormalizerInterface) {
7✔
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);
7✔
654
            // @phpstan-ignore-next-line throwing an explicit exception helps debugging
655
            if (!\is_string($normalizedRelatedObject) && !\is_array($normalizedRelatedObject) && !$normalizedRelatedObject instanceof \ArrayObject && null !== $normalizedRelatedObject) {
7✔
656
                throw new UnexpectedValueException('Expected normalized relation to be an IRI, array, \ArrayObject or null');
×
657
            }
658

659
            return $normalizedRelatedObject;
7✔
660
        }
661

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

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

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

673
        return $iri;
3✔
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));
27✔
679
        $type = $propertyMetadata->getBuiltinTypes()[0] ?? null;
27✔
680

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

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

690
        $collectionValueType = $type->getCollectionValueTypes()[0] ?? null;
22✔
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)))) {
22✔
696
            $value = [$value];
1✔
697
        }
698

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

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

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

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

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

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

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

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

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

746
            return $this->serializer->denormalize($value, $className, $format, $context);
×
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)) {
15✔
754
            if ('' === $value && $type->isNullable() && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_BOOL, Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true)) {
1✔
755
                return null;
×
756
            }
757

758
            switch ($type->getBuiltinType()) {
1✔
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) {
1✔
762
                        $value = false;
1✔
763
                    } elseif ('true' === $value || '1' === $value) {
1✔
764
                        $value = true;
1✔
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;
1✔
769
                case Type::BUILTIN_TYPE_INT:
770
                    if (ctype_digit($value) || ('-' === $value[0] && ctype_digit(substr($value, 1)))) {
1✔
771
                        $value = (int) $value;
1✔
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;
1✔
776
                case Type::BUILTIN_TYPE_FLOAT:
777
                    if (is_numeric($value)) {
1✔
778
                        return (float) $value;
1✔
779
                    }
780

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

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

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

796
        return $value;
13✔
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);
20✔
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