Skip to content

Commit 670e7fb

Browse files
ususoyuka
andauthored
feat(serializer): collect cache tags using a TagCollector (#5758)
* feat(serializer): collect cache tags using a TagCollector * simplify function signature * fix bug in JsonApi normalizer * minor changes as per latest review * disable new tests for Symfony lowest * cs --------- Co-authored-by: Antoine Bluchet <[email protected]>
1 parent 2828157 commit 670e7fb

File tree

17 files changed

+354
-20
lines changed

17 files changed

+354
-20
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -992,7 +992,7 @@ jobs:
992992
- name: Clear test app cache
993993
run: tests/Fixtures/app/console cache:clear --ansi
994994
- name: Run Behat tests
995-
run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction
995+
run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction --tags='~@disableForSymfonyLowest'
996996

997997
phpunit_legacy:
998998
name: PHPUnit Legacy event listeners (PHP ${{ matrix.php }})
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
@sqlite
2+
@customTagCollector
3+
@disableForSymfonyLowest
4+
Feature: Cache invalidation through HTTP Cache tags (custom TagCollector service)
5+
In order to have a fast API
6+
As an API software developer
7+
I need to store API responses in a cache
8+
9+
@createSchema
10+
Scenario: Create a dummy resource
11+
When I add "Content-Type" header equal to "application/ld+json"
12+
And I send a "POST" request to "/relation_embedders" with body:
13+
"""
14+
{
15+
}
16+
"""
17+
Then the response status code should be 201
18+
And the header "Cache-Tags" should not exist
19+
20+
Scenario: TagCollector can identify $object (IRI is overriden with custom logic)
21+
When I send a "GET" request to "/relation_embedders/1"
22+
Then the response status code should be 200
23+
And the header "Cache-Tags" should be equal to "/RE/1#anotherRelated,/RE/1#related,/RE/1"
24+
25+
Scenario: Create some embedded resources
26+
When I add "Content-Type" header equal to "application/ld+json"
27+
And I send a "POST" request to "/relation_embedders" with body:
28+
"""
29+
{
30+
"anotherRelated": {
31+
"name": "Related"
32+
}
33+
}
34+
"""
35+
Then the response status code should be 201
36+
And the header "Cache-Tags" should not exist
37+
38+
Scenario: TagCollector can add cache tags for relations
39+
When I send a "GET" request to "/relation_embedders/2"
40+
Then the response status code should be 200
41+
And the header "Cache-Tags" should be equal to "/related_dummies/1#thirdLevel,/related_dummies/1,/RE/2#anotherRelated,/RE/2#related,/RE/2"
42+
43+
Scenario: Create resource with extraProperties on ApiProperty
44+
When I add "Content-Type" header equal to "application/ld+json"
45+
And I send a "POST" request to "/extra_properties_on_properties" with body:
46+
"""
47+
{
48+
}
49+
"""
50+
Then the response status code should be 201
51+
And the header "Cache-Tags" should not exist
52+
53+
Scenario: TagCollector can read propertyMetadata (tag is overriden with data from extraProperties)
54+
When I send a "GET" request to "/extra_properties_on_properties/1"
55+
Then the response status code should be 200
56+
And the header "Cache-Tags" should be equal to "/extra_properties_on_properties/1#overrideRelationTag,/extra_properties_on_properties/1"

features/http_cache/tags.feature

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Feature: Cache invalidation through HTTP Cache tags
2323
Scenario: Tags must be set for items
2424
When I send a "GET" request to "/relation_embedders/1"
2525
Then the response status code should be 200
26-
And the header "Cache-Tags" should be equal to "/relation_embedders/1,/related_dummies/1,/third_levels/1"
26+
And the header "Cache-Tags" should be equal to "/third_levels/1,/related_dummies/1,/relation_embedders/1"
2727

2828
Scenario: Create some more resources
2929
When I add "Content-Type" header equal to "application/ld+json"
@@ -42,7 +42,7 @@ Feature: Cache invalidation through HTTP Cache tags
4242
Scenario: Tags must be set for collections
4343
When I send a "GET" request to "/relation_embedders"
4444
Then the response status code should be 200
45-
And the header "Cache-Tags" should be equal to "/relation_embedders/1,/related_dummies/1,/third_levels/1,/relation_embedders/2,/related_dummies/2,/third_levels/2,/relation_embedders"
45+
And the header "Cache-Tags" should be equal to "/third_levels/1,/related_dummies/1,/relation_embedders/1,/third_levels/2,/related_dummies/2,/relation_embedders/2,/relation_embedders"
4646

4747
Scenario: Purge item on update
4848
When I add "Content-Type" header equal to "application/ld+json"
@@ -119,7 +119,7 @@ Feature: Cache invalidation through HTTP Cache tags
119119
When I add "Content-Type" header equal to "application/ld+json"
120120
And I send a "GET" request to "/relation3s"
121121
Then the response status code should be 200
122-
And the header "Cache-Tags" should be equal to "/relation3s/1,/relation2s/1,/relation2s/2,/relation3s"
122+
And the header "Cache-Tags" should be equal to "/relation2s/1,/relation2s/2,/relation3s/1,/relation3s"
123123

124124
Scenario: Update a collection member only (legacy non-standard PUT)
125125
When I add "Content-Type" header equal to "application/ld+json"

src/JsonApi/Serializer/ItemNormalizer.php

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
use ApiPlatform\Serializer\AbstractItemNormalizer;
2828
use ApiPlatform\Serializer\CacheKeyTrait;
2929
use ApiPlatform\Serializer\ContextTrait;
30+
use ApiPlatform\Serializer\TagCollectorInterface;
3031
use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface;
3132
use Symfony\Component\ErrorHandler\Exception\FlattenException;
3233
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
@@ -55,9 +56,9 @@ final class ItemNormalizer extends AbstractItemNormalizer
5556

5657
private array $componentsCache = [];
5758

58-
public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface|LegacyIriConverterInterface $iriConverter, ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ResourceAccessCheckerInterface $resourceAccessChecker = null)
59+
public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface|LegacyIriConverterInterface $iriConverter, ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null)
5960
{
60-
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker);
61+
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector);
6162
}
6263

6364
/**
@@ -245,7 +246,7 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel
245246
$iri = $this->iriConverter->getIriFromResource($relatedObject);
246247
$context['iri'] = $iri;
247248

248-
if (isset($context['resources'])) {
249+
if (!$this->tagCollector && isset($context['resources'])) {
249250
$context['resources'][$iri] = $iri;
250251
}
251252
}
@@ -263,12 +264,23 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel
263264
return $normalizedRelatedObject;
264265
}
265266

266-
return [
267+
$context['data'] = [
267268
'data' => [
268269
'type' => $this->getResourceShortName($resourceClass),
269270
'id' => $iri,
270271
],
271272
];
273+
274+
$context['iri'] = $iri;
275+
$context['object'] = $relatedObject;
276+
unset($context['property_metadata']);
277+
unset($context['api_attribute']);
278+
279+
if ($this->tagCollector) {
280+
$this->tagCollector->collect($context);
281+
}
282+
283+
return $context['data'];
272284
}
273285

274286
/**

src/JsonLd/Serializer/ItemNormalizer.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
use ApiPlatform\Metadata\Util\ClassInfoTrait;
2828
use ApiPlatform\Serializer\AbstractItemNormalizer;
2929
use ApiPlatform\Serializer\ContextTrait;
30+
use ApiPlatform\Serializer\TagCollectorInterface;
3031
use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface;
3132
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
3233
use Symfony\Component\Serializer\Exception\LogicException;
@@ -47,9 +48,9 @@ final class ItemNormalizer extends AbstractItemNormalizer
4748

4849
public const FORMAT = 'jsonld';
4950

50-
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface|LegacyIriConverterInterface $iriConverter, ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver, private readonly ContextBuilderInterface $contextBuilder, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ResourceAccessCheckerInterface $resourceAccessChecker = null)
51+
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface|LegacyIriConverterInterface $iriConverter, ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver, private readonly ContextBuilderInterface $contextBuilder, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null)
5152
{
52-
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker);
53+
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector);
5354
}
5455

5556
/**

src/Serializer/AbstractItemNormalizer.php

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ abstract class AbstractItemNormalizer extends AbstractObjectNormalizer
6363
protected array $localCache = [];
6464
protected array $localFactoryOptionsCache = [];
6565

66-
public function __construct(protected PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, protected PropertyMetadataFactoryInterface $propertyMetadataFactory, protected LegacyIriConverterInterface|IriConverterInterface $iriConverter, protected LegacyResourceClassResolverInterface|ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, protected ?ResourceAccessCheckerInterface $resourceAccessChecker = null)
66+
public function __construct(protected PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, protected PropertyMetadataFactoryInterface $propertyMetadataFactory, protected LegacyIriConverterInterface|IriConverterInterface $iriConverter, protected LegacyResourceClassResolverInterface|ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, protected ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null)
6767
{
6868
if (!isset($defaultContext['circular_reference_handler'])) {
6969
$defaultContext['circular_reference_handler'] = fn ($object): ?string => $this->iriConverter->getIriFromResource($object);
@@ -164,16 +164,33 @@ public function normalize(mixed $object, string $format = null, array $context =
164164
$emptyResourceAsIri = $context['api_empty_resource_as_iri'] ?? false;
165165
unset($context['api_empty_resource_as_iri']);
166166

167-
if (isset($context['resources'])) {
167+
if (!$this->tagCollector && isset($context['resources'])) {
168168
$context['resources'][$iri] = $iri;
169169
}
170170

171+
$context['object'] = $object;
172+
$context['format'] = $format;
173+
171174
$data = parent::normalize($object, $format, $context);
172175

176+
$context['data'] = $data;
177+
unset($context['property_metadata']);
178+
unset($context['api_attribute']);
179+
173180
if ($emptyResourceAsIri && \is_array($data) && 0 === \count($data)) {
181+
$context['data'] = $iri;
182+
183+
if ($this->tagCollector) {
184+
$this->tagCollector->collect($context);
185+
}
186+
174187
return $iri;
175188
}
176189

190+
if ($this->tagCollector) {
191+
$this->tagCollector->collect($context);
192+
}
193+
177194
return $data;
178195
}
179196

@@ -633,7 +650,7 @@ protected function getFactoryOptions(array $context): array
633650
protected function getAttributeValue(object $object, string $attribute, string $format = null, array $context = []): mixed
634651
{
635652
$context['api_attribute'] = $attribute;
636-
$propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context));
653+
$context['property_metadata'] = $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context));
637654

638655
if ($context['api_denormalize'] ?? false) {
639656
return $this->propertyAccessor->getValue($object, $attribute);
@@ -670,7 +687,15 @@ protected function getAttributeValue(object $object, string $attribute, string $
670687

671688
$resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
672689

673-
return $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
690+
$data = $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
691+
$context['data'] = $data;
692+
$context['type'] = $type;
693+
694+
if ($this->tagCollector) {
695+
$this->tagCollector->collect($context);
696+
}
697+
698+
return $data;
674699
}
675700

676701
if (
@@ -697,7 +722,15 @@ protected function getAttributeValue(object $object, string $attribute, string $
697722

698723
$resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
699724

700-
return $this->normalizeRelation($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
725+
$data = $this->normalizeRelation($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
726+
$context['data'] = $data;
727+
$context['type'] = $type;
728+
729+
if ($this->tagCollector) {
730+
$this->tagCollector->collect($context);
731+
}
732+
733+
return $data;
701734
}
702735

703736
if (!$this->serializer instanceof NormalizerInterface) {
@@ -789,9 +822,15 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel
789822
return $normalizedRelatedObject;
790823
}
791824

792-
$iri = $this->iriConverter->getIriFromResource(resource: $relatedObject, context: $context);
825+
$context['iri'] = $iri = $this->iriConverter->getIriFromResource(resource: $relatedObject, context: $context);
826+
$context['data'] = $iri;
827+
$context['object'] = $relatedObject;
828+
unset($context['property_metadata']);
829+
unset($context['api_attribute']);
793830

794-
if (isset($context['resources'])) {
831+
if ($this->tagCollector) {
832+
$this->tagCollector->collect($context);
833+
} elseif (isset($context['resources'])) {
795834
$context['resources'][$iri] = $iri;
796835
}
797836

src/Serializer/ItemNormalizer.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ class ItemNormalizer extends AbstractItemNormalizer
3939
{
4040
private readonly LoggerInterface $logger;
4141

42-
public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, LoggerInterface $logger = null, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, ResourceAccessCheckerInterface $resourceAccessChecker = null, array $defaultContext = [])
42+
public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, LoggerInterface $logger = null, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, ResourceAccessCheckerInterface $resourceAccessChecker = null, array $defaultContext = [], protected ?TagCollectorInterface $tagCollector = null)
4343
{
44-
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataFactory, $resourceAccessChecker);
44+
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataFactory, $resourceAccessChecker, $tagCollector);
4545

4646
$this->logger = $logger ?: new NullLogger();
4747
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
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+
/**
17+
* Interface for collecting cache tags during normalization.
18+
*
19+
* @author Urban Suppiger <[email protected]>
20+
*/
21+
interface TagCollectorInterface
22+
{
23+
/**
24+
* Collect cache tags for cache invalidation.
25+
*
26+
* @param array<string, mixed>&array{iri?: string, data?: mixed, object?: mixed, property_metadata?: \ApiPlatform\Metadata\ApiProperty, api_attribute?: string, resources?: array<string, string>} $context
27+
*/
28+
public function collect(array $context = []): void;
29+
}

src/Symfony/Bundle/Resources/config/api.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@
6060
<argument>null</argument>
6161
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" on-invalid="ignore" />
6262
<argument type="service" id="api_platform.security.resource_access_checker" on-invalid="ignore" />
63+
<argument type="collection" />
64+
<argument type="service" id="api_platform.http_cache.tag_collector" on-invalid="ignore" />
6365

6466
<!-- Run before serializer.normalizer.json_serializable -->
6567
<tag name="serializer.normalizer" priority="-895" />

0 commit comments

Comments
 (0)