diff --git a/google-http-client/src/main/java/com/google/api/client/util/DateTime.java b/google-http-client/src/main/java/com/google/api/client/util/DateTime.java index bd071b327..cd0dcd777 100644 --- a/google-http-client/src/main/java/com/google/api/client/util/DateTime.java +++ b/google-http-client/src/main/java/com/google/api/client/util/DateTime.java @@ -14,12 +14,15 @@ package com.google.api.client.util; +import com.google.common.base.Strings; import java.io.Serializable; import java.util.Arrays; import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; +import java.util.Objects; import java.util.TimeZone; +import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -39,12 +42,12 @@ public final class DateTime implements Serializable { private static final TimeZone GMT = TimeZone.getTimeZone("GMT"); /** Regular expression for parsing RFC3339 date/times. */ - private static final Pattern RFC3339_PATTERN = - Pattern.compile( - "^(\\d{4})-(\\d{2})-(\\d{2})" // yyyy-MM-dd - + "([Tt](\\d{2}):(\\d{2}):(\\d{2})(\\.\\d+)?)?" // 'T'HH:mm:ss.milliseconds - + "([Zz]|([+-])(\\d{2}):(\\d{2}))?"); // 'Z' or time zone shift HH:mm following '+' or - // '-' + private static final String RFC3339_REGEX = + "(\\d{4})-(\\d{2})-(\\d{2})" // yyyy-MM-dd + + "([Tt](\\d{2}):(\\d{2}):(\\d{2})(\\.\\d{1,9})?)?" // 'T'HH:mm:ss.nanoseconds + + "([Zz]|([+-])(\\d{2}):(\\d{2}))?"; // 'Z' or time zone shift HH:mm following '+' or '-' + + private static final Pattern RFC3339_PATTERN = Pattern.compile(RFC3339_REGEX); /** * Date/time value expressed as the number of ms since the Unix epoch. @@ -260,6 +263,8 @@ public int hashCode() { * NumberFormatException}. Also, in accordance with the RFC3339 standard, any number of * milliseconds digits is now allowed. * + *
Any time information beyond millisecond precision is truncated. + * *
For the date-only case, the time zone is ignored and the hourOfDay, minute, second, and * millisecond parameters are set to zero. * @@ -269,6 +274,98 @@ public int hashCode() { * time zone shift but no time. */ public static DateTime parseRfc3339(String str) throws NumberFormatException { + return parseRfc3339WithNanoSeconds(str).toDateTime(); + } + + /** + * Parses an RFC3339 timestamp to a pair of seconds and nanoseconds since Unix Epoch. + * + * @param str Date/time string in RFC3339 format + * @throws IllegalArgumentException if {@code str} doesn't match the RFC3339 standard format; an + * exception is thrown if {@code str} doesn't match {@code RFC3339_REGEX} or if it contains a + * time zone shift but no time. + */ + public static SecondsAndNanos parseRfc3339ToSecondsAndNanos(String str) + throws IllegalArgumentException { + return parseRfc3339WithNanoSeconds(str).toSecondsAndNanos(); + } + + /** A timestamp represented as the number of seconds and nanoseconds since Epoch. */ + public static final class SecondsAndNanos implements Serializable { + private final long seconds; + private final int nanos; + + public static SecondsAndNanos ofSecondsAndNanos(long seconds, int nanos) { + return new SecondsAndNanos(seconds, nanos); + } + + private SecondsAndNanos(long seconds, int nanos) { + this.seconds = seconds; + this.nanos = nanos; + } + + public long getSeconds() { + return seconds; + } + + public int getNanos() { + return nanos; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SecondsAndNanos that = (SecondsAndNanos) o; + return seconds == that.seconds && nanos == that.nanos; + } + + @Override + public int hashCode() { + return Objects.hash(seconds, nanos); + } + + @Override + public String toString() { + return String.format("Seconds: %d, Nanos: %d", seconds, nanos); + } + } + + /** Result of parsing a Rfc3339 string. */ + private static class Rfc3339ParseResult implements Serializable { + private final long seconds; + private final int nanos; + private final boolean timeGiven; + private final Integer tzShift; + + private Rfc3339ParseResult(long seconds, int nanos, boolean timeGiven, Integer tzShift) { + this.seconds = seconds; + this.nanos = nanos; + this.timeGiven = timeGiven; + this.tzShift = tzShift; + } + + /** + * Convert this {@link Rfc3339ParseResult} to a {@link DateTime} with millisecond precision. Any + * fraction of a millisecond will be truncated. + */ + private DateTime toDateTime() { + long seconds = TimeUnit.SECONDS.toMillis(this.seconds); + long nanos = TimeUnit.NANOSECONDS.toMillis(this.nanos); + return new DateTime(!timeGiven, seconds + nanos, tzShift); + } + + private SecondsAndNanos toSecondsAndNanos() { + return new SecondsAndNanos(seconds, nanos); + } + } + + private static Rfc3339ParseResult parseRfc3339WithNanoSeconds(String str) + throws NumberFormatException { Matcher matcher = RFC3339_PATTERN.matcher(str); if (!matcher.matches()) { throw new NumberFormatException("Invalid date/time format: " + str); @@ -283,7 +380,7 @@ public static DateTime parseRfc3339(String str) throws NumberFormatException { int hourOfDay = 0; int minute = 0; int second = 0; - int milliseconds = 0; + int nanoseconds = 0; Integer tzShiftInteger = null; if (isTzShiftGiven && !isTimeGiven) { @@ -297,34 +394,32 @@ public static DateTime parseRfc3339(String str) throws NumberFormatException { hourOfDay = Integer.parseInt(matcher.group(5)); // HH minute = Integer.parseInt(matcher.group(6)); // mm second = Integer.parseInt(matcher.group(7)); // ss - if (matcher.group(8) != null) { // contains .milliseconds? - milliseconds = Integer.parseInt(matcher.group(8).substring(1)); // milliseconds - // The number of digits after the dot may not be 3. Need to renormalize. - int fractionDigits = matcher.group(8).substring(1).length() - 3; - milliseconds = (int) ((float) milliseconds / Math.pow(10, fractionDigits)); + if (matcher.group(8) != null) { // contains .nanoseconds? + String fraction = Strings.padEnd(matcher.group(8).substring(1), 9, '0'); + nanoseconds = Integer.parseInt(fraction); } } Calendar dateTime = new GregorianCalendar(GMT); dateTime.set(year, month, day, hourOfDay, minute, second); - dateTime.set(Calendar.MILLISECOND, milliseconds); long value = dateTime.getTimeInMillis(); if (isTimeGiven && isTzShiftGiven) { - int tzShift; - if (Character.toUpperCase(tzShiftRegexGroup.charAt(0)) == 'Z') { - tzShift = 0; - } else { - tzShift = + if (Character.toUpperCase(tzShiftRegexGroup.charAt(0)) != 'Z') { + int tzShift = Integer.parseInt(matcher.group(11)) * 60 // time zone shift HH + Integer.parseInt(matcher.group(12)); // time zone shift mm if (matcher.group(10).charAt(0) == '-') { // time zone shift + or - tzShift = -tzShift; } value -= tzShift * 60000L; // e.g. if 1 hour ahead of UTC, subtract an hour to get UTC time + tzShiftInteger = tzShift; + } else { + tzShiftInteger = 0; } - tzShiftInteger = tzShift; } - return new DateTime(!isTimeGiven, value, tzShiftInteger); + // convert to seconds and nanoseconds + long secondsSinceEpoch = value / 1000L; + return new Rfc3339ParseResult(secondsSinceEpoch, nanoseconds, isTimeGiven, tzShiftInteger); } /** Appends a zero-padded number to a string builder. */ diff --git a/google-http-client/src/test/java/com/google/api/client/util/DateTimeTest.java b/google-http-client/src/test/java/com/google/api/client/util/DateTimeTest.java index 6318e327c..c8b9cd513 100644 --- a/google-http-client/src/test/java/com/google/api/client/util/DateTimeTest.java +++ b/google-http-client/src/test/java/com/google/api/client/util/DateTimeTest.java @@ -14,6 +14,7 @@ package com.google.api.client.util; +import com.google.api.client.util.DateTime.SecondsAndNanos; import java.util.Date; import java.util.TimeZone; import junit.framework.TestCase; @@ -142,6 +143,94 @@ public void testParseRfc3339() { assertEquals( DateTime.parseRfc3339("2007-06-01t18:50:00-04:00").getValue(), DateTime.parseRfc3339("2007-06-01t22:50:00Z").getValue()); // from Section 4.2 Local Offsets + + // Test truncating beyond millisecond precision. + assertEquals( + DateTime.parseRfc3339( + "2018-12-31T23:59:59.999999999Z"), // This value would be rounded up prior to version + // 1.30.2 + DateTime.parseRfc3339("2018-12-31T23:59:59.999Z")); + assertEquals( + DateTime.parseRfc3339( + "2018-12-31T23:59:59.9999Z"), // This value would be truncated prior to version 1.30.2 + DateTime.parseRfc3339("2018-12-31T23:59:59.999Z")); + } + + /** + * The following test values have been generated and verified using the {@link DateTimeFormatter} + * in Java 8. + * + *
+ * Timestamp | Seconds | Nanos + * 2018-03-01T10:11:12.999Z | 1519899072 | 999000000 + * 2018-10-28T02:00:00+02:00 | 1540684800 | 0 + * 2018-10-28T03:00:00+01:00 | 1540692000 | 0 + * 2018-01-01T00:00:00.000000001Z | 1514764800 | 1 + * 2018-10-28T02:00:00Z | 1540692000 | 0 + * 2018-12-31T23:59:59.999999999Z | 1546300799 | 999999999 + * 2018-03-01T10:11:12.9999Z | 1519899072 | 999900000 + * 2018-03-01T10:11:12.000000001Z | 1519899072 | 1 + * 2018-03-01T10:11:12.100000000Z | 1519899072 | 100000000 + * 2018-03-01T10:11:12.100000001Z | 1519899072 | 100000001 + * 2018-03-01T10:11:12-10:00 | 1519935072 | 0 + * 2018-03-01T10:11:12.999999999Z | 1519899072 | 999999999 + * 2018-03-01T10:11:12-12:00 | 1519942272 | 0 + * 2018-10-28T03:00:00Z | 1540695600 | 0 + * 2018-10-28T02:30:00Z | 1540693800 | 0 + * 2018-03-01T10:11:12.123Z | 1519899072 | 123000000 + * 2018-10-28T02:30:00+02:00 | 1540686600 | 0 + * 2018-03-01T10:11:12.123456789Z | 1519899072 | 123456789 + * 2018-03-01T10:11:12.1000Z | 1519899072 | 100000000 + *+ */ + public void testParseRfc3339ToSecondsAndNanos() { + assertParsedRfc3339( + "2018-03-01T10:11:12.999Z", SecondsAndNanos.ofSecondsAndNanos(1519899072L, 999000000)); + assertParsedRfc3339( + "2018-10-28T02:00:00+02:00", SecondsAndNanos.ofSecondsAndNanos(1540684800L, 0)); + assertParsedRfc3339( + "2018-10-28T03:00:00+01:00", SecondsAndNanos.ofSecondsAndNanos(1540692000L, 0)); + assertParsedRfc3339( + "2018-01-01T00:00:00.000000001Z", SecondsAndNanos.ofSecondsAndNanos(1514764800L, 1)); + assertParsedRfc3339("2018-10-28T02:00:00Z", SecondsAndNanos.ofSecondsAndNanos(1540692000L, 0)); + assertParsedRfc3339( + "2018-12-31T23:59:59.999999999Z", + SecondsAndNanos.ofSecondsAndNanos(1546300799L, 999999999)); + assertParsedRfc3339( + "2018-03-01T10:11:12.9999Z", SecondsAndNanos.ofSecondsAndNanos(1519899072L, 999900000)); + assertParsedRfc3339( + "2018-03-01T10:11:12.000000001Z", SecondsAndNanos.ofSecondsAndNanos(1519899072L, 1)); + assertParsedRfc3339( + "2018-03-01T10:11:12.100000000Z", + SecondsAndNanos.ofSecondsAndNanos(1519899072L, 100000000)); + assertParsedRfc3339( + "2018-03-01T10:11:12.100000001Z", + SecondsAndNanos.ofSecondsAndNanos(1519899072L, 100000001)); + assertParsedRfc3339( + "2018-03-01T10:11:12-10:00", SecondsAndNanos.ofSecondsAndNanos(1519935072L, 0)); + assertParsedRfc3339( + "2018-03-01T10:11:12.999999999Z", + SecondsAndNanos.ofSecondsAndNanos(1519899072L, 999999999)); + assertParsedRfc3339( + "2018-03-01T10:11:12-12:00", SecondsAndNanos.ofSecondsAndNanos(1519942272L, 0)); + assertParsedRfc3339("2018-10-28T03:00:00Z", SecondsAndNanos.ofSecondsAndNanos(1540695600L, 0)); + assertParsedRfc3339("2018-10-28T02:30:00Z", SecondsAndNanos.ofSecondsAndNanos(1540693800L, 0)); + assertParsedRfc3339( + "2018-03-01T10:11:12.123Z", SecondsAndNanos.ofSecondsAndNanos(1519899072L, 123000000)); + assertParsedRfc3339( + "2018-10-28T02:30:00+02:00", SecondsAndNanos.ofSecondsAndNanos(1540686600L, 0)); + assertParsedRfc3339( + "2018-03-01T10:11:12.123456789Z", + SecondsAndNanos.ofSecondsAndNanos(1519899072L, 123456789)); + assertParsedRfc3339( + "2018-03-01T10:11:12.1000Z", SecondsAndNanos.ofSecondsAndNanos(1519899072L, 100000000)); + } + + private void assertParsedRfc3339(String input, SecondsAndNanos expected) { + SecondsAndNanos actual = DateTime.parseRfc3339ToSecondsAndNanos(input); + assertEquals( + "Seconds for " + input + " do not match", expected.getSeconds(), actual.getSeconds()); + assertEquals("Nanos for " + input + " do not match", expected.getNanos(), actual.getNanos()); } public void testParseAndFormatRfc3339() {