Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue #11648 - Introducing HttpDateTime class. #11672

Merged
merged 6 commits into from
Apr 23, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@

/**
* ThreadLocal data parsers for HTTP style dates
* @deprecated use {@link HttpDateTime} instead
*/
@Deprecated(since = "12.0.9", forRemoval = true)
public class DateParser
{
private static final TimeZone GMT = TimeZone.getTimeZone("GMT");
Expand All @@ -47,6 +49,10 @@ public class DateParser
"EEE dd-MMM-yy HH:mm:ss zzz", "EEE dd-MMM-yy HH:mm:ss"
};

/**
* @deprecated use {@link HttpDateTime#parseToEpoch(String)} instead
*/
@Deprecated(since = "12.0.9", forRemoval = true)
public static long parseDate(String date)
{
return DATE_PARSER.apply(DateParser::new, DateParser::parse, date);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@
package org.eclipse.jetty.http;

import java.time.Instant;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.Locale;
import java.util.Map;
Expand Down Expand Up @@ -921,22 +918,19 @@ static String toString(HttpCookie httpCookie)
*/
static String formatExpires(Instant expires)
{
return DateTimeFormatter.RFC_1123_DATE_TIME
.withZone(ZoneOffset.UTC)
.format(expires);
return HttpDateTime.format(expires);
}

/**
* <p>Parses the {@code Expires} attribute value
* (in RFC 1123 format) into an {@link Instant}.</p>
* <p>Parses the {@code Expires} Date/Time attribute value
* into an {@link Instant}.</p>
*
* @param expires an instant in the RFC 1123 string format
* @param expires a date/time in one of the RFC6265 supported formats
* @return an {@link Instant} parsed from the given string
*/
static Instant parseExpires(String expires)
{
// TODO: RFC 1123 format only for now, see https://www.rfc-editor.org/rfc/rfc2616#section-3.3.1.
return ZonedDateTime.parse(expires, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant();
return HttpDateTime.parse(expires).toInstant();
}

private static Map<String, String> lazyAttributePut(Map<String, String> attributes, String key, String value)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
//
// ========================================================================
// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
// which is available at https://www.apache.org/licenses/LICENSE-2.0.
//
// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
// ========================================================================
//

package org.eclipse.jetty.http;

import java.nio.charset.StandardCharsets;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAccessor;
import java.util.Calendar;
import java.util.Objects;
import java.util.StringTokenizer;
import java.util.concurrent.TimeUnit;

import org.eclipse.jetty.util.Index;
import org.eclipse.jetty.util.StringUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* HTTP Date/Time parsing and formatting.
*
* <p>Supports the following Date/Time formats found in both
* <a href="https://datatracker.ietf.org/doc/html/rfc9110#name-date-time-formats">RFC 9110 (HTTP Semantics)</a> and
* <a href="https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1">RFC 6265 (HTTP State Management Mechanism)</a>
* </p>
*
* <ul>
* <li>{@code Sun, 06 Nov 1994 08:49:37 GMT} - RFC 1123 (preferred)</li>
* <li>{@code Sunday, 06-Nov-94 08:49:37 GMT} - RFC 850 (obsolete)</li>
* <li>{@code Sun Nov 6 08:49:37 1994} - ANSI C's {@code asctime()} format (obsolete)</li>
* </ul>
*/
public class HttpDateTime
{
private static final Logger LOG = LoggerFactory.getLogger(HttpDateTime.class);
private static final ZoneId GMT = ZoneId.of("GMT");
private static final Index<Integer> MONTH_CACHE = new Index.Builder<Integer>()
.caseSensitive(false)
// Note: Calendar.Month fields are zero based.
.with("Jan", Calendar.JANUARY + 1)
.with("Feb", Calendar.FEBRUARY + 1)
.with("Mar", Calendar.MARCH + 1)
.with("Apr", Calendar.APRIL + 1)
.with("May", Calendar.MAY + 1)
.with("Jun", Calendar.JUNE + 1)
.with("Jul", Calendar.JULY + 1)
.with("Aug", Calendar.AUGUST + 1)
.with("Sep", Calendar.SEPTEMBER + 1)
.with("Oct", Calendar.OCTOBER + 1)
.with("Nov", Calendar.NOVEMBER + 1)
.with("Dec", Calendar.DECEMBER + 1)
.build();
/**
* Delimiters for parsing as found in <a href="https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1">RFC6265: Date/Time Delimiters</a>
*/
private static final String DELIMITERS = new String(
StringUtil.fromHexString(
"09" + // %x09
"202122232425262728292a2b2c2d2e2f" + // %x20-2F
"3b3c3d3e3f40" + // %x3B-40
"5b5c5d5e5f60" + // %x5B-60
"7b7c7d7e" // %x7B-7E
), StandardCharsets.US_ASCII);

private HttpDateTime()
{
}

/**
* Similar to {@link #parse(String)} but returns unix epoch
*
* @param datetime the Date/Time to parse.
* @return unix epoch in milliseconds, or -1 if unable to parse the input date/time
*/
public static long parseToEpoch(String datetime)
{
try
{
ZonedDateTime dateTime = parse(datetime);
return TimeUnit.SECONDS.toMillis(dateTime.toEpochSecond());
}
catch (IllegalArgumentException e)
{
if (LOG.isDebugEnabled())
LOG.debug("Unable to parse Date/Time: {}", datetime, e);
return -1;
}
}

/**
* <p>Parses a Date/Time value</p>
*
* <p>
* Parsing is done according to the algorithm specified in
* <a href="https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.1">RFC6265: Section 5.1.1: Date</a>
* </p>
*
* @param datetime a Date/Time string in a supported format
* @return an {@link ZonedDateTime} parsed from the given string
* @throws IllegalArgumentException if unable to parse date/time
*/
public static ZonedDateTime parse(String datetime)
{
Objects.requireNonNull(datetime, "Date/Time string cannot be null");

int year = -1;
int month = -1;
int day = -1;
int hour = -1;
int minute = -1;
int second = -1;

try
{
int tokenCount = 0;
StringTokenizer tokenizer = new StringTokenizer(datetime, DELIMITERS);
while (tokenizer.hasMoreTokens())
{
String token = tokenizer.nextToken();
// ensure we don't exceed the number of expected tokens.
if (++tokenCount > 6)
{
// This is a horribly bad syntax / format
throw new IllegalStateException("Too many delimiters for a Date/Time format");
}

if (token.isBlank())
continue; // skip blank tokens

// RFC 6265 - Section 5.1.1 - Step 2.1 - time (00:00:00)
if (hour == -1 && token.length() == 8 && token.charAt(2) == ':' && token.charAt(5) == ':')
{
second = StringUtil.toInt(token, 6);
minute = StringUtil.toInt(token, 3);
hour = StringUtil.toInt(token, 0);
continue;
}

// RFC 6265 - Section 5.1.1 - Step 2.2
if (day == -1 && token.length() <= 2)
{
day = StringUtil.toInt(token, 0);
continue;
}

// RFC 6265 - Section 5.1.1 - Step 2.3
if (month == -1 && token.length() == 3)
{
Integer m = MONTH_CACHE.getBest(token);
if (m != null)
{
month = m;
continue;
}
}

// RFC 6265 - Section 5.1.1 - Step 2.4
if (year == -1)
{
if (token.length() <= 2)
{
year = StringUtil.toInt(token, 0);
}
else if (token.length() == 4)
{
year = StringUtil.toInt(token, 0);
}
continue;
}
}
}
catch (Throwable x)
{
if (LOG.isDebugEnabled())
LOG.debug("Ignore: Unable to parse Date/Time", x);
}

// RFC 6265 - Section 5.1.1 - Step 3
if ((year > 70) && (year <= 99))
year += 1900;
// RFC 6265 - Section 5.1.1 - Step 4
if ((year >= 0) && (year <= 69))
year += 2000;

// RFC 6265 - Section 5.1.1 - Step 5
if (day == -1)
throw new IllegalArgumentException("Missing [day]: " + datetime);
if (month == -1)
throw new IllegalArgumentException("Missing [month]: " + datetime);
if (year == -1)
throw new IllegalArgumentException("Missing [year]: " + datetime);
if (year < 1601)
throw new IllegalArgumentException("Too far in past [year]: " + datetime);
if (hour == -1)
throw new IllegalArgumentException("Missing [time]: " + datetime);
if (day < 1 || day > 31)
throw new IllegalArgumentException("Invalid [day]: " + datetime);
if (month < 1 || month > 12)
throw new IllegalArgumentException("Invalid [month]: " + datetime);
if (hour > 23)
throw new IllegalArgumentException("Invalid [hour]: " + datetime);
if (minute > 59)
throw new IllegalArgumentException("Invalid [minute]: " + datetime);
if (second > 59)
throw new IllegalArgumentException("Invalid [second]: " + datetime);

// RFC 6265 - Section 5.1.1 - Step 6
ZonedDateTime dateTime = ZonedDateTime.of(year,
month, day, hour, minute, second, 0, GMT);
gregw marked this conversation as resolved.
Show resolved Hide resolved

// RFC 6265 - Section 5.1.1 - Step 7
return dateTime;
}

/**
* Formats provided Date/Time to a String following preferred RFC 1123 syntax from
* both HTTP and Cookie specs.
*
* @param datetime the date/time to format
* @return the String representation of the date/time
*/
public static String format(TemporalAccessor datetime)
{
return DateTimeFormatter.RFC_1123_DATE_TIME
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh interesting! IT does the hard coded "GMT" for us, even if we pass in "UTC". Nice! Well not nice... ugly history in evidence... but at least it is consistent. The time is in UTC, but just reported as "GMT"!

.withZone(GMT)
gregw marked this conversation as resolved.
Show resolved Hide resolved
.format(datetime);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.function.BiFunction;
import java.util.function.BiPredicate;
import java.util.function.Supplier;
Expand Down Expand Up @@ -517,10 +518,7 @@ private static long parseDateField(HttpField field)
if (val == null)
return -1;

final long date = DateParser.parseDate(val);
if (date == -1)
throw new IllegalArgumentException("Cannot convert date: " + val);
return date;
return TimeUnit.SECONDS.toMillis(HttpDateTime.parse(val).toEpochSecond());
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
package org.eclipse.jetty.http;

import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.List;

import org.junit.jupiter.params.ParameterizedTest;
Expand Down Expand Up @@ -90,7 +89,7 @@ public void testParseCookies(String setCookieValue, HttpCookie expectedCookie)
public static List<Arguments> invalidAttributes()
{
return List.of(
Arguments.of("Expires", "blah", DateTimeParseException.class),
Arguments.of("Expires", "blah", IllegalArgumentException.class),
Arguments.of("HttpOnly", "blah", IllegalArgumentException.class),
Arguments.of("Max-Age", "blah", NumberFormatException.class),
Arguments.of("SameSite", "blah", IllegalArgumentException.class),
Expand All @@ -105,4 +104,5 @@ public void testParseInvalidAttributes(String name, String value, Class<? extend
assertThrows(failure, () -> HttpCookie.build("A", "1")
.attribute(name, value));
}

}
Loading
Loading