From 7a5e93ff16622cbffeac31f514fad11a7102c2cd Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Tue, 22 Mar 2016 21:47:55 -0400 Subject: [PATCH] Add support for setting the "Vary" response header Issue: SPR-14070 --- .../org/springframework/http/HttpHeaders.java | 19 ++++++ .../springframework/http/ResponseEntity.java | 17 ++++++ .../annotation/HttpEntityMethodProcessor.java | 37 ++++++++++- .../servlet/support/WebContentGenerator.java | 61 +++++++++++++++++++ .../HttpEntityMethodProcessorMockTests.java | 56 +++++++++++++++++ .../support/WebContentGeneratorTests.java | 58 +++++++++++++++++- 6 files changed, 246 insertions(+), 2 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java index 886b06a633..0c1d18f614 100644 --- a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java +++ b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java @@ -38,6 +38,7 @@ import java.util.TimeZone; import org.springframework.util.Assert; import org.springframework.util.LinkedCaseInsensitiveMap; import org.springframework.util.MultiValueMap; +import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; /** @@ -947,6 +948,24 @@ public class HttpHeaders implements MultiValueMap, Serializable return getFirst(UPGRADE); } + /** + * Set the request header names (e.g. "Accept-Language") for which the + * response is subject to content negotiation and variances based on the + * value of those request headers. + * @param requestHeaders the request header names + * @since 4.3 + */ + public void setVary(List requestHeaders) { + set(VARY, toCommaDelimitedString(requestHeaders)); + } + + /** + * Return the request header names subject to content negotiation. + */ + public List getVary() { + return getFirstValueAsList(VARY); + } + /** * Parse the first header value for the given header name as a date, * return -1 if there is no value, or raise {@link IllegalArgumentException} 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 9eba72c075..519d28798a 100644 --- a/spring-web/src/main/java/org/springframework/http/ResponseEntity.java +++ b/spring-web/src/main/java/org/springframework/http/ResponseEntity.java @@ -332,6 +332,17 @@ public class ResponseEntity extends HttpEntity { */ B cacheControl(CacheControl cacheControl); + /** + * Configure one or more request header names (e.g. "Accept-Language") to + * add to the "Vary" response header to inform clients that the response is + * subject to content negotiation and variances based on the value of the + * given request headers. The configured request header names are added only + * if not already present in the response "Vary" header. + * @param requestHeaders request header names + * @since 4.3 + */ + B varyBy(String... requestHeaders); + /** * Build the response entity with no body. * @return the response entity @@ -454,6 +465,12 @@ public class ResponseEntity extends HttpEntity { return this; } + @Override + public BodyBuilder varyBy(String... requestHeaders) { + this.headers.setVary(Arrays.asList(requestHeaders)); + return this; + } + @Override public ResponseEntity build() { return new ResponseEntity(null, this.headers, this.status); 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 f69a1c2e3e..a169dc795d 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 @@ -19,7 +19,10 @@ package org.springframework.web.servlet.mvc.method.annotation; import java.io.IOException; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Map; import org.springframework.core.MethodParameter; import org.springframework.core.ResolvableType; @@ -40,6 +43,8 @@ import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.method.support.ModelAndViewContainer; +import static org.springframework.http.HttpHeaders.VARY; + /** * Resolves {@link HttpEntity} and {@link RequestEntity} method argument values * and also handles {@link HttpEntity} and {@link ResponseEntity} return values. @@ -162,9 +167,18 @@ public class HttpEntityMethodProcessor extends AbstractMessageConverterMethodPro Assert.isInstanceOf(HttpEntity.class, returnValue); HttpEntity responseEntity = (HttpEntity) returnValue; + HttpHeaders outputHeaders = outputMessage.getHeaders(); HttpHeaders entityHeaders = responseEntity.getHeaders(); + if (outputHeaders.containsKey(VARY) && entityHeaders.containsKey(VARY)) { + List values = getVaryRequestHeadersToAdd(outputHeaders, entityHeaders); + if (!values.isEmpty()) { + outputHeaders.setVary(values); + } + } if (!entityHeaders.isEmpty()) { - outputMessage.getHeaders().putAll(entityHeaders); + for (Map.Entry> entry : entityHeaders.entrySet()) { + outputHeaders.putIfAbsent(entry.getKey(), entry.getValue()); + } } Object body = responseEntity.getBody(); @@ -188,6 +202,27 @@ public class HttpEntityMethodProcessor extends AbstractMessageConverterMethodPro outputMessage.flush(); } + private List getVaryRequestHeadersToAdd(HttpHeaders responseHeaders, HttpHeaders entityHeaders) { + if (!responseHeaders.containsKey(HttpHeaders.VARY)) { + return entityHeaders.getVary(); + } + List entityHeadersVary = entityHeaders.getVary(); + List result = new ArrayList(entityHeadersVary); + for (String header : responseHeaders.get(HttpHeaders.VARY)) { + for (String existing : StringUtils.tokenizeToStringArray(header, ",")) { + if ("*".equals(existing)) { + return Collections.emptyList(); + } + for (String value : entityHeadersVary) { + if (value.equalsIgnoreCase(existing)) { + result.remove(value); + } + } + } + } + return result; + } + private boolean isResourceNotModified(ServletServerHttpRequest inputMessage, ServletServerHttpResponse outputMessage) { List ifNoneMatch = inputMessage.getHeaders().getIfNoneMatch(); long ifModifiedSince = inputMessage.getHeaders().getIfModifiedSince(); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/support/WebContentGenerator.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/support/WebContentGenerator.java index 130467b6e4..68b6573a4d 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/support/WebContentGenerator.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/support/WebContentGenerator.java @@ -19,6 +19,7 @@ package org.springframework.web.servlet.support; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.LinkedHashSet; import java.util.Set; import java.util.concurrent.TimeUnit; @@ -27,7 +28,9 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; +import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; import org.springframework.web.HttpRequestMethodNotSupportedException; @@ -77,6 +80,10 @@ public abstract class WebContentGenerator extends WebApplicationObjectSupport { protected static final String HEADER_CACHE_CONTROL = "Cache-Control"; + /** Checking for Servlet 3.0+ HttpServletResponse.getHeaders(String) */ + private static final boolean servlet3Present = + ClassUtils.hasMethod(HttpServletResponse.class, "getHeaders", String.class); + /** Set of supported HTTP methods */ private Set supportedMethods; @@ -89,6 +96,11 @@ public abstract class WebContentGenerator extends WebApplicationObjectSupport { private int cacheSeconds = -1; + private String[] varyByRequestHeaders; + + + // deprecated fields + /** Use HTTP 1.0 expires header? */ private boolean useExpiresHeader = false; @@ -245,6 +257,29 @@ public abstract class WebContentGenerator extends WebApplicationObjectSupport { return this.cacheSeconds; } + /** + * Configure one or more request header names (e.g. "Accept-Language") to + * add to the "Vary" response header to inform clients that the response is + * subject to content negotiation and variances based on the value of the + * given request headers. The configured request header names are added only + * if not already present in the response "Vary" header. + * + *

Note: this property is only supported on Servlet 3.0+ + * which allows checking existing response header values. + * @param varyByRequestHeaders one or more request header names + * @since 4.3 + */ + public void setVaryByRequestHeaders(String... varyByRequestHeaders) { + this.varyByRequestHeaders = varyByRequestHeaders; + } + + /** + * Return the configured request header names for the "Vary" response header. + */ + public String[] getVaryByRequestHeaders() { + return this.varyByRequestHeaders; + } + /** * Set whether to use the HTTP 1.0 expires header. Default is "false", * as of 4.2. @@ -363,6 +398,11 @@ public abstract class WebContentGenerator extends WebApplicationObjectSupport { else { applyCacheSeconds(response, this.cacheSeconds); } + if (servlet3Present && this.varyByRequestHeaders != null) { + for (String value : getVaryRequestHeadersToAdd(response)) { + response.addHeader("Vary", value); + } + } } /** @@ -546,4 +586,25 @@ public abstract class WebContentGenerator extends WebApplicationObjectSupport { } } + private Collection getVaryRequestHeadersToAdd(HttpServletResponse response) { + if (!response.containsHeader(HttpHeaders.VARY)) { + return Arrays.asList(getVaryByRequestHeaders()); + } + Collection result = new ArrayList(getVaryByRequestHeaders().length); + Collections.addAll(result, getVaryByRequestHeaders()); + for (String header : response.getHeaders(HttpHeaders.VARY)) { + for (String existing : StringUtils.tokenizeToStringArray(header, ",")) { + if ("*".equals(existing)) { + return Collections.emptyList(); + } + for (String value : getVaryByRequestHeaders()) { + if (value.equalsIgnoreCase(existing)) { + result.remove(value); + } + } + } + } + return result; + } + } 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 d5ed3d646d..1dca78ed4a 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 @@ -21,6 +21,7 @@ import java.net.URI; import java.nio.charset.Charset; import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.List; @@ -517,6 +518,47 @@ public class HttpEntityMethodProcessorMockTests { assertEquals(etagValue, servletResponse.getHeader(HttpHeaders.ETAG)); } + @Test + public void varyHeader() throws Exception { + String[] entityValues = {"Accept-Language", "User-Agent"}; + String[] existingValues = {}; + String[] expected = {"Accept-Language, User-Agent"}; + testVaryHeader(entityValues, existingValues, expected); + } + + @Test + public void varyHeaderWithExistingWildcard() throws Exception { + String[] entityValues = {"Accept-Language"}; + String[] existingValues = {"*"}; + String[] expected = {"*"}; + testVaryHeader(entityValues, existingValues, expected); + } + + @Test + public void varyHeaderWithExistingCommaValues() throws Exception { + String[] entityValues = {"Accept-Language", "User-Agent"}; + String[] existingValues = {"Accept-Encoding", "Accept-Language"}; + String[] expected = {"Accept-Encoding", "Accept-Language", "User-Agent"}; + testVaryHeader(entityValues, existingValues, expected); + } + + @Test + public void varyHeaderWithExistingCommaSeparatedValues() throws Exception { + String[] entityValues = {"Accept-Language", "User-Agent"}; + String[] existingValues = {"Accept-Encoding, Accept-Language"}; + String[] expected = {"Accept-Encoding, Accept-Language", "User-Agent"}; + testVaryHeader(entityValues, existingValues, expected); + } + + @Test + public void handleReturnValueVaryHeader() throws Exception { + String[] entityValues = {"Accept-Language", "User-Agent"}; + String[] existingValues = {"Accept-Encoding, Accept-Language"}; + String[] expected = {"Accept-Encoding, Accept-Language", "User-Agent"}; + testVaryHeader(entityValues, existingValues, expected); + } + + private void initStringMessageConversion(MediaType accepted) { given(messageConverter.canWrite(String.class, null)).willReturn(true); given(messageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN)); @@ -536,6 +578,20 @@ public class HttpEntityMethodProcessorMockTests { verify(messageConverter).write(eq(body), eq(MediaType.TEXT_PLAIN), outputMessage.capture()); } + private void testVaryHeader(String[] entityValues, String[] existingValues, String[] expected) throws Exception { + ResponseEntity returnValue = ResponseEntity.ok().varyBy(entityValues).body("Foo"); + for (String value : existingValues) { + servletResponse.addHeader("Vary", value); + } + initStringMessageConversion(MediaType.TEXT_PLAIN); + processor.handleReturnValue(returnValue, returnTypeResponseEntity, mavContainer, webRequest); + + assertTrue(mavContainer.isRequestHandled()); + assertEquals(Arrays.asList(expected), servletResponse.getHeaders("Vary")); + verify(messageConverter).write(eq("Foo"), eq(MediaType.TEXT_PLAIN), isA(HttpOutputMessage.class)); + } + + @SuppressWarnings("unused") public ResponseEntity handle1(HttpEntity httpEntity, ResponseEntity entity, int i, RequestEntity requestEntity) { diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/support/WebContentGeneratorTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/support/WebContentGeneratorTests.java index 4ee9436bff..28563fe278 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/support/WebContentGeneratorTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/support/WebContentGeneratorTests.java @@ -15,9 +15,14 @@ */ package org.springframework.web.servlet.support; +import java.util.Arrays; + import org.junit.Test; +import org.springframework.mock.web.test.MockHttpServletResponse; + import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; /** * Unit tests for {@link WebContentGenerator}. @@ -25,7 +30,6 @@ import static org.junit.Assert.assertEquals; */ public class WebContentGeneratorTests { - @Test public void getAllowHeaderWithConstructorTrue() throws Exception { WebContentGenerator generator = new TestWebContentGenerator(true); @@ -59,6 +63,58 @@ public class WebContentGeneratorTests { "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS", generator.getAllowHeader()); } + @Test + public void varyHeaderNone() throws Exception { + WebContentGenerator generator = new TestWebContentGenerator(); + MockHttpServletResponse response = new MockHttpServletResponse(); + generator.prepareResponse(response); + + assertNull(response.getHeader("Vary")); + } + + @Test + public void varyHeader() throws Exception { + String[] configuredValues = {"Accept-Language", "User-Agent"}; + String[] responseValues = {}; + String[] expected = {"Accept-Language", "User-Agent"}; + testVaryHeader(configuredValues, responseValues, expected); + } + + @Test + public void varyHeaderWithExistingWildcard() throws Exception { + String[] configuredValues = {"Accept-Language"}; + String[] responseValues = {"*"}; + String[] expected = {"*"}; + testVaryHeader(configuredValues, responseValues, expected); + } + + @Test + public void varyHeaderWithExistingCommaValues() throws Exception { + String[] configuredValues = {"Accept-Language", "User-Agent"}; + String[] responseValues = {"Accept-Encoding", "Accept-Language"}; + String[] expected = {"Accept-Encoding", "Accept-Language", "User-Agent"}; + testVaryHeader(configuredValues, responseValues, expected); + } + + @Test + public void varyHeaderWithExistingCommaSeparatedValues() throws Exception { + String[] configuredValues = {"Accept-Language", "User-Agent"}; + String[] responseValues = {"Accept-Encoding, Accept-Language"}; + String[] expected = {"Accept-Encoding, Accept-Language", "User-Agent"}; + testVaryHeader(configuredValues, responseValues, expected); + } + + private void testVaryHeader(String[] configuredValues, String[] responseValues, String[] expected) { + WebContentGenerator generator = new TestWebContentGenerator(); + generator.setVaryByRequestHeaders(configuredValues); + MockHttpServletResponse response = new MockHttpServletResponse(); + for (String value : responseValues) { + response.addHeader("Vary", value); + } + generator.prepareResponse(response); + assertEquals(Arrays.asList(expected), response.getHeaderValues("Vary")); + } + private static class TestWebContentGenerator extends WebContentGenerator {