Parse RFC 9651-like date headers

RFC 9651 specifies @«timestamp» as a new format for date headers.
The Deprecation header as specified in RFC 9745, for example, makes use of it.
Make sure this can be parsed using `HttpHeaders#getFirstDate` and `HttpHeaders#getFirstZonedDateTime`

Signed-off-by: Raphael Schweikert <any@sabberworm.com>
This commit is contained in:
Raphael Schweikert
2026-09-04 14:28:29 +02:00
committed by Brian Clozel
parent 815911b39c
commit 130b0ec50c
2 changed files with 39 additions and 2 deletions
@@ -29,7 +29,9 @@ import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoField;
import java.util.AbstractSet;
import java.util.ArrayList;
import java.util.Base64;
@@ -435,7 +437,9 @@ public class HttpHeaders implements Serializable {
private static final DateTimeFormatter[] DATE_PARSERS = new DateTimeFormatter[] {
DateTimeFormatter.RFC_1123_DATE_TIME,
DateTimeFormatter.ofPattern("EEEE, dd-MMM-yy HH:mm:ss zzz", Locale.US),
DateTimeFormatter.ofPattern("EEE MMM dd HH:mm:ss yyyy", Locale.US).withZone(GMT)
DateTimeFormatter.ofPattern("EEE MMM dd HH:mm:ss yyyy", Locale.US).withZone(GMT),
// RFC 9651: Structured Field Values for HTTP
new DateTimeFormatterBuilder().appendLiteral('@').appendValue(ChronoField.INSTANT_SECONDS).toFormatter(Locale.US).withZone(GMT)
};
@@ -1621,7 +1625,7 @@ public class HttpHeaders implements Serializable {
// No header value sent at all
return null;
}
if (headerValue.length() >= 3) {
if (headerValue.length() >= 3 || headerValue.startsWith("@")) {
// Short "0" or "-1" like values are never valid HTTP date headers...
// Let's only bother with DateTimeFormatter parsing for long enough values.
@@ -36,6 +36,9 @@ import java.util.TimeZone;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import static java.util.stream.Collectors.toList;
import static org.assertj.core.api.Assertions.assertThat;
@@ -654,6 +657,36 @@ class HttpHeadersTests {
assertThat(authorization).isEqualTo("Bearer foo");
}
@ParameterizedTest
@CsvSource(value = {
"@0,0",
"@1,1000",
"@10,10000",
"@-1,-1000",
"@1659578233,1659578233000", // Example from RFC
"@-62135596800,-62135596800000", // Example from RFC
"@253402214400,253402214400000", // Example from RFC
})
void rfc9651Dates_valid(String value, long timestampMillis) {
headers.set("Deprecation", value);
assertThat(headers.getFirstDate("Deprecation")).isEqualTo(timestampMillis);
}
@ParameterizedTest
@ValueSource(strings = {
"@",
" @1",
"@ 1",
"@0000-",
"@15+",
"@12p",
"@0x15",
})
void rfc9651Dates_invalid(String value) {
headers.set("Deprecation", value);
assertThatIllegalArgumentException().isThrownBy(() -> headers.getFirstDate("Deprecation"));
}
@Nested
class MapEntriesTests {