Skip to content
Merged
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
213 changes: 211 additions & 2 deletions features/http_cache/tag_collector_service.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
}
]
}
}
}
]
}
"""
2 changes: 2 additions & 0 deletions src/Hal/Serializer/ItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'])) {
Expand Down
2 changes: 2 additions & 0 deletions src/JsonApi/Serializer/ItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'])) {
Expand Down
2 changes: 1 addition & 1 deletion src/Serializer/TagCollectorInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ interface TagCollectorInterface
/**
* Collect cache tags for cache invalidation.
*
* @param array<string, mixed>&array{iri?: string, data?: mixed, object?: mixed, property_metadata?: \ApiPlatform\Metadata\ApiProperty, api_attribute?: string, resources?: array<string, string>} $context
* @param array<string, mixed>&array{iri?: string, data?: mixed, object?: mixed, property_metadata?: \ApiPlatform\Metadata\ApiProperty, api_attribute?: string, resources?: array<string, string>, format?: string, operation?: \ApiPlatform\Metadata\Operation} $context
*/
public function collect(array $context = []): void;
}
4 changes: 3 additions & 1 deletion tests/Behat/HttpCacheContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ 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());
/** @phpstan-ignore-next-line */
$iriConverter = $this->driverContainer->get('api_platform.iri_converter');
$this->driverContainer->set('api_platform.http_cache.tag_collector', new TagCollectorCustom($iriConverter));
}

/**
Expand Down
84 changes: 66 additions & 18 deletions tests/Fixtures/TestBundle/HttpCache/TagCollectorCustom.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
}
}