Skip to content

Commit a7e99cd

Browse files
author
Michael Rubin
committed
http Issue 13080044: Fixed DateTime bugs related to parsing RFC3339 Strings
1 parent 9e3803b commit a7e99cd

File tree

2 files changed

+108
-45
lines changed

2 files changed

+108
-45
lines changed

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

Lines changed: 80 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
import java.util.Date;
2121
import java.util.GregorianCalendar;
2222
import java.util.TimeZone;
23+
import java.util.regex.Matcher;
24+
import java.util.regex.Pattern;
2325

2426
/**
2527
* Immutable representation of a date with an optional time and an optional time zone based on <a
@@ -38,6 +40,12 @@ public final class DateTime implements Serializable {
3840

3941
private static final TimeZone GMT = TimeZone.getTimeZone("GMT");
4042

43+
/** Regular expression for parsing RFC3339 date/times. */
44+
private static final Pattern RFC3339_PATTERN = Pattern.compile(
45+
"^(\\d{4})-(\\d{2})-(\\d{2})" // yyyy-MM-dd
46+
+ "([Tt](\\d{2}):(\\d{2}):(\\d{2})(\\.\\d+)?)?" // 'T'HH:mm:ss.milliseconds
47+
+ "([Zz]|([+-])(\\d{2}):(\\d{2}))?"); // 'Z' or time zone shift HH:mm following '+' or '-'
48+
4149
/**
4250
* Date/time value expressed as the number of ms since the Unix epoch.
4351
*
@@ -123,12 +131,22 @@ public DateTime(boolean dateOnly, long value, Integer tzShift) {
123131
* Instantiates {@link DateTime} from an <a href='http://tools.ietf.org/html/rfc3339'>RFC 3339</a>
124132
* date/time value.
125133
*
134+
* <p>
135+
* Upgrade warning: in prior version 1.17, this method required milliseconds to be exactly 3
136+
* digits (if included), and did not throw an exception for all types of invalid input values, but
137+
* starting in version 1.18, the parsing done by this method has become more strict to enforce
138+
* that only valid RFC3339 strings are entered, and if not, it throws a
139+
* {@link NumberFormatException}. Also, in accordance with the RFC3339 standard, any number of
140+
* milliseconds digits is now allowed.
141+
* </p>
142+
*
126143
* @param value an <a href='http://tools.ietf.org/html/rfc3339'>RFC 3339</a> date/time value.
127144
* @since 1.11
128145
*/
129146
public DateTime(String value) {
130-
// TODO(rmistry): Move the implementation of parseRfc3339 into this constructor. Implementation
131-
// of parseRfc3339 can then do "return new DateTime(str);".
147+
// Note, the following refactoring is being considered: Move the implementation of parseRfc3339
148+
// into this constructor. Implementation of parseRfc3339 can then do
149+
// "return new DateTime(str);".
132150
DateTime dateTime = parseRfc3339(value);
133151
this.dateOnly = dateTime.dateOnly;
134152
this.value = dateTime.value;
@@ -248,58 +266,75 @@ public int hashCode() {
248266
* Parses an RFC 3339 date/time value.
249267
*
250268
* <p>
269+
* Upgrade warning: in prior version 1.17, this method required milliseconds to be exactly 3
270+
* digits (if included), and did not throw an exception for all types of invalid input values, but
271+
* starting in version 1.18, the parsing done by this method has become more strict to enforce
272+
* that only valid RFC3339 strings are entered, and if not, it throws a
273+
* {@link NumberFormatException}. Also, in accordance with the RFC3339 standard, any number of
274+
* milliseconds digits is now allowed.
275+
* </p>
276+
*
277+
* <p>
251278
* For the date-only case, the time zone is ignored and the hourOfDay, minute, second, and
252279
* millisecond parameters are set to zero.
253280
* </p>
281+
*
282+
* @param str Date/time string in RFC3339 format
283+
* @throws NumberFormatException if {@code str} doesn't match the RFC3339 standard format; an
284+
* exception is thrown if {@code str} doesn't match {@code RFC3339_REGEX} or if it
285+
* contains a time zone shift but no time.
254286
*/
255287
public static DateTime parseRfc3339(String str) throws NumberFormatException {
256-
try {
257-
int year = Integer.parseInt(str.substring(0, 4));
258-
int month = Integer.parseInt(str.substring(5, 7)) - 1;
259-
int day = Integer.parseInt(str.substring(8, 10));
260-
int tzIndex;
261-
int length = str.length();
262-
boolean dateOnly = length <= 10 || Character.toUpperCase(str.charAt(10)) != 'T';
263-
int hourOfDay = 0;
264-
int minute = 0;
265-
int second = 0;
266-
int milliseconds = 0;
267-
Integer tzShiftInteger = null;
268-
if (dateOnly) {
269-
tzIndex = Integer.MAX_VALUE;
270-
} else {
271-
hourOfDay = Integer.parseInt(str.substring(11, 13));
272-
minute = Integer.parseInt(str.substring(14, 16));
273-
second = Integer.parseInt(str.substring(17, 19));
274-
if (str.charAt(19) == '.') {
275-
milliseconds = Integer.parseInt(str.substring(20, 23));
276-
tzIndex = 23;
277-
} else {
278-
tzIndex = 19;
279-
}
288+
Matcher matcher = RFC3339_PATTERN.matcher(str);
289+
if (!matcher.matches()) {
290+
throw new NumberFormatException("Invalid date/time format: " + str);
291+
}
292+
293+
int year = Integer.parseInt(matcher.group(1)); // yyyy
294+
int month = Integer.parseInt(matcher.group(2)) - 1; // MM
295+
int day = Integer.parseInt(matcher.group(3)); // dd
296+
boolean isTimeGiven = matcher.group(4) != null; // 'T'HH:mm:ss.milliseconds
297+
String tzShiftRegexGroup = matcher.group(9); // 'Z', or time zone shift HH:mm following '+'/'-'
298+
boolean isTzShiftGiven = tzShiftRegexGroup != null;
299+
int hourOfDay = 0;
300+
int minute = 0;
301+
int second = 0;
302+
int milliseconds = 0;
303+
Integer tzShiftInteger = null;
304+
305+
if (isTzShiftGiven && !isTimeGiven) {
306+
throw new NumberFormatException("Invalid date/time format, cannot specify time zone shift" +
307+
" without specifying time: " + str);
308+
}
309+
310+
if (isTimeGiven) {
311+
hourOfDay = Integer.parseInt(matcher.group(5)); // HH
312+
minute = Integer.parseInt(matcher.group(6)); // mm
313+
second = Integer.parseInt(matcher.group(7)); // ss
314+
if (matcher.group(8) != null) { // contains .milliseconds?
315+
milliseconds = Integer.parseInt(matcher.group(8).substring(1)); // milliseconds
280316
}
281-
Calendar dateTime = new GregorianCalendar(GMT);
282-
dateTime.set(year, month, day, hourOfDay, minute, second);
283-
dateTime.set(Calendar.MILLISECOND, milliseconds);
284-
long value = dateTime.getTimeInMillis();
285-
if (length > tzIndex) {
286-
int tzShift;
287-
if (Character.toUpperCase(str.charAt(tzIndex)) == 'Z') {
288-
tzShift = 0;
289-
} else {
290-
tzShift = Integer.parseInt(str.substring(tzIndex + 1, tzIndex + 3)) * 60
291-
+ Integer.parseInt(str.substring(tzIndex + 4, tzIndex + 6));
292-
if (str.charAt(tzIndex) == '-') {
293-
tzShift = -tzShift;
294-
}
295-
value -= tzShift * 60000L;
317+
}
318+
Calendar dateTime = new GregorianCalendar(GMT);
319+
dateTime.set(year, month, day, hourOfDay, minute, second);
320+
dateTime.set(Calendar.MILLISECOND, milliseconds);
321+
long value = dateTime.getTimeInMillis();
322+
323+
if (isTimeGiven && isTzShiftGiven) {
324+
int tzShift;
325+
if (Character.toUpperCase(tzShiftRegexGroup.charAt(0)) == 'Z') {
326+
tzShift = 0;
327+
} else {
328+
tzShift = Integer.parseInt(matcher.group(11)) * 60 // time zone shift HH
329+
+ Integer.parseInt(matcher.group(12)); // time zone shift mm
330+
if (matcher.group(10).charAt(0) == '-') { // time zone shift + or -
331+
tzShift = -tzShift;
296332
}
297-
tzShiftInteger = tzShift;
333+
value -= tzShift * 60000L; // e.g. if 1 hour ahead of UTC, subtract an hour to get UTC time
298334
}
299-
return new DateTime(dateOnly, value, tzShiftInteger);
300-
} catch (StringIndexOutOfBoundsException e) {
301-
throw new NumberFormatException("Invalid date/time format: " + str);
335+
tzShiftInteger = tzShift;
302336
}
337+
return new DateTime(!isTimeGiven, value, tzShiftInteger);
303338
}
304339

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

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,25 @@
2626
*/
2727
public class DateTimeTest extends TestCase {
2828

29+
private TimeZone originalTimeZone;
30+
2931
public DateTimeTest() {
3032
}
3133

3234
public DateTimeTest(String testName) {
3335
super(testName);
3436
}
3537

38+
@Override
39+
protected void setUp() throws Exception {
40+
originalTimeZone = TimeZone.getDefault();
41+
}
42+
43+
@Override
44+
protected void tearDown() throws Exception {
45+
TimeZone.setDefault(originalTimeZone);
46+
}
47+
3648
public void testToStringRfc3339() {
3749
TimeZone.setDefault(TimeZone.getTimeZone("GMT-4"));
3850

@@ -95,19 +107,35 @@ public void testEquals() throws InterruptedException {
95107
public void testParseRfc3339() {
96108
expectExceptionForParseRfc3339("");
97109
expectExceptionForParseRfc3339("abc");
110+
expectExceptionForParseRfc3339("2013-01-01 09:00:02");
111+
expectExceptionForParseRfc3339("2013-01-01T"); // missing time
112+
expectExceptionForParseRfc3339("1937--3-55T12:00:27+00:20"); // invalid month
113+
expectExceptionForParseRfc3339("2013-01-01Z"); // can't have time zone shift without time
114+
98115
DateTime value = DateTime.parseRfc3339("2007-06-01");
99116
assertTrue(value.isDateOnly());
100117
value = DateTime.parseRfc3339("2007-06-01T10:11:30.057");
101118
assertFalse(value.isDateOnly());
102119
value = DateTime.parseRfc3339("2007-06-01T10:11:30Z");
103120
assertEquals(0, value.getValue() % 100);
121+
value = DateTime.parseRfc3339("1997-01-01T12:00:27.87+00:20");
122+
assertFalse(value.isDateOnly());
123+
assertEquals(87, value.getValue() % 1000); // check milliseconds
104124

105125
value = new DateTime("2007-06-01");
106126
assertTrue(value.isDateOnly());
107127
value = new DateTime("2007-06-01T10:11:30.057");
108128
assertFalse(value.isDateOnly());
109129
value = new DateTime("2007-06-01T10:11:30Z");
110130
assertEquals(0, value.getValue() % 100);
131+
132+
// From the RFC3339 Standard
133+
assertEquals(DateTime.parseRfc3339("1996-12-19T16:39:57-08:00").getValue(),
134+
DateTime.parseRfc3339("1996-12-20T00:39:57Z").getValue()); // from Section 5.8 Examples
135+
assertEquals(DateTime.parseRfc3339("1990-12-31T23:59:60Z").getValue(),
136+
DateTime.parseRfc3339("1990-12-31T15:59:60-08:00").getValue()); // from Section 5.8 Examples
137+
assertEquals(DateTime.parseRfc3339("2007-06-01t18:50:00-04:00").getValue(),
138+
DateTime.parseRfc3339("2007-06-01t22:50:00Z").getValue()); // from Section 4.2 Local Offsets
111139
}
112140

113141
private void expectExceptionForParseRfc3339(String input) {

0 commit comments

Comments
 (0)