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. + } + } + } + } } 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..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 @@ -26,6 +26,7 @@ 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 12566a3bd2c..5dd63b19a5c 100644 --- a/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php +++ b/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php @@ -16,8 +16,10 @@ 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; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; @@ -35,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 @@ -50,7 +57,8 @@ public function __construct(private readonly PurgerInterface $purger, private readonly ResourceClassResolverInterface $resourceClassResolver, ?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(); } @@ -127,15 +135,72 @@ private function gatherResourceAndItemTags(object $entity, bool $purgeItem): voi $resources = $this->getResourcesForEntity($entity); foreach ($resources as $resource) { - try { - $iri = $this->iriConverter->getIriFromResource($resource, UrlGeneratorInterface::ABS_PATH, new GetCollection()); + 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 { + yield $this->iriConverter->getIriFromResource($resource, UrlGeneratorInterface::ABS_PATH, new GetCollection()); + } catch (OperationNotFoundException|InvalidArgumentException) { + } - if ($purgeItem) { - $this->addTagForItem($entity); + return; + } + + $resourceClass = $this->resourceClassResolver->getResourceClass($resource); + foreach ($this->resourceMetadataCollectionFactory->create($resourceClass) as $resourceMetadata) { + foreach ($resourceMetadata->getOperations() as $operation) { + if (!$operation instanceof GetCollection) { + continue; + } + 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. } + } + } + } + + /** + * @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) { + } + } } } @@ -202,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 54bdfa6a2c8..c985a5f42c0 100644 --- a/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php +++ b/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php @@ -14,10 +14,14 @@ 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\Get; 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 +72,33 @@ 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($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(Dummy::class), UrlGeneratorInterface::ABS_PATH, Argument::type(GetCollection::class))->willReturn('/dummies')->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()); $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(), + new Get(), + ]), + ])); + $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 +123,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(), $propertyAccessorProphecy->reveal(), null, null, + $resourceMetadataCollectionFactoryProphecy->reveal(), ); + $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 @@ -125,14 +149,33 @@ 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(), + ]), + ])); + $emProphecy = $this->prophesize(EntityManagerInterface::class); $classMetadata = new ClassMetadata(Dummy::class); @@ -142,7 +185,9 @@ 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(), null, null, null, + $resourceMetadataCollectionFactoryProphecy->reveal()); $listener->preUpdate($eventArgs); $listener->postFlush(); } @@ -161,7 +206,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 +216,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(), + null, null, null, $resourceMetadataCollectionFactoryProphecy->reveal()); $listener->preUpdate($eventArgs); $listener->postFlush(); } @@ -187,12 +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(); @@ -217,8 +271,19 @@ 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(), + ]), + ])); + $resourceMetadataCollectionFactoryProphecy + ->create(NotAResource::class)->willReturn(new ResourceMetadataCollection(NotAResource::class, [])); + $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal()); + $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, null, $resourceMetadataCollectionFactoryProphecy->reveal(), ); $listener->onFlush($eventArgs); $listener->postFlush(); } @@ -235,12 +300,13 @@ 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(); + $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class)->shouldBeCalled(); $dummyWithCollection = new Dummy(); $dummyWithCollection->setId(3); @@ -265,7 +331,87 @@ 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(), + new Get(), + ]), + ])); + $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), null, null, + $resourceMetadataCollectionFactoryProphecy->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(), $propertyAccessorProphecy->reveal(), null, null, + $resourceMetadataCollectionFactoryProphecy->reveal(), + ); $listener->onFlush($eventArgs); $listener->postFlush(); } @@ -295,6 +441,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,9 +460,17 @@ 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(), - $objectMapperProphecy->reveal() + $objectMapperProphecy->reveal(), null, $resourceMetadataCollectionFactoryProphecy->reveal() ); $listener->onFlush($eventArgs); $listener->postFlush(); 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.'); + } +}