Skip to content

Commit 851f135

Browse files
feat(httpcache): introduce PurgeTagProviderInterface extension point
PurgeHttpCacheListener cannot invalidate sub-resource collection IRIs such as /parents/{id}/children because it lacks the parent uri_variables when processing the child entity. The new PurgeTagProviderInterface lets users plug in custom tag collection strategies for these cases. Symfony: services tagged api_platform.http_cache.purge_tag_provider are injected automatically. Laravel: bind implementations and tag them with PurgeTagProviderInterface::class via $app->tag(). Signed-off-by: Guillaume Delré <delre.guillaume@gmail.com>
1 parent 1d7695d commit 851f135

6 files changed

Lines changed: 122 additions & 16 deletions

File tree

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 <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\HttpCache;
15+
16+
/**
17+
* Collects extra HTTP cache tags to invalidate for a given entity.
18+
*
19+
* Implement this interface to invalidate sub-resource collection IRIs
20+
* (e.g. /parents/{id}/children) that the built-in listener cannot resolve
21+
* on its own because it lacks the parent uri_variables.
22+
*/
23+
interface PurgeTagProviderInterface
24+
{
25+
/**
26+
* @return iterable<string> additional cache tags to invalidate for $entity
27+
*/
28+
public function getTagsForResource(object $entity): iterable;
29+
}

src/Laravel/Eloquent/ApiPlatformEventProvider.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Laravel\Eloquent;
1515

1616
use ApiPlatform\HttpCache\PurgerInterface;
17+
use ApiPlatform\HttpCache\PurgeTagProviderInterface;
1718
use ApiPlatform\HttpCache\SouinPurger;
1819
use ApiPlatform\HttpCache\VarnishPurger;
1920
use ApiPlatform\HttpCache\VarnishXKeyPurger;
@@ -90,7 +91,8 @@ public function register(): void
9091
return new PurgeHttpCacheListener(
9192
$app->make(PurgerInterface::class),
9293
$app->make(IriConverterInterface::class),
93-
$app->make(ResourceClassResolverInterface::class)
94+
$app->make(ResourceClassResolverInterface::class),
95+
$app->tagged(PurgeTagProviderInterface::class),
9496
);
9597
});
9698
}

src/Laravel/Eloquent/Listener/PurgeHttpCacheListener.php

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Laravel\Eloquent\Listener;
1515

1616
use ApiPlatform\HttpCache\PurgerInterface;
17+
use ApiPlatform\HttpCache\PurgeTagProviderInterface;
1718
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
1819
use ApiPlatform\Metadata\Exception\ItemNotFoundException;
1920
use ApiPlatform\Metadata\GetCollection;
@@ -28,10 +29,14 @@ final class PurgeHttpCacheListener
2829
*/
2930
private array $tags = [];
3031

32+
/**
33+
* @param iterable<PurgeTagProviderInterface> $purgeTagProviders
34+
*/
3135
public function __construct(
3236
private readonly PurgerInterface $purger,
3337
private readonly IriConverterInterface $iriConverter,
3438
private readonly ResourceClassResolverInterface $resourceClassResolver,
39+
private readonly iterable $purgeTagProviders = [],
3540
) {
3641
}
3742

@@ -41,15 +46,19 @@ public function __construct(
4146
public function handleModelSaved(string $eventName, array $data): void
4247
{
4348
foreach ($data as $model) {
44-
if (!$this->resourceClassResolver->isResourceClass($model::class)) {
45-
return;
49+
if ($this->resourceClassResolver->isResourceClass($model::class)) {
50+
try {
51+
$this->tags[] = $this->iriConverter->getIriFromResource($model);
52+
$this->tags[] = $this->iriConverter->getIriFromResource($model::class, operation: new GetCollection(class: $model::class));
53+
} catch (InvalidArgumentException|ItemNotFoundException $e) {
54+
// do nothing
55+
}
4656
}
4757

48-
try {
49-
$this->tags[] = $this->iriConverter->getIriFromResource($model);
50-
$this->tags[] = $this->iriConverter->getIriFromResource($model::class, operation: new GetCollection(class: $model::class));
51-
} catch (InvalidArgumentException|ItemNotFoundException $e) {
52-
// do nothing
58+
foreach ($this->purgeTagProviders as $provider) {
59+
foreach ($provider->getTagsForResource($model) as $tag) {
60+
$this->tags[] = $tag;
61+
}
5362
}
5463
}
5564
}
@@ -60,15 +69,19 @@ public function handleModelSaved(string $eventName, array $data): void
6069
public function handleModelDeleted(string $eventName, array $data): void
6170
{
6271
foreach ($data as $model) {
63-
if (!$this->resourceClassResolver->isResourceClass($model::class)) {
64-
return;
72+
if ($this->resourceClassResolver->isResourceClass($model::class)) {
73+
try {
74+
$this->tags[] = $this->iriConverter->getIriFromResource($model);
75+
$this->tags[] = $this->iriConverter->getIriFromResource($model::class, operation: new GetCollection(class: $model::class));
76+
} catch (InvalidArgumentException|ItemNotFoundException $e) {
77+
// do nothing
78+
}
6579
}
6680

67-
try {
68-
$this->tags[] = $this->iriConverter->getIriFromResource($model);
69-
$this->tags[] = $this->iriConverter->getIriFromResource($model::class, operation: new GetCollection(class: $model::class));
70-
} catch (InvalidArgumentException|ItemNotFoundException $e) {
71-
// do nothing
81+
foreach ($this->purgeTagProviders as $provider) {
82+
foreach ($provider->getTagsForResource($model) as $tag) {
83+
$this->tags[] = $tag;
84+
}
7285
}
7386
}
7487
}

src/Symfony/Bundle/Resources/config/doctrine_orm_http_cache_purger.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
service('api_platform.property_accessor'),
2727
service('api_platform.object_mapper')->nullOnInvalid(),
2828
service('api_platform.object_mapper.metadata_factory')->nullOnInvalid(),
29+
tagged_iterator('api_platform.http_cache.purge_tag_provider'),
2930
])
3031
->tag('doctrine.event_listener', ['event' => 'preUpdate'])
3132
->tag('doctrine.event_listener', ['event' => 'onFlush'])

src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Symfony\Doctrine\EventListener;
1515

1616
use ApiPlatform\HttpCache\PurgerInterface;
17+
use ApiPlatform\HttpCache\PurgeTagProviderInterface;
1718
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
1819
use ApiPlatform\Metadata\Exception\OperationNotFoundException;
1920
use ApiPlatform\Metadata\GetCollection;
@@ -45,12 +46,16 @@ final class PurgeHttpCacheListener
4546

4647
private array $scheduledInsertions = [];
4748

49+
/**
50+
* @param iterable<PurgeTagProviderInterface> $purgeTagProviders
51+
*/
4852
public function __construct(private readonly PurgerInterface $purger,
4953
private readonly IriConverterInterface $iriConverter,
5054
private readonly ResourceClassResolverInterface $resourceClassResolver,
5155
?PropertyAccessorInterface $propertyAccessor = null,
5256
private readonly ?ObjectMapperInterface $objectMapper = null,
53-
private readonly ?ObjectMapperMetadataFactoryInterface $objectMapperMetadata = null)
57+
private readonly ?ObjectMapperMetadataFactoryInterface $objectMapperMetadata = null,
58+
private readonly iterable $purgeTagProviders = [])
5459
{
5560
$this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor();
5661
}
@@ -137,6 +142,12 @@ private function gatherResourceAndItemTags(object $entity, bool $purgeItem): voi
137142
} catch (OperationNotFoundException|InvalidArgumentException) {
138143
}
139144
}
145+
146+
foreach ($this->purgeTagProviders as $provider) {
147+
foreach ($provider->getTagsForResource($entity) as $tag) {
148+
$this->tags[$tag] = $tag;
149+
}
150+
}
140151
}
141152

142153
private function gatherRelationTags(EntityManagerInterface $em, object $entity): void

src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@
1414
namespace ApiPlatform\Symfony\Tests\Doctrine\EventListener;
1515

1616
use ApiPlatform\HttpCache\PurgerInterface;
17+
use ApiPlatform\HttpCache\PurgeTagProviderInterface;
1718
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
1819
use ApiPlatform\Metadata\Exception\ItemNotFoundException;
1920
use ApiPlatform\Metadata\GetCollection;
2021
use ApiPlatform\Metadata\IriConverterInterface;
22+
use ApiPlatform\Metadata\Operation;
2123
use ApiPlatform\Metadata\ResourceClassResolverInterface;
2224
use ApiPlatform\Metadata\UrlGeneratorInterface;
2325
use ApiPlatform\Symfony\Doctrine\EventListener\PurgeHttpCacheListener;
@@ -270,6 +272,54 @@ public function testAddTagsForCollection(): void
270272
$listener->postFlush();
271273
}
272274

275+
public function testPurgeTagProviders(): void
276+
{
277+
$dummy = new Dummy();
278+
$dummy->setId(1);
279+
280+
$purger = $this->createMock(PurgerInterface::class);
281+
$purger->expects($this->once())
282+
->method('purge')
283+
->with(['/dummies', '/dummies/1', '/parents/42/children']);
284+
285+
$iriConverter = $this->createStub(IriConverterInterface::class);
286+
$iriConverter->method('getIriFromResource')
287+
->willReturnCallback(static function (object|string $resource, int $referenceType = UrlGeneratorInterface::ABS_PATH, ?Operation $operation = null, array $context = []): string {
288+
if ($operation instanceof GetCollection) {
289+
return '/dummies';
290+
}
291+
292+
return '/dummies/1';
293+
});
294+
295+
$resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class);
296+
$resourceClassResolver->method('isResourceClass')->willReturn(true);
297+
298+
$classMetadata = new ClassMetadata(Dummy::class);
299+
$classMetadata->associationMappings = [];
300+
301+
$em = $this->createStub(EntityManagerInterface::class);
302+
$em->method('getClassMetadata')->willReturn($classMetadata);
303+
304+
$changeSet = [];
305+
$eventArgs = new PreUpdateEventArgs($dummy, $em, $changeSet);
306+
307+
$provider = $this->createMock(PurgeTagProviderInterface::class);
308+
$provider->expects($this->once())
309+
->method('getTagsForResource')
310+
->with($dummy)
311+
->willReturn(['/parents/42/children']);
312+
313+
$listener = new PurgeHttpCacheListener(
314+
$purger,
315+
$iriConverter,
316+
$resourceClassResolver,
317+
purgeTagProviders: [$provider],
318+
);
319+
$listener->preUpdate($eventArgs);
320+
$listener->postFlush();
321+
}
322+
273323
public function testMappedResources(): void
274324
{
275325
$mappedEntity = new MappedEntity();

0 commit comments

Comments
 (0)