Skip to content

Commit

Permalink
fix(serializer): fix TagCollector for JSONAPI and HAL format
Browse files Browse the repository at this point in the history
  • Loading branch information
usu committed Jan 3, 2024
1 parent 1f250b4 commit 1420adc
Show file tree
Hide file tree
Showing 6 changed files with 284 additions and 22 deletions.
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} $context
*/
public function collect(array $context = []): void;
}
3 changes: 2 additions & 1 deletion tests/Behat/HttpCacheContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Check failure on line 42 in tests/Behat/HttpCacheContext.php

View workflow job for this annotation

GitHub Actions / PHPStan (PHP 8.3)

Service "api_platform.iri_converter" is private.
$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);

Check failure on line 51 in tests/Fixtures/TestBundle/HttpCache/TagCollectorCustom.php

View workflow job for this annotation

GitHub Actions / PHPStan (PHP 8.3)

Offset 'operation' on array{iri?: string, data?: mixed, object?: mixed, property_metadata?: ApiPlatform\Metadata\ApiProperty, api_attribute?: string, resources: array<string, string>, format?: string} on left side of ?? does not exist.
}

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;
}
}

0 comments on commit 1420adc

Please sign in to comment.