Skip to content

Commit f9ce11e

Browse files
committed
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
1 parent 38f32e3 commit f9ce11e

File tree

4 files changed

+209
-7
lines changed

4 files changed

+209
-7
lines changed

spring-web/src/main/java/org/springframework/http/ResponseEntity.java

+25-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2014 the original author or authors.
2+
* Copyright 2002-2015 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -59,6 +59,7 @@
5959
* </pre>
6060
*
6161
* @author Arjen Poutsma
62+
* @author Brian Clozel
6263
* @since 3.0.2
6364
* @see #getStatusCode()
6465
*/
@@ -318,6 +319,20 @@ public interface HeadersBuilder<B extends HeadersBuilder<B>> {
318319
*/
319320
B location(URI location);
320321

322+
/**
323+
* Set the caching directives for the resource, as specified by the
324+
* {@code Cache-Control} header.
325+
*
326+
* <p>A {@code CacheControl} instance can be built like
327+
* {@code CacheControl.maxAge(3600).cachePublic().noTransform()}.
328+
*
329+
* @param cacheControl the instance that builds cache related HTTP response headers
330+
* @return this builder
331+
* @see <a href="https://tools.ietf.org/html/rfc7234#section-5.2">RFC-7234 Section 5.2</a>
332+
* @since 4.2
333+
*/
334+
B cacheControl(CacheControl cacheControl);
335+
321336
/**
322337
* Build the response entity with no body.
323338
* @return the response entity
@@ -423,6 +438,15 @@ public BodyBuilder location(URI location) {
423438
return this;
424439
}
425440

441+
@Override
442+
public BodyBuilder cacheControl(CacheControl cacheControl) {
443+
String ccValue = cacheControl.getHeaderValue();
444+
if(ccValue != null) {
445+
this.headers.setCacheControl(cacheControl.getHeaderValue());
446+
}
447+
return this;
448+
}
449+
426450
@Override
427451
public ResponseEntity<Void> build() {
428452
return new ResponseEntity<Void>(null, this.headers, this.status);

spring-web/src/test/java/org/springframework/http/ResponseEntityTests.java

+59-2
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,14 @@
1919
import java.net.URI;
2020
import java.net.URISyntaxException;
2121
import java.util.List;
22+
import java.util.concurrent.TimeUnit;
2223

24+
import org.hamcrest.Matchers;
2325
import org.junit.Test;
2426

2527
import static org.junit.Assert.*;
2628

29+
2730
/**
2831
* @author Arjen Poutsma
2932
* @author Marcel Overdijk
@@ -163,7 +166,7 @@ public void headers() throws URISyntaxException {
163166
}
164167

165168
@Test
166-
public void headersCopy(){
169+
public void headersCopy() {
167170
HttpHeaders customHeaders = new HttpHeaders();
168171
customHeaders.set("X-CustomHeader", "vale");
169172

@@ -178,7 +181,7 @@ public void headersCopy(){
178181
}
179182

180183
@Test // SPR-12792
181-
public void headersCopyWithEmptyAndNull(){
184+
public void headersCopyWithEmptyAndNull() {
182185
ResponseEntity<Void> responseEntityWithEmptyHeaders =
183186
ResponseEntity.ok().headers(new HttpHeaders()).build();
184187
ResponseEntity<Void> responseEntityWithNullHeaders =
@@ -189,4 +192,58 @@ public void headersCopyWithEmptyAndNull(){
189192
assertEquals(responseEntityWithEmptyHeaders.toString(), responseEntityWithNullHeaders.toString());
190193
}
191194

195+
@Test
196+
public void emptyCacheControl() {
197+
198+
Integer entity = new Integer(42);
199+
200+
ResponseEntity<Integer> responseEntity =
201+
ResponseEntity.status(HttpStatus.OK)
202+
.cacheControl(CacheControl.empty())
203+
.body(entity);
204+
205+
assertNotNull(responseEntity);
206+
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
207+
assertFalse(responseEntity.getHeaders().containsKey(HttpHeaders.CACHE_CONTROL));
208+
assertEquals(entity, responseEntity.getBody());
209+
}
210+
211+
@Test
212+
public void cacheControl() {
213+
214+
Integer entity = new Integer(42);
215+
216+
ResponseEntity<Integer> responseEntity =
217+
ResponseEntity.status(HttpStatus.OK)
218+
.cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePrivate().
219+
mustRevalidate().proxyRevalidate().sMaxAge(30, TimeUnit.MINUTES))
220+
.body(entity);
221+
222+
assertNotNull(responseEntity);
223+
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
224+
assertTrue(responseEntity.getHeaders().containsKey(HttpHeaders.CACHE_CONTROL));
225+
assertEquals(entity, responseEntity.getBody());
226+
String cacheControlHeader = responseEntity.getHeaders().getCacheControl();
227+
assertThat(cacheControlHeader, Matchers.equalTo("max-age=3600, must-revalidate, private, proxy-revalidate, s-maxage=1800"));
228+
}
229+
230+
@Test
231+
public void cacheControlNoCache() {
232+
233+
Integer entity = new Integer(42);
234+
235+
ResponseEntity<Integer> responseEntity =
236+
ResponseEntity.status(HttpStatus.OK)
237+
.cacheControl(CacheControl.noStore())
238+
.body(entity);
239+
240+
assertNotNull(responseEntity);
241+
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
242+
assertTrue(responseEntity.getHeaders().containsKey(HttpHeaders.CACHE_CONTROL));
243+
assertEquals(entity, responseEntity.getBody());
244+
245+
String cacheControlHeader = responseEntity.getHeaders().getCacheControl();
246+
assertThat(cacheControlHeader, Matchers.equalTo("no-store"));
247+
}
248+
192249
}

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java

+27-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2014 the original author or authors.
2+
* Copyright 2002-2015 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -31,6 +31,7 @@
3131
import org.springframework.http.server.ServletServerHttpRequest;
3232
import org.springframework.http.server.ServletServerHttpResponse;
3333
import org.springframework.util.Assert;
34+
import org.springframework.util.StringUtils;
3435
import org.springframework.web.HttpMediaTypeNotSupportedException;
3536
import org.springframework.web.accept.ContentNegotiationManager;
3637
import org.springframework.web.bind.support.WebDataBinderFactory;
@@ -139,12 +140,36 @@ public void handleReturnValue(Object returnValue, MethodParameter returnType,
139140
}
140141

141142
Object body = responseEntity.getBody();
143+
if (responseEntity instanceof ResponseEntity) {
144+
if (isResourceNotModified(webRequest, (ResponseEntity<?>) responseEntity)) {
145+
// Ensure headers are flushed, no body should be written
146+
outputMessage.flush();
147+
// skip call to converters, as they may update the body
148+
return;
149+
}
150+
}
142151

143152
// Try even with null body. ResponseBodyAdvice could get involved.
144153
writeWithMessageConverters(body, returnType, inputMessage, outputMessage);
145154

146155
// Ensure headers are flushed even if no body was written
147-
outputMessage.getBody();
156+
outputMessage.flush();
157+
}
158+
159+
private boolean isResourceNotModified(NativeWebRequest webRequest, ResponseEntity<?> responseEntity) {
160+
String eTag = responseEntity.getHeaders().getETag();
161+
long lastModified = responseEntity.getHeaders().getLastModified();
162+
boolean notModified = false;
163+
if (lastModified != -1 && StringUtils.hasLength(eTag)) {
164+
notModified = webRequest.checkNotModified(eTag, lastModified);
165+
}
166+
else if (lastModified != -1) {
167+
notModified = webRequest.checkNotModified(lastModified);
168+
}
169+
else if (StringUtils.hasLength(eTag)) {
170+
notModified = webRequest.checkNotModified(eTag);
171+
}
172+
return notModified;
148173
}
149174

150175
@Override

spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessorMockTests.java

+98-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2014 the original author or authors.
2+
* Copyright 2002-2015 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -18,8 +18,12 @@
1818

1919
import java.lang.reflect.Method;
2020
import java.net.URI;
21+
import java.text.SimpleDateFormat;
2122
import java.util.Arrays;
2223
import java.util.Collections;
24+
import java.util.Date;
25+
import java.util.Locale;
26+
import java.util.TimeZone;
2327

2428
import org.junit.Before;
2529
import org.junit.Test;
@@ -106,7 +110,7 @@ public void setUp() throws Exception {
106110
returnTypeInt = new MethodParameter(getClass().getMethod("handle3"), -1);
107111

108112
mavContainer = new ModelAndViewContainer();
109-
servletRequest = new MockHttpServletRequest();
113+
servletRequest = new MockHttpServletRequest("GET", "/foo");
110114
servletResponse = new MockHttpServletResponse();
111115
webRequest = new ServletWebRequest(servletRequest, servletResponse);
112116
}
@@ -320,6 +324,98 @@ public void responseHeaderAndBody() throws Exception {
320324
assertEquals("headerValue", outputMessage.getValue().getHeaders().get("header").get(0));
321325
}
322326

327+
@Test
328+
public void handleReturnTypeLastModified() throws Exception {
329+
long currentTime = new Date().getTime();
330+
long oneMinuteAgo = currentTime - (1000 * 60);
331+
servletRequest.addHeader(HttpHeaders.IF_MODIFIED_SINCE, currentTime);
332+
HttpHeaders responseHeaders = new HttpHeaders();
333+
responseHeaders.setDate(HttpHeaders.LAST_MODIFIED, oneMinuteAgo);
334+
ResponseEntity<String> returnValue = new ResponseEntity<String>("body", responseHeaders, HttpStatus.OK);
335+
336+
given(messageConverter.canWrite(String.class, null)).willReturn(true);
337+
given(messageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN));
338+
given(messageConverter.canWrite(String.class, MediaType.TEXT_PLAIN)).willReturn(true);
339+
340+
processor.handleReturnValue(returnValue, returnTypeResponseEntity, mavContainer, webRequest);
341+
342+
assertTrue(mavContainer.isRequestHandled());
343+
assertEquals(HttpStatus.NOT_MODIFIED.value(), servletResponse.getStatus());
344+
assertEquals(oneMinuteAgo/1000 * 1000, Long.parseLong(servletResponse.getHeader(HttpHeaders.LAST_MODIFIED)));
345+
assertEquals(0, servletResponse.getContentAsByteArray().length);
346+
}
347+
348+
@Test
349+
public void handleReturnTypeEtag() throws Exception {
350+
String etagValue = "\"deadb33f8badf00d\"";
351+
servletRequest.addHeader(HttpHeaders.IF_NONE_MATCH, etagValue);
352+
HttpHeaders responseHeaders = new HttpHeaders();
353+
responseHeaders.set(HttpHeaders.ETAG, etagValue);
354+
ResponseEntity<String> returnValue = new ResponseEntity<String>("body", responseHeaders, HttpStatus.OK);
355+
356+
given(messageConverter.canWrite(String.class, null)).willReturn(true);
357+
given(messageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN));
358+
given(messageConverter.canWrite(String.class, MediaType.TEXT_PLAIN)).willReturn(true);
359+
360+
processor.handleReturnValue(returnValue, returnTypeResponseEntity, mavContainer, webRequest);
361+
362+
assertTrue(mavContainer.isRequestHandled());
363+
assertEquals(HttpStatus.NOT_MODIFIED.value(), servletResponse.getStatus());
364+
assertEquals(etagValue, servletResponse.getHeader(HttpHeaders.ETAG));
365+
assertEquals(0, servletResponse.getContentAsByteArray().length);
366+
}
367+
368+
@Test
369+
public void handleReturnTypeETagAndLastModified() throws Exception {
370+
long currentTime = new Date().getTime();
371+
long oneMinuteAgo = currentTime - (1000 * 60);
372+
String etagValue = "\"deadb33f8badf00d\"";
373+
servletRequest.addHeader(HttpHeaders.IF_MODIFIED_SINCE, currentTime);
374+
servletRequest.addHeader(HttpHeaders.IF_NONE_MATCH, etagValue);
375+
HttpHeaders responseHeaders = new HttpHeaders();
376+
responseHeaders.setDate(HttpHeaders.LAST_MODIFIED, oneMinuteAgo);
377+
responseHeaders.set(HttpHeaders.ETAG, etagValue);
378+
ResponseEntity<String> returnValue = new ResponseEntity<String>("body", responseHeaders, HttpStatus.OK);
379+
380+
given(messageConverter.canWrite(String.class, null)).willReturn(true);
381+
given(messageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN));
382+
given(messageConverter.canWrite(String.class, MediaType.TEXT_PLAIN)).willReturn(true);
383+
384+
processor.handleReturnValue(returnValue, returnTypeResponseEntity, mavContainer, webRequest);
385+
386+
assertTrue(mavContainer.isRequestHandled());
387+
assertEquals(HttpStatus.NOT_MODIFIED.value(), servletResponse.getStatus());
388+
assertEquals(oneMinuteAgo/1000 * 1000, Long.parseLong(servletResponse.getHeader(HttpHeaders.LAST_MODIFIED)));
389+
assertEquals(etagValue, servletResponse.getHeader(HttpHeaders.ETAG));
390+
assertEquals(0, servletResponse.getContentAsByteArray().length);
391+
}
392+
393+
@Test
394+
public void handleReturnTypeChangedETagAndLastModified() throws Exception {
395+
long currentTime = new Date().getTime();
396+
long oneMinuteAgo = currentTime - (1000 * 60);
397+
String etagValue = "\"deadb33f8badf00d\"";
398+
String changedEtagValue = "\"changed-etag-value\"";
399+
servletRequest.addHeader(HttpHeaders.IF_MODIFIED_SINCE, currentTime);
400+
servletRequest.addHeader(HttpHeaders.IF_NONE_MATCH, etagValue);
401+
HttpHeaders responseHeaders = new HttpHeaders();
402+
responseHeaders.setDate(HttpHeaders.LAST_MODIFIED, oneMinuteAgo);
403+
responseHeaders.set(HttpHeaders.ETAG, changedEtagValue);
404+
ResponseEntity<String> returnValue = new ResponseEntity<String>("body", responseHeaders, HttpStatus.OK);
405+
406+
given(messageConverter.canWrite(String.class, null)).willReturn(true);
407+
given(messageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN));
408+
given(messageConverter.canWrite(String.class, MediaType.TEXT_PLAIN)).willReturn(true);
409+
410+
processor.handleReturnValue(returnValue, returnTypeResponseEntity, mavContainer, webRequest);
411+
412+
assertTrue(mavContainer.isRequestHandled());
413+
assertEquals(HttpStatus.OK.value(), servletResponse.getStatus());
414+
assertEquals(oneMinuteAgo/1000 * 1000, Long.parseLong(servletResponse.getHeader(HttpHeaders.LAST_MODIFIED)));
415+
assertEquals(changedEtagValue, servletResponse.getHeader(HttpHeaders.ETAG));
416+
assertEquals(0, servletResponse.getContentAsByteArray().length);
417+
}
418+
323419
public ResponseEntity<String> handle1(HttpEntity<String> httpEntity, ResponseEntity<String> responseEntity, int i, RequestEntity<String> requestEntity) {
324420
return responseEntity;
325421
}

0 commit comments

Comments
 (0)