Skip to content

Commit 7a5e93f

Browse files
committed
Add support for setting the "Vary" response header
Issue: SPR-14070
1 parent 6bfe0c0 commit 7a5e93f

File tree

6 files changed

+246
-2
lines changed

6 files changed

+246
-2
lines changed

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import org.springframework.util.Assert;
3939
import org.springframework.util.LinkedCaseInsensitiveMap;
4040
import org.springframework.util.MultiValueMap;
41+
import org.springframework.util.ObjectUtils;
4142
import org.springframework.util.StringUtils;
4243

4344
/**
@@ -947,6 +948,24 @@ public String getUpgrade() {
947948
return getFirst(UPGRADE);
948949
}
949950

951+
/**
952+
* Set the request header names (e.g. "Accept-Language") for which the
953+
* response is subject to content negotiation and variances based on the
954+
* value of those request headers.
955+
* @param requestHeaders the request header names
956+
* @since 4.3
957+
*/
958+
public void setVary(List<String> requestHeaders) {
959+
set(VARY, toCommaDelimitedString(requestHeaders));
960+
}
961+
962+
/**
963+
* Return the request header names subject to content negotiation.
964+
*/
965+
public List<String> getVary() {
966+
return getFirstValueAsList(VARY);
967+
}
968+
950969
/**
951970
* Parse the first header value for the given header name as a date,
952971
* return -1 if there is no value, or raise {@link IllegalArgumentException}

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,17 @@ public interface HeadersBuilder<B extends HeadersBuilder<B>> {
332332
*/
333333
B cacheControl(CacheControl cacheControl);
334334

335+
/**
336+
* Configure one or more request header names (e.g. "Accept-Language") to
337+
* add to the "Vary" response header to inform clients that the response is
338+
* subject to content negotiation and variances based on the value of the
339+
* given request headers. The configured request header names are added only
340+
* if not already present in the response "Vary" header.
341+
* @param requestHeaders request header names
342+
* @since 4.3
343+
*/
344+
B varyBy(String... requestHeaders);
345+
335346
/**
336347
* Build the response entity with no body.
337348
* @return the response entity
@@ -454,6 +465,12 @@ public BodyBuilder cacheControl(CacheControl cacheControl) {
454465
return this;
455466
}
456467

468+
@Override
469+
public BodyBuilder varyBy(String... requestHeaders) {
470+
this.headers.setVary(Arrays.asList(requestHeaders));
471+
return this;
472+
}
473+
457474
@Override
458475
public ResponseEntity<Void> build() {
459476
return new ResponseEntity<Void>(null, this.headers, this.status);

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@
1919
import java.io.IOException;
2020
import java.lang.reflect.ParameterizedType;
2121
import java.lang.reflect.Type;
22+
import java.util.ArrayList;
23+
import java.util.Collections;
2224
import java.util.List;
25+
import java.util.Map;
2326

2427
import org.springframework.core.MethodParameter;
2528
import org.springframework.core.ResolvableType;
@@ -40,6 +43,8 @@
4043
import org.springframework.web.context.request.NativeWebRequest;
4144
import org.springframework.web.method.support.ModelAndViewContainer;
4245

46+
import static org.springframework.http.HttpHeaders.VARY;
47+
4348
/**
4449
* Resolves {@link HttpEntity} and {@link RequestEntity} method argument values
4550
* and also handles {@link HttpEntity} and {@link ResponseEntity} return values.
@@ -162,9 +167,18 @@ public void handleReturnValue(Object returnValue, MethodParameter returnType,
162167
Assert.isInstanceOf(HttpEntity.class, returnValue);
163168
HttpEntity<?> responseEntity = (HttpEntity<?>) returnValue;
164169

170+
HttpHeaders outputHeaders = outputMessage.getHeaders();
165171
HttpHeaders entityHeaders = responseEntity.getHeaders();
172+
if (outputHeaders.containsKey(VARY) && entityHeaders.containsKey(VARY)) {
173+
List<String> values = getVaryRequestHeadersToAdd(outputHeaders, entityHeaders);
174+
if (!values.isEmpty()) {
175+
outputHeaders.setVary(values);
176+
}
177+
}
166178
if (!entityHeaders.isEmpty()) {
167-
outputMessage.getHeaders().putAll(entityHeaders);
179+
for (Map.Entry<String, List<String>> entry : entityHeaders.entrySet()) {
180+
outputHeaders.putIfAbsent(entry.getKey(), entry.getValue());
181+
}
168182
}
169183

170184
Object body = responseEntity.getBody();
@@ -188,6 +202,27 @@ public void handleReturnValue(Object returnValue, MethodParameter returnType,
188202
outputMessage.flush();
189203
}
190204

205+
private List<String> getVaryRequestHeadersToAdd(HttpHeaders responseHeaders, HttpHeaders entityHeaders) {
206+
if (!responseHeaders.containsKey(HttpHeaders.VARY)) {
207+
return entityHeaders.getVary();
208+
}
209+
List<String> entityHeadersVary = entityHeaders.getVary();
210+
List<String> result = new ArrayList<String>(entityHeadersVary);
211+
for (String header : responseHeaders.get(HttpHeaders.VARY)) {
212+
for (String existing : StringUtils.tokenizeToStringArray(header, ",")) {
213+
if ("*".equals(existing)) {
214+
return Collections.emptyList();
215+
}
216+
for (String value : entityHeadersVary) {
217+
if (value.equalsIgnoreCase(existing)) {
218+
result.remove(value);
219+
}
220+
}
221+
}
222+
}
223+
return result;
224+
}
225+
191226
private boolean isResourceNotModified(ServletServerHttpRequest inputMessage, ServletServerHttpResponse outputMessage) {
192227
List<String> ifNoneMatch = inputMessage.getHeaders().getIfNoneMatch();
193228
long ifModifiedSince = inputMessage.getHeaders().getIfModifiedSince();

spring-webmvc/src/main/java/org/springframework/web/servlet/support/WebContentGenerator.java

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import java.util.ArrayList;
2020
import java.util.Arrays;
2121
import java.util.Collection;
22+
import java.util.Collections;
2223
import java.util.LinkedHashSet;
2324
import java.util.Set;
2425
import java.util.concurrent.TimeUnit;
@@ -27,7 +28,9 @@
2728
import javax.servlet.http.HttpServletResponse;
2829

2930
import org.springframework.http.CacheControl;
31+
import org.springframework.http.HttpHeaders;
3032
import org.springframework.http.HttpMethod;
33+
import org.springframework.util.ClassUtils;
3134
import org.springframework.util.ObjectUtils;
3235
import org.springframework.util.StringUtils;
3336
import org.springframework.web.HttpRequestMethodNotSupportedException;
@@ -77,6 +80,10 @@ public abstract class WebContentGenerator extends WebApplicationObjectSupport {
7780

7881
protected static final String HEADER_CACHE_CONTROL = "Cache-Control";
7982

83+
/** Checking for Servlet 3.0+ HttpServletResponse.getHeaders(String) */
84+
private static final boolean servlet3Present =
85+
ClassUtils.hasMethod(HttpServletResponse.class, "getHeaders", String.class);
86+
8087

8188
/** Set of supported HTTP methods */
8289
private Set<String> supportedMethods;
@@ -89,6 +96,11 @@ public abstract class WebContentGenerator extends WebApplicationObjectSupport {
8996

9097
private int cacheSeconds = -1;
9198

99+
private String[] varyByRequestHeaders;
100+
101+
102+
// deprecated fields
103+
92104
/** Use HTTP 1.0 expires header? */
93105
private boolean useExpiresHeader = false;
94106

@@ -245,6 +257,29 @@ public final int getCacheSeconds() {
245257
return this.cacheSeconds;
246258
}
247259

260+
/**
261+
* Configure one or more request header names (e.g. "Accept-Language") to
262+
* add to the "Vary" response header to inform clients that the response is
263+
* subject to content negotiation and variances based on the value of the
264+
* given request headers. The configured request header names are added only
265+
* if not already present in the response "Vary" header.
266+
*
267+
* <p><strong>Note:</strong> this property is only supported on Servlet 3.0+
268+
* which allows checking existing response header values.
269+
* @param varyByRequestHeaders one or more request header names
270+
* @since 4.3
271+
*/
272+
public void setVaryByRequestHeaders(String... varyByRequestHeaders) {
273+
this.varyByRequestHeaders = varyByRequestHeaders;
274+
}
275+
276+
/**
277+
* Return the configured request header names for the "Vary" response header.
278+
*/
279+
public String[] getVaryByRequestHeaders() {
280+
return this.varyByRequestHeaders;
281+
}
282+
248283
/**
249284
* Set whether to use the HTTP 1.0 expires header. Default is "false",
250285
* as of 4.2.
@@ -363,6 +398,11 @@ protected final void prepareResponse(HttpServletResponse response) {
363398
else {
364399
applyCacheSeconds(response, this.cacheSeconds);
365400
}
401+
if (servlet3Present && this.varyByRequestHeaders != null) {
402+
for (String value : getVaryRequestHeadersToAdd(response)) {
403+
response.addHeader("Vary", value);
404+
}
405+
}
366406
}
367407

368408
/**
@@ -546,4 +586,25 @@ protected final void preventCaching(HttpServletResponse response) {
546586
}
547587
}
548588

589+
private Collection<String> getVaryRequestHeadersToAdd(HttpServletResponse response) {
590+
if (!response.containsHeader(HttpHeaders.VARY)) {
591+
return Arrays.asList(getVaryByRequestHeaders());
592+
}
593+
Collection<String> result = new ArrayList<String>(getVaryByRequestHeaders().length);
594+
Collections.addAll(result, getVaryByRequestHeaders());
595+
for (String header : response.getHeaders(HttpHeaders.VARY)) {
596+
for (String existing : StringUtils.tokenizeToStringArray(header, ",")) {
597+
if ("*".equals(existing)) {
598+
return Collections.emptyList();
599+
}
600+
for (String value : getVaryByRequestHeaders()) {
601+
if (value.equalsIgnoreCase(existing)) {
602+
result.remove(value);
603+
}
604+
}
605+
}
606+
}
607+
return result;
608+
}
609+
549610
}

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import java.nio.charset.Charset;
2222
import java.text.SimpleDateFormat;
2323
import java.util.ArrayList;
24+
import java.util.Arrays;
2425
import java.util.Collections;
2526
import java.util.Date;
2627
import java.util.List;
@@ -517,6 +518,47 @@ public void handleReturnValueIfNoneMatchIfUnmodifiedSince() throws Exception {
517518
assertEquals(etagValue, servletResponse.getHeader(HttpHeaders.ETAG));
518519
}
519520

521+
@Test
522+
public void varyHeader() throws Exception {
523+
String[] entityValues = {"Accept-Language", "User-Agent"};
524+
String[] existingValues = {};
525+
String[] expected = {"Accept-Language, User-Agent"};
526+
testVaryHeader(entityValues, existingValues, expected);
527+
}
528+
529+
@Test
530+
public void varyHeaderWithExistingWildcard() throws Exception {
531+
String[] entityValues = {"Accept-Language"};
532+
String[] existingValues = {"*"};
533+
String[] expected = {"*"};
534+
testVaryHeader(entityValues, existingValues, expected);
535+
}
536+
537+
@Test
538+
public void varyHeaderWithExistingCommaValues() throws Exception {
539+
String[] entityValues = {"Accept-Language", "User-Agent"};
540+
String[] existingValues = {"Accept-Encoding", "Accept-Language"};
541+
String[] expected = {"Accept-Encoding", "Accept-Language", "User-Agent"};
542+
testVaryHeader(entityValues, existingValues, expected);
543+
}
544+
545+
@Test
546+
public void varyHeaderWithExistingCommaSeparatedValues() throws Exception {
547+
String[] entityValues = {"Accept-Language", "User-Agent"};
548+
String[] existingValues = {"Accept-Encoding, Accept-Language"};
549+
String[] expected = {"Accept-Encoding, Accept-Language", "User-Agent"};
550+
testVaryHeader(entityValues, existingValues, expected);
551+
}
552+
553+
@Test
554+
public void handleReturnValueVaryHeader() throws Exception {
555+
String[] entityValues = {"Accept-Language", "User-Agent"};
556+
String[] existingValues = {"Accept-Encoding, Accept-Language"};
557+
String[] expected = {"Accept-Encoding, Accept-Language", "User-Agent"};
558+
testVaryHeader(entityValues, existingValues, expected);
559+
}
560+
561+
520562
private void initStringMessageConversion(MediaType accepted) {
521563
given(messageConverter.canWrite(String.class, null)).willReturn(true);
522564
given(messageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN));
@@ -536,6 +578,20 @@ private void assertResponseOkWithBody(String body) throws Exception {
536578
verify(messageConverter).write(eq(body), eq(MediaType.TEXT_PLAIN), outputMessage.capture());
537579
}
538580

581+
private void testVaryHeader(String[] entityValues, String[] existingValues, String[] expected) throws Exception {
582+
ResponseEntity<String> returnValue = ResponseEntity.ok().varyBy(entityValues).body("Foo");
583+
for (String value : existingValues) {
584+
servletResponse.addHeader("Vary", value);
585+
}
586+
initStringMessageConversion(MediaType.TEXT_PLAIN);
587+
processor.handleReturnValue(returnValue, returnTypeResponseEntity, mavContainer, webRequest);
588+
589+
assertTrue(mavContainer.isRequestHandled());
590+
assertEquals(Arrays.asList(expected), servletResponse.getHeaders("Vary"));
591+
verify(messageConverter).write(eq("Foo"), eq(MediaType.TEXT_PLAIN), isA(HttpOutputMessage.class));
592+
}
593+
594+
539595
@SuppressWarnings("unused")
540596
public ResponseEntity<String> handle1(HttpEntity<String> httpEntity, ResponseEntity<String> entity,
541597
int i, RequestEntity<String> requestEntity) {

spring-webmvc/src/test/java/org/springframework/web/servlet/support/WebContentGeneratorTests.java

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,21 @@
1515
*/
1616
package org.springframework.web.servlet.support;
1717

18+
import java.util.Arrays;
19+
1820
import org.junit.Test;
1921

22+
import org.springframework.mock.web.test.MockHttpServletResponse;
23+
2024
import static org.junit.Assert.assertEquals;
25+
import static org.junit.Assert.assertNull;
2126

2227
/**
2328
* Unit tests for {@link WebContentGenerator}.
2429
* @author Rossen Stoyanchev
2530
*/
2631
public class WebContentGeneratorTests {
2732

28-
2933
@Test
3034
public void getAllowHeaderWithConstructorTrue() throws Exception {
3135
WebContentGenerator generator = new TestWebContentGenerator(true);
@@ -59,6 +63,58 @@ public void getAllowHeaderWithSupportedMethodsSetterEmpty() throws Exception {
5963
"GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS", generator.getAllowHeader());
6064
}
6165

66+
@Test
67+
public void varyHeaderNone() throws Exception {
68+
WebContentGenerator generator = new TestWebContentGenerator();
69+
MockHttpServletResponse response = new MockHttpServletResponse();
70+
generator.prepareResponse(response);
71+
72+
assertNull(response.getHeader("Vary"));
73+
}
74+
75+
@Test
76+
public void varyHeader() throws Exception {
77+
String[] configuredValues = {"Accept-Language", "User-Agent"};
78+
String[] responseValues = {};
79+
String[] expected = {"Accept-Language", "User-Agent"};
80+
testVaryHeader(configuredValues, responseValues, expected);
81+
}
82+
83+
@Test
84+
public void varyHeaderWithExistingWildcard() throws Exception {
85+
String[] configuredValues = {"Accept-Language"};
86+
String[] responseValues = {"*"};
87+
String[] expected = {"*"};
88+
testVaryHeader(configuredValues, responseValues, expected);
89+
}
90+
91+
@Test
92+
public void varyHeaderWithExistingCommaValues() throws Exception {
93+
String[] configuredValues = {"Accept-Language", "User-Agent"};
94+
String[] responseValues = {"Accept-Encoding", "Accept-Language"};
95+
String[] expected = {"Accept-Encoding", "Accept-Language", "User-Agent"};
96+
testVaryHeader(configuredValues, responseValues, expected);
97+
}
98+
99+
@Test
100+
public void varyHeaderWithExistingCommaSeparatedValues() throws Exception {
101+
String[] configuredValues = {"Accept-Language", "User-Agent"};
102+
String[] responseValues = {"Accept-Encoding, Accept-Language"};
103+
String[] expected = {"Accept-Encoding, Accept-Language", "User-Agent"};
104+
testVaryHeader(configuredValues, responseValues, expected);
105+
}
106+
107+
private void testVaryHeader(String[] configuredValues, String[] responseValues, String[] expected) {
108+
WebContentGenerator generator = new TestWebContentGenerator();
109+
generator.setVaryByRequestHeaders(configuredValues);
110+
MockHttpServletResponse response = new MockHttpServletResponse();
111+
for (String value : responseValues) {
112+
response.addHeader("Vary", value);
113+
}
114+
generator.prepareResponse(response);
115+
assertEquals(Arrays.asList(expected), response.getHeaderValues("Vary"));
116+
}
117+
62118

63119
private static class TestWebContentGenerator extends WebContentGenerator {
64120

0 commit comments

Comments
 (0)