Skip to content

Commit 5c1d8c7

Browse files
committed
Leverage ZonedDateTime in HttpHeaders
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
1 parent 4f39edc commit 5c1d8c7

File tree

6 files changed

+126
-34
lines changed

6 files changed

+126
-34
lines changed

spring-test/src/test/java/org/springframework/mock/web/MockHttpServletResponseTests.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ public void cookies() {
169169
response.addCookie(cookie);
170170

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

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

Lines changed: 83 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,14 @@
2222
import java.nio.charset.Charset;
2323
import java.text.DecimalFormat;
2424
import java.text.DecimalFormatSymbols;
25-
import java.text.ParseException;
26-
import java.text.SimpleDateFormat;
25+
import java.time.Instant;
26+
import java.time.ZoneId;
27+
import java.time.ZonedDateTime;
28+
import java.time.format.DateTimeFormatter;
29+
import java.time.format.DateTimeParseException;
2730
import java.util.ArrayList;
2831
import java.util.Collection;
2932
import java.util.Collections;
30-
import java.util.Date;
3133
import java.util.EnumSet;
3234
import java.util.Iterator;
3335
import java.util.LinkedHashMap;
@@ -36,7 +38,6 @@
3638
import java.util.Locale;
3739
import java.util.Map;
3840
import java.util.Set;
39-
import java.util.TimeZone;
4041
import java.util.regex.Matcher;
4142
import java.util.regex.Pattern;
4243
import java.util.stream.Collectors;
@@ -47,6 +48,7 @@
4748
import org.springframework.util.MultiValueMap;
4849
import org.springframework.util.StringUtils;
4950

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

375-
/**
376-
* Date formats as specified in the HTTP RFC
377-
* @see <a href="https://tools.ietf.org/html/rfc7231#section-7.1.1.1">Section 7.1.1.1 of RFC 7231</a>
378-
*/
379-
private static final String[] DATE_FORMATS = new String[] {
380-
"EEE, dd MMM yyyy HH:mm:ss zzz",
381-
"EEE, dd-MMM-yy HH:mm:ss zzz",
382-
"EEE MMM dd HH:mm:ss yyyy"
383-
};
384-
385377
/**
386378
* Pattern matching ETag multiple field values in headers such as "If-Match", "If-None-Match"
387379
* @see <a href="https://tools.ietf.org/html/rfc7232#section-2.3">Section 2.3 of RFC 7232</a>
@@ -390,7 +382,17 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
390382

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

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

395397

396398
private final Map<String, List<String>> headers;
@@ -924,6 +926,7 @@ public void setExpires(long expires) {
924926
* as specified by the {@code Expires} header.
925927
* <p>The date is returned as the number of milliseconds since
926928
* January 1, 1970 GMT. Returns -1 when the date is unknown.
929+
* @see #getFirstZonedDateTime(String)
927930
*/
928931
public long getExpires() {
929932
return getFirstDate(EXPIRES, false);
@@ -1010,6 +1013,7 @@ public void setIfModifiedSince(long ifModifiedSince) {
10101013
* Return the value of the {@code If-Modified-Since} header.
10111014
* <p>The date is returned as the number of milliseconds since
10121015
* January 1, 1970 GMT. Returns -1 when the date is unknown.
1016+
* @see #getFirstZonedDateTime(String)
10131017
*/
10141018
public long getIfModifiedSince() {
10151019
return getFirstDate(IF_MODIFIED_SINCE, false);
@@ -1051,6 +1055,7 @@ public void setIfUnmodifiedSince(long ifUnmodifiedSince) {
10511055
* <p>The date is returned as the number of milliseconds since
10521056
* January 1, 1970 GMT. Returns -1 when the date is unknown.
10531057
* @since 4.3
1058+
* @see #getFirstZonedDateTime(String)
10541059
*/
10551060
public long getIfUnmodifiedSince() {
10561061
return getFirstDate(IF_UNMODIFIED_SINCE, false);
@@ -1071,6 +1076,7 @@ public void setLastModified(long lastModified) {
10711076
* {@code Last-Modified} header.
10721077
* <p>The date is returned as the number of milliseconds since
10731078
* January 1, 1970 GMT. Returns -1 when the date is unknown.
1079+
* @see #getFirstZonedDateTime(String)
10741080
*/
10751081
public long getLastModified() {
10761082
return getFirstDate(LAST_MODIFIED, false);
@@ -1178,14 +1184,25 @@ public List<String> getVary() {
11781184

11791185
/**
11801186
* Set the given date under the given header name after formatting it as a string
1181-
* using the pattern {@code "EEE, dd MMM yyyy HH:mm:ss zzz"}. The equivalent of
1187+
* using the RFC-1123 date-time formatter. The equivalent of
11821188
* {@link #set(String, String)} but for date headers.
11831189
* @since 3.2.4
1190+
* @see #setZonedDateTime(String, ZonedDateTime)
11841191
*/
11851192
public void setDate(String headerName, long date) {
1186-
SimpleDateFormat dateFormat = new SimpleDateFormat(DATE_FORMATS[0], Locale.US);
1187-
dateFormat.setTimeZone(GMT);
1188-
set(headerName, dateFormat.format(new Date(date)));
1193+
Instant instant = Instant.ofEpochMilli(date);
1194+
ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(instant, GMT);
1195+
set(headerName, DATE_FORMATTERS[0].format(zonedDateTime));
1196+
}
1197+
1198+
/**
1199+
* Set the given date under the given header name after formatting it as a string
1200+
* using the RFC-1123 date-time formatter. The equivalent of
1201+
* {@link #set(String, String)} but for date headers.
1202+
* @since 5.0
1203+
*/
1204+
public void setZonedDateTime(String headerName, ZonedDateTime date) {
1205+
set(headerName, DATE_FORMATTERS[0].format(date));
11891206
}
11901207

11911208
/**
@@ -1195,6 +1212,7 @@ public void setDate(String headerName, long date) {
11951212
* @param headerName the header name
11961213
* @return the parsed date header, or -1 if none
11971214
* @since 3.2.4
1215+
* @see #getFirstZonedDateTime(String)
11981216
*/
11991217
public long getFirstDate(String headerName) {
12001218
return getFirstDate(headerName, true);
@@ -1210,32 +1228,69 @@ public long getFirstDate(String headerName) {
12101228
* {@link IllegalArgumentException} ({@code true}) or rather return -1
12111229
* in that case ({@code false})
12121230
* @return the parsed date header, or -1 if none (or invalid)
1213-
*/
1231+
* @see #getFirstZonedDateTime(String, boolean)
1232+
*/
12141233
private long getFirstDate(String headerName, boolean rejectInvalid) {
1234+
ZonedDateTime zonedDateTime = getFirstZonedDateTime(headerName, rejectInvalid);
1235+
return (zonedDateTime != null ? zonedDateTime.toInstant().toEpochMilli() : -1);
1236+
}
1237+
1238+
/**
1239+
* Parse the first header value for the given header name as a date,
1240+
* return {@code null} if there is no value, or raise {@link IllegalArgumentException}
1241+
* if the value cannot be parsed as a date.
1242+
* @param headerName the header name
1243+
* @return the parsed date header, or {@code null} if none
1244+
* @since 5.0
1245+
*/
1246+
@Nullable
1247+
public ZonedDateTime getFirstZonedDateTime(String headerName) {
1248+
return getFirstZonedDateTime(headerName, true);
1249+
}
1250+
1251+
/**
1252+
* Parse the first header value for the given header name as a date,
1253+
* return {@code null} if there is no value or also in case of an invalid value
1254+
* (if {@code rejectInvalid=false}), or raise {@link IllegalArgumentException}
1255+
* if the value cannot be parsed as a date.
1256+
* @param headerName the header name
1257+
* @param rejectInvalid whether to reject invalid values with an
1258+
* {@link IllegalArgumentException} ({@code true}) or rather return {@code null}
1259+
* in that case ({@code false})
1260+
* @return the parsed date header, or {@code null} if none (or invalid)
1261+
*/
1262+
@Nullable
1263+
private ZonedDateTime getFirstZonedDateTime(String headerName, boolean rejectInvalid) {
12151264
String headerValue = getFirst(headerName);
12161265
if (headerValue == null) {
12171266
// No header value sent at all
1218-
return -1;
1267+
return null;
12191268
}
12201269
if (headerValue.length() >= 3) {
12211270
// Short "0" or "-1" like values are never valid HTTP date headers...
1222-
// Let's only bother with SimpleDateFormat parsing for long enough values.
1223-
for (String dateFormat : DATE_FORMATS) {
1224-
SimpleDateFormat simpleDateFormat = new SimpleDateFormat(dateFormat, Locale.US);
1225-
simpleDateFormat.setTimeZone(GMT);
1271+
// Let's only bother with DateTimeFormatter parsing for long enough values.
1272+
1273+
// See https://stackoverflow.com/questions/12626699/if-modified-since-http-header-passed-by-ie9-includes-length
1274+
int parametersIndex = headerValue.indexOf(";");
1275+
if (parametersIndex != -1) {
1276+
headerValue = headerValue.substring(0, parametersIndex);
1277+
}
1278+
1279+
for (DateTimeFormatter dateFormatter : DATE_FORMATTERS) {
12261280
try {
1227-
return simpleDateFormat.parse(headerValue).getTime();
1281+
return ZonedDateTime.parse(headerValue, dateFormatter);
12281282
}
1229-
catch (ParseException ex) {
1283+
catch (DateTimeParseException ex) {
12301284
// ignore
12311285
}
12321286
}
1287+
12331288
}
12341289
if (rejectInvalid) {
12351290
throw new IllegalArgumentException("Cannot parse date value \"" + headerValue +
12361291
"\" for \"" + headerName + "\" header");
12371292
}
1238-
return -1;
1293+
return null;
12391294
}
12401295

12411296
/**

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
import java.net.URISyntaxException;
2222
import java.nio.charset.Charset;
2323
import java.nio.charset.StandardCharsets;
24+
import java.time.ZoneId;
25+
import java.time.ZonedDateTime;
2426
import java.util.ArrayList;
2527
import java.util.Arrays;
2628
import java.util.Calendar;
@@ -34,6 +36,7 @@
3436
import org.hamcrest.Matchers;
3537
import org.junit.Test;
3638

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

472+
@Test
473+
public void firstDate() {
474+
headers.setDate(HttpHeaders.DATE, 1229595600000L);
475+
assertThat(headers.getFirstDate(HttpHeaders.DATE), is(1229595600000L));
476+
477+
headers.clear();
478+
479+
headers.add(HttpHeaders.DATE, "Thu, 18 Dec 2008 10:20:00 GMT");
480+
headers.add(HttpHeaders.DATE, "Sat, 18 Dec 2010 10:20:00 GMT");
481+
assertThat(headers.getFirstDate(HttpHeaders.DATE), is(1229595600000L));
482+
}
483+
484+
@Test
485+
public void firstZonedDateTime() {
486+
ZonedDateTime date = ZonedDateTime.of(2017, 6, 22, 22, 22, 0, 0, ZoneId.of("GMT"));
487+
headers.setZonedDateTime(HttpHeaders.DATE, date);
488+
assertThat(headers.getFirst(HttpHeaders.DATE), is("Thu, 22 Jun 2017 22:22:00 GMT"));
489+
assertTrue(headers.getFirstZonedDateTime(HttpHeaders.DATE).isEqual(date));
490+
491+
headers.clear();
492+
ZonedDateTime otherDate = ZonedDateTime.of(2010, 12, 18, 10, 20, 0, 0, ZoneId.of("GMT"));
493+
headers.add(HttpHeaders.DATE, RFC_1123_DATE_TIME.format(date));
494+
headers.add(HttpHeaders.DATE, RFC_1123_DATE_TIME.format(otherDate));
495+
assertTrue(headers.getFirstZonedDateTime(HttpHeaders.DATE).isEqual(date));
496+
497+
// obsolete RFC 850 format
498+
headers.clear();
499+
headers.set(HttpHeaders.DATE, "Thursday, 22-Jun-17 22:22:00 GMT");
500+
assertTrue(headers.getFirstZonedDateTime(HttpHeaders.DATE).isEqual(date));
501+
502+
// ANSI C's asctime() format
503+
headers.clear();
504+
headers.set(HttpHeaders.DATE, "Thu Jun 22 22:22:00 2017");
505+
assertTrue(headers.getFirstZonedDateTime(HttpHeaders.DATE).isEqual(date));
506+
}
507+
469508
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ public void headers() throws URISyntaxException {
115115

116116
assertEquals("text/plain", responseHeaders.getFirst("Accept"));
117117
assertEquals("utf-8", responseHeaders.getFirst("Accept-Charset"));
118-
assertEquals("Thu, 01 Jan 1970 00:00:12 GMT", responseHeaders.getFirst("If-Modified-Since"));
118+
assertEquals("Thu, 1 Jan 1970 00:00:12 GMT", responseHeaders.getFirst("If-Modified-Since"));
119119
assertEquals(ifNoneMatch, responseHeaders.getFirst("If-None-Match"));
120120
assertEquals(String.valueOf(contentLength), responseHeaders.getFirst("Content-Length"));
121121
assertEquals(contentType.toString(), responseHeaders.getFirst("Content-Type"));

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ public void headers() throws URISyntaxException {
160160
HttpHeaders responseHeaders = responseEntity.getHeaders();
161161

162162
assertEquals("GET", responseHeaders.getFirst("Allow"));
163-
assertEquals("Thu, 01 Jan 1970 00:00:12 GMT",
163+
assertEquals("Thu, 1 Jan 1970 00:00:12 GMT",
164164
responseHeaders.getFirst("Last-Modified"));
165165
assertEquals(location.toASCIIString(),
166166
responseHeaders.getFirst("Location"));

spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerResponseBuilder.java

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,7 @@ public ServerResponse.BodyBuilder hint(String key, Object value) {
125125

126126
@Override
127127
public ServerResponse.BodyBuilder lastModified(ZonedDateTime lastModified) {
128-
ZonedDateTime gmt = lastModified.withZoneSameInstant(ZoneId.of("GMT"));
129-
String headerValue = DateTimeFormatter.RFC_1123_DATE_TIME.format(gmt);
130-
this.headers.set(HttpHeaders.LAST_MODIFIED, headerValue);
128+
this.headers.setZonedDateTime(HttpHeaders.LAST_MODIFIED, lastModified);
131129
return this;
132130
}
133131

0 commit comments

Comments
 (0)