Skip to content

Commit 88bd233

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: implementing PurgeTagProviderInterface is sufficient; the registerForAutoconfiguration hook tags the service automatically with api_platform.http_cache.purge_tag_provider. 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 88bd233

8 files changed

Lines changed: 239 additions & 16 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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+
interface PurgeTagProviderInterface
20+
{
21+
/**
22+
* @return iterable<string>
23+
*/
24+
public function getTagsForResource(object $entity): iterable;
25+
}

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
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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\Laravel\Tests\Unit\Listener;
15+
16+
use ApiPlatform\HttpCache\PurgerInterface;
17+
use ApiPlatform\HttpCache\PurgeTagProviderInterface;
18+
use ApiPlatform\Laravel\Eloquent\Listener\PurgeHttpCacheListener;
19+
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
20+
use ApiPlatform\Metadata\GetCollection;
21+
use ApiPlatform\Metadata\IriConverterInterface;
22+
use ApiPlatform\Metadata\ResourceClassResolverInterface;
23+
use Illuminate\Database\Eloquent\Model;
24+
use PHPUnit\Framework\TestCase;
25+
26+
class PurgeHttpCacheListenerTest extends TestCase
27+
{
28+
public function testPurgeTagProviders(): void
29+
{
30+
$model = new class extends Model {
31+
};
32+
33+
$purger = $this->createMock(PurgerInterface::class);
34+
$purger->expects($this->once())
35+
->method('purge')
36+
->with(['/models/1', '/models', '/parents/42/children']);
37+
38+
$iriConverter = $this->createStub(IriConverterInterface::class);
39+
$iriConverter->method('getIriFromResource')
40+
->willReturnCallback(static function (object|string $resource, int $referenceType = 0, ?object $operation = null): string {
41+
if ($operation instanceof GetCollection) {
42+
return '/models';
43+
}
44+
45+
return '/models/1';
46+
});
47+
48+
$resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class);
49+
$resourceClassResolver->method('isResourceClass')->willReturn(true);
50+
51+
$provider = $this->createMock(PurgeTagProviderInterface::class);
52+
$provider->expects($this->once())
53+
->method('getTagsForResource')
54+
->with($model)
55+
->willReturn(['/parents/42/children']);
56+
57+
$listener = new PurgeHttpCacheListener($purger, $iriConverter, $resourceClassResolver, [$provider]);
58+
$listener->handleModelSaved('eloquent.saved: '.$model::class, [$model]);
59+
$listener->postFlush();
60+
}
61+
62+
public function testPurgeTagProvidersOnDelete(): void
63+
{
64+
$model = new class extends Model {
65+
};
66+
67+
$purger = $this->createMock(PurgerInterface::class);
68+
$purger->expects($this->once())
69+
->method('purge')
70+
->with(['/models/1', '/models', '/parents/42/children']);
71+
72+
$iriConverter = $this->createStub(IriConverterInterface::class);
73+
$iriConverter->method('getIriFromResource')
74+
->willReturnCallback(static function (object|string $resource, int $referenceType = 0, ?object $operation = null): string {
75+
if ($operation instanceof GetCollection) {
76+
return '/models';
77+
}
78+
79+
return '/models/1';
80+
});
81+
82+
$resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class);
83+
$resourceClassResolver->method('isResourceClass')->willReturn(true);
84+
85+
$provider = $this->createMock(PurgeTagProviderInterface::class);
86+
$provider->expects($this->once())
87+
->method('getTagsForResource')
88+
->with($model)
89+
->willReturn(['/parents/42/children']);
90+
91+
$listener = new PurgeHttpCacheListener($purger, $iriConverter, $resourceClassResolver, [$provider]);
92+
$listener->handleModelDeleted('eloquent.deleted: '.$model::class, [$model]);
93+
$listener->postFlush();
94+
}
95+
96+
public function testNoTagsWhenNoProviders(): void
97+
{
98+
$model = new class extends Model {
99+
};
100+
101+
$purger = $this->createMock(PurgerInterface::class);
102+
$purger->expects($this->never())->method('purge');
103+
104+
$iriConverter = $this->createStub(IriConverterInterface::class);
105+
$iriConverter->method('getIriFromResource')->willThrowException(new InvalidArgumentException());
106+
107+
$resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class);
108+
$resourceClassResolver->method('isResourceClass')->willReturn(true);
109+
110+
$listener = new PurgeHttpCacheListener($purger, $iriConverter, $resourceClassResolver);
111+
$listener->handleModelSaved('eloquent.saved: '.$model::class, [$model]);
112+
$listener->postFlush();
113+
}
114+
}

src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
use ApiPlatform\GraphQl\Resolver\QueryCollectionResolverInterface;
3131
use ApiPlatform\GraphQl\Resolver\QueryItemResolverInterface;
3232
use ApiPlatform\GraphQl\Type\Definition\TypeInterface as GraphQlTypeInterface;
33+
use ApiPlatform\HttpCache\PurgeTagProviderInterface;
3334
use ApiPlatform\Metadata\ApiResource;
3435
use ApiPlatform\Metadata\AsOperationMutator;
3536
use ApiPlatform\Metadata\AsResourceMutator;
@@ -228,6 +229,8 @@ public function load(array $configs, ContainerBuilder $container): void
228229
->addTag('api_platform.uri_variables.transformer');
229230
$container->registerForAutoconfiguration(ParameterProviderInterface::class)
230231
->addTag('api_platform.parameter_provider');
232+
$container->registerForAutoconfiguration(PurgeTagProviderInterface::class)
233+
->addTag('api_platform.http_cache.purge_tag_provider');
231234

232235
$container->registerAttributeForAutoconfiguration(
233236
AsResourceMutator::class,

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: 54 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,58 @@ public function testAddTagsForCollection(): void
270272
$listener->postFlush();
271273
}
272274

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

0 commit comments

Comments
 (0)