diff --git a/features/http_cache/tag_collector_service.feature b/features/http_cache/tag_collector_service.feature index 74d5c0afc08..70727c20184 100644 --- a/features/http_cache/tag_collector_service.feature +++ b/features/http_cache/tag_collector_service.feature @@ -35,10 +35,83 @@ Feature: Cache invalidation through HTTP Cache tags (custom TagCollector service Then the response status code should be 201 And the header "Cache-Tags" should not exist - Scenario: TagCollector can add cache tags for relations - When I send a "GET" request to "/relation_embedders/2" + Scenario: TagCollector can add cache tags for relations (JSON-LD format) + When I add "Accept" header equal to "application/ld+json" + And I send a "GET" request to "/relation_embedders/2" Then the response status code should be 200 And the header "Cache-Tags" should be equal to "/related_dummies/1#thirdLevel,/related_dummies/1,/RE/2#anotherRelated,/RE/2#related,/RE/2" + And the JSON should be equal to: + """ + { + "@context": "/contexts/RelationEmbedder", + "@id": "/relation_embedders/2", + "@type": "RelationEmbedder", + "krondstadt": "Krondstadt", + "anotherRelated": { + "@id": "/related_dummies/1", + "@type": "https://schema.org/Product", + "symfony": "symfony", + "thirdLevel": null + }, + "related": null + } + """ + + Scenario: TagCollector can add cache tags for relations (HAL format) + When I add "Accept" header equal to "application/hal+json" + And I send a "GET" request to "/relation_embedders/2" + Then the response status code should be 200 + And the header "Cache-Tags" should be equal to "/RE/2,/related_dummies/1,/related_dummies/1#thirdLevel,/RE/2#anotherRelated,/RE/2#related" + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "/relation_embedders/2" + }, + "anotherRelated": { + "href": "/related_dummies/1" + } + }, + "_embedded": { + "anotherRelated": { + "_links": { + "self": { + "href": "/related_dummies/1" + } + }, + "symfony": "symfony" + } + }, + "krondstadt": "Krondstadt" + } + """ + + Scenario: TagCollector can add cache tags for relations (JSONAPI format) + When I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/relation_embedders/2" + Then the response status code should be 200 + And the header "Cache-Tags" should be equal to "/RE/2,/RE/2#anotherRelated,/RE/2#related" + And the JSON should be equal to: + """ + { + "data": { + "id": "/relation_embedders/2", + "type": "RelationEmbedder", + "attributes": { + "krondstadt": "Krondstadt" + }, + "relationships": { + "anotherRelated": { + "data": { + "type": "RelatedDummy", + "id": "/related_dummies/1" + } + } + } + } + } + """ Scenario: Create resource with extraProperties on ApiProperty When I add "Content-Type" header equal to "application/ld+json" @@ -54,3 +127,139 @@ Feature: Cache invalidation through HTTP Cache tags (custom TagCollector service When I send a "GET" request to "/extra_properties_on_properties/1" Then the response status code should be 200 And the header "Cache-Tags" should be equal to "/extra_properties_on_properties/1#overrideRelationTag,/extra_properties_on_properties/1" + + Scenario: Create two Relation2 + When I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/relation2s" with body: + """ + { + } + """ + When I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/relation2s" with body: + """ + { + } + """ + Then the response status code should be 201 + + Scenario: Create a Relation3 with many to many + When I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/relation3s" with body: + """ + { + "relation2s": ["/relation2s/1", "/relation2s/2"] + } + """ + Then the response status code should be 201 + + Scenario: Get a Relation3 (test collection of links; JSON-LD format) + When I add "Accept" header equal to "application/ld+json" + And I send a "GET" request to "/relation3s" + Then the response status code should be 200 + And the header "Cache-Tags" should be equal to "/relation3s/1#relation2s,/relation3s/1,/relation3s" + And the JSON should be equal to: + """ + { + "@context": "/contexts/Relation3", + "@id": "/relation3s", + "@type": "hydra:Collection", + "hydra:totalItems": 1, + "hydra:member": [ + { + "@id": "/relation3s/1", + "@type": "Relation3", + "id": 1, + "relation2s": [ + "/relation2s/1", + "/relation2s/2" + ] + } + ] + } + """ + + Scenario: Get a Relation3 (test collection of links; HAL format) + When I add "Accept" header equal to "application/hal+json" + And I send a "GET" request to "/relation3s" + Then the response status code should be 200 + And the header "Cache-Tags" should be equal to "/relation3s/1,/relation3s/1#relation2s,/relation3s" + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "/relation3s" + }, + "item": [ + { + "href": "/relation3s/1" + } + ] + }, + "totalItems": 1, + "itemsPerPage": 3, + "_embedded": { + "item": [ + { + "_links": { + "self": { + "href": "/relation3s/1" + }, + "relation2s": [ + { + "href": "/relation2s/1" + }, + { + "href": "/relation2s/2" + } + ] + }, + "id": 1 + } + ] + } + } + """ + + Scenario: Get a Relation3 (test collection of links; HAL format) + When I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/relation3s" + Then the response status code should be 200 + And the header "Cache-Tags" should be equal to "/relation3s/1,/relation3s/1#relation2s,/relation3s" + And the JSON should be equal to: + """ + { + "links": { + "self": "/relation3s" + }, + "meta": { + "totalItems": 1, + "itemsPerPage": 3, + "currentPage": 1 + }, + "data": [ + { + "id": "/relation3s/1", + "type": "Relation3", + "attributes": { + "_id": 1 + }, + "relationships": { + "relation2s": { + "data": [ + { + "type": "Relation2", + "id": "/relation2s/1" + }, + { + "type": "Relation2", + "id": "/relation2s/2" + } + ] + } + } + } + ] + } + """ diff --git a/src/Hal/Serializer/ItemNormalizer.php b/src/Hal/Serializer/ItemNormalizer.php index 5c9cfff26cb..642974e3ead 100644 --- a/src/Hal/Serializer/ItemNormalizer.php +++ b/src/Hal/Serializer/ItemNormalizer.php @@ -69,6 +69,8 @@ public function normalize(mixed $object, string $format = null, array $context = $iri = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context); $context['iri'] = $iri; + $context['object'] = $object; + $context['format'] = $format; $context['api_normalize'] = true; if (!isset($context['cache_key'])) { diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index 06181aedcf1..ea3624185f6 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -95,6 +95,8 @@ public function normalize(mixed $object, string $format = null, array $context = $context = $this->initContext($resourceClass, $context); $iri = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context); $context['iri'] = $iri; + $context['object'] = $object; + $context['format'] = $format; $context['api_normalize'] = true; if (!isset($context['cache_key'])) { diff --git a/src/Serializer/TagCollectorInterface.php b/src/Serializer/TagCollectorInterface.php index 9659c0e5d91..40d71f22147 100644 --- a/src/Serializer/TagCollectorInterface.php +++ b/src/Serializer/TagCollectorInterface.php @@ -23,7 +23,7 @@ interface TagCollectorInterface /** * Collect cache tags for cache invalidation. * - * @param array&array{iri?: string, data?: mixed, object?: mixed, property_metadata?: \ApiPlatform\Metadata\ApiProperty, api_attribute?: string, resources?: array} $context + * @param array&array{iri?: string, data?: mixed, object?: mixed, property_metadata?: \ApiPlatform\Metadata\ApiProperty, api_attribute?: string, resources?: array, format?: string} $context */ public function collect(array $context = []): void; } diff --git a/tests/Behat/HttpCacheContext.php b/tests/Behat/HttpCacheContext.php index be22cc55253..bdf0cb86dfe 100644 --- a/tests/Behat/HttpCacheContext.php +++ b/tests/Behat/HttpCacheContext.php @@ -39,7 +39,8 @@ public function __construct(private readonly KernelInterface $kernel, private Co public function registerCustomTagCollector(BeforeScenarioScope $scope): void { $this->disableReboot($scope); - $this->driverContainer->set('api_platform.http_cache.tag_collector', new TagCollectorCustom()); + $iriConverter = $this->driverContainer->get('api_platform.iri_converter'); + $this->driverContainer->set('api_platform.http_cache.tag_collector', new TagCollectorCustom($iriConverter)); } /** diff --git a/tests/Fixtures/TestBundle/HttpCache/TagCollectorCustom.php b/tests/Fixtures/TestBundle/HttpCache/TagCollectorCustom.php index 4b74f01f743..19d1947e4c9 100644 --- a/tests/Fixtures/TestBundle/HttpCache/TagCollectorCustom.php +++ b/tests/Fixtures/TestBundle/HttpCache/TagCollectorCustom.php @@ -14,6 +14,8 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\HttpCache; use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Serializer\TagCollectorInterface; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationEmbedder; @@ -26,41 +28,87 @@ class TagCollectorCustom implements TagCollectorInterface { public const IRI_RELATION_DELIMITER = '#'; + public function __construct(protected IriConverterInterface $iriConverter) + { + } + public function collect(array $context = []): void { - $iri = $context['iri']; - $object = $context['object']; + if (!isset($context['resources'])) { + return; + } + + $iri = $context['iri'] ?? null; + $object = $context['object'] ?? null; - if ($object instanceof RelationEmbedder) { + // Example on using known objects to shorten/simplify the cache tag (e.g. using ID only or using shorter identifiers) + if ($object && $object instanceof RelationEmbedder) { $iri = '/RE/'.$object->id; } + // manually generate IRI, if object is known but IRI is not populated + if (!$iri && $object) { + $iri = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context); + } + + if (!$iri) { + return; + } + if (isset($context['property_metadata'])) { $this->addCacheTagsForRelation($context, $iri, $context['property_metadata']); - } elseif (\is_array($context['data'])) { - $this->addCacheTagForResource($context, $iri); + + return; + } + + // Example on how to not include "link-only" resources + if ($this->isLinkOnly($context)) { + return; } + + $this->addCacheTagForResource($context, $iri); } - private function addCacheTagForResource(array $context, ?string $iri): void + private function addCacheTagForResource(array $context, string $iri): void { - if (isset($context['resources']) && isset($iri)) { - $context['resources'][$iri] = $iri; - } + $context['resources'][$iri] = $iri; } - private function addCacheTagsForRelation(array $context, ?string $iri, ApiProperty $propertyMetadata): void + private function addCacheTagsForRelation(array $context, string $iri, ApiProperty $propertyMetadata): void { - if (isset($context['resources']) && isset($iri)) { - if (isset($propertyMetadata->getExtraProperties()['cacheDependencies'])) { - foreach ($propertyMetadata->getExtraProperties()['cacheDependencies'] as $dependency) { - $cacheTag = $iri.self::IRI_RELATION_DELIMITER.$dependency; - $context['resources'][$cacheTag] = $cacheTag; - } - } else { - $cacheTag = $iri.self::IRI_RELATION_DELIMITER.$context['api_attribute']; + // Example on how extra properties could be used to fine-control cache tag behavior for a specific ApiProperty + if (isset($propertyMetadata->getExtraProperties()['cacheDependencies'])) { + foreach ($propertyMetadata->getExtraProperties()['cacheDependencies'] as $dependency) { + $cacheTag = $iri.self::IRI_RELATION_DELIMITER.$dependency; $context['resources'][$cacheTag] = $cacheTag; } + + return; + } + + $cacheTag = $iri.self::IRI_RELATION_DELIMITER.$context['api_attribute']; + $context['resources'][$cacheTag] = $cacheTag; + } + + /** + * Returns true, if a resource was normalized into a link only + * Returns false, if a resource was normalized into a fully embedded resource. + */ + private function isLinkOnly(array $context): bool + { + $format = $context['format'] ?? null; + $data = $context['data'] ?? null; + + // resource was normalized into JSONAPI link format + if ('jsonapi' === $format && isset($data['data']) && \is_array($data['data']) && array_keys($data['data']) === ['type', 'id']) { + return true; } + + // resource was normalized into a string IRI only + if (\in_array($format, ['jsonld', 'jsonhal'], true) && \is_string($data)) { + return true; + } + + return false; } }