From 1c6bebac1d0c8c7e455b5db58832f2ce10581222 Mon Sep 17 00:00:00 2001 From: jwilson Date: Sat, 26 Sep 2015 18:25:01 -0400 Subject: [PATCH] RFC3339 adapter. Much thanks to Jackson for doing all the real work. --- adapters/pom.xml | 31 ++ .../java/com/squareup/moshi/Iso8601Utils.java | 271 ++++++++++++++++++ .../moshi/Rfc3339DateJsonAdapter.java | 35 +++ .../moshi/Rfc3339DateJsonAdapterTest.java | 72 +++++ pom.xml | 1 + 5 files changed, 410 insertions(+) create mode 100644 adapters/pom.xml create mode 100644 adapters/src/main/java/com/squareup/moshi/Iso8601Utils.java create mode 100644 adapters/src/main/java/com/squareup/moshi/Rfc3339DateJsonAdapter.java create mode 100644 adapters/src/test/java/com/squareup/moshi/Rfc3339DateJsonAdapterTest.java diff --git a/adapters/pom.xml b/adapters/pom.xml new file mode 100644 index 0000000..70a0e81 --- /dev/null +++ b/adapters/pom.xml @@ -0,0 +1,31 @@ + + + + 4.0.0 + + + com.squareup.moshi + moshi-parent + 1.0.0-SNAPSHOT + + + moshi-adapters + + + + com.squareup.moshi + moshi + ${project.version} + + + junit + junit + test + + + org.assertj + assertj-core + test + + + diff --git a/adapters/src/main/java/com/squareup/moshi/Iso8601Utils.java b/adapters/src/main/java/com/squareup/moshi/Iso8601Utils.java new file mode 100644 index 0000000..3ca492f --- /dev/null +++ b/adapters/src/main/java/com/squareup/moshi/Iso8601Utils.java @@ -0,0 +1,271 @@ +/* + * Copyright (C) 2011 FasterXML, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.moshi; + +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.Locale; +import java.util.TimeZone; + +/** + * Jackson’s date formatter, pruned to Moshi's needs. Forked from this file: + * https://github.com/FasterXML/jackson-databind/blob/master/src/main/java/com/fasterxml/jackson/databind/util/ISO8601Utils.java + * + * Utilities methods for manipulating dates in iso8601 format. This is much much faster and GC + * friendly than using SimpleDateFormat so highly suitable if you (un)serialize lots of date + * objects. + * + * Supported parse format: [yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:]mm]] + * + * @see this specification + */ +final class Iso8601Utils { + /** ID to represent the 'GMT' string */ + static final String GMT_ID = "GMT"; + + /** The GMT timezone, prefetched to avoid more lookups. */ + static final TimeZone TIMEZONE_Z = TimeZone.getTimeZone(GMT_ID); + + /** Returns {@code date} formatted as yyyy-MM-ddThh:mm:ss.sssZ */ + public static String format(Date date) { + Calendar calendar = new GregorianCalendar(TIMEZONE_Z, Locale.US); + calendar.setTime(date); + + // estimate capacity of buffer as close as we can (yeah, that's pedantic ;) + int capacity = "yyyy-MM-ddThh:mm:ss.sssZ".length(); + StringBuilder formatted = new StringBuilder(capacity); + padInt(formatted, calendar.get(Calendar.YEAR), "yyyy".length()); + formatted.append('-'); + padInt(formatted, calendar.get(Calendar.MONTH) + 1, "MM".length()); + formatted.append('-'); + padInt(formatted, calendar.get(Calendar.DAY_OF_MONTH), "dd".length()); + formatted.append('T'); + padInt(formatted, calendar.get(Calendar.HOUR_OF_DAY), "hh".length()); + formatted.append(':'); + padInt(formatted, calendar.get(Calendar.MINUTE), "mm".length()); + formatted.append(':'); + padInt(formatted, calendar.get(Calendar.SECOND), "ss".length()); + formatted.append('.'); + padInt(formatted, calendar.get(Calendar.MILLISECOND), "sss".length()); + formatted.append('Z'); + return formatted.toString(); + } + + /** + * Parse a date from ISO-8601 formatted string. It expects a format + * [yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh:mm]] + * + * @param date ISO string to parse in the appropriate format. + * @return the parsed date + */ + public static Date parse(String date) { + try { + int offset = 0; + + // extract year + int year = parseInt(date, offset, offset += 4); + if (checkOffset(date, offset, '-')) { + offset += 1; + } + + // extract month + int month = parseInt(date, offset, offset += 2); + if (checkOffset(date, offset, '-')) { + offset += 1; + } + + // extract day + int day = parseInt(date, offset, offset += 2); + // default time value + int hour = 0; + int minutes = 0; + int seconds = 0; + int milliseconds = + 0; // always use 0 otherwise returned date will include millis of current time + + // if the value has no time component (and no time zone), we are done + boolean hasT = checkOffset(date, offset, 'T'); + + if (!hasT && (date.length() <= offset)) { + Calendar calendar = new GregorianCalendar(year, month - 1, day); + + return calendar.getTime(); + } + + if (hasT) { + + // extract hours, minutes, seconds and milliseconds + hour = parseInt(date, offset += 1, offset += 2); + if (checkOffset(date, offset, ':')) { + offset += 1; + } + + minutes = parseInt(date, offset, offset += 2); + if (checkOffset(date, offset, ':')) { + offset += 1; + } + // second and milliseconds can be optional + if (date.length() > offset) { + char c = date.charAt(offset); + if (c != 'Z' && c != '+' && c != '-') { + seconds = parseInt(date, offset, offset += 2); + if (seconds > 59 && seconds < 63) seconds = 59; // truncate up to 3 leap seconds + // milliseconds can be optional in the format + if (checkOffset(date, offset, '.')) { + offset += 1; + int endOffset = indexOfNonDigit(date, offset + 1); // assume at least one digit + int parseEndOffset = Math.min(endOffset, offset + 3); // parse up to 3 digits + int fraction = parseInt(date, offset, parseEndOffset); + milliseconds = (int) (Math.pow(10, 3 - (parseEndOffset - offset)) * fraction); + offset = endOffset; + } + } + } + } + + // extract timezone + if (date.length() <= offset) { + throw new IllegalArgumentException("No time zone indicator"); + } + + TimeZone timezone; + char timezoneIndicator = date.charAt(offset); + + if (timezoneIndicator == 'Z') { + timezone = TIMEZONE_Z; + } else if (timezoneIndicator == '+' || timezoneIndicator == '-') { + String timezoneOffset = date.substring(offset); + // 18-Jun-2015, tatu: Minor simplification, skip offset of "+0000"/"+00:00" + if ("+0000".equals(timezoneOffset) || "+00:00".equals(timezoneOffset)) { + timezone = TIMEZONE_Z; + } else { + // 18-Jun-2015, tatu: Looks like offsets only work from GMT, not UTC... + // not sure why, but it is what it is. + String timezoneId = GMT_ID + timezoneOffset; + timezone = TimeZone.getTimeZone(timezoneId); + String act = timezone.getID(); + if (!act.equals(timezoneId)) { + /* 22-Jan-2015, tatu: Looks like canonical version has colons, but we may be given + * one without. If so, don't sweat. + * Yes, very inefficient. Hopefully not hit often. + * If it becomes a perf problem, add 'loose' comparison instead. + */ + String cleaned = act.replace(":", ""); + if (!cleaned.equals(timezoneId)) { + throw new IndexOutOfBoundsException("Mismatching time zone indicator: " + + timezoneId + " given, resolves to " + timezone.getID()); + } + } + } + } else { + throw new IndexOutOfBoundsException( + "Invalid time zone indicator '" + timezoneIndicator + "'"); + } + + Calendar calendar = new GregorianCalendar(timezone); + calendar.setLenient(false); + calendar.set(Calendar.YEAR, year); + calendar.set(Calendar.MONTH, month - 1); + calendar.set(Calendar.DAY_OF_MONTH, day); + calendar.set(Calendar.HOUR_OF_DAY, hour); + calendar.set(Calendar.MINUTE, minutes); + calendar.set(Calendar.SECOND, seconds); + calendar.set(Calendar.MILLISECOND, milliseconds); + + return calendar.getTime(); + // If we get a ParseException it'll already have the right message/offset. + // Other exception types can convert here. + } catch (IndexOutOfBoundsException | IllegalArgumentException e) { + throw new JsonDataException("Not an RFC 3339 date: " + date); + } + } + + /** + * Check if the expected character exist at the given offset in the value. + * + * @param value the string to check at the specified offset + * @param offset the offset to look for the expected character + * @param expected the expected character + * @return true if the expected character exist at the given offset + */ + private static boolean checkOffset(String value, int offset, char expected) { + return (offset < value.length()) && (value.charAt(offset) == expected); + } + + /** + * Parse an integer located between 2 given offsets in a string + * + * @param value the string to parse + * @param beginIndex the start index for the integer in the string + * @param endIndex the end index for the integer in the string + * @return the int + * @throws NumberFormatException if the value is not a number + */ + private static int parseInt(String value, int beginIndex, int endIndex) + throws NumberFormatException { + if (beginIndex < 0 || endIndex > value.length() || beginIndex > endIndex) { + throw new NumberFormatException(value); + } + // use same logic as in Integer.parseInt() but less generic we're not supporting negative values + int i = beginIndex; + int result = 0; + int digit; + if (i < endIndex) { + digit = Character.digit(value.charAt(i++), 10); + if (digit < 0) { + throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)); + } + result = -digit; + } + while (i < endIndex) { + digit = Character.digit(value.charAt(i++), 10); + if (digit < 0) { + throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)); + } + result *= 10; + result -= digit; + } + return -result; + } + + /** + * Zero pad a number to a specified length + * + * @param buffer buffer to use for padding + * @param value the integer value to pad if necessary. + * @param length the length of the string we should zero pad + */ + private static void padInt(StringBuilder buffer, int value, int length) { + String strValue = Integer.toString(value); + for (int i = length - strValue.length(); i > 0; i--) { + buffer.append('0'); + } + buffer.append(strValue); + } + + /** + * Returns the index of the first character in the string that is not a digit, starting at + * offset. + */ + private static int indexOfNonDigit(String string, int offset) { + for (int i = offset; i < string.length(); i++) { + char c = string.charAt(i); + if (c < '0' || c > '9') return i; + } + return string.length(); + } +} diff --git a/adapters/src/main/java/com/squareup/moshi/Rfc3339DateJsonAdapter.java b/adapters/src/main/java/com/squareup/moshi/Rfc3339DateJsonAdapter.java new file mode 100644 index 0000000..f1108c7 --- /dev/null +++ b/adapters/src/main/java/com/squareup/moshi/Rfc3339DateJsonAdapter.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2015 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.moshi; + +import java.io.IOException; +import java.util.Date; + +/** + * Formats dates using RFC 3339, which is + * formatted like {@code 2015-09-26T18:23:50.250Z}. + */ +public final class Rfc3339DateJsonAdapter extends JsonAdapter { + @Override public synchronized Date fromJson(JsonReader reader) throws IOException { + String string = reader.nextString(); + return Iso8601Utils.parse(string); + } + + @Override public synchronized void toJson(JsonWriter writer, Date value) throws IOException { + String string = Iso8601Utils.format(value); + writer.value(string); + } +} diff --git a/adapters/src/test/java/com/squareup/moshi/Rfc3339DateJsonAdapterTest.java b/adapters/src/test/java/com/squareup/moshi/Rfc3339DateJsonAdapterTest.java new file mode 100644 index 0000000..f7bab59 --- /dev/null +++ b/adapters/src/test/java/com/squareup/moshi/Rfc3339DateJsonAdapterTest.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2015 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.moshi; + +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.TimeZone; +import java.util.concurrent.TimeUnit; +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public final class Rfc3339DateJsonAdapterTest { + private final JsonAdapter adapter = new Rfc3339DateJsonAdapter().lenient(); + + @Test public void fromJsonWithTwoDigitMillis() throws Exception { + assertThat(adapter.fromJson("\"1985-04-12T23:20:50.52Z\"")) + .isEqualTo(newDate(1985, 4, 12, 23, 20, 50, 520, 0)); + } + + @Test public void fromJson() throws Exception { + assertThat(adapter.fromJson("\"1970-01-01T00:00:00.000Z\"")) + .isEqualTo(newDate(1970, 1, 1, 0, 0, 0, 0, 0)); + assertThat(adapter.fromJson("\"1985-04-12T23:20:50.520Z\"")) + .isEqualTo(newDate(1985, 4, 12, 23, 20, 50, 520, 0)); + assertThat(adapter.fromJson("\"1996-12-19T16:39:57-08:00\"")) + .isEqualTo(newDate(1996, 12, 19, 16, 39, 57, 0, -8 * 60)); + assertThat(adapter.fromJson("\"1990-12-31T23:59:60Z\"")) + .isEqualTo(newDate(1990, 12, 31, 23, 59, 59, 0, 0)); + assertThat(adapter.fromJson("\"1990-12-31T15:59:60-08:00\"")) + .isEqualTo(newDate(1990, 12, 31, 15, 59, 59, 0, -8 * 60)); + assertThat(adapter.fromJson("\"1937-01-01T12:00:27.870+00:20\"")) + .isEqualTo(newDate(1937, 1, 1, 12, 0, 27, 870, 20)); + } + + @Test public void toJson() throws Exception { + assertThat(adapter.toJson(newDate(1970, 1, 1, 0, 0, 0, 0, 0))) + .isEqualTo("\"1970-01-01T00:00:00.000Z\""); + assertThat(adapter.toJson(newDate(1985, 4, 12, 23, 20, 50, 520, 0))) + .isEqualTo("\"1985-04-12T23:20:50.520Z\""); + assertThat(adapter.toJson(newDate(1996, 12, 19, 16, 39, 57, 0, -8 * 60))) + .isEqualTo("\"1996-12-20T00:39:57.000Z\""); + assertThat(adapter.toJson(newDate(1990, 12, 31, 23, 59, 59, 0, 0))) + .isEqualTo("\"1990-12-31T23:59:59.000Z\""); + assertThat(adapter.toJson(newDate(1990, 12, 31, 15, 59, 59, 0, -8 * 60))) + .isEqualTo("\"1990-12-31T23:59:59.000Z\""); + assertThat(adapter.toJson(newDate(1937, 1, 1, 12, 0, 27, 870, 20))) + .isEqualTo("\"1937-01-01T11:40:27.870Z\""); + } + + private Date newDate( + int year, int month, int day, int hour, int minute, int second, int millis, int offset) { + Calendar calendar = new GregorianCalendar(TimeZone.getTimeZone("GMT")); + calendar.set(year, month - 1, day, hour, minute, second); + calendar.set(Calendar.MILLISECOND, millis); + return new Date(calendar.getTimeInMillis() - TimeUnit.MINUTES.toMillis(offset)); + } +} diff --git a/pom.xml b/pom.xml index 91df51f..5b74cea 100644 --- a/pom.xml +++ b/pom.xml @@ -20,6 +20,7 @@ moshi examples + adapters