diff --git a/src/HttpCache/PurgeTagProviderInterface.php b/src/HttpCache/PurgeTagProviderInterface.php new file mode 100644 index 00000000000..a284dc808be --- /dev/null +++ b/src/HttpCache/PurgeTagProviderInterface.php @@ -0,0 +1,25 @@ + + * + * 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\HttpCache; + +/** + * Collects extra HTTP cache tags to invalidate for a given entity. + */ +interface PurgeTagProviderInterface +{ + /** + * @return iterable + */ + public function getTagsForResource(object $entity): iterable; +} diff --git a/src/Laravel/Eloquent/ApiPlatformEventProvider.php b/src/Laravel/Eloquent/ApiPlatformEventProvider.php index c9c7aa23d34..f56c2857026 100644 --- a/src/Laravel/Eloquent/ApiPlatformEventProvider.php +++ b/src/Laravel/Eloquent/ApiPlatformEventProvider.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Laravel\Eloquent; use ApiPlatform\HttpCache\PurgerInterface; +use ApiPlatform\HttpCache\PurgeTagProviderInterface; use ApiPlatform\HttpCache\SouinPurger; use ApiPlatform\HttpCache\VarnishPurger; use ApiPlatform\HttpCache\VarnishXKeyPurger; @@ -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->tagged(PurgeTagProviderInterface::class), ); }); } diff --git a/src/Laravel/Eloquent/Listener/PurgeHttpCacheListener.php b/src/Laravel/Eloquent/Listener/PurgeHttpCacheListener.php index 2be9aae6a6a..43be51dc809 100644 --- a/src/Laravel/Eloquent/Listener/PurgeHttpCacheListener.php +++ b/src/Laravel/Eloquent/Listener/PurgeHttpCacheListener.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Laravel\Eloquent\Listener; use ApiPlatform\HttpCache\PurgerInterface; +use ApiPlatform\HttpCache\PurgeTagProviderInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\GetCollection; @@ -28,10 +29,14 @@ final class PurgeHttpCacheListener */ private array $tags = []; + /** + * @param iterable $purgeTagProviders + */ public function __construct( private readonly PurgerInterface $purger, private readonly IriConverterInterface $iriConverter, private readonly ResourceClassResolverInterface $resourceClassResolver, + private readonly iterable $purgeTagProviders = [], ) { } @@ -41,15 +46,19 @@ public function __construct( public function handleModelSaved(string $eventName, array $data): void { foreach ($data as $model) { - if (!$this->resourceClassResolver->isResourceClass($model::class)) { - return; + if ($this->resourceClassResolver->isResourceClass($model::class)) { + 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 + } } - 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 + foreach ($this->purgeTagProviders as $provider) { + foreach ($provider->getTagsForResource($model) as $tag) { + $this->tags[] = $tag; + } } } } @@ -60,15 +69,19 @@ 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; + if ($this->resourceClassResolver->isResourceClass($model::class)) { + 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 + } } - 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 + foreach ($this->purgeTagProviders as $provider) { + foreach ($provider->getTagsForResource($model) as $tag) { + $this->tags[] = $tag; + } } } } diff --git a/src/Laravel/Tests/Unit/Listener/PurgeHttpCacheListenerTest.php b/src/Laravel/Tests/Unit/Listener/PurgeHttpCacheListenerTest.php new file mode 100644 index 00000000000..66410182b39 --- /dev/null +++ b/src/Laravel/Tests/Unit/Listener/PurgeHttpCacheListenerTest.php @@ -0,0 +1,114 @@ + + * + * 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\Laravel\Tests\Unit\Listener; + +use ApiPlatform\HttpCache\PurgerInterface; +use ApiPlatform\HttpCache\PurgeTagProviderInterface; +use ApiPlatform\Laravel\Eloquent\Listener\PurgeHttpCacheListener; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use Illuminate\Database\Eloquent\Model; +use PHPUnit\Framework\TestCase; + +class PurgeHttpCacheListenerTest extends TestCase +{ + public function testPurgeTagProviders(): void + { + $model = new class extends Model { + }; + + $purger = $this->createMock(PurgerInterface::class); + $purger->expects($this->once()) + ->method('purge') + ->with(['/models/1', '/models', '/parents/42/children']); + + $iriConverter = $this->createStub(IriConverterInterface::class); + $iriConverter->method('getIriFromResource') + ->willReturnCallback(static function (object|string $resource, int $referenceType = 0, ?object $operation = null): string { + if ($operation instanceof GetCollection) { + return '/models'; + } + + return '/models/1'; + }); + + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturn(true); + + $provider = $this->createMock(PurgeTagProviderInterface::class); + $provider->expects($this->once()) + ->method('getTagsForResource') + ->with($model) + ->willReturn(['/parents/42/children']); + + $listener = new PurgeHttpCacheListener($purger, $iriConverter, $resourceClassResolver, [$provider]); + $listener->handleModelSaved('eloquent.saved: '.$model::class, [$model]); + $listener->postFlush(); + } + + public function testPurgeTagProvidersOnDelete(): void + { + $model = new class extends Model { + }; + + $purger = $this->createMock(PurgerInterface::class); + $purger->expects($this->once()) + ->method('purge') + ->with(['/models/1', '/models', '/parents/42/children']); + + $iriConverter = $this->createStub(IriConverterInterface::class); + $iriConverter->method('getIriFromResource') + ->willReturnCallback(static function (object|string $resource, int $referenceType = 0, ?object $operation = null): string { + if ($operation instanceof GetCollection) { + return '/models'; + } + + return '/models/1'; + }); + + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturn(true); + + $provider = $this->createMock(PurgeTagProviderInterface::class); + $provider->expects($this->once()) + ->method('getTagsForResource') + ->with($model) + ->willReturn(['/parents/42/children']); + + $listener = new PurgeHttpCacheListener($purger, $iriConverter, $resourceClassResolver, [$provider]); + $listener->handleModelDeleted('eloquent.deleted: '.$model::class, [$model]); + $listener->postFlush(); + } + + public function testNoTagsWhenNoProviders(): void + { + $model = new class extends Model { + }; + + $purger = $this->createMock(PurgerInterface::class); + $purger->expects($this->never())->method('purge'); + + $iriConverter = $this->createStub(IriConverterInterface::class); + $iriConverter->method('getIriFromResource')->willThrowException(new InvalidArgumentException()); + + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturn(true); + + $listener = new PurgeHttpCacheListener($purger, $iriConverter, $resourceClassResolver); + $listener->handleModelSaved('eloquent.saved: '.$model::class, [$model]); + $listener->postFlush(); + } +} diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 0164d273aa6..d4806cd1b30 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -30,6 +30,7 @@ use ApiPlatform\GraphQl\Resolver\QueryCollectionResolverInterface; use ApiPlatform\GraphQl\Resolver\QueryItemResolverInterface; use ApiPlatform\GraphQl\Type\Definition\TypeInterface as GraphQlTypeInterface; +use ApiPlatform\HttpCache\PurgeTagProviderInterface; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\AsOperationMutator; use ApiPlatform\Metadata\AsResourceMutator; @@ -228,6 +229,8 @@ public function load(array $configs, ContainerBuilder $container): void ->addTag('api_platform.uri_variables.transformer'); $container->registerForAutoconfiguration(ParameterProviderInterface::class) ->addTag('api_platform.parameter_provider'); + $container->registerForAutoconfiguration(PurgeTagProviderInterface::class) + ->addTag('api_platform.http_cache.purge_tag_provider'); $container->registerAttributeForAutoconfiguration( AsResourceMutator::class, 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..e281cc0aea1 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(), + tagged_iterator('api_platform.http_cache.purge_tag_provider'), ]) ->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..7da6072d8cc 100644 --- a/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php +++ b/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Symfony\Doctrine\EventListener; use ApiPlatform\HttpCache\PurgerInterface; +use ApiPlatform\HttpCache\PurgeTagProviderInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Exception\OperationNotFoundException; use ApiPlatform\Metadata\GetCollection; @@ -45,12 +46,16 @@ final class PurgeHttpCacheListener private array $scheduledInsertions = []; + /** + * @param iterable $purgeTagProviders + */ public function __construct(private readonly PurgerInterface $purger, private readonly IriConverterInterface $iriConverter, private readonly ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, private readonly ?ObjectMapperInterface $objectMapper = null, - private readonly ?ObjectMapperMetadataFactoryInterface $objectMapperMetadata = null) + private readonly ?ObjectMapperMetadataFactoryInterface $objectMapperMetadata = null, + private readonly iterable $purgeTagProviders = []) { $this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor(); } @@ -137,6 +142,12 @@ private function gatherResourceAndItemTags(object $entity, bool $purgeItem): voi } catch (OperationNotFoundException|InvalidArgumentException) { } } + + foreach ($this->purgeTagProviders as $provider) { + foreach ($provider->getTagsForResource($entity) as $tag) { + $this->tags[$tag] = $tag; + } + } } private function gatherRelationTags(EntityManagerInterface $em, object $entity): void diff --git a/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php b/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php index 54bdfa6a2c8..0e387f87cb3 100644 --- a/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php +++ b/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php @@ -14,10 +14,12 @@ namespace ApiPlatform\Symfony\Tests\Doctrine\EventListener; use ApiPlatform\HttpCache\PurgerInterface; +use ApiPlatform\HttpCache\PurgeTagProviderInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Symfony\Doctrine\EventListener\PurgeHttpCacheListener; @@ -270,6 +272,58 @@ public function testAddTagsForCollection(): void $listener->postFlush(); } + public function testPurgeTagProviders(): void + { + if (!interface_exists(PurgeTagProviderInterface::class)) { + $this->markTestSkipped('PurgeTagProviderInterface not available in installed api-platform/http-cache version.'); + } + + $dummy = new Dummy(); + $dummy->setId(1); + + $purger = $this->createMock(PurgerInterface::class); + $purger->expects($this->once()) + ->method('purge') + ->with(['/dummies', '/dummies/1', '/parents/42/children']); + + $iriConverter = $this->createStub(IriConverterInterface::class); + $iriConverter->method('getIriFromResource') + ->willReturnCallback(static function (object|string $resource, int $referenceType = UrlGeneratorInterface::ABS_PATH, ?Operation $operation = null, array $context = []): string { + if ($operation instanceof GetCollection) { + return '/dummies'; + } + + return '/dummies/1'; + }); + + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturn(true); + + $classMetadata = new ClassMetadata(Dummy::class); + $classMetadata->associationMappings = []; + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('getClassMetadata')->willReturn($classMetadata); + + $changeSet = []; + $eventArgs = new PreUpdateEventArgs($dummy, $em, $changeSet); + + $provider = $this->createMock(PurgeTagProviderInterface::class); + $provider->expects($this->once()) + ->method('getTagsForResource') + ->with($dummy) + ->willReturn(['/parents/42/children']); + + $listener = new PurgeHttpCacheListener( + $purger, + $iriConverter, + $resourceClassResolver, + purgeTagProviders: [$provider], + ); + $listener->preUpdate($eventArgs); + $listener->postFlush(); + } + public function testMappedResources(): void { $mappedEntity = new MappedEntity();