diff --git a/src/HttpCache/PurgeTagProviderInterface.php b/src/HttpCache/PurgeTagProviderInterface.php new file mode 100644 index 00000000000..5903e7ddc90 --- /dev/null +++ b/src/HttpCache/PurgeTagProviderInterface.php @@ -0,0 +1,35 @@ + + * + * 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 resource. + */ +interface PurgeTagProviderInterface +{ + /** + * @return iterable + */ + public function getTagsForInsert(object $resource): iterable; + + /** + * @return iterable + */ + public function getTagsForUpdate(object $resource, object $previousResource): iterable; + + /** + * @return iterable + */ + public function getTagsForDelete(object $resource): iterable; +} diff --git a/src/HttpCache/State/PurgeTagsProcessor.php b/src/HttpCache/State/PurgeTagsProcessor.php new file mode 100644 index 00000000000..cf9cf12109c --- /dev/null +++ b/src/HttpCache/State/PurgeTagsProcessor.php @@ -0,0 +1,65 @@ + + * + * 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\State; + +use ApiPlatform\HttpCache\PurgerInterface; +use ApiPlatform\HttpCache\PurgeTagProviderInterface; +use ApiPlatform\Metadata\DeleteOperationInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; + +final class PurgeTagsProcessor implements ProcessorInterface +{ + /** + * @param iterable $providers + */ + public function __construct( + private readonly ProcessorInterface $decorated, + private readonly PurgerInterface $purger, + private readonly iterable $providers = [], + ) { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + $isDelete = $operation instanceof DeleteOperationInterface; + $previousData = $context['previous_data'] ?? null; + $resourceForDelete = $isDelete && \is_object($data) ? $data : null; + + $result = $this->decorated->process($data, $operation, $uriVariables, $context); + + $tags = []; + foreach ($this->providers as $provider) { + if ($isDelete && null !== $resourceForDelete) { + foreach ($provider->getTagsForDelete($resourceForDelete) as $tag) { + $tags[$tag] = $tag; + } + } elseif (\is_object($previousData) && \is_object($result)) { + foreach ($provider->getTagsForUpdate($result, $previousData) as $tag) { + $tags[$tag] = $tag; + } + } elseif (null === $previousData && \is_object($result)) { + foreach ($provider->getTagsForInsert($result) as $tag) { + $tags[$tag] = $tag; + } + } + } + + if ($tags) { + $this->purger->purge(array_values($tags)); + } + + return $result; + } +} diff --git a/src/HttpCache/Tests/State/PurgeTagsProcessorTest.php b/src/HttpCache/Tests/State/PurgeTagsProcessorTest.php new file mode 100644 index 00000000000..e9264aab165 --- /dev/null +++ b/src/HttpCache/Tests/State/PurgeTagsProcessorTest.php @@ -0,0 +1,156 @@ + + * + * 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\Tests\State; + +use ApiPlatform\HttpCache\PurgerInterface; +use ApiPlatform\HttpCache\PurgeTagProviderInterface; +use ApiPlatform\HttpCache\State\PurgeTagsProcessor; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; +use ApiPlatform\State\ProcessorInterface; +use PHPUnit\Framework\TestCase; + +class PurgeTagsProcessorTest extends TestCase +{ + public function testCallsGetTagsForInsertOnPost(): void + { + $resource = new \stdClass(); + + $decorated = $this->createStub(ProcessorInterface::class); + $decorated->method('process')->willReturn($resource); + + $provider = $this->createMock(PurgeTagProviderInterface::class); + $provider->expects($this->once())->method('getTagsForInsert')->with($resource)->willReturn(['/parents/1/children']); + $provider->expects($this->never())->method('getTagsForUpdate'); + $provider->expects($this->never())->method('getTagsForDelete'); + + $purger = $this->createMock(PurgerInterface::class); + $purger->expects($this->once())->method('purge')->with(['/parents/1/children']); + + $processor = new PurgeTagsProcessor($decorated, $purger, [$provider]); + $processor->process($resource, new Post(), [], []); + } + + public function testCallsGetTagsForUpdateOnPut(): void + { + $resource = new \stdClass(); + $previousResource = new \stdClass(); + + $decorated = $this->createStub(ProcessorInterface::class); + $decorated->method('process')->willReturn($resource); + + $provider = $this->createMock(PurgeTagProviderInterface::class); + $provider->expects($this->never())->method('getTagsForInsert'); + $provider->expects($this->once())->method('getTagsForUpdate')->with($resource, $previousResource)->willReturn(['/parents/1/children', '/parents/2/children']); + $provider->expects($this->never())->method('getTagsForDelete'); + + $purger = $this->createMock(PurgerInterface::class); + $purger->expects($this->once())->method('purge')->with(['/parents/1/children', '/parents/2/children']); + + $processor = new PurgeTagsProcessor($decorated, $purger, [$provider]); + $processor->process($resource, new Put(), [], ['previous_data' => $previousResource]); + } + + public function testCallsGetTagsForUpdateOnPatch(): void + { + $resource = new \stdClass(); + $previousResource = new \stdClass(); + + $decorated = $this->createStub(ProcessorInterface::class); + $decorated->method('process')->willReturn($resource); + + $provider = $this->createMock(PurgeTagProviderInterface::class); + $provider->expects($this->once())->method('getTagsForUpdate')->with($resource, $previousResource)->willReturn(['/parents/1/children']); + $provider->expects($this->never())->method('getTagsForInsert'); + $provider->expects($this->never())->method('getTagsForDelete'); + + $purger = $this->createMock(PurgerInterface::class); + $purger->expects($this->once())->method('purge')->with(['/parents/1/children']); + + $processor = new PurgeTagsProcessor($decorated, $purger, [$provider]); + $processor->process($resource, new Patch(), [], ['previous_data' => $previousResource]); + } + + public function testCallsGetTagsForDeleteOnDelete(): void + { + $resource = new \stdClass(); + + $decorated = $this->createStub(ProcessorInterface::class); + $decorated->method('process')->willReturn(null); + + $provider = $this->createMock(PurgeTagProviderInterface::class); + $provider->expects($this->never())->method('getTagsForInsert'); + $provider->expects($this->never())->method('getTagsForUpdate'); + $provider->expects($this->once())->method('getTagsForDelete')->with($resource)->willReturn(['/parents/1/children']); + + $purger = $this->createMock(PurgerInterface::class); + $purger->expects($this->once())->method('purge')->with(['/parents/1/children']); + + $processor = new PurgeTagsProcessor($decorated, $purger, [$provider]); + $processor->process($resource, new Delete(), [], []); + } + + public function testNoPurgeWhenNoTags(): void + { + $resource = new \stdClass(); + + $decorated = $this->createStub(ProcessorInterface::class); + $decorated->method('process')->willReturn($resource); + + $provider = $this->createStub(PurgeTagProviderInterface::class); + $provider->method('getTagsForInsert')->willReturn([]); + + $purger = $this->createMock(PurgerInterface::class); + $purger->expects($this->never())->method('purge'); + + $processor = new PurgeTagsProcessor($decorated, $purger, [$provider]); + $processor->process($resource, new Post(), [], []); + } + + public function testDeduplicatesTags(): void + { + $resource = new \stdClass(); + + $decorated = $this->createStub(ProcessorInterface::class); + $decorated->method('process')->willReturn($resource); + + $provider1 = $this->createStub(PurgeTagProviderInterface::class); + $provider1->method('getTagsForInsert')->willReturn(['/parents/1/children']); + + $provider2 = $this->createStub(PurgeTagProviderInterface::class); + $provider2->method('getTagsForInsert')->willReturn(['/parents/1/children', '/parents/2/children']); + + $purger = $this->createMock(PurgerInterface::class); + $purger->expects($this->once())->method('purge')->with(['/parents/1/children', '/parents/2/children']); + + $processor = new PurgeTagsProcessor($decorated, $purger, [$provider1, $provider2]); + $processor->process($resource, new Post(), [], []); + } + + public function testNoPurgeWhenNoProviders(): void + { + $resource = new \stdClass(); + + $decorated = $this->createStub(ProcessorInterface::class); + $decorated->method('process')->willReturn($resource); + + $purger = $this->createMock(PurgerInterface::class); + $purger->expects($this->never())->method('purge'); + + $processor = new PurgeTagsProcessor($decorated, $purger, []); + $processor->process($resource, new Post(), [], []); + } +} diff --git a/src/Laravel/ApiPlatformDeferredProvider.php b/src/Laravel/ApiPlatformDeferredProvider.php index b8e263b4a62..0f5efec24e2 100644 --- a/src/Laravel/ApiPlatformDeferredProvider.php +++ b/src/Laravel/ApiPlatformDeferredProvider.php @@ -20,6 +20,9 @@ use ApiPlatform\GraphQl\Type\FieldsBuilderEnumInterface; use ApiPlatform\GraphQl\Type\TypeConverterInterface; use ApiPlatform\GraphQl\Type\TypesContainerInterface; +use ApiPlatform\HttpCache\PurgerInterface; +use ApiPlatform\HttpCache\PurgeTagProviderInterface; +use ApiPlatform\HttpCache\State\PurgeTagsProcessor; use ApiPlatform\JsonApi\Filter\SparseFieldset; use ApiPlatform\JsonApi\Filter\SparseFieldsetParameterProvider; use ApiPlatform\Laravel\Controller\ApiPlatformController; @@ -190,11 +193,25 @@ public function register(): void $tagged['api_platform.swagger_ui.processor'] = $app->make(SwaggerUiProcessor::class); } + if (interface_exists(PurgerInterface::class) && $app->bound(PurgerInterface::class)) { + $purger = $app->make(PurgerInterface::class); + $providers = iterator_to_array($app->tagged(PurgeTagProviderInterface::class)); + foreach ($tagged as $processor) { + if ($processor instanceof PersistProcessor || $processor instanceof RemoveProcessor) { + $tagged[$processor::class] = new PurgeTagsProcessor($processor, $purger, $providers); + } + } + } + return new CallableProcessor(new ServiceLocator($tagged)); }); $this->autoconfigure($classes, ProcessorInterface::class, [RemoveProcessor::class, PersistProcessor::class]); + if (interface_exists(PurgeTagProviderInterface::class)) { + $this->autoconfigure($classes, PurgeTagProviderInterface::class, []); + } + $this->app->singleton(CallableProvider::class, static function (Application $app) { $tagged = iterator_to_array($app->tagged(ProviderInterface::class)); diff --git a/src/Laravel/Eloquent/ApiPlatformEventProvider.php b/src/Laravel/Eloquent/ApiPlatformEventProvider.php index c9c7aa23d34..957a876de28 100644 --- a/src/Laravel/Eloquent/ApiPlatformEventProvider.php +++ b/src/Laravel/Eloquent/ApiPlatformEventProvider.php @@ -90,7 +90,7 @@ public function register(): void return new PurgeHttpCacheListener( $app->make(PurgerInterface::class), $app->make(IriConverterInterface::class), - $app->make(ResourceClassResolverInterface::class) + $app->make(ResourceClassResolverInterface::class), ); }); } diff --git a/src/Laravel/Tests/MockPurgeTagProvider.php b/src/Laravel/Tests/MockPurgeTagProvider.php new file mode 100644 index 00000000000..aee1d277879 --- /dev/null +++ b/src/Laravel/Tests/MockPurgeTagProvider.php @@ -0,0 +1,34 @@ + + * + * 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; + +use ApiPlatform\HttpCache\PurgeTagProviderInterface; + +class MockPurgeTagProvider implements PurgeTagProviderInterface +{ + public function getTagsForInsert(object $resource): iterable + { + return ['provider_insert']; + } + + public function getTagsForUpdate(object $resource, object $previousResource): iterable + { + return ['provider_update']; + } + + public function getTagsForDelete(object $resource): iterable + { + return ['provider_delete']; + } +} diff --git a/src/Laravel/Tests/PurgeTagProviderTest.php b/src/Laravel/Tests/PurgeTagProviderTest.php new file mode 100644 index 00000000000..c80552e9437 --- /dev/null +++ b/src/Laravel/Tests/PurgeTagProviderTest.php @@ -0,0 +1,104 @@ + + * + * 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; + +use ApiPlatform\HttpCache\PurgeTagProviderInterface; +use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; +use Workbench\App\Models\Author; +use Workbench\App\Models\Book; +use Workbench\App\Purger\MockPurger; +use Workbench\Database\Factories\AuthorFactory; +use Workbench\Database\Factories\BookFactory; + +class PurgeTagProviderTest extends TestCase +{ + use ApiTestAssertionsTrait; + use RefreshDatabase; + use WithWorkbench; + + protected function getEnvironmentSetUp($app): void + { + $app['config']->set('api-platform.http_cache.invalidation.purger', MockPurger::class); + $app->tag([MockPurgeTagProvider::class], PurgeTagProviderInterface::class); + } + + protected function setUp(): void + { + parent::setUp(); + MockPurger::reset(); + } + + public function testProviderTagsOnCreate(): void + { + AuthorFactory::new()->create(); + $author = Author::first(); + + $r = $this->postJson('/api/books', [ + 'isbn' => '9783161484100', + 'name' => 'The Test Book', + 'author' => '/api/authors/'.$author->id, + ], ['Accept' => 'application/ld+json', 'content-type' => 'application/ld+json']); + + $this->assertTagsWerePurged([ + $r->json()['@id'], + '/api/books', + 'provider_insert', + ]); + } + + public function testProviderTagsOnUpdate(): void + { + BookFactory::new()->has(AuthorFactory::new())->create(); + $book = Book::first(); + + $this->patchJson('/api/books/'.$book->id, [ + 'name' => 'An Updated Name', + ], [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/merge-patch+json', + ]); + + $this->assertTagsWerePurged([ + '/api/books', + '/api/books/'.$book->id, + 'provider_update', + ]); + } + + public function testProviderTagsOnDelete(): void + { + BookFactory::new()->has(AuthorFactory::new())->create(); + $book = Book::first(); + + $this->delete('/api/books/'.$book->id, headers: ['accept' => 'application/ld+json']); + + $this->assertTagsWerePurged([ + '/api/books', + '/api/books/'.$book->id, + 'provider_delete', + ]); + } + + /** + * @param string[] $expectedTags + */ + private function assertTagsWerePurged(array $expectedTags): void + { + sort($expectedTags); + $this->assertEquals($expectedTags, MockPurger::getPurgedTags()); + } +} diff --git a/src/Laravel/Tests/Unit/Listener/PurgeHttpCacheListenerTest.php b/src/Laravel/Tests/Unit/Listener/PurgeHttpCacheListenerTest.php new file mode 100644 index 00000000000..9e3ae9a8eb3 --- /dev/null +++ b/src/Laravel/Tests/Unit/Listener/PurgeHttpCacheListenerTest.php @@ -0,0 +1,101 @@ + + * + * 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\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 testHandleModelSaved(): void + { + $model = new class extends Model { + }; + + $purger = $this->createMock(PurgerInterface::class); + $purger->expects($this->once()) + ->method('purge') + ->with(['/models/1', '/models']); + + $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); + + $listener = new PurgeHttpCacheListener($purger, $iriConverter, $resourceClassResolver); + $listener->handleModelSaved('eloquent.saved: '.$model::class, [$model]); + $listener->postFlush(); + } + + public function testHandleModelDeleted(): void + { + $model = new class extends Model { + }; + + $purger = $this->createMock(PurgerInterface::class); + $purger->expects($this->once()) + ->method('purge') + ->with(['/models/1', '/models']); + + $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); + + $listener = new PurgeHttpCacheListener($purger, $iriConverter, $resourceClassResolver); + $listener->handleModelDeleted('eloquent.deleted: '.$model::class, [$model]); + $listener->postFlush(); + } + + public function testNoPurgeWhenIriConversionFails(): 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..80a4806ba02 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 @@ -13,6 +13,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use ApiPlatform\HttpCache\State\PurgeTagsProcessor; use ApiPlatform\Symfony\Doctrine\EventListener\PurgeHttpCacheListener; return static function (ContainerConfigurator $container) { @@ -30,4 +31,20 @@ ->tag('doctrine.event_listener', ['event' => 'preUpdate']) ->tag('doctrine.event_listener', ['event' => 'onFlush']) ->tag('doctrine.event_listener', ['event' => 'postFlush']); + + $services->set('api_platform.http_cache.purge_tags.persist_processor', PurgeTagsProcessor::class) + ->decorate('api_platform.doctrine.orm.state.persist_processor') + ->args([ + service('api_platform.http_cache.purge_tags.persist_processor.inner'), + service('api_platform.http_cache.purger'), + tagged_iterator('api_platform.http_cache.purge_tag_provider'), + ]); + + $services->set('api_platform.http_cache.purge_tags.remove_processor', PurgeTagsProcessor::class) + ->decorate('api_platform.doctrine.orm.state.remove_processor') + ->args([ + service('api_platform.http_cache.purge_tags.remove_processor.inner'), + service('api_platform.http_cache.purger'), + tagged_iterator('api_platform.http_cache.purge_tag_provider'), + ]); };