Thanks to 4k4's answer. I took a deeper dive into the issue. Here is something that might benefits others who want to do the same thing.
ETag Header Setting
The class FinishResponseSubscriber will check if response is cacheable. If it is, you're good to go. Then it will also check if the response is customized by checking if you have setup "Cache-Control"
header to "public"
:
- If it is "customized", it will assume you have properly setup your header yourself.
- If not it will call setResponseCacheable and rewrites the ETag header with whatever timestamp it think is suitable (either your
"Last-Modified"
header value or the request time).
Customized Header
As 4k4 pointed out, you can achieve the ETag setup by this:
use Drupal\Core\Cache\CacheableResponse;
use Drupal\Core\Controller\ControllerBase;
use Symfony\Component\HttpFoundation\Request;
class MyController extends ControllerBase {
public static function viewGraph(string $id, Request $request) {
// ... generate $etag
$response = new CacheableResponse();
$response
->setPublic()
->setMaxAge(60)
->setEtag($etag);
if ($response->isNotModified($request)) {
return $response;
}
return $response;
}
}
Hands off to Drupal
use Drupal\Core\Cache\CacheableResponse;
use Drupal\Core\Controller\ControllerBase;
use Symfony\Component\HttpFoundation\Request;
class MyController extends ControllerBase {
public static function viewGraph(string $id, Request $request) {
// ... get $last_modified
$response = new CacheableResponse();
$response->setLastModified($last_modified);
if ($response->isNotModified($request)) {
return $response;
}
return $response;
}
}
Similarly, if it found that the response is not cacheable, it would call FinishResponseSubscriber::setResponseNotCacheable and unset ETag header.
So unless you were to override FinishResponseSubscriber somehow, you can only stick with the ETag generated by Drupal.
And the ETag header will be generated by your Last-Modified
header automatically.
Wait, there's more...
Oh right. Even you got the header right, it does not mean your response is cacheable! It's because that cache header is not the only requirement for you to see ETag and have browser cache.
As of Drupal 8.7, to have a cached response, the FinishResponseSubscriber checks both the request and response to see if both are cacheable.
The below points are the extra criterias for browser cache to work.
Setup Browser Cache Max Age
As 4k4 mentioned, you need to setup the browser / proxy cache maximum age to a non-zero value in order to have any browser cache. The setting is in "Home > Administration > Configuration > Development > Performance".
Make sure your Request is cacheable
The request, unless otherwise configured / programmed, will be checked by DefaultRequestPolicy, which contains 2 rules:
- Drupal\Core\PageCache\RequestPolicy\CommandLineOrUnsafeMethod
Denies cache if Drupal is run in CLI or by unsafe method (i.e. request method is neither GET
nor HEAD
); and
- Drupal\Core\PageCache\RequestPolicy\NoSessionOpen
Denies cache if the Drupal user has a login session.
Make sure your Response is also cacheable
The response cache policy in my installation includes these rules:
Drupal\Core\PageCache\ResponsePolicy\KillSwitch
A policy evaluating to static::DENY when the kill switch was triggered.
Drupal\Core\PageCache\ResponsePolicy\DenyNoCacheRoutes
Cache policy for routes with the 'no_cache' option set.
This policy rule denies caching of responses generated for routes that have the 'no_cache' option set to TRUE.
Drupal\Core\PageCache\ResponsePolicy\NoServerError
A policy denying caching of a server error (HTTP 5xx) responses.
Drupal\image\PageCache\DenyPrivateImageStyleDownload
Cache policy for image preview page.
This policy rule denies caching of responses generated by the entity.image.preview route.
Drupal\node\PageCache\DenyNodePreview
Cache policy for node preview page.
This policy rule denies caching of responses generated by the entity.node.preview route.
Besides, your Response object would also need to implement the CacheableResponseInterface. The simplest way to implement it would be to use the CacheableResponse class
Summary
To summarize. For a custom controller to have browser cache, you need to meet these criteria:
- The controller method of the endpoint needs to return an object that implements the CacheableResponseInterface (e.g. a CacheableResponse instance)
- Have correct cache header setup.
- Have non-zero cache's maximum age.
- Have the browser / client calling the controller's endpoint with
GET
or HEAD
method.
- Make sure no kill switch nor error is trigger during the rendering process.
- The route does not match the preview page of node or image.
When the criterias are met, the ETag setup would be effective.
Update: Correct my "You cannot set ETag" claim. Thanks 4k4 for correcting me.
$etag
being converted tomd5
? From symfony's docs$response->setEtag(md5($response->getContent()));
.$etag
. I think the specification only needed it to be string, not specifically md5.Header unset ETag
andFileETag None
in an Apache .conf.