mirror of
https://github.com/fankes/moshi.git
synced 2025-10-19 16:09:21 +08:00
RFC3339 adapter.
Much thanks to Jackson for doing all the real work.
This commit is contained in:
31
adapters/pom.xml
Normal file
31
adapters/pom.xml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>com.squareup.moshi</groupId>
|
||||||
|
<artifactId>moshi-parent</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>moshi-adapters</artifactId>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.squareup.moshi</groupId>
|
||||||
|
<artifactId>moshi</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>junit</groupId>
|
||||||
|
<artifactId>junit</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.assertj</groupId>
|
||||||
|
<artifactId>assertj-core</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
271
adapters/src/main/java/com/squareup/moshi/Iso8601Utils.java
Normal file
271
adapters/src/main/java/com/squareup/moshi/Iso8601Utils.java
Normal file
@@ -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 <a href="http://www.w3.org/TR/NOTE-datetime">this specification</a>
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
@@ -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 <a href="https://www.ietf.org/rfc/rfc3339.txt">RFC 3339</a>, which is
|
||||||
|
* formatted like {@code 2015-09-26T18:23:50.250Z}.
|
||||||
|
*/
|
||||||
|
public final class Rfc3339DateJsonAdapter extends JsonAdapter<Date> {
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
@@ -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<Date> 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));
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user