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
This commit is contained in:
parent
15138ed96f
commit
55dae618a6
|
@ -34,6 +34,8 @@ import java.util.Locale;
|
|||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TimeZone;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.LinkedCaseInsensitiveMap;
|
||||
|
@ -55,6 +57,7 @@ import org.springframework.util.StringUtils;
|
|||
*
|
||||
* @author Arjen Poutsma
|
||||
* @author Sebastien Deleuze
|
||||
* @author Brian Clozel
|
||||
* @since 3.0
|
||||
*/
|
||||
public class HttpHeaders implements MultiValueMap<String, String>, Serializable {
|
||||
|
@ -372,6 +375,12 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
|
|||
"EEE MMM dd HH:mm:ss yyyy"
|
||||
};
|
||||
|
||||
/**
|
||||
* Pattern matching ETag multiple field values in headers such as "If-Match", "If-None-Match"
|
||||
* @see <a href="https://tools.ietf.org/html/rfc7232#section-2.3">Section 2.3 of RFC 7232</a>
|
||||
*/
|
||||
private static final Pattern ETAG_HEADER_VALUE_PATTERN = Pattern.compile("\\*|\\s*((W\\/)?(\"[^\"]*\"))\\s*,?");
|
||||
|
||||
private static TimeZone GMT = TimeZone.getTimeZone("GMT");
|
||||
|
||||
|
||||
|
@ -459,7 +468,7 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
|
|||
* Returns the value of the {@code Access-Control-Allow-Headers} response header.
|
||||
*/
|
||||
public List<String> getAccessControlAllowHeaders() {
|
||||
return getFirstValueAsList(ACCESS_CONTROL_ALLOW_HEADERS);
|
||||
return getValuesAsList(ACCESS_CONTROL_ALLOW_HEADERS);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -476,7 +485,7 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
|
|||
List<HttpMethod> result = new ArrayList<HttpMethod>();
|
||||
String value = getFirst(ACCESS_CONTROL_ALLOW_METHODS);
|
||||
if (value != null) {
|
||||
String[] tokens = value.split(",\\s*");
|
||||
String[] tokens = StringUtils.tokenizeToStringArray(value, ",", true, true);
|
||||
for (String token : tokens) {
|
||||
HttpMethod resolved = HttpMethod.resolve(token);
|
||||
if (resolved != null) {
|
||||
|
@ -498,7 +507,23 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
|
|||
* Return the value of the {@code Access-Control-Allow-Origin} response header.
|
||||
*/
|
||||
public String getAccessControlAllowOrigin() {
|
||||
return getFirst(ACCESS_CONTROL_ALLOW_ORIGIN);
|
||||
return getFieldValues(ACCESS_CONTROL_ALLOW_ORIGIN);
|
||||
}
|
||||
|
||||
protected String getFieldValues(String headerName) {
|
||||
List<String> headerValues = this.headers.get(headerName);
|
||||
if (headerValues != null) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (Iterator<String> iterator = headerValues.iterator(); iterator.hasNext(); ) {
|
||||
String ifNoneMatch = iterator.next();
|
||||
builder.append(ifNoneMatch);
|
||||
if (iterator.hasNext()) {
|
||||
builder.append(", ");
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -512,7 +537,7 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
|
|||
* Returns the value of the {@code Access-Control-Expose-Headers} response header.
|
||||
*/
|
||||
public List<String> getAccessControlExposeHeaders() {
|
||||
return getFirstValueAsList(ACCESS_CONTROL_EXPOSE_HEADERS);
|
||||
return getValuesAsList(ACCESS_CONTROL_EXPOSE_HEADERS);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -542,7 +567,7 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
|
|||
* Returns the value of the {@code Access-Control-Request-Headers} request header.
|
||||
*/
|
||||
public List<String> getAccessControlRequestHeaders() {
|
||||
return getFirstValueAsList(ACCESS_CONTROL_REQUEST_HEADERS);
|
||||
return getValuesAsList(ACCESS_CONTROL_REQUEST_HEADERS);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -643,7 +668,7 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
|
|||
* Return the value of the {@code Cache-Control} header.
|
||||
*/
|
||||
public String getCacheControl() {
|
||||
return getFirst(CACHE_CONTROL);
|
||||
return getFieldValues(CACHE_CONTROL);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -664,7 +689,7 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
|
|||
* Return the value of the {@code Connection} header.
|
||||
*/
|
||||
public List<String> getConnection() {
|
||||
return getFirstValueAsList(CONNECTION);
|
||||
return getValuesAsList(CONNECTION);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -782,6 +807,64 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
|
|||
return getFirstDate(EXPIRES, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the (new) value of the {@code If-Match} header.
|
||||
*/
|
||||
public void setIfMatch(String ifMatch) {
|
||||
set(IF_MATCH, ifMatch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the (new) value of the {@code If-Match} header.
|
||||
*/
|
||||
public void setIfMatch(List<String> ifMatchList) {
|
||||
set(IF_MATCH, toCommaDelimitedString(ifMatchList));
|
||||
}
|
||||
|
||||
protected String toCommaDelimitedString(List<String> list) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (Iterator<String> iterator = list.iterator(); iterator.hasNext(); ) {
|
||||
String ifNoneMatch = iterator.next();
|
||||
builder.append(ifNoneMatch);
|
||||
if (iterator.hasNext()) {
|
||||
builder.append(", ");
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the value of the {@code If-Match} header.
|
||||
*/
|
||||
public List<String> getIfMatch() {
|
||||
return getETagValuesAsList(IF_MATCH);
|
||||
}
|
||||
|
||||
protected List<String> getETagValuesAsList(String headerName) {
|
||||
List<String> values = get(headerName);
|
||||
if (values != null) {
|
||||
List<String> result = new ArrayList<String>();
|
||||
for (String value : values) {
|
||||
if (value != null) {
|
||||
Matcher matcher = ETAG_HEADER_VALUE_PATTERN.matcher(value);
|
||||
while (matcher.find()) {
|
||||
if ("*".equals(matcher.group())) {
|
||||
result.add(matcher.group());
|
||||
}
|
||||
else {
|
||||
result.add(matcher.group(1));
|
||||
}
|
||||
}
|
||||
if(result.size() == 0) {
|
||||
throw new IllegalArgumentException("Could not parse '" + headerName + "' value=" + value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the (new) value of the {@code If-Modified-Since} header.
|
||||
* <p>The date should be specified as the number of milliseconds since
|
||||
|
@ -814,36 +897,52 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
|
|||
set(IF_NONE_MATCH, toCommaDelimitedString(ifNoneMatchList));
|
||||
}
|
||||
|
||||
protected String toCommaDelimitedString(List<String> list) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (Iterator<String> iterator = list.iterator(); iterator.hasNext();) {
|
||||
String ifNoneMatch = iterator.next();
|
||||
builder.append(ifNoneMatch);
|
||||
if (iterator.hasNext()) {
|
||||
builder.append(", ");
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the value of the {@code If-None-Match} header.
|
||||
*/
|
||||
public List<String> getIfNoneMatch() {
|
||||
return getFirstValueAsList(IF_NONE_MATCH);
|
||||
return getETagValuesAsList(IF_NONE_MATCH);
|
||||
}
|
||||
|
||||
protected List<String> getFirstValueAsList(String header) {
|
||||
/**
|
||||
* Return all values of a given header name,
|
||||
* even if this header is set multiple times.
|
||||
* @since 4.3.0
|
||||
*/
|
||||
public List<String> getValuesAsList(String headerName) {
|
||||
List<String> values = get(headerName);
|
||||
if (values != null) {
|
||||
List<String> result = new ArrayList<String>();
|
||||
String value = getFirst(header);
|
||||
for (String value : values) {
|
||||
if (value != null) {
|
||||
String[] tokens = value.split(",\\s*");
|
||||
String[] tokens = StringUtils.tokenizeToStringArray(value, ",");
|
||||
for (String token : tokens) {
|
||||
result.add(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the (new) value of the {@code If-Unmodified-Since} header.
|
||||
* <p>The date should be specified as the number of milliseconds since
|
||||
* January 1, 1970 GMT.
|
||||
*/
|
||||
public void setIfUnmodifiedSince(long ifUnmodifiedSince) {
|
||||
setDate(IF_UNMODIFIED_SINCE, ifUnmodifiedSince);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the value of the {@code If-Unmodified-Since} header.
|
||||
* <p>The date is returned as the number of milliseconds since
|
||||
* January 1, 1970 GMT. Returns -1 when the date is unknown.
|
||||
*/
|
||||
public long getIfUnmodifiedSince() {
|
||||
return getFirstDate(IF_UNMODIFIED_SINCE, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the time the resource was last changed, as specified by the
|
||||
|
@ -957,7 +1056,7 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
|
|||
* Return the request header names subject to content negotiation.
|
||||
*/
|
||||
public List<String> getVary() {
|
||||
return getFirstValueAsList(VARY);
|
||||
return getValuesAsList(VARY);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -32,6 +32,7 @@ import java.util.TimeZone;
|
|||
import org.hamcrest.Matchers;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.hamcrest.Matchers.is;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
|
@ -39,12 +40,20 @@ import static org.junit.Assert.*;
|
|||
*
|
||||
* @author Arjen Poutsma
|
||||
* @author Sebastien Deleuze
|
||||
* @author Brian Clozel
|
||||
*/
|
||||
public class HttpHeadersTests {
|
||||
|
||||
private final HttpHeaders headers = new HttpHeaders();
|
||||
|
||||
|
||||
@Test
|
||||
public void getFirst() {
|
||||
headers.add(HttpHeaders.CACHE_CONTROL, "max-age=1000, public");
|
||||
headers.add(HttpHeaders.CACHE_CONTROL, "s-maxage=1000");
|
||||
assertThat(headers.getFirst(HttpHeaders.CACHE_CONTROL), is("max-age=1000, public"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void accept() {
|
||||
MediaType mediaType1 = new MediaType("text", "html");
|
||||
|
@ -132,6 +141,29 @@ public class HttpHeadersTests {
|
|||
assertEquals("Invalid ETag header", "\"v2.6\"", headers.getFirst("ETag"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ifMatch() {
|
||||
String ifMatch = "\"v2.6\"";
|
||||
headers.setIfMatch(ifMatch);
|
||||
assertEquals("Invalid If-Match header", ifMatch, headers.getIfMatch().get(0));
|
||||
assertEquals("Invalid If-Match header", "\"v2.6\"", headers.getFirst("If-Match"));
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException.class)
|
||||
public void ifMatchIllegalHeader() {
|
||||
headers.setIfMatch("Illegal");
|
||||
headers.getIfMatch();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ifMatchMultipleHeaders() {
|
||||
headers.add(HttpHeaders.IF_MATCH, "\"v2,0\"");
|
||||
headers.add(HttpHeaders.IF_MATCH, "W/\"v2,1\", \"v2,2\"");
|
||||
assertEquals("Invalid If-Match header", "\"v2,0\"", headers.get(HttpHeaders.IF_MATCH).get(0));
|
||||
assertEquals("Invalid If-Match header", "W/\"v2,1\", \"v2,2\"", headers.get(HttpHeaders.IF_MATCH).get(1));
|
||||
assertThat(headers.getIfMatch(), Matchers.contains("\"v2,0\"", "W/\"v2,1\"", "\"v2,2\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ifNoneMatch() {
|
||||
String ifNoneMatch = "\"v2.6\"";
|
||||
|
@ -140,16 +172,24 @@ public class HttpHeadersTests {
|
|||
assertEquals("Invalid If-None-Match header", "\"v2.6\"", headers.getFirst("If-None-Match"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ifNoneMatchWildCard() {
|
||||
String ifNoneMatch = "*";
|
||||
headers.setIfNoneMatch(ifNoneMatch);
|
||||
assertEquals("Invalid If-None-Match header", ifNoneMatch, headers.getIfNoneMatch().get(0));
|
||||
assertEquals("Invalid If-None-Match header", "*", headers.getFirst("If-None-Match"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ifNoneMatchList() {
|
||||
String ifNoneMatch1 = "\"v2.6\"";
|
||||
String ifNoneMatch2 = "\"v2.7\"";
|
||||
String ifNoneMatch2 = "\"v2.7\", \"v2.8\"";
|
||||
List<String> ifNoneMatchList = new ArrayList<String>(2);
|
||||
ifNoneMatchList.add(ifNoneMatch1);
|
||||
ifNoneMatchList.add(ifNoneMatch2);
|
||||
headers.setIfNoneMatch(ifNoneMatchList);
|
||||
assertEquals("Invalid If-None-Match header", ifNoneMatchList, headers.getIfNoneMatch());
|
||||
assertEquals("Invalid If-None-Match header", "\"v2.6\", \"v2.7\"", headers.getFirst("If-None-Match"));
|
||||
assertThat(headers.getIfNoneMatch(), Matchers.contains("\"v2.6\"", "\"v2.7\"", "\"v2.8\""));
|
||||
assertEquals("Invalid If-None-Match header", "\"v2.6\", \"v2.7\", \"v2.8\"", headers.getFirst("If-None-Match"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -255,6 +295,13 @@ public class HttpHeadersTests {
|
|||
assertEquals("Invalid Cache-Control header", "no-cache", headers.getFirst("cache-control"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cacheControlAllValues() {
|
||||
headers.add(HttpHeaders.CACHE_CONTROL, "max-age=1000, public");
|
||||
headers.add(HttpHeaders.CACHE_CONTROL, "s-maxage=1000");
|
||||
assertThat(headers.getCacheControl(), is("max-age=1000, public, s-maxage=1000"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void contentDisposition() {
|
||||
headers.setContentDispositionFormData("name", null);
|
||||
|
@ -290,6 +337,16 @@ public class HttpHeadersTests {
|
|||
assertEquals(allowedHeaders, Arrays.asList("header1", "header2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void accessControlAllowHeadersMultipleValues() {
|
||||
List<String> allowedHeaders = headers.getAccessControlAllowHeaders();
|
||||
assertThat(allowedHeaders, Matchers.emptyCollectionOf(String.class));
|
||||
headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "header1, header2");
|
||||
headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "header3");
|
||||
allowedHeaders = headers.getAccessControlAllowHeaders();
|
||||
assertEquals(Arrays.asList("header1", "header2", "header3"), allowedHeaders);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void accessControlAllowMethods() {
|
||||
List<HttpMethod> allowedMethods = headers.getAccessControlAllowMethods();
|
||||
|
|
|
@ -172,7 +172,7 @@ public class WebSocketHttpHeaders extends HttpHeaders {
|
|||
return Collections.emptyList();
|
||||
}
|
||||
else if (values.size() == 1) {
|
||||
return getFirstValueAsList(SEC_WEBSOCKET_PROTOCOL);
|
||||
return getValuesAsList(SEC_WEBSOCKET_PROTOCOL);
|
||||
}
|
||||
else {
|
||||
return values;
|
||||
|
|
Loading…
Reference in New Issue