1

I am working on a Drupal Controller implementation that would return a generated image as response. I used Symfony's Response and I was trying to add ETag header to it:

use Drupal\Core\Controller\ControllerBase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class MyController extends ControllerBase {

  public static function viewGraph(string $id, Request $request) {
    $response = new Response();
    $response->setEtag($etag); // some etag genrated from $id
    if ($response->isNotModified($request)) {
      return $response;
    }
    // ...
    return $response;
  }

}

I expected to see the "ETag" header in my response. But there is nothing. I've even tried CacheableResponse and it didn't work as well.

What's wrong with my approach? What is the right approach to it?

4
  • Is $etag being converted to md5? From symfony's docs $response->setEtag(md5($response->getContent()));.
    – user72672
    Commented Sep 9, 2019 at 8:43
  • I'm using the hash for $etag. I think the specification only needed it to be string, not specifically md5. Commented Sep 9, 2019 at 8:45
  • And the problem is not generating the etag, but to actually get etag header in the browser ersponse. Commented Sep 9, 2019 at 8:54
  • Keep in mind that some server config will strip etags, eg Header unset ETag and FileETag None in an Apache .conf.
    – mpdonadio
    Commented Sep 9, 2019 at 19:11

2 Answers 2

6

FinishResponseSubscriber overwrites the ETag header, unless your response meets certain conditions. That is it has to be cacheable and provides a custom Cache-Control header. When testing the code make sure you have browser caching enabled (in the performance settings).

Example:

  public function view() {
    $response = new CacheableResponse();
    $response->headers->set('Cache-Control', 'public, max-age=60');
    $response->setEtag('123'); 
    return $response;
  }

Besides browser caching you might also want to set Drupal caching data to invalidate the cached response when the content becomes outdated, see for example Invalidate cache on a REST endpoint.

3
  • I have already switched on the browser caching option. But I still don't see the eTag. How do my controller response meet the "certain condition"? Commented Sep 9, 2019 at 9:57
  • Still unable to cache on browser after all these. Is there an easy way to debug the cache header generation process? Commented Sep 9, 2019 at 11:14
  • You can debug the response handling by setting a breakpoint in FinishResponseSubscriber::onRespond
    – 4uk4
    Commented Sep 9, 2019 at 11:25
1

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:

  1. 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
  2. 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:

  1. Drupal\Core\PageCache\ResponsePolicy\KillSwitch
    A policy evaluating to static::DENY when the kill switch was triggered.

  2. 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.

  3. Drupal\Core\PageCache\ResponsePolicy\NoServerError
    A policy denying caching of a server error (HTTP 5xx) responses.

  4. Drupal\image\PageCache\DenyPrivateImageStyleDownload
    Cache policy for image preview page.
    This policy rule denies caching of responses generated by the entity.image.preview route.

  5. 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:

  1. The controller method of the endpoint needs to return an object that implements the CacheableResponseInterface (e.g. a CacheableResponse instance)
  2. Have correct cache header setup.
  3. Have non-zero cache's maximum age.
  4. Have the browser / client calling the controller's endpoint with GET or HEAD method.
  5. Make sure no kill switch nor error is trigger during the rendering process.
  6. 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.

1
  • 1
    You can set your own ETag header when you set at the same time a custom Cache-Control header, see the code example in my answer and api.drupal.org/api/drupal/…
    – 4uk4
    Commented Sep 10, 2019 at 9:54

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.