feat(httpcache): introduce PurgeTagProviderInterface extension point#7970
feat(httpcache): introduce PurgeTagProviderInterface extension point#7970guillaumedelre wants to merge 4 commits into
Conversation
|
The associated documentation PR is available at api-platform/docs#2282. |
851f135 to
daa7ad5
Compare
|
Sorry for the ping, I've made it because it's in the
|
|
Sorry for the noise on this PR. A push to |
PurgeHttpCacheListener cannot invalidate sub-resource collection IRIs
such as /parents/{id}/children because it lacks the parent uri_variables
when processing the child entity. The new PurgeTagProviderInterface
lets users plug in custom tag collection strategies for these cases.
Symfony: implementing PurgeTagProviderInterface is sufficient; the
registerForAutoconfiguration hook tags the service automatically with
api_platform.http_cache.purge_tag_provider. Laravel: bind implementations
and tag them with PurgeTagProviderInterface::class via $app->tag().
Signed-off-by: Guillaume Delré <delre.guillaume@gmail.com>
|
The "PHPUnit (PHP 8.5) (Symfony 8.1)" and "PHPUnit (PHP 8.5) (Symfony dev)" failures are pre-existing and unrelated to this PR. The failing test is |
daa7ad5 to
88bd233
Compare
| /** | ||
| * @return iterable<string> | ||
| */ | ||
| public function getTagsForResource(object $entity): iterable; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Yeah, good point. We currently have an own implementation of the listener, where we use the Doctrine changeset to manually calculate the state of the previous entity. It works well for us, but cannot guarantee it is bulletproof:
https://github.com/ecamp/ecamp3/blob/6bebc1320d7f92bea8ecda743517632556b807a1/api/src/HttpCache/PurgeHttpCacheListener.php#L140C22-L140C39
Regarding the signature, shouldn't $entity also be nullable in case of deletions? In that case the provider could distinguish all 3 cases (updates, deletions, inserts) based on which parameter is null or populated.
There was a problem hiding this comment.
There's an architectural constraint worth surfacing here. The providers are currently called from inside the Doctrine event listener, which operates at the ORM layer — it has no access to API Platform's request context. That means $previousResource cannot be populated on updates from that layer: Doctrine only exposes getEntityChangeSet() (scalar diffs), not a fully hydrated snapshot of the previous entity.
Two options:
Option 1 — keep providers in the listener, add array $context = []
public function getTagsForResource(object $resource, ?object $previousResource = null, array $context = []): iterable;The listener only knows the Doctrine event type, so $context would carry a string at best:
$provider->getTagsForResource($entity, null, ['operation_type' => 'update']);$previousResource would always be null on updates, defeating the purpose.
Option 2 — move provider calls to the processor layer
PersistProcessor has $context['previous_data'] natively (a full hydrated snapshot set by ReadProvider), and the actual API Platform Operation object as a dedicated parameter. Both can be forwarded to the provider:
// POST: ($data, null, ['operation' => $operation, ...])
// PUT: ($data, $context['previous_data'], ['operation' => $operation, ...])
// DELETE: ($data, $context['previous_data'], ['operation' => $operation, ...])This is the only way to get a real $previousResource on updates, plus a typed Operation instance (e.g. Put, Delete) instead of a plain string. The catch: IRI isn't available before flush on inserts (no auto-generated ID yet), which the current listener handles via $scheduledInsertions + postFlush() — a complementary post-flush listener would still be needed for that case.
Option 2 gives the interface you described, at the cost of a broader refactor. Happy to go that route — wanted to flag the tradeoff first. What do you think?
| /** | ||
| * @return iterable<string> | ||
| */ | ||
| public function getTagsForResource(object $entity): iterable; |
There was a problem hiding this comment.
Nitpicking : if it's getTagsForResource, it should be called $resource, not $entity, shouldn't it?
That said I likely would have split methods for different operations, I agree with @usu that you're possibly not invalidating the same tags when inserting / modifying / deleting...but the caller should know which case they're in so why not just let the caller use the correct method?
It would imply some refactoring in the Doctrine event listener, but it would be for the best IMO.
There was a problem hiding this comment.
Agreed, renaming to $resource.
Regarding splitting into separate methods: the single-method approach with a nullable $previousResource is consistent with how the rest of the codebase handles this. ProcessorInterface::process() is a single method regardless of the HTTP operation, and PersistProcessor already uses $context['previous_data'] === null to distinguish INSERT from UPDATE. Splitting into three methods would also require three separate tagged iterables in the DI container, adding complexity without a clear gain here.
There was a problem hiding this comment.
I'm probably missing something, but i don't see how having 3 methods in the PurgeTagProviderInterface would change anything for DI.
How would you differentiate between delete and update if you only rely on previous data? There is previous data in both cases, but you might not want to invalidate the same tags.
There was a problem hiding this comment.
I'm probably missing something, but i don't see how having 3 methods in the PurgeTagProviderInterface would change anything for DI.
You're right, my DI argument was wrong. Apologies.
How would you differentiate between delete and update if you only rely on previous data?
Agreed, and that also pointed to a deeper issue: splitting the calls between the listener and a processor was the wrong architecture. Moving everything to the processor layer is cleaner.
PersistProcessor has both $context['previous_data'] (null on POST, populated on PUT/PATCH) and calls flush() + refresh() before returning, so the auto-generated ID is available before any provider call. RemoveProcessor similarly calls flush() before returning, with $data still in memory. A processor decorator can call all three methods from a single place.
The listener keeps its existing logic (collection IRI, item IRI, relations). The providers, as an extension point, belong in the processor layer where the context is richer.
So the interface becomes:
public function getTagsForInsert(object $resource): iterable;
public function getTagsForUpdate(object $resource, object $previousResource): iterable;
public function getTagsForDelete(object $resource): iterable;Called from a PurgeTagsProcessor decorator that wraps PersistProcessor / RemoveProcessor. I'll rework the PR along these lines.
…ion methods
Replace the single `getTagsForResource()` with three dedicated methods:
`getTagsForInsert()`, `getTagsForUpdate($resource, $previousResource)`, and
`getTagsForDelete()`. Move provider invocation from `PurgeHttpCacheListener`
to a new `PurgeTagsProcessor` decorator that wraps `PersistProcessor` and
`RemoveProcessor`.
The listener layer (Doctrine ORM events) cannot provide a hydrated snapshot of
the previous entity on updates. Moving to the processor layer gives access to
`$context['previous_data']` (set by `ReadProvider` before deserialization),
which is the only reliable source for the pre-update state. This enables the
parent-change invalidation use case (`/parents/{old_id}/children` +
`/parents/{new_id}/children`) that the single-method approach could not handle.
Register PurgeTagsProcessor decorators around PersistProcessor and RemoveProcessor in the CallableProcessor factory. User implementations of PurgeTagProviderInterface are auto-discovered via autoconfiguration.
Summary
Introduces
ApiPlatform\HttpCache\PurgeTagProviderInterfaceto let users invalidate additional HTTP cache tags (e.g. sub-resource collection IRIs like/parents/{id}/children) beyond whatPurgeHttpCacheListenercan resolve on its own.The interface exposes three dedicated methods to distinguish between operations:
Providers are called from a new
PurgeTagsProcessordecorator (wrappingPersistProcessorandRemoveProcessor) rather than from the Doctrine event listener. This gives access to$context['previous_data']— the hydrated snapshot of the entity before update set byReadProvider— which is required to invalidate tags for both the old and new parent on a relationship change.api_platform.http_cache.purge_tag_providerare automatically injected via autoconfigurationPurgeTagProviderInterfaceare automatically discovered and injected via autoconfigurationCloses #7965
Documentation
api-platform/docs#2282