From 1ec3ff91e8bd9db627e50e7cee60d92d0a642454 Mon Sep 17 00:00:00 2001 From: mrossard Date: Wed, 6 May 2026 11:36:40 +0200 Subject: [PATCH 1/4] fix(symfony): cache invalidation should handle multiple GetCollection operations --- .../config/doctrine_orm_http_cache_purger.php | 1 + .../EventListener/PurgeHttpCacheListener.php | 17 +- .../PurgeHttpCacheListenerTest.php | 169 ++++++++++++++++-- 3 files changed, 173 insertions(+), 14 deletions(-) diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm_http_cache_purger.php b/src/Symfony/Bundle/Resources/config/doctrine_orm_http_cache_purger.php index cce74a00600..4883a65edea 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm_http_cache_purger.php +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm_http_cache_purger.php @@ -23,6 +23,7 @@ service('api_platform.http_cache.purger'), service('api_platform.iri_converter'), service('api_platform.resource_class_resolver'), + service('api_platform.metadata.resource.metadata_collection_factory'), service('api_platform.property_accessor'), service('api_platform.object_mapper')->nullOnInvalid(), service('api_platform.object_mapper.metadata_factory')->nullOnInvalid(), diff --git a/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php b/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php index 12566a3bd2c..8c8cd0fa3a3 100644 --- a/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php +++ b/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php @@ -18,6 +18,7 @@ use ApiPlatform\Metadata\Exception\OperationNotFoundException; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; @@ -48,9 +49,10 @@ final class PurgeHttpCacheListener public function __construct(private readonly PurgerInterface $purger, private readonly IriConverterInterface $iriConverter, private readonly ResourceClassResolverInterface $resourceClassResolver, + private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ?PropertyAccessorInterface $propertyAccessor = null, private readonly ?ObjectMapperInterface $objectMapper = null, - private readonly ?ObjectMapperMetadataFactoryInterface $objectMapperMetadata = null) + private readonly ?ObjectMapperMetadataFactoryInterface $objectMapperMetadata = null, ) { $this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor(); } @@ -128,8 +130,17 @@ private function gatherResourceAndItemTags(object $entity, bool $purgeItem): voi foreach ($resources as $resource) { try { - $iri = $this->iriConverter->getIriFromResource($resource, UrlGeneratorInterface::ABS_PATH, new GetCollection()); - $this->tags[$iri] = $iri; + // Here we need to loop on all GetCollection Operations, there can be multiple for a single resource class + $resourceClass = $this->resourceClassResolver->getResourceClass($resource); + $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass); + foreach ($resourceMetadataCollection as $resourceMetadata) { + foreach ($resourceMetadata->getOperations() as $operation) { + if ($operation instanceof GetCollection) { + $iri = $this->iriConverter->getIriFromResource($resource, UrlGeneratorInterface::ABS_PATH, $operation); + $this->tags[$iri] = $iri; + } + } + } if ($purgeItem) { $this->addTagForItem($entity); diff --git a/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php b/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php index 54bdfa6a2c8..cc8c2b93b84 100644 --- a/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php +++ b/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php @@ -14,10 +14,13 @@ namespace ApiPlatform\Symfony\Tests\Doctrine\EventListener; use ApiPlatform\HttpCache\PurgerInterface; +use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Symfony\Doctrine\EventListener\PurgeHttpCacheListener; @@ -68,16 +71,32 @@ public function testOnFlush(): void $purgerProphecy->purge(['/dummies', '/dummies/1', '/dummies/2', '/dummies/3', '/dummies/4'])->shouldBeCalled(); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getIriFromResource(Argument::type(Dummy::class), UrlGeneratorInterface::ABS_PATH, new GetCollection())->willReturn('/dummies')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource(Argument::type(Dummy::class), UrlGeneratorInterface::ABS_PATH, Argument::type(GetCollection::class))->willReturn('/dummies')->shouldBeCalled(); $iriConverterProphecy->getIriFromResource($toUpdate1)->willReturn('/dummies/1')->shouldBeCalled(); $iriConverterProphecy->getIriFromResource($toUpdate2)->willReturn('/dummies/2')->shouldBeCalled(); $iriConverterProphecy->getIriFromResource($toDelete1)->willReturn('/dummies/3')->shouldBeCalled(); $iriConverterProphecy->getIriFromResource($toDelete2)->willReturn('/dummies/4')->shouldBeCalled(); - $iriConverterProphecy->getIriFromResource(Argument::type(DummyNoGetOperation::class), UrlGeneratorInterface::ABS_PATH, new GetCollection())->willThrow(new InvalidArgumentException())->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource(Argument::type(DummyNoGetOperation::class), UrlGeneratorInterface::ABS_PATH, Argument::type(GetCollection::class))->willThrow(new InvalidArgumentException())->shouldBeCalled(); $iriConverterProphecy->getIriFromResource(Argument::any())->willThrow(new ItemNotFoundException()); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(Argument::type('string'))->willReturn(true)->shouldBeCalled(); + $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class)->shouldBeCalled(); + $resourceClassResolverProphecy->getResourceClass(Argument::type(DummyNoGetOperation::class))->willReturn(DummyNoGetOperation::class)->shouldBeCalled(); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy + ->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + new ApiResource(operations: [ + new GetCollection(), + ]), + ])); + $resourceMetadataCollectionFactoryProphecy + ->create(DummyNoGetOperation::class)->willReturn(new ResourceMetadataCollection(DummyNoGetOperation::class, [ + new ApiResource(operations: [ + new GetCollection(), + ]), + ])); $uowProphecy = $this->prophesize(UnitOfWork::class); $uowProphecy->getScheduledEntityInsertions()->willReturn([$toInsert1, $toInsert2])->shouldBeCalled(); @@ -102,12 +121,15 @@ public function testOnFlush(): void $propertyAccessorProphecy->getValue(Argument::type(Dummy::class), 'relatedDummy')->willReturn(null)->shouldBeCalled(); $propertyAccessorProphecy->getValue(Argument::type(Dummy::class), 'relatedOwningDummy')->willReturn(null)->shouldNotBeCalled(); - $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal()); + $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), $resourceMetadataCollectionFactoryProphecy->reveal(), + $propertyAccessorProphecy->reveal(), null, null); + $listener->onFlush($eventArgs); $listener->postFlush(); - $iriConverterProphecy->getIriFromResource(Argument::type(Dummy::class), UrlGeneratorInterface::ABS_PATH, new GetCollection())->shouldHaveBeenCalled(); - $iriConverterProphecy->getIriFromResource(Argument::type(DummyNoGetOperation::class), UrlGeneratorInterface::ABS_PATH, new GetCollection())->shouldHaveBeenCalled(); + $iriConverterProphecy->getIriFromResource(Argument::type(Dummy::class), UrlGeneratorInterface::ABS_PATH, Argument::type(GetCollection::class))->shouldHaveBeenCalled(); + $iriConverterProphecy->getIriFromResource(Argument::type(DummyNoGetOperation::class), UrlGeneratorInterface::ABS_PATH, Argument::type(GetCollection::class))->shouldHaveBeenCalled(); } public function testPreUpdate(): void @@ -133,6 +155,22 @@ public function testPreUpdate(): void $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(Argument::type('string'))->willReturn(true)->shouldBeCalled(); + $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class)->shouldBeCalled(); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy + ->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + new ApiResource(operations: [ + new GetCollection(), + ]), + ])); + $resourceMetadataCollectionFactoryProphecy + ->create(RelatedDummy::class)->willReturn(new ResourceMetadataCollection(RelatedDummy::class, [ + new ApiResource(operations: [ + new GetCollection(), + ]), + ])); + $emProphecy = $this->prophesize(EntityManagerInterface::class); $classMetadata = new ClassMetadata(Dummy::class); @@ -142,7 +180,8 @@ public function testPreUpdate(): void $changeSet = ['relatedDummy' => [$oldRelatedDummy, $newRelatedDummy]]; $eventArgs = new PreUpdateEventArgs($dummy, $emProphecy->reveal(), $changeSet); - $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal()); + $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), $resourceMetadataCollectionFactoryProphecy->reveal()); $listener->preUpdate($eventArgs); $listener->postFlush(); } @@ -161,7 +200,7 @@ public function testNothingToPurge(): void $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(DummyNoGetOperation::class)->willReturn(true)->shouldBeCalled(); - $resourceClassResolverProphecy->getResourceClass(Argument::type(DummyNoGetOperation::class))->willReturn(DummyNoGetOperation::class)->shouldNotBeCalled(); + $resourceClassResolverProphecy->getResourceClass(Argument::type(DummyNoGetOperation::class))->willReturn(DummyNoGetOperation::class)->shouldBeCalled(); $emProphecy = $this->prophesize(EntityManagerInterface::class); @@ -171,7 +210,16 @@ public function testNothingToPurge(): void $changeSet = ['lorem' => ['ipsum1', 'ipsum2']]; $eventArgs = new PreUpdateEventArgs($dummyNoGetOperation, $emProphecy->reveal(), $changeSet); - $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal()); + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy + ->create(DummyNoGetOperation::class)->willReturn(new ResourceMetadataCollection(DummyNoGetOperation::class, [ + new ApiResource(operations: [ + new GetCollection(), + ]), + ])); + + $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), + $resourceMetadataCollectionFactoryProphecy->reveal()); $listener->preUpdate($eventArgs); $listener->postFlush(); } @@ -193,6 +241,7 @@ public function testNotAResourceClass(): void $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(Argument::type('string'))->willReturn(true)->shouldBeCalled(); + $resourceClassResolverProphecy->getResourceClass(Argument::type(ContainNonResource::class))->willReturn(ContainNonResource::class)->shouldBeCalled(); $uowProphecy = $this->prophesize(UnitOfWork::class); $uowProphecy->getScheduledEntityInsertions()->willReturn([$containNonResource])->shouldBeCalled(); @@ -217,8 +266,17 @@ public function testNotAResourceClass(): void $propertyAccessorProphecy->getValue(Argument::type(ContainNonResource::class), 'notAResource')->shouldBeCalled()->willReturn($nonResource1); $propertyAccessorProphecy->getValue(Argument::type(ContainNonResource::class), 'collectionOfNotAResource')->shouldBeCalled()->willReturn($collectionOfNotAResource); + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy + ->create(ContainNonResource::class)->willReturn(new ResourceMetadataCollection(ContainNonResource::class, [ + new ApiResource(operations: [ + new GetCollection(), + ]), + ])); + $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal()); + $resourceClassResolverProphecy->reveal(), $resourceMetadataCollectionFactoryProphecy->reveal(), + $propertyAccessorProphecy->reveal()); $listener->onFlush($eventArgs); $listener->postFlush(); } @@ -241,6 +299,7 @@ public function testAddTagsForCollection(): void $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(Argument::type('string'))->willReturn(true)->shouldBeCalled(); + $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class)->shouldBeCalled(); $dummyWithCollection = new Dummy(); $dummyWithCollection->setId(3); @@ -265,7 +324,85 @@ public function testAddTagsForCollection(): void $propertyAccessorProphecy->isReadable(Argument::type(Dummy::class), 'relatedDummies')->willReturn(true); $propertyAccessorProphecy->getValue(Argument::type(Dummy::class), 'relatedDummies')->willReturn($collection)->shouldBeCalled(); - $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal()); + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy + ->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + new ApiResource(operations: [ + new GetCollection(), + ]), + ])); + $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), $resourceMetadataCollectionFactoryProphecy->reveal(), $propertyAccessorProphecy->reveal(), ); + $listener->onFlush($eventArgs); + $listener->postFlush(); + } + + public function testOnFlushWithMultipleGetCollectionOperations(): void + { + $toInsert = new Dummy(); + + $getCollection1 = new GetCollection(uriTemplate: '/dummies'); + $getCollection2 = new GetCollection(uriTemplate: '/dummies/special'); + + $purgerProphecy = $this->prophesize(PurgerInterface::class); + $purgerProphecy->purge(['/dummies', '/dummies/special'])->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy + ->getIriFromResource( + Argument::type(Dummy::class), + UrlGeneratorInterface::ABS_PATH, + Argument::that(static fn (GetCollection $operation): bool => '/dummies' === $operation->getUriTemplate()) + ) + ->willReturn('/dummies') + ->shouldBeCalled(); + $iriConverterProphecy + ->getIriFromResource( + Argument::type(Dummy::class), + UrlGeneratorInterface::ABS_PATH, + Argument::that(static fn (GetCollection $operation): bool => '/dummies/special' === $operation->getUriTemplate()) + ) + ->willReturn('/dummies/special') + ->shouldBeCalled(); + $iriConverterProphecy + ->getIriFromResource( + Argument::type(Dummy::class), + UrlGeneratorInterface::ABS_PATH, + new GetCollection() + ) + ->willReturn('/dummies'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy + ->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + new ApiResource(operations: [ + $getCollection1, + $getCollection2, + ]), + ])); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([$toInsert])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $dummyClassMetadata = new ClassMetadata(Dummy::class); + $dummyClassMetadata->associationMappings = []; + $emProphecy->getClassMetadata(Dummy::class)->willReturn($dummyClassMetadata)->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + + $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), $resourceMetadataCollectionFactoryProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + ); $listener->onFlush($eventArgs); $listener->postFlush(); } @@ -295,6 +432,8 @@ public function testMappedResources(): void $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(MappedEntity::class)->willReturn(false)->shouldBeCalled(); + $resourceClassResolverProphecy->getResourceClass(Argument::type(MappedResource::class))->willReturn(MappedResource::class)->shouldBeCalled(); + $objectMapperProphecy = $this->prophesize(ObjectMapperInterface::class); $objectMapperProphecy->map($mappedEntity, MappedResource::class)->shouldBeCalled()->willReturn($mappedResource); @@ -312,8 +451,16 @@ public function testMappedResources(): void $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy + ->create(MappedResource::class)->willReturn(new ResourceMetadataCollection(MappedResource::class, [ + new ApiResource(operations: [ + new GetCollection(), + ]), + ])); + $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), $resourceMetadataCollectionFactoryProphecy->reveal(), $propertyAccessorProphecy->reveal(), $objectMapperProphecy->reveal() ); $listener->onFlush($eventArgs); From f28b0b025f38a675b81c6d2b53e81bc019b5adfe Mon Sep 17 00:00:00 2001 From: Manuel Rossard Date: Thu, 7 May 2026 15:51:38 +0200 Subject: [PATCH 2/4] fix: backward compatibility --- .../config/doctrine_orm_http_cache_purger.php | 2 +- .../EventListener/PurgeHttpCacheListener.php | 10 ++++++-- .../PurgeHttpCacheListenerTest.php | 24 ++++++++++--------- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm_http_cache_purger.php b/src/Symfony/Bundle/Resources/config/doctrine_orm_http_cache_purger.php index 4883a65edea..bbb221b549b 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm_http_cache_purger.php +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm_http_cache_purger.php @@ -23,10 +23,10 @@ service('api_platform.http_cache.purger'), service('api_platform.iri_converter'), service('api_platform.resource_class_resolver'), - service('api_platform.metadata.resource.metadata_collection_factory'), service('api_platform.property_accessor'), service('api_platform.object_mapper')->nullOnInvalid(), service('api_platform.object_mapper.metadata_factory')->nullOnInvalid(), + service('api_platform.metadata.resource.metadata_collection_factory'), ]) ->tag('doctrine.event_listener', ['event' => 'preUpdate']) ->tag('doctrine.event_listener', ['event' => 'onFlush']) diff --git a/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php b/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php index 8c8cd0fa3a3..81e382e1aea 100644 --- a/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php +++ b/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php @@ -49,10 +49,10 @@ final class PurgeHttpCacheListener public function __construct(private readonly PurgerInterface $purger, private readonly IriConverterInterface $iriConverter, private readonly ResourceClassResolverInterface $resourceClassResolver, - private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ?PropertyAccessorInterface $propertyAccessor = null, private readonly ?ObjectMapperInterface $objectMapper = null, - private readonly ?ObjectMapperMetadataFactoryInterface $objectMapperMetadata = null, ) + private readonly ?ObjectMapperMetadataFactoryInterface $objectMapperMetadata = null, + private readonly ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null) { $this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor(); } @@ -130,6 +130,12 @@ private function gatherResourceAndItemTags(object $entity, bool $purgeItem): voi foreach ($resources as $resource) { try { + if (!$this->resourceMetadataCollectionFactory) { + // BC: fallback to the previous version + $iri = $this->iriConverter->getIriFromResource($resource, UrlGeneratorInterface::ABS_PATH, new GetCollection()); + $this->tags[$iri] = $iri; + continue; + } // Here we need to loop on all GetCollection Operations, there can be multiple for a single resource class $resourceClass = $this->resourceClassResolver->getResourceClass($resource); $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass); diff --git a/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php b/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php index cc8c2b93b84..98e5d1fe77d 100644 --- a/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php +++ b/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php @@ -122,8 +122,8 @@ public function testOnFlush(): void $propertyAccessorProphecy->getValue(Argument::type(Dummy::class), 'relatedOwningDummy')->willReturn(null)->shouldNotBeCalled(); $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal(), $resourceMetadataCollectionFactoryProphecy->reveal(), - $propertyAccessorProphecy->reveal(), null, null); + $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), null, null, + $resourceMetadataCollectionFactoryProphecy->reveal(), ); $listener->onFlush($eventArgs); $listener->postFlush(); @@ -181,7 +181,8 @@ public function testPreUpdate(): void $eventArgs = new PreUpdateEventArgs($dummy, $emProphecy->reveal(), $changeSet); $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal(), $resourceMetadataCollectionFactoryProphecy->reveal()); + $resourceClassResolverProphecy->reveal(), null, null, null, + $resourceMetadataCollectionFactoryProphecy->reveal()); $listener->preUpdate($eventArgs); $listener->postFlush(); } @@ -219,7 +220,7 @@ public function testNothingToPurge(): void ])); $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), - $resourceMetadataCollectionFactoryProphecy->reveal()); + null, null, null, $resourceMetadataCollectionFactoryProphecy->reveal()); $listener->preUpdate($eventArgs); $listener->postFlush(); } @@ -275,8 +276,8 @@ public function testNotAResourceClass(): void ])); $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal(), $resourceMetadataCollectionFactoryProphecy->reveal(), - $propertyAccessorProphecy->reveal()); + $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, null, $resourceMetadataCollectionFactoryProphecy->reveal(), ); $listener->onFlush($eventArgs); $listener->postFlush(); } @@ -332,7 +333,8 @@ public function testAddTagsForCollection(): void ]), ])); $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal(), $resourceMetadataCollectionFactoryProphecy->reveal(), $propertyAccessorProphecy->reveal(), ); + $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), null, null, + $resourceMetadataCollectionFactoryProphecy->reveal()); $listener->onFlush($eventArgs); $listener->postFlush(); } @@ -400,8 +402,8 @@ public function testOnFlushWithMultipleGetCollectionOperations(): void $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal(), $resourceMetadataCollectionFactoryProphecy->reveal(), - $propertyAccessorProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), null, null, + $resourceMetadataCollectionFactoryProphecy->reveal(), ); $listener->onFlush($eventArgs); $listener->postFlush(); @@ -460,8 +462,8 @@ public function testMappedResources(): void ])); $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal(), $resourceMetadataCollectionFactoryProphecy->reveal(), $propertyAccessorProphecy->reveal(), - $objectMapperProphecy->reveal() + $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + $objectMapperProphecy->reveal(), null, $resourceMetadataCollectionFactoryProphecy->reveal() ); $listener->onFlush($eventArgs); $listener->postFlush(); From 8ba6510cfe15469b51636fdf7da4e9ecc9da22b2 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 11 May 2026 11:19:55 +0200 Subject: [PATCH 3/4] fix(symfony): purge all Get IRIs and restore BC item tagging * loop over every #[Get] when collecting item tags (mirrors the GetCollection fix from #7951) * restore item tag emission when ResourceMetadataCollectionFactory is not wired (BC fallback regression) * document sub-resource collection limitation, tracked in #7965 --- .../EventListener/PurgeHttpCacheListener.php | 86 +++++-- ...ttpCacheListenerMultipleOperationsTest.php | 217 ++++++++++++++++++ .../PurgeHttpCacheListenerTest.php | 35 +-- .../HttpCachePurgeMultiCollection/Dummy.php | 49 ++++ .../HttpCachePurgeMultiCollectionTest.php | 97 ++++++++ 5 files changed, 450 insertions(+), 34 deletions(-) create mode 100644 src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerMultipleOperationsTest.php create mode 100644 tests/Fixtures/TestBundle/Entity/HttpCachePurgeMultiCollection/Dummy.php create mode 100644 tests/Functional/HttpCachePurgeMultiCollectionTest.php diff --git a/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php b/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php index 81e382e1aea..5dd63b19a5c 100644 --- a/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php +++ b/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php @@ -16,6 +16,7 @@ use ApiPlatform\HttpCache\PurgerInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Exception\OperationNotFoundException; +use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -36,6 +37,11 @@ /** * Purges responses containing modified entities from the proxy cache. * + * Sub-resource collection operations (those whose URI depends on parent + * `uriVariables` such as `/parents/{parentId}/children`) are not invalidated + * here: the listener has no parent context available. Decorate this service + * or register an additional Doctrine event listener to invalidate such tags. + * * @author Kévin Dunglas */ final class PurgeHttpCacheListener @@ -129,30 +135,72 @@ private function gatherResourceAndItemTags(object $entity, bool $purgeItem): voi $resources = $this->getResourcesForEntity($entity); foreach ($resources as $resource) { + foreach ($this->getCollectionIris($resource) as $iri) { + $this->tags[$iri] = $iri; + } + + if ($purgeItem) { + $this->addTagForItem($entity); + } + } + } + + /** + * @return iterable + */ + private function getCollectionIris(object|string $resource): iterable + { + if (!$this->resourceMetadataCollectionFactory) { + // BC: fall back to the default GetCollection resolution. try { - if (!$this->resourceMetadataCollectionFactory) { - // BC: fallback to the previous version - $iri = $this->iriConverter->getIriFromResource($resource, UrlGeneratorInterface::ABS_PATH, new GetCollection()); - $this->tags[$iri] = $iri; + yield $this->iriConverter->getIriFromResource($resource, UrlGeneratorInterface::ABS_PATH, new GetCollection()); + } catch (OperationNotFoundException|InvalidArgumentException) { + } + + return; + } + + $resourceClass = $this->resourceClassResolver->getResourceClass($resource); + foreach ($this->resourceMetadataCollectionFactory->create($resourceClass) as $resourceMetadata) { + foreach ($resourceMetadata->getOperations() as $operation) { + if (!$operation instanceof GetCollection) { continue; } - // Here we need to loop on all GetCollection Operations, there can be multiple for a single resource class - $resourceClass = $this->resourceClassResolver->getResourceClass($resource); - $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass); - foreach ($resourceMetadataCollection as $resourceMetadata) { - foreach ($resourceMetadata->getOperations() as $operation) { - if ($operation instanceof GetCollection) { - $iri = $this->iriConverter->getIriFromResource($resource, UrlGeneratorInterface::ABS_PATH, $operation); - $this->tags[$iri] = $iri; - } - } + try { + yield $this->iriConverter->getIriFromResource($resource, UrlGeneratorInterface::ABS_PATH, $operation); + } catch (OperationNotFoundException|InvalidArgumentException) { + // Sub-resource collections (needing parent uri_variables) cannot + // be resolved here and are intentionally skipped. } + } + } + } - if ($purgeItem) { - $this->addTagForItem($entity); - } + /** + * @return iterable + */ + private function getItemIris(object|string $resource): iterable + { + if (!$this->resourceMetadataCollectionFactory) { + try { + yield $this->iriConverter->getIriFromResource($resource); } catch (OperationNotFoundException|InvalidArgumentException) { } + + return; + } + + $resourceClass = $this->resourceClassResolver->getResourceClass($resource); + foreach ($this->resourceMetadataCollectionFactory->create($resourceClass) as $resourceMetadata) { + foreach ($resourceMetadata->getOperations() as $operation) { + if (!$operation instanceof Get) { + continue; + } + try { + yield $this->iriConverter->getIriFromResource($resource, UrlGeneratorInterface::ABS_PATH, $operation); + } catch (OperationNotFoundException|InvalidArgumentException) { + } + } } } @@ -219,10 +267,8 @@ private function addTagForItem(mixed $value): void $resources = $this->getResourcesForEntity($value); foreach ($resources as $resource) { - try { - $iri = $this->iriConverter->getIriFromResource($resource); + foreach ($this->getItemIris($resource) as $iri) { $this->tags[$iri] = $iri; - } catch (OperationNotFoundException|InvalidArgumentException) { } } } diff --git a/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerMultipleOperationsTest.php b/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerMultipleOperationsTest.php new file mode 100644 index 00000000000..f68feac4944 --- /dev/null +++ b/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerMultipleOperationsTest.php @@ -0,0 +1,217 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Tests\Doctrine\EventListener; + +use ApiPlatform\HttpCache\PurgerInterface; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Symfony\Doctrine\EventListener\PurgeHttpCacheListener; +use ApiPlatform\Symfony\Tests\Fixtures\TestBundle\Entity\Dummy; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Event\OnFlushEventArgs; +use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\UnitOfWork; +use PHPUnit\Framework\TestCase; + +/** + * Non-regression tests for the multi-operation purge fix. + * + * Uses native PHPUnit mocks (no Prophecy) to keep expectations close to the + * actual call shape. + */ +final class PurgeHttpCacheListenerMultipleOperationsTest extends TestCase +{ + /** + * (b) When several #[GetCollection] are declared on a resource, every + * collection URI must be added to the purge tags. + */ + public function testPurgesAllCollectionUrisOnInsert(): void + { + $entity = new Dummy(); + + $getCollection1 = new GetCollection(uriTemplate: '/dummies'); + $getCollection2 = new GetCollection(uriTemplate: '/dummies/featured'); + + $purger = $this->createMock(PurgerInterface::class); + $purger->expects(self::once())->method('purge') + ->with(self::callback(function (array $iris) { + self::assertContains('/dummies', $iris); + self::assertContains('/dummies/featured', $iris); + + return true; + })); + + $iriConverter = $this->createStub(IriConverterInterface::class); + $iriConverter->method('getIriFromResource') + ->willReturnCallback(static function (object|string $resource, int $referenceType, ?Operation $operation): string { + self::assertSame(UrlGeneratorInterface::ABS_PATH, $referenceType); + self::assertInstanceOf(GetCollection::class, $operation); + + return $operation->getUriTemplate(); + }); + + $listener = new PurgeHttpCacheListener( + $purger, + $iriConverter, + $this->mockResourceClassResolver(), + null, + null, + null, + $this->mockMetadataFactory([$getCollection1, $getCollection2]), + ); + + $listener->onFlush($this->onFlushArgs([$entity], [], [])); + $listener->postFlush(); + } + + /** + * (b) When several #[Get] are declared on a resource, every item URI must + * be added to the purge tags on update. + */ + public function testPurgesAllItemUrisOnUpdate(): void + { + $entity = new Dummy(); + $entity->setId(7); + + $get1 = new Get(uriTemplate: '/dummies/{id}'); + $get2 = new Get(uriTemplate: '/dummies/{id}/details'); + + $purger = $this->createMock(PurgerInterface::class); + $purger->expects(self::once())->method('purge') + ->with(self::callback(function (array $iris) { + self::assertContains('/dummies/7', $iris); + self::assertContains('/dummies/7/details', $iris); + + return true; + })); + + $iriConverter = $this->createStub(IriConverterInterface::class); + $iriConverter->method('getIriFromResource') + ->willReturnCallback(static function (object|string $resource, int $referenceType, ?Operation $operation): string { + self::assertSame(UrlGeneratorInterface::ABS_PATH, $referenceType); + self::assertNotNull($operation); + + return str_replace('{id}', '7', (string) $operation->getUriTemplate()); + }); + + $listener = new PurgeHttpCacheListener( + $purger, + $iriConverter, + $this->mockResourceClassResolver(), + null, + null, + null, + $this->mockMetadataFactory([new GetCollection(uriTemplate: '/dummies'), $get1, $get2]), + ); + + $listener->onFlush($this->onFlushArgs([], [$entity], [])); + $listener->postFlush(); + } + + /** + * (a) BC regression: when the optional ResourceMetadataCollectionFactory + * is not injected (legacy wiring or manual instantiation), the listener + * must still emit item tags alongside the collection tag — the previous + * iteration of the fix lost item tags in the null-factory fallback. + */ + public function testBcFallbackStillEmitsItemTagsOnUpdate(): void + { + $entity = new Dummy(); + $entity->setId(11); + + $purger = $this->createMock(PurgerInterface::class); + $purger->expects(self::once())->method('purge') + ->with(self::callback(function (array $iris) { + self::assertContains('/dummies', $iris, 'collection IRI must be present in BC fallback'); + self::assertContains('/dummies/11', $iris, 'item IRI must still be emitted when no metadata factory is wired'); + + return true; + })); + + $iriConverter = $this->createStub(IriConverterInterface::class); + $iriConverter->method('getIriFromResource') + ->willReturnCallback(static function (object|string $resource, int $referenceType = UrlGeneratorInterface::ABS_PATH, ?Operation $operation = null): string { + if ($operation instanceof GetCollection) { + return '/dummies'; + } + + return '/dummies/11'; + }); + + $listener = new PurgeHttpCacheListener( + $purger, + $iriConverter, + $this->mockResourceClassResolver(), + null, + null, + null, + null, // no metadata factory → BC path + ); + + $listener->onFlush($this->onFlushArgs([], [$entity], [])); + $listener->postFlush(); + } + + private function mockResourceClassResolver(): ResourceClassResolverInterface + { + $resolver = $this->createStub(ResourceClassResolverInterface::class); + $resolver->method('isResourceClass')->willReturn(true); + $resolver->method('getResourceClass')->willReturn(Dummy::class); + + return $resolver; + } + + /** + * @param Operation[] $operations + */ + private function mockMetadataFactory(array $operations): ResourceMetadataCollectionFactoryInterface + { + $factory = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $factory->method('create')->willReturn( + new ResourceMetadataCollection(Dummy::class, [new ApiResource(operations: $operations)]) + ); + + return $factory; + } + + /** + * @param array $insertions + * @param array $updates + * @param array $deletions + */ + private function onFlushArgs(array $insertions, array $updates, array $deletions): OnFlushEventArgs + { + $uow = $this->createStub(UnitOfWork::class); + $uow->method('getScheduledEntityInsertions')->willReturn($insertions); + $uow->method('getScheduledEntityUpdates')->willReturn($updates); + $uow->method('getScheduledEntityDeletions')->willReturn($deletions); + + $classMetadata = new ClassMetadata(Dummy::class); + // @phpstan-ignore-next-line + $classMetadata->associationMappings = []; + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getUnitOfWork')->willReturn($uow); + $em->method('getClassMetadata')->willReturn($classMetadata); + + return new OnFlushEventArgs($em); + } +} diff --git a/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php b/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php index 98e5d1fe77d..c985a5f42c0 100644 --- a/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php +++ b/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php @@ -17,6 +17,7 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Exception\ItemNotFoundException; +use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -72,10 +73,10 @@ public function testOnFlush(): void $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromResource(Argument::type(Dummy::class), UrlGeneratorInterface::ABS_PATH, Argument::type(GetCollection::class))->willReturn('/dummies')->shouldBeCalled(); - $iriConverterProphecy->getIriFromResource($toUpdate1)->willReturn('/dummies/1')->shouldBeCalled(); - $iriConverterProphecy->getIriFromResource($toUpdate2)->willReturn('/dummies/2')->shouldBeCalled(); - $iriConverterProphecy->getIriFromResource($toDelete1)->willReturn('/dummies/3')->shouldBeCalled(); - $iriConverterProphecy->getIriFromResource($toDelete2)->willReturn('/dummies/4')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($toUpdate1, UrlGeneratorInterface::ABS_PATH, Argument::type(Get::class))->willReturn('/dummies/1')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($toUpdate2, UrlGeneratorInterface::ABS_PATH, Argument::type(Get::class))->willReturn('/dummies/2')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($toDelete1, UrlGeneratorInterface::ABS_PATH, Argument::type(Get::class))->willReturn('/dummies/3')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($toDelete2, UrlGeneratorInterface::ABS_PATH, Argument::type(Get::class))->willReturn('/dummies/4')->shouldBeCalled(); $iriConverterProphecy->getIriFromResource(Argument::type(DummyNoGetOperation::class), UrlGeneratorInterface::ABS_PATH, Argument::type(GetCollection::class))->willThrow(new InvalidArgumentException())->shouldBeCalled(); $iriConverterProphecy->getIriFromResource(Argument::any())->willThrow(new ItemNotFoundException()); @@ -89,6 +90,7 @@ public function testOnFlush(): void ->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ new ApiResource(operations: [ new GetCollection(), + new Get(), ]), ])); $resourceMetadataCollectionFactoryProphecy @@ -147,27 +149,30 @@ public function testPreUpdate(): void $purgerProphecy->purge(['/dummies', '/dummies/1', '/related_dummies/old', '/related_dummies/new'])->shouldBeCalled(); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getIriFromResource(Argument::type(Dummy::class), UrlGeneratorInterface::ABS_PATH, new GetCollection())->willReturn('/dummies')->shouldBeCalled(); - $iriConverterProphecy->getIriFromResource($dummy)->willReturn('/dummies/1')->shouldBeCalled(); - $iriConverterProphecy->getIriFromResource($oldRelatedDummy)->willReturn('/related_dummies/old')->shouldBeCalled(); - $iriConverterProphecy->getIriFromResource($newRelatedDummy)->willReturn('/related_dummies/new')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource(Argument::type(Dummy::class), UrlGeneratorInterface::ABS_PATH, Argument::type(GetCollection::class))->willReturn('/dummies')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($dummy, UrlGeneratorInterface::ABS_PATH, Argument::type(Get::class))->willReturn('/dummies/1')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($oldRelatedDummy, UrlGeneratorInterface::ABS_PATH, Argument::type(Get::class))->willReturn('/related_dummies/old')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($newRelatedDummy, UrlGeneratorInterface::ABS_PATH, Argument::type(Get::class))->willReturn('/related_dummies/new')->shouldBeCalled(); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(Argument::type('string'))->willReturn(true)->shouldBeCalled(); $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class)->shouldBeCalled(); + $resourceClassResolverProphecy->getResourceClass(Argument::type(RelatedDummy::class))->willReturn(RelatedDummy::class)->shouldBeCalled(); $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceMetadataCollectionFactoryProphecy ->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ new ApiResource(operations: [ new GetCollection(), + new Get(), ]), ])); $resourceMetadataCollectionFactoryProphecy ->create(RelatedDummy::class)->willReturn(new ResourceMetadataCollection(RelatedDummy::class, [ new ApiResource(operations: [ new GetCollection(), + new Get(), ]), ])); @@ -236,13 +241,12 @@ public function testNotAResourceClass(): void $purgerProphecy->purge(['/dummies'])->shouldBeCalled(); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getIriFromResource(Argument::type(ContainNonResource::class), UrlGeneratorInterface::ABS_PATH, new GetCollection())->willReturn('/dummies')->shouldBeCalled(); - $iriConverterProphecy->getIriFromResource($nonResource1)->willThrow(new InvalidArgumentException())->shouldBeCalled(); - $iriConverterProphecy->getIriFromResource($nonResource2)->willThrow(new InvalidArgumentException())->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource(Argument::type(ContainNonResource::class), UrlGeneratorInterface::ABS_PATH, Argument::type(GetCollection::class))->willReturn('/dummies')->shouldBeCalled(); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(Argument::type('string'))->willReturn(true)->shouldBeCalled(); $resourceClassResolverProphecy->getResourceClass(Argument::type(ContainNonResource::class))->willReturn(ContainNonResource::class)->shouldBeCalled(); + $resourceClassResolverProphecy->getResourceClass(Argument::type(NotAResource::class))->willReturn(NotAResource::class); $uowProphecy = $this->prophesize(UnitOfWork::class); $uowProphecy->getScheduledEntityInsertions()->willReturn([$containNonResource])->shouldBeCalled(); @@ -274,6 +278,8 @@ public function testNotAResourceClass(): void new GetCollection(), ]), ])); + $resourceMetadataCollectionFactoryProphecy + ->create(NotAResource::class)->willReturn(new ResourceMetadataCollection(NotAResource::class, [])); $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), @@ -294,9 +300,9 @@ public function testAddTagsForCollection(): void $purgerProphecy->purge(['/dummies/1', '/dummies/2', '/dummies'])->shouldBeCalled(); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getIriFromResource(Argument::type(Dummy::class), UrlGeneratorInterface::ABS_PATH, new GetCollection())->willReturn('/dummies')->shouldBeCalled(); - $iriConverterProphecy->getIriFromResource($dummy1)->willReturn('/dummies/1')->shouldBeCalled(); - $iriConverterProphecy->getIriFromResource($dummy2)->willReturn('/dummies/2')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource(Argument::type(Dummy::class), UrlGeneratorInterface::ABS_PATH, Argument::type(GetCollection::class))->willReturn('/dummies')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($dummy1, UrlGeneratorInterface::ABS_PATH, Argument::type(Get::class))->willReturn('/dummies/1')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($dummy2, UrlGeneratorInterface::ABS_PATH, Argument::type(Get::class))->willReturn('/dummies/2')->shouldBeCalled(); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(Argument::type('string'))->willReturn(true)->shouldBeCalled(); @@ -330,6 +336,7 @@ public function testAddTagsForCollection(): void ->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ new ApiResource(operations: [ new GetCollection(), + new Get(), ]), ])); $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), diff --git a/tests/Fixtures/TestBundle/Entity/HttpCachePurgeMultiCollection/Dummy.php b/tests/Fixtures/TestBundle/Entity/HttpCachePurgeMultiCollection/Dummy.php new file mode 100644 index 00000000000..32b52d42b0a --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/HttpCachePurgeMultiCollection/Dummy.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\HttpCachePurgeMultiCollection; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[ApiResource( + shortName: 'HttpCachePurgeMultiCollectionDummy', + operations: [ + new Post(uriTemplate: '/http_cache_purge_multi_collection_dummies'), + new GetCollection(uriTemplate: '/http_cache_purge_multi_collection_dummies'), + new GetCollection(uriTemplate: '/http_cache_purge_multi_collection_dummies/featured'), + new Get(uriTemplate: '/http_cache_purge_multi_collection_dummies/{id}'), + new Get(uriTemplate: '/http_cache_purge_multi_collection_dummies/{id}/details'), + new Patch(uriTemplate: '/http_cache_purge_multi_collection_dummies/{id}'), + ] +)] +class Dummy +{ + #[ORM\Id] + #[ORM\Column(type: 'integer')] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column(type: 'string')] + public string $name = ''; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Functional/HttpCachePurgeMultiCollectionTest.php b/tests/Functional/HttpCachePurgeMultiCollectionTest.php new file mode 100644 index 00000000000..1d7043a4920 --- /dev/null +++ b/tests/Functional/HttpCachePurgeMultiCollectionTest.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\NullPurger; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\HttpCachePurgeMultiCollection\Dummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * Regression: when a resource declares several GetCollection operations, the + * HTTP cache purger must invalidate every collection URI, not just the first + * one resolved from a bare `new GetCollection()`. + */ +final class HttpCachePurgeMultiCollectionTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Dummy::class]; + } + + public function testPostPurgesAllGetCollectionUris(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('The Doctrine ORM PurgeHttpCacheListener is not loaded with MongoDB.'); + } + + $this->recreateSchema([Dummy::class]); + + $client = static::createClient(); + $purger = static::getContainer()->get('test.api_platform.http_cache.purger'); + self::assertInstanceOf(NullPurger::class, $purger); + $purger->clear(); + + $client->request('POST', '/http_cache_purge_multi_collection_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'foo'], + ]); + $this->assertResponseStatusCodeSame(201); + + $iris = $purger->getIris(); + self::assertContains('/http_cache_purge_multi_collection_dummies', $iris, 'The default collection URI must be purged.'); + self::assertContains('/http_cache_purge_multi_collection_dummies/featured', $iris, 'The secondary collection URI must also be purged.'); + } + + public function testPatchPurgesAllGetItemUris(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('The Doctrine ORM PurgeHttpCacheListener is not loaded with MongoDB.'); + } + + $this->recreateSchema([Dummy::class]); + + $manager = $this->getManager(); + $dummy = new Dummy(); + $dummy->name = 'before'; + $manager->persist($dummy); + $manager->flush(); + $id = $dummy->getId(); + $manager->clear(); + + $client = static::createClient(); + $purger = static::getContainer()->get('test.api_platform.http_cache.purger'); + self::assertInstanceOf(NullPurger::class, $purger); + $purger->clear(); + + $client->request('PATCH', '/http_cache_purge_multi_collection_dummies/'.$id, [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['name' => 'after'], + ]); + $this->assertResponseStatusCodeSame(200); + + $iris = $purger->getIris(); + self::assertContains('/http_cache_purge_multi_collection_dummies/'.$id, $iris, 'The canonical item URI must be purged.'); + self::assertContains('/http_cache_purge_multi_collection_dummies/'.$id.'/details', $iris, 'The secondary item URI must also be purged.'); + } +} From c96ea7fb2ad620c1719fe8ac0abe8c50576e0087 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 11 May 2026 11:20:00 +0200 Subject: [PATCH 4/4] fix(laravel): handle multiple Get and GetCollection operations in cache invalidation Mirror the Symfony fix on the Eloquent listener: iterate every #[Get] and #[GetCollection] declared on the resource when collecting purge tags. --- .../Eloquent/ApiPlatformEventProvider.php | 4 +- .../Listener/PurgeHttpCacheListener.php | 105 ++++++++++++++---- 2 files changed, 88 insertions(+), 21 deletions(-) diff --git a/src/Laravel/Eloquent/ApiPlatformEventProvider.php b/src/Laravel/Eloquent/ApiPlatformEventProvider.php index c9c7aa23d34..3afe31985b4 100644 --- a/src/Laravel/Eloquent/ApiPlatformEventProvider.php +++ b/src/Laravel/Eloquent/ApiPlatformEventProvider.php @@ -19,6 +19,7 @@ use ApiPlatform\HttpCache\VarnishXKeyPurger; use ApiPlatform\Laravel\Eloquent\Listener\PurgeHttpCacheListener; use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use Illuminate\Contracts\Foundation\Application; use Illuminate\Foundation\Http\Events\RequestHandled; @@ -90,7 +91,8 @@ public function register(): void return new PurgeHttpCacheListener( $app->make(PurgerInterface::class), $app->make(IriConverterInterface::class), - $app->make(ResourceClassResolverInterface::class) + $app->make(ResourceClassResolverInterface::class), + $app->make(ResourceMetadataCollectionFactoryInterface::class), ); }); } diff --git a/src/Laravel/Eloquent/Listener/PurgeHttpCacheListener.php b/src/Laravel/Eloquent/Listener/PurgeHttpCacheListener.php index 2be9aae6a6a..edfa5859740 100644 --- a/src/Laravel/Eloquent/Listener/PurgeHttpCacheListener.php +++ b/src/Laravel/Eloquent/Listener/PurgeHttpCacheListener.php @@ -16,11 +16,22 @@ use ApiPlatform\HttpCache\PurgerInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Exception\ItemNotFoundException; +use ApiPlatform\Metadata\Exception\OperationNotFoundException; +use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; use Illuminate\Database\Eloquent\Model; +/** + * Purges responses containing modified models from the proxy cache. + * + * Sub-resource collection operations (those whose URI depends on parent + * `uriVariables` such as `/parents/{parentId}/children`) are not invalidated + * here: the listener has no parent context available. + */ final class PurgeHttpCacheListener { /** @@ -32,6 +43,7 @@ public function __construct( private readonly PurgerInterface $purger, private readonly IriConverterInterface $iriConverter, private readonly ResourceClassResolverInterface $resourceClassResolver, + private readonly ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ) { } @@ -41,16 +53,7 @@ public function __construct( public function handleModelSaved(string $eventName, array $data): void { foreach ($data as $model) { - if (!$this->resourceClassResolver->isResourceClass($model::class)) { - return; - } - - try { - $this->tags[] = $this->iriConverter->getIriFromResource($model); - $this->tags[] = $this->iriConverter->getIriFromResource($model::class, operation: new GetCollection(class: $model::class)); - } catch (InvalidArgumentException|ItemNotFoundException $e) { - // do nothing - } + $this->collectTagsFor($model); } } @@ -60,16 +63,7 @@ public function handleModelSaved(string $eventName, array $data): void public function handleModelDeleted(string $eventName, array $data): void { foreach ($data as $model) { - if (!$this->resourceClassResolver->isResourceClass($model::class)) { - return; - } - - try { - $this->tags[] = $this->iriConverter->getIriFromResource($model); - $this->tags[] = $this->iriConverter->getIriFromResource($model::class, operation: new GetCollection(class: $model::class)); - } catch (InvalidArgumentException|ItemNotFoundException $e) { - // do nothing - } + $this->collectTagsFor($model); } } @@ -85,4 +79,75 @@ public function postFlush(): void $this->purger->purge(array_values(array_unique($this->tags))); $this->tags = []; } + + private function collectTagsFor(Model $model): void + { + if (!$this->resourceClassResolver->isResourceClass($model::class)) { + return; + } + + foreach ($this->getItemIris($model) as $iri) { + $this->tags[] = $iri; + } + + foreach ($this->getCollectionIris($model) as $iri) { + $this->tags[] = $iri; + } + } + + /** + * @return iterable + */ + private function getItemIris(Model $model): iterable + { + if (!$this->resourceMetadataCollectionFactory) { + try { + yield $this->iriConverter->getIriFromResource($model); + } catch (InvalidArgumentException|ItemNotFoundException|OperationNotFoundException) { + } + + return; + } + + foreach ($this->resourceMetadataCollectionFactory->create($model::class) as $resourceMetadata) { + foreach ($resourceMetadata->getOperations() as $operation) { + if (!$operation instanceof Get) { + continue; + } + try { + yield $this->iriConverter->getIriFromResource($model, UrlGeneratorInterface::ABS_PATH, $operation); + } catch (InvalidArgumentException|ItemNotFoundException|OperationNotFoundException) { + } + } + } + } + + /** + * @return iterable + */ + private function getCollectionIris(Model $model): iterable + { + if (!$this->resourceMetadataCollectionFactory) { + try { + yield $this->iriConverter->getIriFromResource($model::class, operation: new GetCollection(class: $model::class)); + } catch (InvalidArgumentException|ItemNotFoundException|OperationNotFoundException) { + } + + return; + } + + foreach ($this->resourceMetadataCollectionFactory->create($model::class) as $resourceMetadata) { + foreach ($resourceMetadata->getOperations() as $operation) { + if (!$operation instanceof GetCollection) { + continue; + } + try { + yield $this->iriConverter->getIriFromResource($model::class, UrlGeneratorInterface::ABS_PATH, $operation); + } catch (InvalidArgumentException|ItemNotFoundException|OperationNotFoundException) { + // Sub-resource collections (needing parent uri_variables) cannot + // be resolved here and are intentionally skipped. + } + } + } + } }