REST Endpoints annotated for Cache-Control

“All programming is an exercise in caching.”

Terje Mathisen

Spring Boot endpoints can be formulated briefly and concisely. Their return values are automatically converted into suitable responses. In this example, the list of users is converted into an HTTP response with a JSON body.

@GetMapping
public List<User> getUsers() {
  return userService.getAllUsers();
}

Occasionally, the developer has to fall back on the ResponseEntity class as a return value. This is the case, for example, when additional HTTP headers are required, such as the Cache-Control header.

@GetMapping
public ResponseEntity<List<User>> getUsers() {
  return ResponseEntity.ok().cacheControl(CacheControl.maxAge(Duration.ofSeconds(60)))
    .body(userService.getAllUsers());
}

The example provides a maxAge value of 60 seconds as HTTP headers. This means that the corresponding response is cached by a browser for one minute.

The solution works but is considerably more complex compared to the first version. As in many other places, a suitable annotation would be nicer.

The desired implementation is a @Cache annotation with which the headers are automatically appended to the response.

@GetMapping
@Cache(maxAge=60)
public List<User> getUsers() {
  return userService.getAllUsers();
}

For this solution, a corresponding annotation is required first. As an example with some of the usual cache control attributes.

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Documented
public @interface Cache {
  long maxAge() default 0;

  TimeUnit maxAgeTimeUnit() default TimeUnit.SECONDS;

  boolean noCache() default false;

  boolean noStore() default false;

  boolean cachePrivate() default false;
}

A HandlerMethodReturnValueHandler is implemented to read the annotation from the method and then set the corresponding HTTP headers.

public class CacheControlHandlerMethodReturnValueHandler implements HandlerMethodReturnValueHandler {
    private final HandlerMethodReturnValueHandler handler;

    public CacheControlHandlerMethodReturnValueHandler(HandlerMethodReturnValueHandler handler) {
        this.handler = handler;
    }

    @Override
    public boolean supportsReturnType(MethodParameter returnType) {
        return handler.supportsReturnType(returnType);
    }

    @Override
    public void handleReturnValue(Object returnValue, MethodParameter returnType, ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
        Cache annotation = AnnotationUtils.findAnnotation(Objects.requireNonNull(returnType.getMethod()), Cache.class);
        if (annotation == null) {
            return;
        }
        CacheControl cacheControl = getCacheControl(annotation);
        Optional.ofNullable(webRequest.getNativeResponse(HttpServletResponse.class))
                .ifPresent(response -> response.setHeader(HttpHeaders.CACHE_CONTROL, cacheControl.getHeaderValue()));
        handler.handleReturnValue(returnValue, returnType, mavContainer, webRequest);
    }

    private static CacheControl getCacheControl(Cache annotation) {
        CacheControl cacheControl;
        if (annotation.noCache()) {
            cacheControl = CacheControl.noCache();
        } else if (annotation.noStore()) {
            cacheControl = CacheControl.noStore();
        } else if (annotation.maxAge() > 0) {
            Duration maxAge = Duration.ofSeconds(annotation.maxAgeTimeUnit().toSeconds(annotation.maxAge()));
            cacheControl = CacheControl.maxAge(maxAge);
        } else {
            cacheControl = CacheControl.empty();
        }

        if (annotation.cachePrivate()) {
            cacheControl.cachePrivate();
        }
        return cacheControl;
    }
}

The attributes of the annotation are used to configure a CachControl instance as before. Finally, the custom HandlerMethodReturnValueHandler only needs to be added to the list of configured HandlerMethodReturnValueHandlers. As the new HandlerMethodReturnValueHandler only supplements existing ones, it delegates to the existing ones after it has written the Cache-Control header.

@Configuration
public class WebConfig {

    private final RequestMappingHandlerAdapter handlerAdapter;

    public WebConfig(RequestMappingHandlerAdapter handlerAdapter) {
        this.handlerAdapter = handlerAdapter;
    }

    @PostConstruct
    public void init() {
        List<HandlerMethodReturnValueHandler> handlers = new ArrayList<>(handlerAdapter.getReturnValueHandlers());
        for (int i = 0; i  < handlers.size(); i++) {
            HandlerMethodReturnValueHandler handler = handlers.get(i);
            if (handler instanceof  HttpEntityMethodProcessor processor) {
                handlers.set(i, new CacheControlHandlerMethodReturnValueHandler(processor));
            } else if (handler instanceof RequestResponseBodyMethodProcessor processor) {
                handlers.set(i, new CacheControlHandlerMethodReturnValueHandler(processor));

            }
        }
        handlerAdapter.setReturnValueHandlers(handlers);
    }
}

This completes the annotation-based solution for Cache-Control headers and caching can be configured without the ResponseEntity return values.

Leave a Comment