Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/HttpCache/PurgeTagProviderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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<string>
*/
public function getTagsForResource(object $entity): iterable;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this method better receive both the old entity (before an update) and the new entity (after an update)? In the example provided in the initial issue, it will otherwise be difficult to detect when a child changes it's parent and hence 2 separate tags need to be purged?
Also providing old entity and new entity would provide an easy method for the provider to detect if the method was called from an insertion or deletion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the feedback, you're right that the current signature is insufficient for the parent-change use case.

When a Child changes its parent, two tags need to be purged: /parents/{old_id}/children and /parents/{new_id}/children. With the current getTagsForResource(object $entity), the implementor only receives the entity in its new state and has no way to compute the old parent's tag.

The fix I have in mind is to change the signature to:

public function getTagsForResource(object $entity, ?object $previousEntity = null): iterable;

Where previousEntity is null on insertion and populated on update/deletion. This is BC-safe since the second parameter is nullable with a default value.

The key architectural point is where this is called. The current implementation calls providers from inside PurgeHttpCacheListener (a Doctrine ORM event listener). At that layer there is no previous_data — Doctrine's changeset only exposes scalar/identifier diffs, not a fully hydrated snapshot of the previous entity.

API Platform already solves this at a higher level: ReadProvider clones the entity before deserialization and stores it in $context['previous_data']. This snapshot is exactly what we need. The right place to call the providers is therefore the processor layer (e.g. PersistProcessor or a dedicated purge processor), where $context['previous_data'] is naturally available for PUT/PATCH operations and null for POST.

I can rework the PR to move the provider calls to that layer and update the interface accordingly. @soyuka what do you think?

}
4 changes: 3 additions & 1 deletion src/Laravel/Eloquent/ApiPlatformEventProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
);
});
}
Expand Down
41 changes: 27 additions & 14 deletions src/Laravel/Eloquent/Listener/PurgeHttpCacheListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,10 +29,14 @@ final class PurgeHttpCacheListener
*/
private array $tags = [];

/**
* @param iterable<PurgeTagProviderInterface> $purgeTagProviders
*/
public function __construct(
private readonly PurgerInterface $purger,
private readonly IriConverterInterface $iriConverter,
private readonly ResourceClassResolverInterface $resourceClassResolver,
private readonly iterable $purgeTagProviders = [],
) {
}

Expand All @@ -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;
}
}
}
}
Expand All @@ -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;
}
}
}
}
Expand Down
114 changes: 114 additions & 0 deletions src/Laravel/Tests/Unit/Listener/PurgeHttpCacheListenerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down
13 changes: 12 additions & 1 deletion src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -45,12 +46,16 @@ final class PurgeHttpCacheListener

private array $scheduledInsertions = [];

/**
* @param iterable<PurgeTagProviderInterface> $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();
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Loading