Skip to content

Commit 55dae61

Browse files
committed
Improve multi-valued HTTP headers support
Prior to this change, getting header values with `HttpHeaders` when headers are multi-valued would cause issues. For example, for a given HTTP message with headers: Cache-Control: public, s-maxage=50 Cache-Control: max-age=42 Getting a `List` of all values would return <"public", "s-maxage=50"> and getting the header value would return "public, s-maxage=50". This commit takes now into account multi-valued HTTP headers and adds new getters/setters for "If-Match" and "If-Unmodified-Since" headers. Note that for ETag-related headers such as "If-Match" and "If-None-Match", a special parser has been implemented since ETag values can contain separator characters. Issue: SPR-14223, SPR-14228
1 parent 15138ed commit 55dae61

File tree

3 files changed

+189
-33
lines changed

3 files changed

+189
-33
lines changed

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

Lines changed: 128 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
import java.util.Map;
3535
import java.util.Set;
3636
import java.util.TimeZone;
37+
import java.util.regex.Matcher;
38+
import java.util.regex.Pattern;
3739

3840
import org.springframework.util.Assert;
3941
import org.springframework.util.LinkedCaseInsensitiveMap;
@@ -55,6 +57,7 @@
5557
*
5658
* @author Arjen Poutsma
5759
* @author Sebastien Deleuze
60+
* @author Brian Clozel
5861
* @since 3.0
5962
*/
6063
public class HttpHeaders implements MultiValueMap<String, String>, Serializable {
@@ -372,6 +375,12 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
372375
"EEE MMM dd HH:mm:ss yyyy"
373376
};
374377

378+
/**
379+
* Pattern matching ETag multiple field values in headers such as "If-Match", "If-None-Match"
380+
* @see <a href="https://tools.ietf.org/html/rfc7232#section-2.3">Section 2.3 of RFC 7232</a>
381+
*/
382+
private static final Pattern ETAG_HEADER_VALUE_PATTERN = Pattern.compile("\\*|\\s*((W\\/)?(\"[^\"]*\"))\\s*,?");
383+
375384
private static TimeZone GMT = TimeZone.getTimeZone("GMT");
376385

377386

@@ -459,7 +468,7 @@ public void setAccessControlAllowHeaders(List<String> allowedHeaders) {
459468
* Returns the value of the {@code Access-Control-Allow-Headers} response header.
460469
*/
461470
public List<String> getAccessControlAllowHeaders() {
462-
return getFirstValueAsList(ACCESS_CONTROL_ALLOW_HEADERS);
471+
return getValuesAsList(ACCESS_CONTROL_ALLOW_HEADERS);
463472
}
464473

465474
/**
@@ -476,7 +485,7 @@ public List<HttpMethod> getAccessControlAllowMethods() {
476485
List<HttpMethod> result = new ArrayList<HttpMethod>();
477486
String value = getFirst(ACCESS_CONTROL_ALLOW_METHODS);
478487
if (value != null) {
479-
String[] tokens = value.split(",\\s*");
488+
String[] tokens = StringUtils.tokenizeToStringArray(value, ",", true, true);
480489
for (String token : tokens) {
481490
HttpMethod resolved = HttpMethod.resolve(token);
482491
if (resolved != null) {
@@ -498,7 +507,23 @@ public void setAccessControlAllowOrigin(String allowedOrigin) {
498507
* Return the value of the {@code Access-Control-Allow-Origin} response header.
499508
*/
500509
public String getAccessControlAllowOrigin() {
501-
return getFirst(ACCESS_CONTROL_ALLOW_ORIGIN);
510+
return getFieldValues(ACCESS_CONTROL_ALLOW_ORIGIN);
511+
}
512+
513+
protected String getFieldValues(String headerName) {
514+
List<String> headerValues = this.headers.get(headerName);
515+
if (headerValues != null) {
516+
StringBuilder builder = new StringBuilder();
517+
for (Iterator<String> iterator = headerValues.iterator(); iterator.hasNext(); ) {
518+
String ifNoneMatch = iterator.next();
519+
builder.append(ifNoneMatch);
520+
if (iterator.hasNext()) {
521+
builder.append(", ");
522+
}
523+
}
524+
return builder.toString();
525+
}
526+
return null;
502527
}
503528

504529
/**
@@ -512,7 +537,7 @@ public void setAccessControlExposeHeaders(List<String> exposedHeaders) {
512537
* Returns the value of the {@code Access-Control-Expose-Headers} response header.
513538
*/
514539
public List<String> getAccessControlExposeHeaders() {
515-
return getFirstValueAsList(ACCESS_CONTROL_EXPOSE_HEADERS);
540+
return getValuesAsList(ACCESS_CONTROL_EXPOSE_HEADERS);
516541
}
517542

518543
/**
@@ -542,7 +567,7 @@ public void setAccessControlRequestHeaders(List<String> requestHeaders) {
542567
* Returns the value of the {@code Access-Control-Request-Headers} request header.
543568
*/
544569
public List<String> getAccessControlRequestHeaders() {
545-
return getFirstValueAsList(ACCESS_CONTROL_REQUEST_HEADERS);
570+
return getValuesAsList(ACCESS_CONTROL_REQUEST_HEADERS);
546571
}
547572

548573
/**
@@ -643,7 +668,7 @@ public void setCacheControl(String cacheControl) {
643668
* Return the value of the {@code Cache-Control} header.
644669
*/
645670
public String getCacheControl() {
646-
return getFirst(CACHE_CONTROL);
671+
return getFieldValues(CACHE_CONTROL);
647672
}
648673

649674
/**
@@ -664,7 +689,7 @@ public void setConnection(List<String> connection) {
664689
* Return the value of the {@code Connection} header.
665690
*/
666691
public List<String> getConnection() {
667-
return getFirstValueAsList(CONNECTION);
692+
return getValuesAsList(CONNECTION);
668693
}
669694

670695
/**
@@ -782,6 +807,64 @@ public long getExpires() {
782807
return getFirstDate(EXPIRES, false);
783808
}
784809

810+
/**
811+
* Set the (new) value of the {@code If-Match} header.
812+
*/
813+
public void setIfMatch(String ifMatch) {
814+
set(IF_MATCH, ifMatch);
815+
}
816+
817+
/**
818+
* Set the (new) value of the {@code If-Match} header.
819+
*/
820+
public void setIfMatch(List<String> ifMatchList) {
821+
set(IF_MATCH, toCommaDelimitedString(ifMatchList));
822+
}
823+
824+
protected String toCommaDelimitedString(List<String> list) {
825+
StringBuilder builder = new StringBuilder();
826+
for (Iterator<String> iterator = list.iterator(); iterator.hasNext(); ) {
827+
String ifNoneMatch = iterator.next();
828+
builder.append(ifNoneMatch);
829+
if (iterator.hasNext()) {
830+
builder.append(", ");
831+
}
832+
}
833+
return builder.toString();
834+
}
835+
836+
/**
837+
* Return the value of the {@code If-Match} header.
838+
*/
839+
public List<String> getIfMatch() {
840+
return getETagValuesAsList(IF_MATCH);
841+
}
842+
843+
protected List<String> getETagValuesAsList(String headerName) {
844+
List<String> values = get(headerName);
845+
if (values != null) {
846+
List<String> result = new ArrayList<String>();
847+
for (String value : values) {
848+
if (value != null) {
849+
Matcher matcher = ETAG_HEADER_VALUE_PATTERN.matcher(value);
850+
while (matcher.find()) {
851+
if ("*".equals(matcher.group())) {
852+
result.add(matcher.group());
853+
}
854+
else {
855+
result.add(matcher.group(1));
856+
}
857+
}
858+
if(result.size() == 0) {
859+
throw new IllegalArgumentException("Could not parse '" + headerName + "' value=" + value);
860+
}
861+
}
862+
}
863+
return result;
864+
}
865+
return Collections.emptyList();
866+
}
867+
785868
/**
786869
* Set the (new) value of the {@code If-Modified-Since} header.
787870
* <p>The date should be specified as the number of milliseconds since
@@ -814,35 +897,51 @@ public void setIfNoneMatch(List<String> ifNoneMatchList) {
814897
set(IF_NONE_MATCH, toCommaDelimitedString(ifNoneMatchList));
815898
}
816899

817-
protected String toCommaDelimitedString(List<String> list) {
818-
StringBuilder builder = new StringBuilder();
819-
for (Iterator<String> iterator = list.iterator(); iterator.hasNext();) {
820-
String ifNoneMatch = iterator.next();
821-
builder.append(ifNoneMatch);
822-
if (iterator.hasNext()) {
823-
builder.append(", ");
824-
}
825-
}
826-
return builder.toString();
827-
}
828-
829900
/**
830901
* Return the value of the {@code If-None-Match} header.
831902
*/
832903
public List<String> getIfNoneMatch() {
833-
return getFirstValueAsList(IF_NONE_MATCH);
904+
return getETagValuesAsList(IF_NONE_MATCH);
834905
}
835906

836-
protected List<String> getFirstValueAsList(String header) {
837-
List<String> result = new ArrayList<String>();
838-
String value = getFirst(header);
839-
if (value != null) {
840-
String[] tokens = value.split(",\\s*");
841-
for (String token : tokens) {
842-
result.add(token);
907+
/**
908+
* Return all values of a given header name,
909+
* even if this header is set multiple times.
910+
* @since 4.3.0
911+
*/
912+
public List<String> getValuesAsList(String headerName) {
913+
List<String> values = get(headerName);
914+
if (values != null) {
915+
List<String> result = new ArrayList<String>();
916+
for (String value : values) {
917+
if (value != null) {
918+
String[] tokens = StringUtils.tokenizeToStringArray(value, ",");
919+
for (String token : tokens) {
920+
result.add(token);
921+
}
922+
}
843923
}
924+
return result;
844925
}
845-
return result;
926+
return Collections.emptyList();
927+
}
928+
929+
/**
930+
* Set the (new) value of the {@code If-Unmodified-Since} header.
931+
* <p>The date should be specified as the number of milliseconds since
932+
* January 1, 1970 GMT.
933+
*/
934+
public void setIfUnmodifiedSince(long ifUnmodifiedSince) {
935+
setDate(IF_UNMODIFIED_SINCE, ifUnmodifiedSince);
936+
}
937+
938+
/**
939+
* Return the value of the {@code If-Unmodified-Since} header.
940+
* <p>The date is returned as the number of milliseconds since
941+
* January 1, 1970 GMT. Returns -1 when the date is unknown.
942+
*/
943+
public long getIfUnmodifiedSince() {
944+
return getFirstDate(IF_UNMODIFIED_SINCE, false);
846945
}
847946

848947
/**
@@ -957,7 +1056,7 @@ public void setVary(List<String> requestHeaders) {
9571056
* Return the request header names subject to content negotiation.
9581057
*/
9591058
public List<String> getVary() {
960-
return getFirstValueAsList(VARY);
1059+
return getValuesAsList(VARY);
9611060
}
9621061

9631062
/**

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

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,19 +32,28 @@
3232
import org.hamcrest.Matchers;
3333
import org.junit.Test;
3434

35+
import static org.hamcrest.Matchers.is;
3536
import static org.junit.Assert.*;
3637

3738
/**
3839
* Unit tests for {@link org.springframework.http.HttpHeaders}.
3940
*
4041
* @author Arjen Poutsma
4142
* @author Sebastien Deleuze
43+
* @author Brian Clozel
4244
*/
4345
public class HttpHeadersTests {
4446

4547
private final HttpHeaders headers = new HttpHeaders();
4648

4749

50+
@Test
51+
public void getFirst() {
52+
headers.add(HttpHeaders.CACHE_CONTROL, "max-age=1000, public");
53+
headers.add(HttpHeaders.CACHE_CONTROL, "s-maxage=1000");
54+
assertThat(headers.getFirst(HttpHeaders.CACHE_CONTROL), is("max-age=1000, public"));
55+
}
56+
4857
@Test
4958
public void accept() {
5059
MediaType mediaType1 = new MediaType("text", "html");
@@ -132,6 +141,29 @@ public void illegalETag() {
132141
assertEquals("Invalid ETag header", "\"v2.6\"", headers.getFirst("ETag"));
133142
}
134143

144+
@Test
145+
public void ifMatch() {
146+
String ifMatch = "\"v2.6\"";
147+
headers.setIfMatch(ifMatch);
148+
assertEquals("Invalid If-Match header", ifMatch, headers.getIfMatch().get(0));
149+
assertEquals("Invalid If-Match header", "\"v2.6\"", headers.getFirst("If-Match"));
150+
}
151+
152+
@Test(expected = IllegalArgumentException.class)
153+
public void ifMatchIllegalHeader() {
154+
headers.setIfMatch("Illegal");
155+
headers.getIfMatch();
156+
}
157+
158+
@Test
159+
public void ifMatchMultipleHeaders() {
160+
headers.add(HttpHeaders.IF_MATCH, "\"v2,0\"");
161+
headers.add(HttpHeaders.IF_MATCH, "W/\"v2,1\", \"v2,2\"");
162+
assertEquals("Invalid If-Match header", "\"v2,0\"", headers.get(HttpHeaders.IF_MATCH).get(0));
163+
assertEquals("Invalid If-Match header", "W/\"v2,1\", \"v2,2\"", headers.get(HttpHeaders.IF_MATCH).get(1));
164+
assertThat(headers.getIfMatch(), Matchers.contains("\"v2,0\"", "W/\"v2,1\"", "\"v2,2\""));
165+
}
166+
135167
@Test
136168
public void ifNoneMatch() {
137169
String ifNoneMatch = "\"v2.6\"";
@@ -140,16 +172,24 @@ public void ifNoneMatch() {
140172
assertEquals("Invalid If-None-Match header", "\"v2.6\"", headers.getFirst("If-None-Match"));
141173
}
142174

175+
@Test
176+
public void ifNoneMatchWildCard() {
177+
String ifNoneMatch = "*";
178+
headers.setIfNoneMatch(ifNoneMatch);
179+
assertEquals("Invalid If-None-Match header", ifNoneMatch, headers.getIfNoneMatch().get(0));
180+
assertEquals("Invalid If-None-Match header", "*", headers.getFirst("If-None-Match"));
181+
}
182+
143183
@Test
144184
public void ifNoneMatchList() {
145185
String ifNoneMatch1 = "\"v2.6\"";
146-
String ifNoneMatch2 = "\"v2.7\"";
186+
String ifNoneMatch2 = "\"v2.7\", \"v2.8\"";
147187
List<String> ifNoneMatchList = new ArrayList<String>(2);
148188
ifNoneMatchList.add(ifNoneMatch1);
149189
ifNoneMatchList.add(ifNoneMatch2);
150190
headers.setIfNoneMatch(ifNoneMatchList);
151-
assertEquals("Invalid If-None-Match header", ifNoneMatchList, headers.getIfNoneMatch());
152-
assertEquals("Invalid If-None-Match header", "\"v2.6\", \"v2.7\"", headers.getFirst("If-None-Match"));
191+
assertThat(headers.getIfNoneMatch(), Matchers.contains("\"v2.6\"", "\"v2.7\"", "\"v2.8\""));
192+
assertEquals("Invalid If-None-Match header", "\"v2.6\", \"v2.7\", \"v2.8\"", headers.getFirst("If-None-Match"));
153193
}
154194

155195
@Test
@@ -255,6 +295,13 @@ public void cacheControl() {
255295
assertEquals("Invalid Cache-Control header", "no-cache", headers.getFirst("cache-control"));
256296
}
257297

298+
@Test
299+
public void cacheControlAllValues() {
300+
headers.add(HttpHeaders.CACHE_CONTROL, "max-age=1000, public");
301+
headers.add(HttpHeaders.CACHE_CONTROL, "s-maxage=1000");
302+
assertThat(headers.getCacheControl(), is("max-age=1000, public, s-maxage=1000"));
303+
}
304+
258305
@Test
259306
public void contentDisposition() {
260307
headers.setContentDispositionFormData("name", null);
@@ -290,6 +337,16 @@ public void accessControlAllowHeaders() {
290337
assertEquals(allowedHeaders, Arrays.asList("header1", "header2"));
291338
}
292339

340+
@Test
341+
public void accessControlAllowHeadersMultipleValues() {
342+
List<String> allowedHeaders = headers.getAccessControlAllowHeaders();
343+
assertThat(allowedHeaders, Matchers.emptyCollectionOf(String.class));
344+
headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "header1, header2");
345+
headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "header3");
346+
allowedHeaders = headers.getAccessControlAllowHeaders();
347+
assertEquals(Arrays.asList("header1", "header2", "header3"), allowedHeaders);
348+
}
349+
293350
@Test
294351
public void accessControlAllowMethods() {
295352
List<HttpMethod> allowedMethods = headers.getAccessControlAllowMethods();

spring-websocket/src/main/java/org/springframework/web/socket/WebSocketHttpHeaders.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ public List<String> getSecWebSocketProtocol() {
172172
return Collections.emptyList();
173173
}
174174
else if (values.size() == 1) {
175-
return getFirstValueAsList(SEC_WEBSOCKET_PROTOCOL);
175+
return getValuesAsList(SEC_WEBSOCKET_PROTOCOL);
176176
}
177177
else {
178178
return values;

0 commit comments

Comments
 (0)