Skip to content

Commit e4908ac

Browse files
committed
feat(api): implemented HTTP-native conditional caching for paginated API list endpoints
1 parent cbf6e9b commit e4908ac

3 files changed

Lines changed: 78 additions & 1 deletion

File tree

phpmyfaq/src/phpMyFAQ/Controller/Api/AbstractApiController.php

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,15 @@ protected function paginatedResponse(
154154
filters: $options->filters,
155155
);
156156

157-
return new JsonResponse($responseData, $options->status);
157+
$response = new JsonResponse($responseData, $options->status);
158+
$response->setPublic();
159+
$response->setMaxAge(0);
160+
$response->headers->addCacheControlDirective('must-revalidate');
161+
$response->setVary(['Accept-Language'], false);
162+
$response->setEtag($this->createResponseEtag($responseData));
163+
$response->isNotModified($request);
164+
165+
return $response;
158166
}
159167

160168
/**
@@ -192,4 +200,15 @@ protected function errorResponse(
192200

193201
return new JsonResponse($responseData, $status);
194202
}
203+
204+
/**
205+
* Creates a stable ETag for a JSON API response payload.
206+
*
207+
* @param array $responseData
208+
* @return string
209+
*/
210+
private function createResponseEtag(array $responseData): string
211+
{
212+
return hash('sha256', json_encode($responseData, JSON_THROW_ON_ERROR));
213+
}
195214
}

tests/phpMyFAQ/Controller/Api/AbstractApiControllerTest.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,42 @@ public function testPaginatedResponseReturnsEnvelopeWithMetadata(): void
113113
self::assertSame(5, $payload['meta']['pagination']['total']);
114114
self::assertSame('name', $payload['meta']['sorting']['field']);
115115
self::assertTrue($payload['meta']['filters']['active']);
116+
self::assertStringContainsString('public', (string) $response->headers->get('Cache-Control'));
117+
self::assertStringContainsString('max-age=0', (string) $response->headers->get('Cache-Control'));
118+
self::assertStringContainsString('must-revalidate', (string) $response->headers->get('Cache-Control'));
119+
self::assertSame('Accept-Language', (string) $response->headers->get('Vary'));
120+
self::assertNotNull($response->headers->get('ETag'));
121+
}
122+
123+
public function testPaginatedResponseReturnsNotModifiedForMatchingIfNoneMatch(): void
124+
{
125+
$controller = new AbstractApiControllerTestStub();
126+
$request = new Request(['page' => '1', 'per_page' => '2'], [], [], [], [], ['REQUEST_URI' => '/api/items']);
127+
$pagination = $controller->getPaginationRequestPublic($request);
128+
129+
$initialResponse = $controller->paginatedResponsePublic(
130+
$request,
131+
[['id' => 1], ['id' => 2]],
132+
2,
133+
$pagination,
134+
);
135+
$etag = $initialResponse->headers->get('ETag');
136+
137+
self::assertNotNull($etag);
138+
139+
$conditionalRequest = new Request(['page' => '1', 'per_page' => '2'], [], [], [], [], ['REQUEST_URI' => '/api/items']);
140+
$conditionalRequest->headers->set('If-None-Match', $etag);
141+
142+
$response = $controller->paginatedResponsePublic(
143+
$conditionalRequest,
144+
[['id' => 1], ['id' => 2]],
145+
2,
146+
$pagination,
147+
);
148+
149+
self::assertSame(Response::HTTP_NOT_MODIFIED, $response->getStatusCode());
150+
self::assertSame('', (string) $response->getContent());
151+
self::assertSame($etag, $response->headers->get('ETag'));
116152
}
117153

118154
public function testApiResponseReturnsSuccessEnvelope(): void

tests/phpMyFAQ/Controller/Api/CategoryControllerWebTest.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,28 @@ public function testCategoriesEndpointReturnsJson(): void
4040
self::assertResponseIsSuccessful($response);
4141
self::assertSame('application/json', $response->headers->get('Content-Type'));
4242
self::assertJson((string) $response->getContent());
43+
self::assertNotNull($response->headers->get('ETag'));
44+
self::assertSame('Accept-Language', (string) $response->headers->get('Vary'));
45+
}
46+
47+
public function testCategoriesEndpointReturnsNotModifiedWhenEtagMatches(): void
48+
{
49+
$this->getConfiguration('api')->getAll();
50+
$this->overrideConfigurationValues(['api.enableAccess' => true], 'api');
51+
52+
$initialResponse = $this->requestApi('GET', '/v3.2/categories');
53+
$etag = $initialResponse->headers->get('ETag');
54+
55+
self::assertResponseIsSuccessful($initialResponse);
56+
self::assertNotNull($etag);
57+
58+
$response = $this->requestApi('GET', '/v3.2/categories', [], [
59+
'HTTP_IF_NONE_MATCH' => $etag,
60+
]);
61+
62+
self::assertResponseStatusCodeSame(304, $response);
63+
self::assertSame('', (string) $response->getContent());
64+
self::assertSame($etag, $response->headers->get('ETag'));
4365
}
4466

4567
public function testCreateWithoutTokenReturnsUnauthorizedJson(): void

0 commit comments

Comments
 (0)