Skip to content

Commit ac8a7aa

Browse files
committed
fix(serializer): fix TagCollector for JSONAPI and HAL format
1 parent 1f250b4 commit ac8a7aa

File tree

6 files changed

+285
-22
lines changed

6 files changed

+285
-22
lines changed

features/http_cache/tag_collector_service.feature

Lines changed: 211 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,83 @@ Feature: Cache invalidation through HTTP Cache tags (custom TagCollector service
3535
Then the response status code should be 201
3636
And the header "Cache-Tags" should not exist
3737

38-
Scenario: TagCollector can add cache tags for relations
39-
When I send a "GET" request to "/relation_embedders/2"
38+
Scenario: TagCollector can add cache tags for relations (JSON-LD format)
39+
When I add "Accept" header equal to "application/ld+json"
40+
And I send a "GET" request to "/relation_embedders/2"
4041
Then the response status code should be 200
4142
And the header "Cache-Tags" should be equal to "/related_dummies/1#thirdLevel,/related_dummies/1,/RE/2#anotherRelated,/RE/2#related,/RE/2"
43+
And the JSON should be equal to:
44+
"""
45+
{
46+
"@context": "/contexts/RelationEmbedder",
47+
"@id": "/relation_embedders/2",
48+
"@type": "RelationEmbedder",
49+
"krondstadt": "Krondstadt",
50+
"anotherRelated": {
51+
"@id": "/related_dummies/1",
52+
"@type": "https://schema.org/Product",
53+
"symfony": "symfony",
54+
"thirdLevel": null
55+
},
56+
"related": null
57+
}
58+
"""
59+
60+
Scenario: TagCollector can add cache tags for relations (HAL format)
61+
When I add "Accept" header equal to "application/hal+json"
62+
And I send a "GET" request to "/relation_embedders/2"
63+
Then the response status code should be 200
64+
And the header "Cache-Tags" should be equal to "/RE/2,/related_dummies/1,/related_dummies/1#thirdLevel,/RE/2#anotherRelated,/RE/2#related"
65+
And the JSON should be equal to:
66+
"""
67+
{
68+
"_links": {
69+
"self": {
70+
"href": "/relation_embedders/2"
71+
},
72+
"anotherRelated": {
73+
"href": "/related_dummies/1"
74+
}
75+
},
76+
"_embedded": {
77+
"anotherRelated": {
78+
"_links": {
79+
"self": {
80+
"href": "/related_dummies/1"
81+
}
82+
},
83+
"symfony": "symfony"
84+
}
85+
},
86+
"krondstadt": "Krondstadt"
87+
}
88+
"""
89+
90+
Scenario: TagCollector can add cache tags for relations (JSONAPI format)
91+
When I add "Accept" header equal to "application/vnd.api+json"
92+
And I send a "GET" request to "/relation_embedders/2"
93+
Then the response status code should be 200
94+
And the header "Cache-Tags" should be equal to "/RE/2,/RE/2#anotherRelated,/RE/2#related"
95+
And the JSON should be equal to:
96+
"""
97+
{
98+
"data": {
99+
"id": "/relation_embedders/2",
100+
"type": "RelationEmbedder",
101+
"attributes": {
102+
"krondstadt": "Krondstadt"
103+
},
104+
"relationships": {
105+
"anotherRelated": {
106+
"data": {
107+
"type": "RelatedDummy",
108+
"id": "/related_dummies/1"
109+
}
110+
}
111+
}
112+
}
113+
}
114+
"""
42115

43116
Scenario: Create resource with extraProperties on ApiProperty
44117
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
54127
When I send a "GET" request to "/extra_properties_on_properties/1"
55128
Then the response status code should be 200
56129
And the header "Cache-Tags" should be equal to "/extra_properties_on_properties/1#overrideRelationTag,/extra_properties_on_properties/1"
130+
131+
Scenario: Create two Relation2
132+
When I add "Content-Type" header equal to "application/ld+json"
133+
And I send a "POST" request to "/relation2s" with body:
134+
"""
135+
{
136+
}
137+
"""
138+
When I add "Content-Type" header equal to "application/ld+json"
139+
And I send a "POST" request to "/relation2s" with body:
140+
"""
141+
{
142+
}
143+
"""
144+
Then the response status code should be 201
145+
146+
Scenario: Create a Relation3 with many to many
147+
When I add "Content-Type" header equal to "application/ld+json"
148+
And I send a "POST" request to "/relation3s" with body:
149+
"""
150+
{
151+
"relation2s": ["/relation2s/1", "/relation2s/2"]
152+
}
153+
"""
154+
Then the response status code should be 201
155+
156+
Scenario: Get a Relation3 (test collection of links; JSON-LD format)
157+
When I add "Accept" header equal to "application/ld+json"
158+
And I send a "GET" request to "/relation3s"
159+
Then the response status code should be 200
160+
And the header "Cache-Tags" should be equal to "/relation3s/1#relation2s,/relation3s/1,/relation3s"
161+
And the JSON should be equal to:
162+
"""
163+
{
164+
"@context": "/contexts/Relation3",
165+
"@id": "/relation3s",
166+
"@type": "hydra:Collection",
167+
"hydra:totalItems": 1,
168+
"hydra:member": [
169+
{
170+
"@id": "/relation3s/1",
171+
"@type": "Relation3",
172+
"id": 1,
173+
"relation2s": [
174+
"/relation2s/1",
175+
"/relation2s/2"
176+
]
177+
}
178+
]
179+
}
180+
"""
181+
182+
Scenario: Get a Relation3 (test collection of links; HAL format)
183+
When I add "Accept" header equal to "application/hal+json"
184+
And I send a "GET" request to "/relation3s"
185+
Then the response status code should be 200
186+
And the header "Cache-Tags" should be equal to "/relation3s/1,/relation3s/1#relation2s,/relation3s"
187+
And the JSON should be equal to:
188+
"""
189+
{
190+
"_links": {
191+
"self": {
192+
"href": "/relation3s"
193+
},
194+
"item": [
195+
{
196+
"href": "/relation3s/1"
197+
}
198+
]
199+
},
200+
"totalItems": 1,
201+
"itemsPerPage": 3,
202+
"_embedded": {
203+
"item": [
204+
{
205+
"_links": {
206+
"self": {
207+
"href": "/relation3s/1"
208+
},
209+
"relation2s": [
210+
{
211+
"href": "/relation2s/1"
212+
},
213+
{
214+
"href": "/relation2s/2"
215+
}
216+
]
217+
},
218+
"id": 1
219+
}
220+
]
221+
}
222+
}
223+
"""
224+
225+
Scenario: Get a Relation3 (test collection of links; HAL format)
226+
When I add "Accept" header equal to "application/vnd.api+json"
227+
And I send a "GET" request to "/relation3s"
228+
Then the response status code should be 200
229+
And the header "Cache-Tags" should be equal to "/relation3s/1,/relation3s/1#relation2s,/relation3s"
230+
And the JSON should be equal to:
231+
"""
232+
{
233+
"links": {
234+
"self": "/relation3s"
235+
},
236+
"meta": {
237+
"totalItems": 1,
238+
"itemsPerPage": 3,
239+
"currentPage": 1
240+
},
241+
"data": [
242+
{
243+
"id": "/relation3s/1",
244+
"type": "Relation3",
245+
"attributes": {
246+
"_id": 1
247+
},
248+
"relationships": {
249+
"relation2s": {
250+
"data": [
251+
{
252+
"type": "Relation2",
253+
"id": "/relation2s/1"
254+
},
255+
{
256+
"type": "Relation2",
257+
"id": "/relation2s/2"
258+
}
259+
]
260+
}
261+
}
262+
}
263+
]
264+
}
265+
"""

src/Hal/Serializer/ItemNormalizer.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ public function normalize(mixed $object, string $format = null, array $context =
6969
$iri = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context);
7070

7171
$context['iri'] = $iri;
72+
$context['object'] = $object;
73+
$context['format'] = $format;
7274
$context['api_normalize'] = true;
7375

7476
if (!isset($context['cache_key'])) {

src/JsonApi/Serializer/ItemNormalizer.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ public function normalize(mixed $object, string $format = null, array $context =
9595
$context = $this->initContext($resourceClass, $context);
9696
$iri = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context);
9797
$context['iri'] = $iri;
98+
$context['object'] = $object;
99+
$context['format'] = $format;
98100
$context['api_normalize'] = true;
99101

100102
if (!isset($context['cache_key'])) {

src/Serializer/TagCollectorInterface.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ interface TagCollectorInterface
2323
/**
2424
* Collect cache tags for cache invalidation.
2525
*
26-
* @param array<string, mixed>&array{iri?: string, data?: mixed, object?: mixed, property_metadata?: \ApiPlatform\Metadata\ApiProperty, api_attribute?: string, resources?: array<string, string>} $context
26+
* @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
2727
*/
2828
public function collect(array $context = []): void;
2929
}

tests/Behat/HttpCacheContext.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ public function __construct(private readonly KernelInterface $kernel, private Co
3939
public function registerCustomTagCollector(BeforeScenarioScope $scope): void
4040
{
4141
$this->disableReboot($scope);
42-
$this->driverContainer->set('api_platform.http_cache.tag_collector', new TagCollectorCustom());
42+
/** @phpstan-ignore-next-line */
43+
$iriConverter = $this->driverContainer->get('api_platform.iri_converter');
44+
$this->driverContainer->set('api_platform.http_cache.tag_collector', new TagCollectorCustom($iriConverter));
4345
}
4446

4547
/**

tests/Fixtures/TestBundle/HttpCache/TagCollectorCustom.php

Lines changed: 66 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
namespace ApiPlatform\Tests\Fixtures\TestBundle\HttpCache;
1515

1616
use ApiPlatform\Metadata\ApiProperty;
17+
use ApiPlatform\Metadata\IriConverterInterface;
18+
use ApiPlatform\Metadata\UrlGeneratorInterface;
1719
use ApiPlatform\Serializer\TagCollectorInterface;
1820
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationEmbedder;
1921

@@ -26,41 +28,87 @@ class TagCollectorCustom implements TagCollectorInterface
2628
{
2729
public const IRI_RELATION_DELIMITER = '#';
2830

31+
public function __construct(protected IriConverterInterface $iriConverter)
32+
{
33+
}
34+
2935
public function collect(array $context = []): void
3036
{
31-
$iri = $context['iri'];
32-
$object = $context['object'];
37+
if (!isset($context['resources'])) {
38+
return;
39+
}
40+
41+
$iri = $context['iri'] ?? null;
42+
$object = $context['object'] ?? null;
3343

34-
if ($object instanceof RelationEmbedder) {
44+
// Example on using known objects to shorten/simplify the cache tag (e.g. using ID only or using shorter identifiers)
45+
if ($object && $object instanceof RelationEmbedder) {
3546
$iri = '/RE/'.$object->id;
3647
}
3748

49+
// manually generate IRI, if object is known but IRI is not populated
50+
if (!$iri && $object) {
51+
$iri = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context);
52+
}
53+
54+
if (!$iri) {
55+
return;
56+
}
57+
3858
if (isset($context['property_metadata'])) {
3959
$this->addCacheTagsForRelation($context, $iri, $context['property_metadata']);
40-
} elseif (\is_array($context['data'])) {
41-
$this->addCacheTagForResource($context, $iri);
60+
61+
return;
62+
}
63+
64+
// Example on how to not include "link-only" resources
65+
if ($this->isLinkOnly($context)) {
66+
return;
4267
}
68+
69+
$this->addCacheTagForResource($context, $iri);
4370
}
4471

45-
private function addCacheTagForResource(array $context, ?string $iri): void
72+
private function addCacheTagForResource(array $context, string $iri): void
4673
{
47-
if (isset($context['resources']) && isset($iri)) {
48-
$context['resources'][$iri] = $iri;
49-
}
74+
$context['resources'][$iri] = $iri;
5075
}
5176

52-
private function addCacheTagsForRelation(array $context, ?string $iri, ApiProperty $propertyMetadata): void
77+
private function addCacheTagsForRelation(array $context, string $iri, ApiProperty $propertyMetadata): void
5378
{
54-
if (isset($context['resources']) && isset($iri)) {
55-
if (isset($propertyMetadata->getExtraProperties()['cacheDependencies'])) {
56-
foreach ($propertyMetadata->getExtraProperties()['cacheDependencies'] as $dependency) {
57-
$cacheTag = $iri.self::IRI_RELATION_DELIMITER.$dependency;
58-
$context['resources'][$cacheTag] = $cacheTag;
59-
}
60-
} else {
61-
$cacheTag = $iri.self::IRI_RELATION_DELIMITER.$context['api_attribute'];
79+
// Example on how extra properties could be used to fine-control cache tag behavior for a specific ApiProperty
80+
if (isset($propertyMetadata->getExtraProperties()['cacheDependencies'])) {
81+
foreach ($propertyMetadata->getExtraProperties()['cacheDependencies'] as $dependency) {
82+
$cacheTag = $iri.self::IRI_RELATION_DELIMITER.$dependency;
6283
$context['resources'][$cacheTag] = $cacheTag;
6384
}
85+
86+
return;
87+
}
88+
89+
$cacheTag = $iri.self::IRI_RELATION_DELIMITER.$context['api_attribute'];
90+
$context['resources'][$cacheTag] = $cacheTag;
91+
}
92+
93+
/**
94+
* Returns true, if a resource was normalized into a link only
95+
* Returns false, if a resource was normalized into a fully embedded resource.
96+
*/
97+
private function isLinkOnly(array $context): bool
98+
{
99+
$format = $context['format'] ?? null;
100+
$data = $context['data'] ?? null;
101+
102+
// resource was normalized into JSONAPI link format
103+
if ('jsonapi' === $format && isset($data['data']) && \is_array($data['data']) && array_keys($data['data']) === ['type', 'id']) {
104+
return true;
64105
}
106+
107+
// resource was normalized into a string IRI only
108+
if (\in_array($format, ['jsonld', 'jsonhal'], true) && \is_string($data)) {
109+
return true;
110+
}
111+
112+
return false;
65113
}
66114
}

0 commit comments

Comments
 (0)