Skip to content

Commit

Permalink
Leverage ZonedDateTime in HttpHeaders
Browse files Browse the repository at this point in the history
This commit introduces 2 new public methods in HttpHeaders in order
to leverage Java 8 ZonedDateTime in addition to the existing long
(with GMT time zone implied) variants:
 - ZonedDateTime getFirstZonedDateTime(String headerName)
 - void setZonedDateTime(String headerName, ZonedDateTime date)

This commit also leverages Java 8 thread-safe DateTimeFormatter for
HttpHeader implementation instead of SimpleDateFormat. As a consequence
of the usage of DateTimeFormatter.RFC_1123_DATE_TIME, HTTP date header
serialization could change slightly for single digit days from for
example "Thu, 01 Jan 1970 00:00:00 GMT" to
"Thu, 1 Jan 1970 00:00:00 GMT".

Issue: SPR-15661
  • Loading branch information
sdeleuze committed Jun 22, 2017
1 parent 4f39edc commit 5c1d8c7
Show file tree
Hide file tree
Showing 6 changed files with 126 additions and 34 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ public void cookies() {
response.addCookie(cookie);

assertEquals("foo=bar; Path=/path; Domain=example.com; " +
"Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; " +
"Max-Age=0; Expires=Thu, 1 Jan 1970 00:00:00 GMT; " +
"Secure; HttpOnly", response.getHeader(HttpHeaders.SET_COOKIE));
}

Expand Down
111 changes: 83 additions & 28 deletions spring-web/src/main/java/org/springframework/http/HttpHeaders.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@
import java.nio.charset.Charset;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.EnumSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
Expand All @@ -36,7 +38,6 @@
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 java.util.stream.Collectors;
Expand All @@ -47,6 +48,7 @@
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;


/**
* Represents HTTP request and response headers, mapping string header names to a list of string values.
*
Expand Down Expand Up @@ -372,16 +374,6 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
*/
public static final String WWW_AUTHENTICATE = "WWW-Authenticate";

/**
* Date formats as specified in the HTTP RFC
* @see <a href="https://tools.ietf.org/html/rfc7231#section-7.1.1.1">Section 7.1.1.1 of RFC 7231</a>
*/
private static final String[] DATE_FORMATS = new String[] {
"EEE, dd MMM yyyy HH:mm:ss zzz",
"EEE, dd-MMM-yy HH:mm:ss zzz",
"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>
Expand All @@ -390,7 +382,17 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable

private static final DecimalFormatSymbols DECIMAL_FORMAT_SYMBOLS = new DecimalFormatSymbols(Locale.ENGLISH);

private static TimeZone GMT = TimeZone.getTimeZone("GMT");
private static final ZoneId GMT = ZoneId.of("GMT");

/**
* Date formats with time zone as specified in the HTTP RFC
* @see <a href="https://tools.ietf.org/html/rfc7231#section-7.1.1.1">Section 7.1.1.1 of RFC 7231</a>
*/
private static final DateTimeFormatter[] DATE_FORMATTERS = new DateTimeFormatter[] {
DateTimeFormatter.RFC_1123_DATE_TIME,
DateTimeFormatter.ofPattern("EEEE, dd-MMM-yy HH:mm:ss zz", Locale.US),
DateTimeFormatter.ofPattern("EEE MMM dd HH:mm:ss yyyy",Locale.US).withZone(GMT)
};


private final Map<String, List<String>> headers;
Expand Down Expand Up @@ -924,6 +926,7 @@ public void setExpires(long expires) {
* as specified by the {@code Expires} header.
* <p>The date is returned as the number of milliseconds since
* January 1, 1970 GMT. Returns -1 when the date is unknown.
* @see #getFirstZonedDateTime(String)
*/
public long getExpires() {
return getFirstDate(EXPIRES, false);
Expand Down Expand Up @@ -1010,6 +1013,7 @@ public void setIfModifiedSince(long ifModifiedSince) {
* Return the value of the {@code If-Modified-Since} header.
* <p>The date is returned as the number of milliseconds since
* January 1, 1970 GMT. Returns -1 when the date is unknown.
* @see #getFirstZonedDateTime(String)
*/
public long getIfModifiedSince() {
return getFirstDate(IF_MODIFIED_SINCE, false);
Expand Down Expand Up @@ -1051,6 +1055,7 @@ public void setIfUnmodifiedSince(long ifUnmodifiedSince) {
* <p>The date is returned as the number of milliseconds since
* January 1, 1970 GMT. Returns -1 when the date is unknown.
* @since 4.3
* @see #getFirstZonedDateTime(String)
*/
public long getIfUnmodifiedSince() {
return getFirstDate(IF_UNMODIFIED_SINCE, false);
Expand All @@ -1071,6 +1076,7 @@ public void setLastModified(long lastModified) {
* {@code Last-Modified} header.
* <p>The date is returned as the number of milliseconds since
* January 1, 1970 GMT. Returns -1 when the date is unknown.
* @see #getFirstZonedDateTime(String)
*/
public long getLastModified() {
return getFirstDate(LAST_MODIFIED, false);
Expand Down Expand Up @@ -1178,14 +1184,25 @@ public List<String> getVary() {

/**
* Set the given date under the given header name after formatting it as a string
* using the pattern {@code "EEE, dd MMM yyyy HH:mm:ss zzz"}. The equivalent of
* using the RFC-1123 date-time formatter. The equivalent of
* {@link #set(String, String)} but for date headers.
* @since 3.2.4
* @see #setZonedDateTime(String, ZonedDateTime)
*/
public void setDate(String headerName, long date) {
SimpleDateFormat dateFormat = new SimpleDateFormat(DATE_FORMATS[0], Locale.US);
dateFormat.setTimeZone(GMT);
set(headerName, dateFormat.format(new Date(date)));
Instant instant = Instant.ofEpochMilli(date);
ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(instant, GMT);
set(headerName, DATE_FORMATTERS[0].format(zonedDateTime));
}

/**
* Set the given date under the given header name after formatting it as a string
* using the RFC-1123 date-time formatter. The equivalent of
* {@link #set(String, String)} but for date headers.
* @since 5.0
*/
public void setZonedDateTime(String headerName, ZonedDateTime date) {
set(headerName, DATE_FORMATTERS[0].format(date));
}

/**
Expand All @@ -1195,6 +1212,7 @@ public void setDate(String headerName, long date) {
* @param headerName the header name
* @return the parsed date header, or -1 if none
* @since 3.2.4
* @see #getFirstZonedDateTime(String)
*/
public long getFirstDate(String headerName) {
return getFirstDate(headerName, true);
Expand All @@ -1210,32 +1228,69 @@ public long getFirstDate(String headerName) {
* {@link IllegalArgumentException} ({@code true}) or rather return -1
* in that case ({@code false})
* @return the parsed date header, or -1 if none (or invalid)
*/
* @see #getFirstZonedDateTime(String, boolean)
*/
private long getFirstDate(String headerName, boolean rejectInvalid) {
ZonedDateTime zonedDateTime = getFirstZonedDateTime(headerName, rejectInvalid);
return (zonedDateTime != null ? zonedDateTime.toInstant().toEpochMilli() : -1);
}

/**
* Parse the first header value for the given header name as a date,
* return {@code null} if there is no value, or raise {@link IllegalArgumentException}
* if the value cannot be parsed as a date.
* @param headerName the header name
* @return the parsed date header, or {@code null} if none
* @since 5.0
*/
@Nullable
public ZonedDateTime getFirstZonedDateTime(String headerName) {
return getFirstZonedDateTime(headerName, true);
}

/**
* Parse the first header value for the given header name as a date,
* return {@code null} if there is no value or also in case of an invalid value
* (if {@code rejectInvalid=false}), or raise {@link IllegalArgumentException}
* if the value cannot be parsed as a date.
* @param headerName the header name
* @param rejectInvalid whether to reject invalid values with an
* {@link IllegalArgumentException} ({@code true}) or rather return {@code null}
* in that case ({@code false})
* @return the parsed date header, or {@code null} if none (or invalid)
*/
@Nullable
private ZonedDateTime getFirstZonedDateTime(String headerName, boolean rejectInvalid) {
String headerValue = getFirst(headerName);
if (headerValue == null) {
// No header value sent at all
return -1;
return null;
}
if (headerValue.length() >= 3) {
// Short "0" or "-1" like values are never valid HTTP date headers...
// Let's only bother with SimpleDateFormat parsing for long enough values.
for (String dateFormat : DATE_FORMATS) {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat(dateFormat, Locale.US);
simpleDateFormat.setTimeZone(GMT);
// Let's only bother with DateTimeFormatter parsing for long enough values.

// See https://stackoverflow.com/questions/12626699/if-modified-since-http-header-passed-by-ie9-includes-length
int parametersIndex = headerValue.indexOf(";");
if (parametersIndex != -1) {
headerValue = headerValue.substring(0, parametersIndex);
}

for (DateTimeFormatter dateFormatter : DATE_FORMATTERS) {
try {
return simpleDateFormat.parse(headerValue).getTime();
return ZonedDateTime.parse(headerValue, dateFormatter);
}
catch (ParseException ex) {
catch (DateTimeParseException ex) {
// ignore
}
}

}
if (rejectInvalid) {
throw new IllegalArgumentException("Cannot parse date value \"" + headerValue +
"\" for \"" + headerName + "\" header");
}
return -1;
return null;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
Expand All @@ -34,6 +36,7 @@
import org.hamcrest.Matchers;
import org.junit.Test;

import static java.time.format.DateTimeFormatter.*;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
Expand Down Expand Up @@ -466,4 +469,40 @@ public void contentLanguageSerialized() {
assertEquals("Expected one (first) locale", Locale.GERMAN, headers.getContentLanguage());
}

@Test
public void firstDate() {
headers.setDate(HttpHeaders.DATE, 1229595600000L);
assertThat(headers.getFirstDate(HttpHeaders.DATE), is(1229595600000L));

headers.clear();

headers.add(HttpHeaders.DATE, "Thu, 18 Dec 2008 10:20:00 GMT");
headers.add(HttpHeaders.DATE, "Sat, 18 Dec 2010 10:20:00 GMT");
assertThat(headers.getFirstDate(HttpHeaders.DATE), is(1229595600000L));
}

@Test
public void firstZonedDateTime() {
ZonedDateTime date = ZonedDateTime.of(2017, 6, 22, 22, 22, 0, 0, ZoneId.of("GMT"));
headers.setZonedDateTime(HttpHeaders.DATE, date);
assertThat(headers.getFirst(HttpHeaders.DATE), is("Thu, 22 Jun 2017 22:22:00 GMT"));
assertTrue(headers.getFirstZonedDateTime(HttpHeaders.DATE).isEqual(date));

headers.clear();
ZonedDateTime otherDate = ZonedDateTime.of(2010, 12, 18, 10, 20, 0, 0, ZoneId.of("GMT"));
headers.add(HttpHeaders.DATE, RFC_1123_DATE_TIME.format(date));
headers.add(HttpHeaders.DATE, RFC_1123_DATE_TIME.format(otherDate));
assertTrue(headers.getFirstZonedDateTime(HttpHeaders.DATE).isEqual(date));

// obsolete RFC 850 format
headers.clear();
headers.set(HttpHeaders.DATE, "Thursday, 22-Jun-17 22:22:00 GMT");
assertTrue(headers.getFirstZonedDateTime(HttpHeaders.DATE).isEqual(date));

// ANSI C's asctime() format
headers.clear();
headers.set(HttpHeaders.DATE, "Thu Jun 22 22:22:00 2017");
assertTrue(headers.getFirstZonedDateTime(HttpHeaders.DATE).isEqual(date));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ public void headers() throws URISyntaxException {

assertEquals("text/plain", responseHeaders.getFirst("Accept"));
assertEquals("utf-8", responseHeaders.getFirst("Accept-Charset"));
assertEquals("Thu, 01 Jan 1970 00:00:12 GMT", responseHeaders.getFirst("If-Modified-Since"));
assertEquals("Thu, 1 Jan 1970 00:00:12 GMT", responseHeaders.getFirst("If-Modified-Since"));
assertEquals(ifNoneMatch, responseHeaders.getFirst("If-None-Match"));
assertEquals(String.valueOf(contentLength), responseHeaders.getFirst("Content-Length"));
assertEquals(contentType.toString(), responseHeaders.getFirst("Content-Type"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ public void headers() throws URISyntaxException {
HttpHeaders responseHeaders = responseEntity.getHeaders();

assertEquals("GET", responseHeaders.getFirst("Allow"));
assertEquals("Thu, 01 Jan 1970 00:00:12 GMT",
assertEquals("Thu, 1 Jan 1970 00:00:12 GMT",
responseHeaders.getFirst("Last-Modified"));
assertEquals(location.toASCIIString(),
responseHeaders.getFirst("Location"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,7 @@ public ServerResponse.BodyBuilder hint(String key, Object value) {

@Override
public ServerResponse.BodyBuilder lastModified(ZonedDateTime lastModified) {
ZonedDateTime gmt = lastModified.withZoneSameInstant(ZoneId.of("GMT"));
String headerValue = DateTimeFormatter.RFC_1123_DATE_TIME.format(gmt);
this.headers.set(HttpHeaders.LAST_MODIFIED, headerValue);
this.headers.setZonedDateTime(HttpHeaders.LAST_MODIFIED, lastModified);
return this;
}

Expand Down

0 comments on commit 5c1d8c7

Please sign in to comment.