Skip to content

Commit 1d496dc

Browse files
olavloitekolea2
authored andcommitted
Add support for nanosecond precision when parsing rfc3339 strings (#752)
* support nanosecond precision when parsing rfc3339 strings * removed hamcrest matchers * fix javadoc + change exception type * assert parse directly instead of using a map * put expected and actual in correct order
1 parent 4916aa3 commit 1d496dc

File tree

2 files changed

+204
-20
lines changed

2 files changed

+204
-20
lines changed

google-http-client/src/main/java/com/google/api/client/util/DateTime.java

Lines changed: 115 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,15 @@
1414

1515
package com.google.api.client.util;
1616

17+
import com.google.common.base.Strings;
1718
import java.io.Serializable;
1819
import java.util.Arrays;
1920
import java.util.Calendar;
2021
import java.util.Date;
2122
import java.util.GregorianCalendar;
23+
import java.util.Objects;
2224
import java.util.TimeZone;
25+
import java.util.concurrent.TimeUnit;
2326
import java.util.regex.Matcher;
2427
import java.util.regex.Pattern;
2528

@@ -39,12 +42,12 @@ public final class DateTime implements Serializable {
3942
private static final TimeZone GMT = TimeZone.getTimeZone("GMT");
4043

4144
/** Regular expression for parsing RFC3339 date/times. */
42-
private static final Pattern RFC3339_PATTERN =
43-
Pattern.compile(
44-
"^(\\d{4})-(\\d{2})-(\\d{2})" // yyyy-MM-dd
45-
+ "([Tt](\\d{2}):(\\d{2}):(\\d{2})(\\.\\d+)?)?" // 'T'HH:mm:ss.milliseconds
46-
+ "([Zz]|([+-])(\\d{2}):(\\d{2}))?"); // 'Z' or time zone shift HH:mm following '+' or
47-
// '-'
45+
private static final String RFC3339_REGEX =
46+
"(\\d{4})-(\\d{2})-(\\d{2})" // yyyy-MM-dd
47+
+ "([Tt](\\d{2}):(\\d{2}):(\\d{2})(\\.\\d{1,9})?)?" // 'T'HH:mm:ss.nanoseconds
48+
+ "([Zz]|([+-])(\\d{2}):(\\d{2}))?"; // 'Z' or time zone shift HH:mm following '+' or '-'
49+
50+
private static final Pattern RFC3339_PATTERN = Pattern.compile(RFC3339_REGEX);
4851

4952
/**
5053
* Date/time value expressed as the number of ms since the Unix epoch.
@@ -260,6 +263,8 @@ public int hashCode() {
260263
* NumberFormatException}. Also, in accordance with the RFC3339 standard, any number of
261264
* milliseconds digits is now allowed.
262265
*
266+
* <p>Any time information beyond millisecond precision is truncated.
267+
*
263268
* <p>For the date-only case, the time zone is ignored and the hourOfDay, minute, second, and
264269
* millisecond parameters are set to zero.
265270
*
@@ -269,6 +274,98 @@ public int hashCode() {
269274
* time zone shift but no time.
270275
*/
271276
public static DateTime parseRfc3339(String str) throws NumberFormatException {
277+
return parseRfc3339WithNanoSeconds(str).toDateTime();
278+
}
279+
280+
/**
281+
* Parses an RFC3339 timestamp to a pair of seconds and nanoseconds since Unix Epoch.
282+
*
283+
* @param str Date/time string in RFC3339 format
284+
* @throws IllegalArgumentException if {@code str} doesn't match the RFC3339 standard format; an
285+
* exception is thrown if {@code str} doesn't match {@code RFC3339_REGEX} or if it contains a
286+
* time zone shift but no time.
287+
*/
288+
public static SecondsAndNanos parseRfc3339ToSecondsAndNanos(String str)
289+
throws IllegalArgumentException {
290+
return parseRfc3339WithNanoSeconds(str).toSecondsAndNanos();
291+
}
292+
293+
/** A timestamp represented as the number of seconds and nanoseconds since Epoch. */
294+
public static final class SecondsAndNanos implements Serializable {
295+
private final long seconds;
296+
private final int nanos;
297+
298+
public static SecondsAndNanos ofSecondsAndNanos(long seconds, int nanos) {
299+
return new SecondsAndNanos(seconds, nanos);
300+
}
301+
302+
private SecondsAndNanos(long seconds, int nanos) {
303+
this.seconds = seconds;
304+
this.nanos = nanos;
305+
}
306+
307+
public long getSeconds() {
308+
return seconds;
309+
}
310+
311+
public int getNanos() {
312+
return nanos;
313+
}
314+
315+
@Override
316+
public boolean equals(Object o) {
317+
if (this == o) {
318+
return true;
319+
}
320+
if (o == null || getClass() != o.getClass()) {
321+
return false;
322+
}
323+
SecondsAndNanos that = (SecondsAndNanos) o;
324+
return seconds == that.seconds && nanos == that.nanos;
325+
}
326+
327+
@Override
328+
public int hashCode() {
329+
return Objects.hash(seconds, nanos);
330+
}
331+
332+
@Override
333+
public String toString() {
334+
return String.format("Seconds: %d, Nanos: %d", seconds, nanos);
335+
}
336+
}
337+
338+
/** Result of parsing a Rfc3339 string. */
339+
private static class Rfc3339ParseResult implements Serializable {
340+
private final long seconds;
341+
private final int nanos;
342+
private final boolean timeGiven;
343+
private final Integer tzShift;
344+
345+
private Rfc3339ParseResult(long seconds, int nanos, boolean timeGiven, Integer tzShift) {
346+
this.seconds = seconds;
347+
this.nanos = nanos;
348+
this.timeGiven = timeGiven;
349+
this.tzShift = tzShift;
350+
}
351+
352+
/**
353+
* Convert this {@link Rfc3339ParseResult} to a {@link DateTime} with millisecond precision. Any
354+
* fraction of a millisecond will be truncated.
355+
*/
356+
private DateTime toDateTime() {
357+
long seconds = TimeUnit.SECONDS.toMillis(this.seconds);
358+
long nanos = TimeUnit.NANOSECONDS.toMillis(this.nanos);
359+
return new DateTime(!timeGiven, seconds + nanos, tzShift);
360+
}
361+
362+
private SecondsAndNanos toSecondsAndNanos() {
363+
return new SecondsAndNanos(seconds, nanos);
364+
}
365+
}
366+
367+
private static Rfc3339ParseResult parseRfc3339WithNanoSeconds(String str)
368+
throws NumberFormatException {
272369
Matcher matcher = RFC3339_PATTERN.matcher(str);
273370
if (!matcher.matches()) {
274371
throw new NumberFormatException("Invalid date/time format: " + str);
@@ -283,7 +380,7 @@ public static DateTime parseRfc3339(String str) throws NumberFormatException {
283380
int hourOfDay = 0;
284381
int minute = 0;
285382
int second = 0;
286-
int milliseconds = 0;
383+
int nanoseconds = 0;
287384
Integer tzShiftInteger = null;
288385

289386
if (isTzShiftGiven && !isTimeGiven) {
@@ -297,34 +394,32 @@ public static DateTime parseRfc3339(String str) throws NumberFormatException {
297394
hourOfDay = Integer.parseInt(matcher.group(5)); // HH
298395
minute = Integer.parseInt(matcher.group(6)); // mm
299396
second = Integer.parseInt(matcher.group(7)); // ss
300-
if (matcher.group(8) != null) { // contains .milliseconds?
301-
milliseconds = Integer.parseInt(matcher.group(8).substring(1)); // milliseconds
302-
// The number of digits after the dot may not be 3. Need to renormalize.
303-
int fractionDigits = matcher.group(8).substring(1).length() - 3;
304-
milliseconds = (int) ((float) milliseconds / Math.pow(10, fractionDigits));
397+
if (matcher.group(8) != null) { // contains .nanoseconds?
398+
String fraction = Strings.padEnd(matcher.group(8).substring(1), 9, '0');
399+
nanoseconds = Integer.parseInt(fraction);
305400
}
306401
}
307402
Calendar dateTime = new GregorianCalendar(GMT);
308403
dateTime.set(year, month, day, hourOfDay, minute, second);
309-
dateTime.set(Calendar.MILLISECOND, milliseconds);
310404
long value = dateTime.getTimeInMillis();
311405

312406
if (isTimeGiven && isTzShiftGiven) {
313-
int tzShift;
314-
if (Character.toUpperCase(tzShiftRegexGroup.charAt(0)) == 'Z') {
315-
tzShift = 0;
316-
} else {
317-
tzShift =
407+
if (Character.toUpperCase(tzShiftRegexGroup.charAt(0)) != 'Z') {
408+
int tzShift =
318409
Integer.parseInt(matcher.group(11)) * 60 // time zone shift HH
319410
+ Integer.parseInt(matcher.group(12)); // time zone shift mm
320411
if (matcher.group(10).charAt(0) == '-') { // time zone shift + or -
321412
tzShift = -tzShift;
322413
}
323414
value -= tzShift * 60000L; // e.g. if 1 hour ahead of UTC, subtract an hour to get UTC time
415+
tzShiftInteger = tzShift;
416+
} else {
417+
tzShiftInteger = 0;
324418
}
325-
tzShiftInteger = tzShift;
326419
}
327-
return new DateTime(!isTimeGiven, value, tzShiftInteger);
420+
// convert to seconds and nanoseconds
421+
long secondsSinceEpoch = value / 1000L;
422+
return new Rfc3339ParseResult(secondsSinceEpoch, nanoseconds, isTimeGiven, tzShiftInteger);
328423
}
329424

330425
/** Appends a zero-padded number to a string builder. */

google-http-client/src/test/java/com/google/api/client/util/DateTimeTest.java

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
package com.google.api.client.util;
1616

17+
import com.google.api.client.util.DateTime.SecondsAndNanos;
1718
import java.util.Date;
1819
import java.util.TimeZone;
1920
import junit.framework.TestCase;
@@ -142,6 +143,94 @@ public void testParseRfc3339() {
142143
assertEquals(
143144
DateTime.parseRfc3339("2007-06-01t18:50:00-04:00").getValue(),
144145
DateTime.parseRfc3339("2007-06-01t22:50:00Z").getValue()); // from Section 4.2 Local Offsets
146+
147+
// Test truncating beyond millisecond precision.
148+
assertEquals(
149+
DateTime.parseRfc3339(
150+
"2018-12-31T23:59:59.999999999Z"), // This value would be rounded up prior to version
151+
// 1.30.2
152+
DateTime.parseRfc3339("2018-12-31T23:59:59.999Z"));
153+
assertEquals(
154+
DateTime.parseRfc3339(
155+
"2018-12-31T23:59:59.9999Z"), // This value would be truncated prior to version 1.30.2
156+
DateTime.parseRfc3339("2018-12-31T23:59:59.999Z"));
157+
}
158+
159+
/**
160+
* The following test values have been generated and verified using the {@link DateTimeFormatter}
161+
* in Java 8.
162+
*
163+
* <pre>
164+
* Timestamp | Seconds | Nanos
165+
* 2018-03-01T10:11:12.999Z | 1519899072 | 999000000
166+
* 2018-10-28T02:00:00+02:00 | 1540684800 | 0
167+
* 2018-10-28T03:00:00+01:00 | 1540692000 | 0
168+
* 2018-01-01T00:00:00.000000001Z | 1514764800 | 1
169+
* 2018-10-28T02:00:00Z | 1540692000 | 0
170+
* 2018-12-31T23:59:59.999999999Z | 1546300799 | 999999999
171+
* 2018-03-01T10:11:12.9999Z | 1519899072 | 999900000
172+
* 2018-03-01T10:11:12.000000001Z | 1519899072 | 1
173+
* 2018-03-01T10:11:12.100000000Z | 1519899072 | 100000000
174+
* 2018-03-01T10:11:12.100000001Z | 1519899072 | 100000001
175+
* 2018-03-01T10:11:12-10:00 | 1519935072 | 0
176+
* 2018-03-01T10:11:12.999999999Z | 1519899072 | 999999999
177+
* 2018-03-01T10:11:12-12:00 | 1519942272 | 0
178+
* 2018-10-28T03:00:00Z | 1540695600 | 0
179+
* 2018-10-28T02:30:00Z | 1540693800 | 0
180+
* 2018-03-01T10:11:12.123Z | 1519899072 | 123000000
181+
* 2018-10-28T02:30:00+02:00 | 1540686600 | 0
182+
* 2018-03-01T10:11:12.123456789Z | 1519899072 | 123456789
183+
* 2018-03-01T10:11:12.1000Z | 1519899072 | 100000000
184+
* </pre>
185+
*/
186+
public void testParseRfc3339ToSecondsAndNanos() {
187+
assertParsedRfc3339(
188+
"2018-03-01T10:11:12.999Z", SecondsAndNanos.ofSecondsAndNanos(1519899072L, 999000000));
189+
assertParsedRfc3339(
190+
"2018-10-28T02:00:00+02:00", SecondsAndNanos.ofSecondsAndNanos(1540684800L, 0));
191+
assertParsedRfc3339(
192+
"2018-10-28T03:00:00+01:00", SecondsAndNanos.ofSecondsAndNanos(1540692000L, 0));
193+
assertParsedRfc3339(
194+
"2018-01-01T00:00:00.000000001Z", SecondsAndNanos.ofSecondsAndNanos(1514764800L, 1));
195+
assertParsedRfc3339("2018-10-28T02:00:00Z", SecondsAndNanos.ofSecondsAndNanos(1540692000L, 0));
196+
assertParsedRfc3339(
197+
"2018-12-31T23:59:59.999999999Z",
198+
SecondsAndNanos.ofSecondsAndNanos(1546300799L, 999999999));
199+
assertParsedRfc3339(
200+
"2018-03-01T10:11:12.9999Z", SecondsAndNanos.ofSecondsAndNanos(1519899072L, 999900000));
201+
assertParsedRfc3339(
202+
"2018-03-01T10:11:12.000000001Z", SecondsAndNanos.ofSecondsAndNanos(1519899072L, 1));
203+
assertParsedRfc3339(
204+
"2018-03-01T10:11:12.100000000Z",
205+
SecondsAndNanos.ofSecondsAndNanos(1519899072L, 100000000));
206+
assertParsedRfc3339(
207+
"2018-03-01T10:11:12.100000001Z",
208+
SecondsAndNanos.ofSecondsAndNanos(1519899072L, 100000001));
209+
assertParsedRfc3339(
210+
"2018-03-01T10:11:12-10:00", SecondsAndNanos.ofSecondsAndNanos(1519935072L, 0));
211+
assertParsedRfc3339(
212+
"2018-03-01T10:11:12.999999999Z",
213+
SecondsAndNanos.ofSecondsAndNanos(1519899072L, 999999999));
214+
assertParsedRfc3339(
215+
"2018-03-01T10:11:12-12:00", SecondsAndNanos.ofSecondsAndNanos(1519942272L, 0));
216+
assertParsedRfc3339("2018-10-28T03:00:00Z", SecondsAndNanos.ofSecondsAndNanos(1540695600L, 0));
217+
assertParsedRfc3339("2018-10-28T02:30:00Z", SecondsAndNanos.ofSecondsAndNanos(1540693800L, 0));
218+
assertParsedRfc3339(
219+
"2018-03-01T10:11:12.123Z", SecondsAndNanos.ofSecondsAndNanos(1519899072L, 123000000));
220+
assertParsedRfc3339(
221+
"2018-10-28T02:30:00+02:00", SecondsAndNanos.ofSecondsAndNanos(1540686600L, 0));
222+
assertParsedRfc3339(
223+
"2018-03-01T10:11:12.123456789Z",
224+
SecondsAndNanos.ofSecondsAndNanos(1519899072L, 123456789));
225+
assertParsedRfc3339(
226+
"2018-03-01T10:11:12.1000Z", SecondsAndNanos.ofSecondsAndNanos(1519899072L, 100000000));
227+
}
228+
229+
private void assertParsedRfc3339(String input, SecondsAndNanos expected) {
230+
SecondsAndNanos actual = DateTime.parseRfc3339ToSecondsAndNanos(input);
231+
assertEquals(
232+
"Seconds for " + input + " do not match", expected.getSeconds(), actual.getSeconds());
233+
assertEquals("Nanos for " + input + " do not match", expected.getNanos(), actual.getNanos());
145234
}
146235

147236
public void testParseAndFormatRfc3339() {

0 commit comments

Comments
 (0)