Skip to content

Commit 6e2f920

Browse files
authored
fix(serializer): empty object as array with supports cache (#5100)
* fix(serializer): empty object as array with supports cache reverts #4999 * fix issue when empty operation * revert 08450c2 * review
1 parent a527504 commit 6e2f920

12 files changed

+173
-53
lines changed

src/Api/IdentifiersExtractor.php

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,26 +49,34 @@ public function __construct(ResourceMetadataCollectionFactoryInterface $resource
4949
*/
5050
public function getIdentifiersFromItem(object $item, Operation $operation = null, array $context = []): array
5151
{
52-
$identifiers = [];
53-
5452
if (!$this->isResourceClass($this->getObjectClass($item))) {
5553
return ['id' => $this->propertyAccessor->getValue($item, 'id')];
5654
}
5755

56+
if ($operation && $operation->getClass()) {
57+
return $this->getIdentifiersFromOperation($item, $operation, $context);
58+
}
59+
5860
$resourceClass = $this->getResourceClass($item, true);
5961
$operation ??= $this->resourceMetadataFactory->create($resourceClass)->getOperation(null, false, true);
6062

63+
return $this->getIdentifiersFromOperation($item, $operation, $context);
64+
}
65+
66+
private function getIdentifiersFromOperation(object $item, Operation $operation, array $context = []): array
67+
{
6168
if ($operation instanceof HttpOperation) {
6269
$links = $operation->getUriVariables();
6370
} elseif ($operation instanceof GraphQlOperation) {
6471
$links = $operation->getLinks();
6572
}
6673

74+
$identifiers = [];
6775
foreach ($links ?? [] as $link) {
6876
if (1 < (is_countable($link->getIdentifiers()) ? \count($link->getIdentifiers()) : 0)) {
6977
$compositeIdentifiers = [];
7078
foreach ($link->getIdentifiers() as $identifier) {
71-
$compositeIdentifiers[$identifier] = $this->getIdentifierValue($item, $link->getFromClass() ?? $resourceClass, $identifier, $link->getParameterName());
79+
$compositeIdentifiers[$identifier] = $this->getIdentifierValue($item, $link->getFromClass() ?? $operation->getClass(), $identifier, $link->getParameterName());
7280
}
7381

7482
$identifiers[$link->getParameterName()] = CompositeIdentifierParser::stringify($compositeIdentifiers);

src/Hydra/Serializer/CollectionFiltersNormalizer.php

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2020
use Psr\Container\ContainerInterface;
2121
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
22+
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
2223
use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface;
2324
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
2425
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
@@ -58,13 +59,18 @@ public function hasCacheableSupportsMethod(): bool
5859
*/
5960
public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
6061
{
61-
$data = $this->collectionNormalizer->normalize($object, $format, $context);
62-
if (!\is_array($data)) {
63-
throw new UnexpectedValueException('Expected data to be an array');
62+
if (($context[AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS] ?? false) && $object instanceof \ArrayObject && !\count($object)) {
63+
return $object;
6464
}
65+
66+
$data = $this->collectionNormalizer->normalize($object, $format, $context);
6567
if (!isset($context['resource_class']) || isset($context['api_sub_level'])) {
6668
return $data;
6769
}
70+
71+
if (!\is_array($data)) {
72+
throw new UnexpectedValueException('Expected data to be an array');
73+
}
6874
$resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class']);
6975
$operation = $context['operation'] ?? $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation($context['operation_name'] ?? null);
7076
$resourceFilters = $operation->getFilters();

src/Hydra/Serializer/CollectionNormalizer.php

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
2828
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
2929
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
30+
use Symfony\Component\Serializer\Serializer;
3031

3132
/**
3233
* This normalizer handles collections.
@@ -56,16 +57,20 @@ public function __construct(private readonly ContextBuilderInterface $contextBui
5657
*/
5758
public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
5859
{
59-
return self::FORMAT === $format && is_iterable($data) && isset($context['resource_class']) && !isset($context['api_sub_level']);
60+
return self::FORMAT === $format && is_iterable($data);
6061
}
6162

6263
/**
6364
* {@inheritdoc}
6465
*
6566
* @param iterable $object
6667
*/
67-
public function normalize(mixed $object, string $format = null, array $context = []): array
68+
public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
6869
{
70+
if (!isset($context['resource_class']) || isset($context['api_sub_level'])) {
71+
return $this->normalizeRawCollection($object, $format, $context);
72+
}
73+
6974
$resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class']);
7075
$context = $this->initContext($resourceClass, $context);
7176
$context['api_collection_sub_level'] = true;
@@ -103,6 +108,23 @@ public function normalize(mixed $object, string $format = null, array $context =
103108

104109
public function hasCacheableSupportsMethod(): bool
105110
{
106-
return false;
111+
return true;
112+
}
113+
114+
/**
115+
* Normalizes a raw collection (not API resources).
116+
*/
117+
protected function normalizeRawCollection(iterable $object, string $format = null, array $context = []): array|\ArrayObject
118+
{
119+
if (\is_array($object) && !$object && ($context[Serializer::EMPTY_ARRAY_AS_OBJECT] ?? false)) {
120+
return new \ArrayObject();
121+
}
122+
123+
$data = [];
124+
foreach ($object as $index => $obj) {
125+
$data[$index] = $this->normalizer->normalize($obj, $format, $context);
126+
}
127+
128+
return $data;
107129
}
108130
}

src/Hydra/Serializer/PartialCollectionViewNormalizer.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,15 @@ public function __construct(private readonly NormalizerInterface $collectionNorm
4545
public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
4646
{
4747
$data = $this->collectionNormalizer->normalize($object, $format, $context);
48-
if (!\is_array($data)) {
49-
throw new UnexpectedValueException('Expected data to be an array');
50-
}
5148

5249
if (isset($context['api_sub_level'])) {
5350
return $data;
5451
}
5552

53+
if (!\is_array($data)) {
54+
throw new UnexpectedValueException('Expected data to be an array');
55+
}
56+
5657
$currentPage = $lastPage = $itemsPerPage = $pageTotalItems = null;
5758
if ($paginated = ($object instanceof PartialPaginatorInterface)) {
5859
if ($object instanceof PaginatorInterface) {

src/Metadata/Resource/ResourceMetadataCollection.php

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
*/
2525
final class ResourceMetadataCollection extends \ArrayObject
2626
{
27+
private const GRAPHQL_PREFIX = 'g_';
28+
private const HTTP_PREFIX = 'h_';
2729
private array $operationCache = [];
2830

2931
public function __construct(private readonly string $resourceClass, array $input = [])
@@ -34,12 +36,12 @@ public function __construct(private readonly string $resourceClass, array $input
3436
public function getOperation(?string $operationName = null, bool $forceCollection = false, bool $httpOperation = false): Operation
3537
{
3638
$operationName ??= '';
37-
if (isset($this->operationCache[$operationName])) {
38-
return $this->operationCache[$operationName];
39+
if (isset($this->operationCache[self::HTTP_PREFIX.$operationName])) {
40+
return $this->operationCache[self::HTTP_PREFIX.$operationName];
3941
}
4042

41-
if (isset($this->operationCache['graphql_'.$operationName])) {
42-
return $this->operationCache['graphql_'.$operationName];
43+
if (isset($this->operationCache[self::GRAPHQL_PREFIX.$operationName])) {
44+
return $this->operationCache[self::GRAPHQL_PREFIX.$operationName];
4345
}
4446

4547
$it = $this->getIterator();
@@ -51,27 +53,29 @@ public function getOperation(?string $operationName = null, bool $forceCollectio
5153

5254
foreach ($metadata->getOperations() ?? [] as $name => $operation) {
5355
$isCollection = $operation instanceof CollectionOperationInterface;
54-
if ('' === $operationName && \in_array($operation->getMethod() ?? HttpOperation::METHOD_GET, [HttpOperation::METHOD_GET, HttpOperation::METHOD_OPTIONS, HttpOperation::METHOD_HEAD], true) && ($forceCollection ? $isCollection : !$isCollection)) {
55-
return $this->operationCache[$operationName] = $operation;
56+
$method = $operation->getMethod() ?? HttpOperation::METHOD_GET;
57+
$isGetOperation = HttpOperation::METHOD_GET === $method || HttpOperation::METHOD_OPTIONS === $method || HttpOperation::METHOD_HEAD === $method;
58+
if ('' === $operationName && $isGetOperation && ($forceCollection ? $isCollection : !$isCollection)) {
59+
return $this->operationCache[self::HTTP_PREFIX.$operationName] = $operation;
5660
}
5761

5862
if ($name === $operationName) {
59-
return $this->operationCache[$operationName] = $operation;
63+
return $this->operationCache[self::HTTP_PREFIX.$operationName] = $operation;
6064
}
6165

6266
if ($operation->getUriTemplate() === $operationName) {
63-
return $this->operationCache[$operationName] = $operation;
67+
return $this->operationCache[self::HTTP_PREFIX.$operationName] = $operation;
6468
}
6569
}
6670

6771
foreach ($metadata->getGraphQlOperations() ?? [] as $name => $operation) {
6872
$isCollection = $operation instanceof CollectionOperationInterface;
6973
if ('' === $operationName && ($forceCollection ? $isCollection : !$isCollection) && false === $httpOperation) {
70-
return $this->operationCache['graphql_'.$operationName] = $operation;
74+
return $this->operationCache[self::GRAPHQL_PREFIX.$operationName] = $operation;
7175
}
7276

7377
if ($name === $operationName) {
74-
return $this->operationCache['graphql_'.$operationName] = $operation;
78+
return $this->operationCache[self::GRAPHQL_PREFIX.$operationName] = $operation;
7579
}
7680
}
7781

src/Serializer/AbstractCollectionNormalizer.php

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
2323
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
2424
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
25+
use Symfony\Component\Serializer\Serializer;
2526

2627
/**
2728
* Base collection normalizer.
@@ -50,12 +51,12 @@ public function __construct(protected ResourceClassResolverInterface $resourceCl
5051
*/
5152
public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
5253
{
53-
return static::FORMAT === $format && is_iterable($data) && isset($context['resource_class']) && !isset($context['api_sub_level']);
54+
return static::FORMAT === $format && is_iterable($data);
5455
}
5556

5657
public function hasCacheableSupportsMethod(): bool
5758
{
58-
return false;
59+
return true;
5960
}
6061

6162
/**
@@ -65,6 +66,10 @@ public function hasCacheableSupportsMethod(): bool
6566
*/
6667
public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
6768
{
69+
if (!isset($context['resource_class']) || isset($context['api_sub_level'])) {
70+
return $this->normalizeRawCollection($object, $format, $context);
71+
}
72+
6873
$resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class']);
6974
$context = $this->initContext($resourceClass, $context);
7075
$data = [];
@@ -82,6 +87,23 @@ public function normalize(mixed $object, string $format = null, array $context =
8287
return array_merge_recursive($data, $paginationData, $itemsData);
8388
}
8489

90+
/**
91+
* Normalizes a raw collection (not API resources).
92+
*/
93+
protected function normalizeRawCollection(iterable $object, string $format = null, array $context = []): array|\ArrayObject
94+
{
95+
if (!$object && ($context[Serializer::EMPTY_ARRAY_AS_OBJECT] ?? false) && \is_array($object)) {
96+
return new \ArrayObject();
97+
}
98+
99+
$data = [];
100+
foreach ($object as $index => $obj) {
101+
$data[$index] = $this->normalizer->normalize($obj, $format, $context);
102+
}
103+
104+
return $data;
105+
}
106+
85107
/**
86108
* Gets the pagination configuration.
87109
*/

src/Serializer/AbstractItemNormalizer.php

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
4141
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
4242
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
43+
use Symfony\Component\Serializer\Serializer;
4344

4445
/**
4546
* Base item normalizer.
@@ -54,6 +55,7 @@ abstract class AbstractItemNormalizer extends AbstractObjectNormalizer
5455

5556
protected PropertyAccessorInterface $propertyAccessor;
5657
protected array $localCache = [];
58+
protected array $localFactoryOptionsCache = [];
5759

5860
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)
5961
{
@@ -504,23 +506,35 @@ protected function denormalizeRelation(string $attributeName, ApiProperty $prope
504506
*/
505507
protected function getFactoryOptions(array $context): array
506508
{
509+
$operationCacheKey = ($context['resource_class'] ?? '').($context['operation_name'] ?? '').($context['api_normalize'] ?? '');
510+
if ($operationCacheKey && isset($this->localFactoryOptionsCache[$operationCacheKey])) {
511+
return $this->localFactoryOptionsCache[$operationCacheKey];
512+
}
513+
507514
$options = [];
508515

509516
if (isset($context[self::GROUPS])) {
510517
/* @see https://github.com/symfony/symfony/blob/v4.2.6/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php */
511518
$options['serializer_groups'] = (array) $context[self::GROUPS];
512519
}
513520

514-
if (isset($context['resource_class']) && $this->resourceClassResolver->isResourceClass($context['resource_class']) && $this->resourceMetadataCollectionFactory) {
515-
$resourceClass = $this->resourceClassResolver->getResourceClass(null, $context['resource_class']); // fix for abstract classes and interfaces
516-
// This is a hot spot, we should avoid calling this here but in many cases we can't
517-
$operation = $context['operation'] ?? $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation($context['operation_name'] ?? null);
518-
$options['normalization_groups'] = $operation->getNormalizationContext()['groups'] ?? null;
519-
$options['denormalization_groups'] = $operation->getDenormalizationContext()['groups'] ?? null;
520-
$options['operation_name'] = $operation->getName();
521+
// This is a hot spot
522+
if (isset($context['resource_class'])) {
523+
$operation = $context['operation'] ?? null;
524+
525+
if (!$operation && $this->resourceMetadataCollectionFactory && $this->resourceClassResolver->isResourceClass($context['resource_class'])) {
526+
$resourceClass = $this->resourceClassResolver->getResourceClass(null, $context['resource_class']); // fix for abstract classes and interfaces
527+
$operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation($context['operation_name'] ?? null);
528+
}
529+
530+
if ($operation) {
531+
$options['normalization_groups'] = $operation->getNormalizationContext()['groups'] ?? null;
532+
$options['denormalization_groups'] = $operation->getDenormalizationContext()['groups'] ?? null;
533+
$options['operation_name'] = $operation->getName();
534+
}
521535
}
522536

523-
return $options;
537+
return $this->localFactoryOptionsCache[$operationCacheKey] = $options;
524538
}
525539

526540
/**

0 commit comments

Comments
 (0)