diff --git a/spring-test/src/test/java/org/springframework/mock/web/MockHttpServletResponseTests.java b/spring-test/src/test/java/org/springframework/mock/web/MockHttpServletResponseTests.java index 2cd5e1f93d..fef993a0a1 100644 --- a/spring-test/src/test/java/org/springframework/mock/web/MockHttpServletResponseTests.java +++ b/spring-test/src/test/java/org/springframework/mock/web/MockHttpServletResponseTests.java @@ -169,7 +169,7 @@ public class MockHttpServletResponseTests { 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)); } diff --git a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java index 0094ea16d0..0a005081ac 100644 --- a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java +++ b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java @@ -22,12 +22,14 @@ import java.net.URI; 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; @@ -36,7 +38,6 @@ import java.util.List; 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; @@ -47,6 +48,7 @@ import org.springframework.util.LinkedCaseInsensitiveMap; 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. * @@ -372,16 +374,6 @@ public class HttpHeaders implements MultiValueMap, Serializable */ public static final String WWW_AUTHENTICATE = "WWW-Authenticate"; - /** - * Date formats as specified in the HTTP RFC - * @see Section 7.1.1.1 of RFC 7231 - */ - 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 Section 2.3 of RFC 7232 @@ -390,7 +382,17 @@ public class HttpHeaders implements MultiValueMap, 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 Section 7.1.1.1 of RFC 7231 + */ + 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> headers; @@ -924,6 +926,7 @@ public class HttpHeaders implements MultiValueMap, Serializable * as specified by the {@code Expires} header. *

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); @@ -1010,6 +1013,7 @@ public class HttpHeaders implements MultiValueMap, Serializable * Return the value of the {@code If-Modified-Since} header. *

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); @@ -1051,6 +1055,7 @@ public class HttpHeaders implements MultiValueMap, Serializable *

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); @@ -1071,6 +1076,7 @@ public class HttpHeaders implements MultiValueMap, Serializable * {@code Last-Modified} header. *

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); @@ -1178,14 +1184,25 @@ public class HttpHeaders implements MultiValueMap, Serializable /** * 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)); } /** @@ -1195,6 +1212,7 @@ public class HttpHeaders implements MultiValueMap, Serializable * @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); @@ -1210,32 +1228,69 @@ public class HttpHeaders implements MultiValueMap, Serializable * {@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; } /** diff --git a/spring-web/src/test/java/org/springframework/http/HttpHeadersTests.java b/spring-web/src/test/java/org/springframework/http/HttpHeadersTests.java index da57a96722..362b9b2556 100644 --- a/spring-web/src/test/java/org/springframework/http/HttpHeadersTests.java +++ b/spring-web/src/test/java/org/springframework/http/HttpHeadersTests.java @@ -21,6 +21,8 @@ import java.net.URI; 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; @@ -34,6 +36,7 @@ import java.util.TimeZone; 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; @@ -466,4 +469,40 @@ public class HttpHeadersTests { 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)); + } + } diff --git a/spring-web/src/test/java/org/springframework/http/RequestEntityTests.java b/spring-web/src/test/java/org/springframework/http/RequestEntityTests.java index d2cd4db456..82bd43cded 100644 --- a/spring-web/src/test/java/org/springframework/http/RequestEntityTests.java +++ b/spring-web/src/test/java/org/springframework/http/RequestEntityTests.java @@ -115,7 +115,7 @@ public class RequestEntityTests { 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")); diff --git a/spring-web/src/test/java/org/springframework/http/ResponseEntityTests.java b/spring-web/src/test/java/org/springframework/http/ResponseEntityTests.java index 1e45e184b4..eb4c854d98 100644 --- a/spring-web/src/test/java/org/springframework/http/ResponseEntityTests.java +++ b/spring-web/src/test/java/org/springframework/http/ResponseEntityTests.java @@ -160,7 +160,7 @@ public class ResponseEntityTests { 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")); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerResponseBuilder.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerResponseBuilder.java index 294b5c5918..ee19094188 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerResponseBuilder.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerResponseBuilder.java @@ -125,9 +125,7 @@ class DefaultServerResponseBuilder implements ServerResponse.BodyBuilder { @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; }