From f9ce11eef8b05e7e31b45a428d63ae35eed8ed42 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Wed, 11 Mar 2015 11:19:52 +0100 Subject: [PATCH] Provide controller level Cache-Control support Prior to this commit, Cache-Control HTTP headers could be set using a WebContentInterceptor and configured cache mappings. This commit adds support for cache-related HTTP headers at the controller method level, by returning a ResponseEntity instance: ResponseEntity.status(HttpStatus.OK) .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePublic()) .eTag("deadb33f8badf00d") .body(entity); Also, this change now automatically checks the "ETag" and "Last-Modified" headers in ResponseEntity, in order to respond HTTP "304 - Not Modified" if necessary. Issue: SPR-8550 --- .../springframework/http/ResponseEntity.java | 26 ++++- .../http/ResponseEntityTests.java | 61 ++++++++++- .../annotation/HttpEntityMethodProcessor.java | 29 ++++- .../HttpEntityMethodProcessorMockTests.java | 100 +++++++++++++++++- 4 files changed, 209 insertions(+), 7 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/http/ResponseEntity.java b/spring-web/src/main/java/org/springframework/http/ResponseEntity.java index 840f696bd7..5d00716d5f 100644 --- a/spring-web/src/main/java/org/springframework/http/ResponseEntity.java +++ b/spring-web/src/main/java/org/springframework/http/ResponseEntity.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2014 the original author or authors. + * Copyright 2002-2015 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -59,6 +59,7 @@ import org.springframework.util.ObjectUtils; * * * @author Arjen Poutsma + * @author Brian Clozel * @since 3.0.2 * @see #getStatusCode() */ @@ -318,6 +319,20 @@ public class ResponseEntity extends HttpEntity { */ B location(URI location); + /** + * Set the caching directives for the resource, as specified by the + * {@code Cache-Control} header. + * + *

A {@code CacheControl} instance can be built like + * {@code CacheControl.maxAge(3600).cachePublic().noTransform()}. + * + * @param cacheControl the instance that builds cache related HTTP response headers + * @return this builder + * @see RFC-7234 Section 5.2 + * @since 4.2 + */ + B cacheControl(CacheControl cacheControl); + /** * Build the response entity with no body. * @return the response entity @@ -423,6 +438,15 @@ public class ResponseEntity extends HttpEntity { return this; } + @Override + public BodyBuilder cacheControl(CacheControl cacheControl) { + String ccValue = cacheControl.getHeaderValue(); + if(ccValue != null) { + this.headers.setCacheControl(cacheControl.getHeaderValue()); + } + return this; + } + @Override public ResponseEntity build() { return new ResponseEntity(null, this.headers, this.status); diff --git a/spring-web/src/test/java/org/springframework/http/ResponseEntityTests.java b/spring-web/src/test/java/org/springframework/http/ResponseEntityTests.java index bb54104652..5ce43e8dad 100644 --- a/spring-web/src/test/java/org/springframework/http/ResponseEntityTests.java +++ b/spring-web/src/test/java/org/springframework/http/ResponseEntityTests.java @@ -19,11 +19,14 @@ package org.springframework.http; import java.net.URI; import java.net.URISyntaxException; import java.util.List; +import java.util.concurrent.TimeUnit; +import org.hamcrest.Matchers; import org.junit.Test; import static org.junit.Assert.*; + /** * @author Arjen Poutsma * @author Marcel Overdijk @@ -163,7 +166,7 @@ public class ResponseEntityTests { } @Test - public void headersCopy(){ + public void headersCopy() { HttpHeaders customHeaders = new HttpHeaders(); customHeaders.set("X-CustomHeader", "vale"); @@ -178,7 +181,7 @@ public class ResponseEntityTests { } @Test // SPR-12792 - public void headersCopyWithEmptyAndNull(){ + public void headersCopyWithEmptyAndNull() { ResponseEntity responseEntityWithEmptyHeaders = ResponseEntity.ok().headers(new HttpHeaders()).build(); ResponseEntity responseEntityWithNullHeaders = @@ -189,4 +192,58 @@ public class ResponseEntityTests { assertEquals(responseEntityWithEmptyHeaders.toString(), responseEntityWithNullHeaders.toString()); } + @Test + public void emptyCacheControl() { + + Integer entity = new Integer(42); + + ResponseEntity responseEntity = + ResponseEntity.status(HttpStatus.OK) + .cacheControl(CacheControl.empty()) + .body(entity); + + assertNotNull(responseEntity); + assertEquals(HttpStatus.OK, responseEntity.getStatusCode()); + assertFalse(responseEntity.getHeaders().containsKey(HttpHeaders.CACHE_CONTROL)); + assertEquals(entity, responseEntity.getBody()); + } + + @Test + public void cacheControl() { + + Integer entity = new Integer(42); + + ResponseEntity responseEntity = + ResponseEntity.status(HttpStatus.OK) + .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePrivate(). + mustRevalidate().proxyRevalidate().sMaxAge(30, TimeUnit.MINUTES)) + .body(entity); + + assertNotNull(responseEntity); + assertEquals(HttpStatus.OK, responseEntity.getStatusCode()); + assertTrue(responseEntity.getHeaders().containsKey(HttpHeaders.CACHE_CONTROL)); + assertEquals(entity, responseEntity.getBody()); + String cacheControlHeader = responseEntity.getHeaders().getCacheControl(); + assertThat(cacheControlHeader, Matchers.equalTo("max-age=3600, must-revalidate, private, proxy-revalidate, s-maxage=1800")); + } + + @Test + public void cacheControlNoCache() { + + Integer entity = new Integer(42); + + ResponseEntity responseEntity = + ResponseEntity.status(HttpStatus.OK) + .cacheControl(CacheControl.noStore()) + .body(entity); + + assertNotNull(responseEntity); + assertEquals(HttpStatus.OK, responseEntity.getStatusCode()); + assertTrue(responseEntity.getHeaders().containsKey(HttpHeaders.CACHE_CONTROL)); + assertEquals(entity, responseEntity.getBody()); + + String cacheControlHeader = responseEntity.getHeaders().getCacheControl(); + assertThat(cacheControlHeader, Matchers.equalTo("no-store")); + } + } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java index 27989f45a3..90b7b059fc 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2014 the original author or authors. + * Copyright 2002-2015 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,6 +31,7 @@ import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.server.ServletServerHttpRequest; import org.springframework.http.server.ServletServerHttpResponse; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.accept.ContentNegotiationManager; import org.springframework.web.bind.support.WebDataBinderFactory; @@ -139,12 +140,36 @@ public class HttpEntityMethodProcessor extends AbstractMessageConverterMethodPro } Object body = responseEntity.getBody(); + if (responseEntity instanceof ResponseEntity) { + if (isResourceNotModified(webRequest, (ResponseEntity) responseEntity)) { + // Ensure headers are flushed, no body should be written + outputMessage.flush(); + // skip call to converters, as they may update the body + return; + } + } // Try even with null body. ResponseBodyAdvice could get involved. writeWithMessageConverters(body, returnType, inputMessage, outputMessage); // Ensure headers are flushed even if no body was written - outputMessage.getBody(); + outputMessage.flush(); + } + + private boolean isResourceNotModified(NativeWebRequest webRequest, ResponseEntity responseEntity) { + String eTag = responseEntity.getHeaders().getETag(); + long lastModified = responseEntity.getHeaders().getLastModified(); + boolean notModified = false; + if (lastModified != -1 && StringUtils.hasLength(eTag)) { + notModified = webRequest.checkNotModified(eTag, lastModified); + } + else if (lastModified != -1) { + notModified = webRequest.checkNotModified(lastModified); + } + else if (StringUtils.hasLength(eTag)) { + notModified = webRequest.checkNotModified(eTag); + } + return notModified; } @Override diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessorMockTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessorMockTests.java index 273283fdbc..dc3e70b3c2 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessorMockTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessorMockTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2014 the original author or authors. + * Copyright 2002-2015 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,8 +18,12 @@ package org.springframework.web.servlet.mvc.method.annotation; import java.lang.reflect.Method; import java.net.URI; +import java.text.SimpleDateFormat; import java.util.Arrays; import java.util.Collections; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; import org.junit.Before; import org.junit.Test; @@ -106,7 +110,7 @@ public class HttpEntityMethodProcessorMockTests { returnTypeInt = new MethodParameter(getClass().getMethod("handle3"), -1); mavContainer = new ModelAndViewContainer(); - servletRequest = new MockHttpServletRequest(); + servletRequest = new MockHttpServletRequest("GET", "/foo"); servletResponse = new MockHttpServletResponse(); webRequest = new ServletWebRequest(servletRequest, servletResponse); } @@ -320,6 +324,98 @@ public class HttpEntityMethodProcessorMockTests { assertEquals("headerValue", outputMessage.getValue().getHeaders().get("header").get(0)); } + @Test + public void handleReturnTypeLastModified() throws Exception { + long currentTime = new Date().getTime(); + long oneMinuteAgo = currentTime - (1000 * 60); + servletRequest.addHeader(HttpHeaders.IF_MODIFIED_SINCE, currentTime); + HttpHeaders responseHeaders = new HttpHeaders(); + responseHeaders.setDate(HttpHeaders.LAST_MODIFIED, oneMinuteAgo); + ResponseEntity returnValue = new ResponseEntity("body", responseHeaders, HttpStatus.OK); + + given(messageConverter.canWrite(String.class, null)).willReturn(true); + given(messageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN)); + given(messageConverter.canWrite(String.class, MediaType.TEXT_PLAIN)).willReturn(true); + + processor.handleReturnValue(returnValue, returnTypeResponseEntity, mavContainer, webRequest); + + assertTrue(mavContainer.isRequestHandled()); + assertEquals(HttpStatus.NOT_MODIFIED.value(), servletResponse.getStatus()); + assertEquals(oneMinuteAgo/1000 * 1000, Long.parseLong(servletResponse.getHeader(HttpHeaders.LAST_MODIFIED))); + assertEquals(0, servletResponse.getContentAsByteArray().length); + } + + @Test + public void handleReturnTypeEtag() throws Exception { + String etagValue = "\"deadb33f8badf00d\""; + servletRequest.addHeader(HttpHeaders.IF_NONE_MATCH, etagValue); + HttpHeaders responseHeaders = new HttpHeaders(); + responseHeaders.set(HttpHeaders.ETAG, etagValue); + ResponseEntity returnValue = new ResponseEntity("body", responseHeaders, HttpStatus.OK); + + given(messageConverter.canWrite(String.class, null)).willReturn(true); + given(messageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN)); + given(messageConverter.canWrite(String.class, MediaType.TEXT_PLAIN)).willReturn(true); + + processor.handleReturnValue(returnValue, returnTypeResponseEntity, mavContainer, webRequest); + + assertTrue(mavContainer.isRequestHandled()); + assertEquals(HttpStatus.NOT_MODIFIED.value(), servletResponse.getStatus()); + assertEquals(etagValue, servletResponse.getHeader(HttpHeaders.ETAG)); + assertEquals(0, servletResponse.getContentAsByteArray().length); + } + + @Test + public void handleReturnTypeETagAndLastModified() throws Exception { + long currentTime = new Date().getTime(); + long oneMinuteAgo = currentTime - (1000 * 60); + String etagValue = "\"deadb33f8badf00d\""; + servletRequest.addHeader(HttpHeaders.IF_MODIFIED_SINCE, currentTime); + servletRequest.addHeader(HttpHeaders.IF_NONE_MATCH, etagValue); + HttpHeaders responseHeaders = new HttpHeaders(); + responseHeaders.setDate(HttpHeaders.LAST_MODIFIED, oneMinuteAgo); + responseHeaders.set(HttpHeaders.ETAG, etagValue); + ResponseEntity returnValue = new ResponseEntity("body", responseHeaders, HttpStatus.OK); + + given(messageConverter.canWrite(String.class, null)).willReturn(true); + given(messageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN)); + given(messageConverter.canWrite(String.class, MediaType.TEXT_PLAIN)).willReturn(true); + + processor.handleReturnValue(returnValue, returnTypeResponseEntity, mavContainer, webRequest); + + assertTrue(mavContainer.isRequestHandled()); + assertEquals(HttpStatus.NOT_MODIFIED.value(), servletResponse.getStatus()); + assertEquals(oneMinuteAgo/1000 * 1000, Long.parseLong(servletResponse.getHeader(HttpHeaders.LAST_MODIFIED))); + assertEquals(etagValue, servletResponse.getHeader(HttpHeaders.ETAG)); + assertEquals(0, servletResponse.getContentAsByteArray().length); + } + + @Test + public void handleReturnTypeChangedETagAndLastModified() throws Exception { + long currentTime = new Date().getTime(); + long oneMinuteAgo = currentTime - (1000 * 60); + String etagValue = "\"deadb33f8badf00d\""; + String changedEtagValue = "\"changed-etag-value\""; + servletRequest.addHeader(HttpHeaders.IF_MODIFIED_SINCE, currentTime); + servletRequest.addHeader(HttpHeaders.IF_NONE_MATCH, etagValue); + HttpHeaders responseHeaders = new HttpHeaders(); + responseHeaders.setDate(HttpHeaders.LAST_MODIFIED, oneMinuteAgo); + responseHeaders.set(HttpHeaders.ETAG, changedEtagValue); + ResponseEntity returnValue = new ResponseEntity("body", responseHeaders, HttpStatus.OK); + + given(messageConverter.canWrite(String.class, null)).willReturn(true); + given(messageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN)); + given(messageConverter.canWrite(String.class, MediaType.TEXT_PLAIN)).willReturn(true); + + processor.handleReturnValue(returnValue, returnTypeResponseEntity, mavContainer, webRequest); + + assertTrue(mavContainer.isRequestHandled()); + assertEquals(HttpStatus.OK.value(), servletResponse.getStatus()); + assertEquals(oneMinuteAgo/1000 * 1000, Long.parseLong(servletResponse.getHeader(HttpHeaders.LAST_MODIFIED))); + assertEquals(changedEtagValue, servletResponse.getHeader(HttpHeaders.ETAG)); + assertEquals(0, servletResponse.getContentAsByteArray().length); + } + public ResponseEntity handle1(HttpEntity httpEntity, ResponseEntity responseEntity, int i, RequestEntity requestEntity) { return responseEntity; }