From 484d525db44f7d7b1c5a6f6f30008b12f405e1e4 Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Thu, 6 Jan 2022 15:15:48 -0500 Subject: [PATCH] Convert adapters to Kotlin (#1460) * Convert Rfc3339DateJsonAdapter * Convert EnumJsonAdapter * Convert PolymorphicJsonAdapterFactory * Convert Rfc3339DateJsonAdapter and Iso8601Utils * Doc indent fix * Use template * Address CR comments * Spotless and jsr cleanup * Couple small tweaks * Remove toList() * Use simpler map * Inline GregorianCalendar * Interp * Fix copyright * interp * Fix another copyright * Restore toList() --- moshi-adapters/build.gradle.kts | 1 - .../moshi/Rfc3339DateJsonAdapter.java | 38 -- .../squareup/moshi/Rfc3339DateJsonAdapter.kt | 41 +++ .../moshi/adapters/EnumJsonAdapter.java | 116 ------ .../moshi/adapters/EnumJsonAdapter.kt | 111 ++++++ .../squareup/moshi/adapters/Iso8601Utils.java | 275 --------------- .../squareup/moshi/adapters/Iso8601Utils.kt | 267 ++++++++++++++ .../PolymorphicJsonAdapterFactory.java | 330 ------------------ .../adapters/PolymorphicJsonAdapterFactory.kt | 266 ++++++++++++++ .../adapters/Rfc3339DateJsonAdapter.java | 54 --- .../moshi/adapters/Rfc3339DateJsonAdapter.kt | 57 +++ .../PolymorphicJsonAdapterFactoryTest.java | 2 +- 12 files changed, 743 insertions(+), 815 deletions(-) delete mode 100644 moshi-adapters/src/main/java/com/squareup/moshi/Rfc3339DateJsonAdapter.java create mode 100644 moshi-adapters/src/main/java/com/squareup/moshi/Rfc3339DateJsonAdapter.kt delete mode 100644 moshi-adapters/src/main/java/com/squareup/moshi/adapters/EnumJsonAdapter.java create mode 100644 moshi-adapters/src/main/java/com/squareup/moshi/adapters/EnumJsonAdapter.kt delete mode 100644 moshi-adapters/src/main/java/com/squareup/moshi/adapters/Iso8601Utils.java create mode 100644 moshi-adapters/src/main/java/com/squareup/moshi/adapters/Iso8601Utils.kt delete mode 100644 moshi-adapters/src/main/java/com/squareup/moshi/adapters/PolymorphicJsonAdapterFactory.java create mode 100644 moshi-adapters/src/main/java/com/squareup/moshi/adapters/PolymorphicJsonAdapterFactory.kt delete mode 100644 moshi-adapters/src/main/java/com/squareup/moshi/adapters/Rfc3339DateJsonAdapter.java create mode 100644 moshi-adapters/src/main/java/com/squareup/moshi/adapters/Rfc3339DateJsonAdapter.kt diff --git a/moshi-adapters/build.gradle.kts b/moshi-adapters/build.gradle.kts index 5de3a2e..5ac3780 100644 --- a/moshi-adapters/build.gradle.kts +++ b/moshi-adapters/build.gradle.kts @@ -13,7 +13,6 @@ dependencies { compileOnly(libs.jsr305) api(project(":moshi")) - testCompileOnly(libs.jsr305) testImplementation(libs.junit) testImplementation(libs.truth) } diff --git a/moshi-adapters/src/main/java/com/squareup/moshi/Rfc3339DateJsonAdapter.java b/moshi-adapters/src/main/java/com/squareup/moshi/Rfc3339DateJsonAdapter.java deleted file mode 100644 index fe0d610..0000000 --- a/moshi-adapters/src/main/java/com/squareup/moshi/Rfc3339DateJsonAdapter.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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 - * - * https://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; - -/** - * @deprecated this class moved to avoid a package name conflict in the Java Platform Module System. - * The new class is {@code com.squareup.moshi.adapters.Rfc3339DateJsonAdapter}. - */ -public final class Rfc3339DateJsonAdapter extends JsonAdapter { - private final com.squareup.moshi.adapters.Rfc3339DateJsonAdapter delegate = - new com.squareup.moshi.adapters.Rfc3339DateJsonAdapter(); - - @Override - public Date fromJson(JsonReader reader) throws IOException { - return delegate.fromJson(reader); - } - - @Override - public void toJson(JsonWriter writer, Date value) throws IOException { - delegate.toJson(writer, value); - } -} diff --git a/moshi-adapters/src/main/java/com/squareup/moshi/Rfc3339DateJsonAdapter.kt b/moshi-adapters/src/main/java/com/squareup/moshi/Rfc3339DateJsonAdapter.kt new file mode 100644 index 0000000..72d885b --- /dev/null +++ b/moshi-adapters/src/main/java/com/squareup/moshi/Rfc3339DateJsonAdapter.kt @@ -0,0 +1,41 @@ +/* + * 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 + * + * https://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 com.squareup.moshi.adapters.Rfc3339DateJsonAdapter +import java.io.IOException +import java.util.Date + +@Deprecated( + """This class moved to avoid a package name conflict in the Java Platform Module System. + The new class is com.squareup.moshi.adapters.Rfc3339DateJsonAdapter.""", + replaceWith = ReplaceWith("com.squareup.moshi.adapters.Rfc3339DateJsonAdapter"), + level = DeprecationLevel.ERROR +) +public class Rfc3339DateJsonAdapter : JsonAdapter() { + + private val delegate = Rfc3339DateJsonAdapter() + + @Throws(IOException::class) + override fun fromJson(reader: JsonReader): Date? { + return delegate.fromJson(reader) + } + + @Throws(IOException::class) + override fun toJson(writer: JsonWriter, value: Date?) { + delegate.toJson(writer, value) + } +} diff --git a/moshi-adapters/src/main/java/com/squareup/moshi/adapters/EnumJsonAdapter.java b/moshi-adapters/src/main/java/com/squareup/moshi/adapters/EnumJsonAdapter.java deleted file mode 100644 index 6f9e038..0000000 --- a/moshi-adapters/src/main/java/com/squareup/moshi/adapters/EnumJsonAdapter.java +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright (C) 2018 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 - * - * https://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.adapters; - -import static com.squareup.moshi.internal.Util.jsonName; - -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.JsonDataException; -import com.squareup.moshi.JsonReader; -import com.squareup.moshi.JsonWriter; -import java.io.IOException; -import java.util.Arrays; -import javax.annotation.Nullable; - -/** - * A JsonAdapter for enums that allows having a fallback enum value when a deserialized string does - * not match any enum value. To use, add this as an adapter for your enum type on your {@link - * com.squareup.moshi.Moshi.Builder Moshi.Builder}: - * - *
{@code
- * Moshi moshi = new Moshi.Builder()
- *     .add(CurrencyCode.class, EnumJsonAdapter.create(CurrencyCode.class)
- *         .withUnknownFallback(CurrencyCode.USD))
- *     .build();
- * }
- */ -public final class EnumJsonAdapter> extends JsonAdapter { - final Class enumType; - final String[] nameStrings; - final T[] constants; - final JsonReader.Options options; - final boolean useFallbackValue; - final @Nullable T fallbackValue; - - public static > EnumJsonAdapter create(Class enumType) { - return new EnumJsonAdapter<>(enumType, null, false); - } - - /** - * Create a new adapter for this enum with a fallback value to use when the JSON string does not - * match any of the enum's constants. Note that this value will not be used when the JSON value is - * null, absent, or not a string. Also, the string values are case-sensitive, and this fallback - * value will be used even on case mismatches. - */ - public EnumJsonAdapter withUnknownFallback(@Nullable T fallbackValue) { - return new EnumJsonAdapter<>(enumType, fallbackValue, true); - } - - EnumJsonAdapter(Class enumType, @Nullable T fallbackValue, boolean useFallbackValue) { - this.enumType = enumType; - this.fallbackValue = fallbackValue; - this.useFallbackValue = useFallbackValue; - try { - constants = enumType.getEnumConstants(); - nameStrings = new String[constants.length]; - for (int i = 0; i < constants.length; i++) { - String constantName = constants[i].name(); - nameStrings[i] = jsonName(enumType.getField(constantName), constantName); - } - options = JsonReader.Options.of(nameStrings); - } catch (NoSuchFieldException e) { - throw new AssertionError("Missing field in " + enumType.getName(), e); - } - } - - @Override - public @Nullable T fromJson(JsonReader reader) throws IOException { - int index = reader.selectString(options); - if (index != -1) return constants[index]; - - String path = reader.getPath(); - if (!useFallbackValue) { - String name = reader.nextString(); - throw new JsonDataException( - "Expected one of " - + Arrays.asList(nameStrings) - + " but was " - + name - + " at path " - + path); - } - if (reader.peek() != JsonReader.Token.STRING) { - throw new JsonDataException( - "Expected a string but was " + reader.peek() + " at path " + path); - } - reader.skipValue(); - return fallbackValue; - } - - @Override - public void toJson(JsonWriter writer, T value) throws IOException { - if (value == null) { - throw new NullPointerException( - "value was null! Wrap in .nullSafe() to write nullable values."); - } - writer.value(nameStrings[value.ordinal()]); - } - - @Override - public String toString() { - return "EnumJsonAdapter(" + enumType.getName() + ")"; - } -} diff --git a/moshi-adapters/src/main/java/com/squareup/moshi/adapters/EnumJsonAdapter.kt b/moshi-adapters/src/main/java/com/squareup/moshi/adapters/EnumJsonAdapter.kt new file mode 100644 index 0000000..5e3f51f --- /dev/null +++ b/moshi-adapters/src/main/java/com/squareup/moshi/adapters/EnumJsonAdapter.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2018 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 + * + * https://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.adapters + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonDataException +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonReader.Options +import com.squareup.moshi.JsonReader.Token.STRING +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.internal.jsonName +import java.io.IOException +import java.lang.NoSuchFieldException + +/** + * A JsonAdapter for enums that allows having a fallback enum value when a deserialized string does + * not match any enum value. To use, add this as an adapter for your enum type on your + * [Moshi.Builder][com.squareup.moshi.Moshi.Builder]: + * + * ``` + * Moshi moshi = new Moshi.Builder() + * .add(CurrencyCode.class, EnumJsonAdapter.create(CurrencyCode.class) + * .withUnknownFallback(CurrencyCode.USD)) + * .build(); + * ``` + */ +public class EnumJsonAdapter> internal constructor( + private val enumType: Class, + private val fallbackValue: T?, + private val useFallbackValue: Boolean, +) : JsonAdapter() { + + private val constants: Array + private val options: Options + private val nameStrings: Array + + init { + try { + constants = enumType.enumConstants + nameStrings = Array(constants.size) { i -> + val constantName = constants[i].name + enumType.getField(constantName).jsonName(constantName) + } + options = Options.of(*nameStrings) + } catch (e: NoSuchFieldException) { + throw AssertionError("Missing field in ${enumType.name}", e) + } + } + + /** + * Create a new adapter for this enum with a fallback value to use when the JSON string does not + * match any of the enum's constants. Note that this value will not be used when the JSON value is + * null, absent, or not a string. Also, the string values are case-sensitive, and this fallback + * value will be used even on case mismatches. + */ + public fun withUnknownFallback(fallbackValue: T?): EnumJsonAdapter { + return EnumJsonAdapter(enumType, fallbackValue, useFallbackValue = true) + } + + @Throws(IOException::class) + override fun fromJson(reader: JsonReader): T? { + val index = reader.selectString(options) + if (index != -1) return constants[index] + val path = reader.path + if (!useFallbackValue) { + val name = reader.nextString() + throw JsonDataException( + "Expected one of ${nameStrings.toList()} but was $name at path $path" + ) + } + if (reader.peek() != STRING) { + throw JsonDataException( + "Expected a string but was ${reader.peek()} at path $path" + ) + } + reader.skipValue() + return fallbackValue + } + + @Throws(IOException::class) + override fun toJson(writer: JsonWriter, value: T?) { + if (value == null) { + throw NullPointerException( + "value was null! Wrap in .nullSafe() to write nullable values." + ) + } + writer.value(nameStrings[value.ordinal]) + } + + override fun toString(): String = "EnumJsonAdapter(${enumType.name})" + + public companion object { + @JvmStatic + public fun > create(enumType: Class): EnumJsonAdapter { + return EnumJsonAdapter(enumType, fallbackValue = null, useFallbackValue = false) + } + } +} diff --git a/moshi-adapters/src/main/java/com/squareup/moshi/adapters/Iso8601Utils.java b/moshi-adapters/src/main/java/com/squareup/moshi/adapters/Iso8601Utils.java deleted file mode 100644 index 43ee6a1..0000000 --- a/moshi-adapters/src/main/java/com/squareup/moshi/adapters/Iso8601Utils.java +++ /dev/null @@ -1,275 +0,0 @@ -/* - * 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.adapters; - -import com.squareup.moshi.JsonDataException; -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/67ebf7305f492285a8f9f4de31545f5f16fc7c3a/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, e); - } - } - - /** - * 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/moshi-adapters/src/main/java/com/squareup/moshi/adapters/Iso8601Utils.kt b/moshi-adapters/src/main/java/com/squareup/moshi/adapters/Iso8601Utils.kt new file mode 100644 index 0000000..26c0569 --- /dev/null +++ b/moshi-adapters/src/main/java/com/squareup/moshi/adapters/Iso8601Utils.kt @@ -0,0 +1,267 @@ +/* + * 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 + * + * https://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.adapters + +import com.squareup.moshi.JsonDataException +import java.util.Calendar +import java.util.Date +import java.util.GregorianCalendar +import java.util.Locale +import java.util.TimeZone +import kotlin.math.min +import kotlin.math.pow + +/* + * Jackson’s date formatter, pruned to Moshi's needs. Forked from this file: + * https://github.com/FasterXML/jackson-databind/blob/67ebf7305f492285a8f9f4de31545f5f16fc7c3a/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](http://www.w3.org/TR/NOTE-datetime) + */ + +/** ID to represent the 'GMT' string */ +private const val GMT_ID = "GMT" + +/** The GMT timezone, prefetched to avoid more lookups. */ +private val TIMEZONE_Z: TimeZone = TimeZone.getTimeZone(GMT_ID) + +/** Returns `date` formatted as yyyy-MM-ddThh:mm:ss.sssZ */ +internal fun Date.formatIsoDate(): String { + val calendar: Calendar = GregorianCalendar(TIMEZONE_Z, Locale.US) + calendar.time = this + + // estimate capacity of buffer as close as we can (yeah, that's pedantic ;) + val capacity = "yyyy-MM-ddThh:mm:ss.sssZ".length + val formatted = StringBuilder(capacity) + padInt(formatted, calendar[Calendar.YEAR], "yyyy".length) + formatted.append('-') + padInt(formatted, calendar[Calendar.MONTH] + 1, "MM".length) + formatted.append('-') + padInt(formatted, calendar[Calendar.DAY_OF_MONTH], "dd".length) + formatted.append('T') + padInt(formatted, calendar[Calendar.HOUR_OF_DAY], "hh".length) + formatted.append(':') + padInt(formatted, calendar[Calendar.MINUTE], "mm".length) + formatted.append(':') + padInt(formatted, calendar[Calendar.SECOND], "ss".length) + formatted.append('.') + padInt(formatted, calendar[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]]` + * + * @receiver ISO string to parse in the appropriate format. + * @return the parsed date + */ +internal fun String.parseIsoDate(): Date { + return try { + var offset = 0 + + // extract year + val year = parseInt(this, offset, 4.let { offset += it; offset }) + if (checkOffset(this, offset, '-')) { + offset += 1 + } + + // extract month + val month = parseInt(this, offset, 2.let { offset += it; offset }) + if (checkOffset(this, offset, '-')) { + offset += 1 + } + + // extract day + val day = parseInt(this, offset, 2.let { offset += it; offset }) + // default time value + var hour = 0 + var minutes = 0 + var seconds = 0 + // always use 0 otherwise returned date will include millis of current time + var milliseconds = 0 + + // if the value has no time component (and no time zone), we are done + val hasT = checkOffset(this, offset, 'T') + if (!hasT && this.length <= offset) { + return GregorianCalendar(year, month - 1, day).time + } + if (hasT) { + + // extract hours, minutes, seconds and milliseconds + hour = parseInt(this, 1.let { offset += it; offset }, 2.let { offset += it; offset }) + if (checkOffset(this, offset, ':')) { + offset += 1 + } + minutes = parseInt(this, offset, 2.let { offset += it; offset }) + if (checkOffset(this, offset, ':')) { + offset += 1 + } + // second and milliseconds can be optional + if (this.length > offset) { + val c = this[offset] + if (c != 'Z' && c != '+' && c != '-') { + seconds = parseInt(this, offset, 2.let { offset += it; offset }) + if (seconds in 60..62) seconds = 59 // truncate up to 3 leap seconds + // milliseconds can be optional in the format + if (checkOffset(this, offset, '.')) { + offset += 1 + val endOffset = indexOfNonDigit(this, offset + 1) // assume at least one digit + val parseEndOffset = min(endOffset, offset + 3) // parse up to 3 digits + val fraction = parseInt(this, offset, parseEndOffset) + milliseconds = + (10.0.pow((3 - (parseEndOffset - offset)).toDouble()) * fraction).toInt() + offset = endOffset + } + } + } + } + + // extract timezone + require(this.length > offset) { "No time zone indicator" } + val timezone: TimeZone + val timezoneIndicator = this[offset] + if (timezoneIndicator == 'Z') { + timezone = TIMEZONE_Z + } else if (timezoneIndicator == '+' || timezoneIndicator == '-') { + val timezoneOffset = this.substring(offset) + // 18-Jun-2015, tatu: Minor simplification, skip offset of "+0000"/"+00:00" + if ("+0000" == timezoneOffset || "+00:00" == 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. + val timezoneId = GMT_ID + timezoneOffset + timezone = TimeZone.getTimeZone(timezoneId) + val act = timezone.id + if (act != 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. + */ + val cleaned = act.replace(":", "") + if (cleaned != timezoneId) { + throw IndexOutOfBoundsException( + "Mismatching time zone indicator: $timezoneId given, resolves to ${timezone.id}" + ) + } + } + } + } else { + throw IndexOutOfBoundsException( + "Invalid time zone indicator '$timezoneIndicator'" + ) + } + val calendar: Calendar = GregorianCalendar(timezone) + calendar.isLenient = false + calendar[Calendar.YEAR] = year + calendar[Calendar.MONTH] = month - 1 + calendar[Calendar.DAY_OF_MONTH] = day + calendar[Calendar.HOUR_OF_DAY] = hour + calendar[Calendar.MINUTE] = minutes + calendar[Calendar.SECOND] = seconds + calendar[Calendar.MILLISECOND] = milliseconds + calendar.time + // If we get a ParseException it'll already have the right message/offset. + // Other exception types can convert here. + } catch (e: IndexOutOfBoundsException) { + throw JsonDataException("Not an RFC 3339 date: $this", e) + } catch (e: IllegalArgumentException) { + throw JsonDataException("Not an RFC 3339 date: $this", e) + } +} + +/** + * 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 fun checkOffset(value: String, offset: Int, expected: Char): Boolean { + return offset < value.length && value[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 fun parseInt(value: String, beginIndex: Int, endIndex: Int): Int { + if (beginIndex < 0 || endIndex > value.length || beginIndex > endIndex) { + throw NumberFormatException(value) + } + // use same logic as in Integer.parseInt() but less generic we're not supporting negative values + var i = beginIndex + var result = 0 + var digit: Int + if (i < endIndex) { + digit = Character.digit(value[i++], 10) + if (digit < 0) { + throw NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)) + } + result = -digit + } + while (i < endIndex) { + digit = Character.digit(value[i++], 10) + if (digit < 0) { + throw 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 fun padInt(buffer: StringBuilder, value: Int, length: Int) { + val strValue = value.toString() + for (i in length - strValue.length downTo 1) { + 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 fun indexOfNonDigit(string: String, offset: Int): Int { + for (i in offset until string.length) { + val c = string[i] + if (c < '0' || c > '9') return i + } + return string.length +} diff --git a/moshi-adapters/src/main/java/com/squareup/moshi/adapters/PolymorphicJsonAdapterFactory.java b/moshi-adapters/src/main/java/com/squareup/moshi/adapters/PolymorphicJsonAdapterFactory.java deleted file mode 100644 index ee736f3..0000000 --- a/moshi-adapters/src/main/java/com/squareup/moshi/adapters/PolymorphicJsonAdapterFactory.java +++ /dev/null @@ -1,330 +0,0 @@ -/* - * Copyright (C) 2011 Google 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 - * - * https://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.adapters; - -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.JsonDataException; -import com.squareup.moshi.JsonReader; -import com.squareup.moshi.JsonWriter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; -import java.io.IOException; -import java.lang.annotation.Annotation; -import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Set; -import javax.annotation.CheckReturnValue; -import javax.annotation.Nullable; - -/** - * A JsonAdapter factory for objects that include type information in the JSON. When decoding JSON - * Moshi uses this type information to determine which class to decode to. When encoding Moshi uses - * the object’s class to determine what type information to include. - * - *

Suppose we have an interface, its implementations, and a class that uses them: - * - *

{@code
- * interface HandOfCards {
- * }
- *
- * class BlackjackHand implements HandOfCards {
- *   Card hidden_card;
- *   List visible_cards;
- * }
- *
- * class HoldemHand implements HandOfCards {
- *   Set hidden_cards;
- * }
- *
- * class Player {
- *   String name;
- *   HandOfCards hand;
- * }
- * }
- * - *

We want to decode the following JSON into the player model above: - * - *

{@code
- * {
- *   "name": "Jesse",
- *   "hand": {
- *     "hand_type": "blackjack",
- *     "hidden_card": "9D",
- *     "visible_cards": ["8H", "4C"]
- *   }
- * }
- * }
- * - *

Left unconfigured, Moshi would incorrectly attempt to decode the hand object to the abstract - * {@code HandOfCards} interface. We configure it to use the appropriate subtype instead: - * - *

{@code
- * Moshi moshi = new Moshi.Builder()
- *     .add(PolymorphicJsonAdapterFactory.of(HandOfCards.class, "hand_type")
- *         .withSubtype(BlackjackHand.class, "blackjack")
- *         .withSubtype(HoldemHand.class, "holdem"))
- *     .build();
- * }
- * - *

This class imposes strict requirements on its use: - * - *

    - *
  • Base types may be classes or interfaces. - *
  • Subtypes must encode as JSON objects. - *
  • Type information must be in the encoded object. Each message must have a type label like - * {@code hand_type} whose value is a string like {@code blackjack} that identifies which type - * to use. - *
  • Each type identifier must be unique. - *
- * - *

For best performance type information should be the first field in the object. Otherwise Moshi - * must reprocess the JSON stream once it knows the object's type. - * - *

If an unknown subtype is encountered when decoding: - * - *

    - *
  • If {@link #withDefaultValue(Object)} is used, then {@code defaultValue} will be returned. - *
  • If {@link #withFallbackJsonAdapter(JsonAdapter)} is used, then the {@code - * fallbackJsonAdapter.fromJson(reader)} result will be returned. - *
  • Otherwise a {@link JsonDataException} will be thrown. - *
- * - *

If an unknown type is encountered when encoding: - * - *

    - *
  • If {@link #withFallbackJsonAdapter(JsonAdapter)} is used, then the {@code - * fallbackJsonAdapter.toJson(writer, value)} result will be returned. - *
  • Otherwise a {@link IllegalArgumentException} will be thrown. - *
- * - *

If the same subtype has multiple labels the first one is used when encoding. - */ -public final class PolymorphicJsonAdapterFactory implements JsonAdapter.Factory { - final Class baseType; - final String labelKey; - final List labels; - final List subtypes; - @Nullable final JsonAdapter fallbackJsonAdapter; - - PolymorphicJsonAdapterFactory( - Class baseType, - String labelKey, - List labels, - List subtypes, - @Nullable JsonAdapter fallbackJsonAdapter) { - this.baseType = baseType; - this.labelKey = labelKey; - this.labels = labels; - this.subtypes = subtypes; - this.fallbackJsonAdapter = fallbackJsonAdapter; - } - - /** - * @param baseType The base type for which this factory will create adapters. Cannot be Object. - * @param labelKey The key in the JSON object whose value determines the type to which to map the - * JSON object. - */ - @CheckReturnValue - public static PolymorphicJsonAdapterFactory of(Class baseType, String labelKey) { - if (baseType == null) throw new NullPointerException("baseType == null"); - if (labelKey == null) throw new NullPointerException("labelKey == null"); - return new PolymorphicJsonAdapterFactory<>( - baseType, labelKey, Collections.emptyList(), Collections.emptyList(), null); - } - - /** Returns a new factory that decodes instances of {@code subtype}. */ - public PolymorphicJsonAdapterFactory withSubtype(Class subtype, String label) { - if (subtype == null) throw new NullPointerException("subtype == null"); - if (label == null) throw new NullPointerException("label == null"); - if (labels.contains(label)) { - throw new IllegalArgumentException("Labels must be unique."); - } - List newLabels = new ArrayList<>(labels); - newLabels.add(label); - List newSubtypes = new ArrayList<>(subtypes); - newSubtypes.add(subtype); - return new PolymorphicJsonAdapterFactory<>( - baseType, labelKey, newLabels, newSubtypes, fallbackJsonAdapter); - } - - /** - * Returns a new factory that with default to {@code fallbackJsonAdapter.fromJson(reader)} upon - * decoding of unrecognized labels. - * - *

The {@link JsonReader} instance will not be automatically consumed, so make sure to consume - * it within your implementation of {@link JsonAdapter#fromJson(JsonReader)} - */ - public PolymorphicJsonAdapterFactory withFallbackJsonAdapter( - @Nullable JsonAdapter fallbackJsonAdapter) { - return new PolymorphicJsonAdapterFactory<>( - baseType, labelKey, labels, subtypes, fallbackJsonAdapter); - } - - /** - * Returns a new factory that will default to {@code defaultValue} upon decoding of unrecognized - * labels. The default value should be immutable. - */ - public PolymorphicJsonAdapterFactory withDefaultValue(@Nullable T defaultValue) { - return withFallbackJsonAdapter(buildFallbackJsonAdapter(defaultValue)); - } - - private JsonAdapter buildFallbackJsonAdapter(final T defaultValue) { - return new JsonAdapter() { - @Override - public @Nullable Object fromJson(JsonReader reader) throws IOException { - reader.skipValue(); - return defaultValue; - } - - @Override - public void toJson(JsonWriter writer, Object value) throws IOException { - throw new IllegalArgumentException( - "Expected one of " - + subtypes - + " but found " - + value - + ", a " - + value.getClass() - + ". Register this subtype."); - } - }; - } - - @Override - public JsonAdapter create(Type type, Set annotations, Moshi moshi) { - if (Types.getRawType(type) != baseType || !annotations.isEmpty()) { - return null; - } - - List> jsonAdapters = new ArrayList<>(subtypes.size()); - for (int i = 0, size = subtypes.size(); i < size; i++) { - jsonAdapters.add(moshi.adapter(subtypes.get(i))); - } - - return new PolymorphicJsonAdapter(labelKey, labels, subtypes, jsonAdapters, fallbackJsonAdapter) - .nullSafe(); - } - - static final class PolymorphicJsonAdapter extends JsonAdapter { - final String labelKey; - final List labels; - final List subtypes; - final List> jsonAdapters; - @Nullable final JsonAdapter fallbackJsonAdapter; - - /** Single-element options containing the label's key only. */ - final JsonReader.Options labelKeyOptions; - /** Corresponds to subtypes. */ - final JsonReader.Options labelOptions; - - PolymorphicJsonAdapter( - String labelKey, - List labels, - List subtypes, - List> jsonAdapters, - @Nullable JsonAdapter fallbackJsonAdapter) { - this.labelKey = labelKey; - this.labels = labels; - this.subtypes = subtypes; - this.jsonAdapters = jsonAdapters; - this.fallbackJsonAdapter = fallbackJsonAdapter; - - this.labelKeyOptions = JsonReader.Options.of(labelKey); - this.labelOptions = JsonReader.Options.of(labels.toArray(new String[0])); - } - - @Override - public Object fromJson(JsonReader reader) throws IOException { - JsonReader peeked = reader.peekJson(); - peeked.setFailOnUnknown(false); - int labelIndex; - try { - labelIndex = labelIndex(peeked); - } finally { - peeked.close(); - } - if (labelIndex == -1) { - return this.fallbackJsonAdapter.fromJson(reader); - } else { - return jsonAdapters.get(labelIndex).fromJson(reader); - } - } - - private int labelIndex(JsonReader reader) throws IOException { - reader.beginObject(); - while (reader.hasNext()) { - if (reader.selectName(labelKeyOptions) == -1) { - reader.skipName(); - reader.skipValue(); - continue; - } - - int labelIndex = reader.selectString(labelOptions); - if (labelIndex == -1 && this.fallbackJsonAdapter == null) { - throw new JsonDataException( - "Expected one of " - + labels - + " for key '" - + labelKey - + "' but found '" - + reader.nextString() - + "'. Register a subtype for this label."); - } - return labelIndex; - } - - throw new JsonDataException("Missing label for " + labelKey); - } - - @Override - public void toJson(JsonWriter writer, Object value) throws IOException { - Class type = value.getClass(); - int labelIndex = subtypes.indexOf(type); - final JsonAdapter adapter; - if (labelIndex == -1) { - if (fallbackJsonAdapter == null) { - throw new IllegalArgumentException( - "Expected one of " - + subtypes - + " but found " - + value - + ", a " - + value.getClass() - + ". Register this subtype."); - } - adapter = fallbackJsonAdapter; - } else { - adapter = jsonAdapters.get(labelIndex); - } - - writer.beginObject(); - if (adapter != fallbackJsonAdapter) { - writer.name(labelKey).value(labels.get(labelIndex)); - } - int flattenToken = writer.beginFlatten(); - adapter.toJson(writer, value); - writer.endFlatten(flattenToken); - writer.endObject(); - } - - @Override - public String toString() { - return "PolymorphicJsonAdapter(" + labelKey + ")"; - } - } -} diff --git a/moshi-adapters/src/main/java/com/squareup/moshi/adapters/PolymorphicJsonAdapterFactory.kt b/moshi-adapters/src/main/java/com/squareup/moshi/adapters/PolymorphicJsonAdapterFactory.kt new file mode 100644 index 0000000..3957efd --- /dev/null +++ b/moshi-adapters/src/main/java/com/squareup/moshi/adapters/PolymorphicJsonAdapterFactory.kt @@ -0,0 +1,266 @@ +/* + * Copyright (C) 2011 Google 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 + * + * https://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.adapters + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonAdapter.Factory +import com.squareup.moshi.JsonDataException +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonReader.Options +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.Moshi +import com.squareup.moshi.rawType +import java.io.IOException +import java.lang.reflect.Type +import javax.annotation.CheckReturnValue + +/** + * A JsonAdapter factory for objects that include type information in the JSON. When decoding JSON + * Moshi uses this type information to determine which class to decode to. When encoding Moshi uses + * the object’s class to determine what type information to include. + * + * Suppose we have an interface, its implementations, and a class that uses them: + * + * ``` + * interface HandOfCards { } + * + * class BlackjackHand implements HandOfCards { + * Card hidden_card; + * List visible_cards; + * } + * + * class HoldemHand implements HandOfCards { + * Set hidden_cards; + * } + * + * class Player { + * String name; + * HandOfCards hand; + * } + * ``` + * + * We want to decode the following JSON into the player model above: + * + * ``` + * { + * "name": "Jesse", + * "hand": { + * "hand_type": "blackjack", + * "hidden_card": "9D", + * "visible_cards": ["8H", "4C"] + * } + * } + *``` + * + * Left unconfigured, Moshi would incorrectly attempt to decode the hand object to the abstract + * `HandOfCards` interface. We configure it to use the appropriate subtype instead: + * + * ``` + * Moshi moshi = new Moshi.Builder() + * .add(PolymorphicJsonAdapterFactory.of(HandOfCards.class, "hand_type") + * .withSubtype(BlackjackHand.class, "blackjack") + * .withSubtype(HoldemHand.class, "holdem")) + * .build(); + * ``` + * + * This class imposes strict requirements on its use: + * * Base types may be classes or interfaces. + * * Subtypes must encode as JSON objects. + * * Type information must be in the encoded object. Each message must have a type label like + * `hand_type` whose value is a string like `blackjack` that identifies which type + * to use. + * * Each type identifier must be unique. + * + * For best performance type information should be the first field in the object. Otherwise Moshi + * must reprocess the JSON stream once it knows the object's type. + * + * If an unknown subtype is encountered when decoding: + * * If [withDefaultValue] is used, then `defaultValue` will be returned. + * * If [withFallbackJsonAdapter] is used, then the `fallbackJsonAdapter.fromJson(reader)` result will be returned. + * * Otherwise a [JsonDataException] will be thrown. + * + * If an unknown type is encountered when encoding: + * * If [withFallbackJsonAdapter] is used, then the `fallbackJsonAdapter.toJson(writer, value)` result will be returned. + * * Otherwise a [IllegalArgumentException] will be thrown. + * + * If the same subtype has multiple labels the first one is used when encoding. + */ +public class PolymorphicJsonAdapterFactory internal constructor( + private val baseType: Class, + private val labelKey: String, + private val labels: List, + private val subtypes: List, + private val fallbackJsonAdapter: JsonAdapter? +) : Factory { + /** Returns a new factory that decodes instances of `subtype`. */ + public fun withSubtype(subtype: Class, label: String): PolymorphicJsonAdapterFactory { + require(!labels.contains(label)) { "Labels must be unique." } + val newLabels = buildList { + addAll(labels) + add(label) + } + val newSubtypes = buildList { + addAll(subtypes) + add(subtype) + } + return PolymorphicJsonAdapterFactory( + baseType = baseType, + labelKey = labelKey, + labels = newLabels, + subtypes = newSubtypes, + fallbackJsonAdapter = fallbackJsonAdapter + ) + } + + /** + * Returns a new factory that with default to `fallbackJsonAdapter.fromJson(reader)` upon + * decoding of unrecognized labels. + * + * The [JsonReader] instance will not be automatically consumed, so make sure to consume + * it within your implementation of [JsonAdapter.fromJson] + */ + public fun withFallbackJsonAdapter( + fallbackJsonAdapter: JsonAdapter? + ): PolymorphicJsonAdapterFactory { + return PolymorphicJsonAdapterFactory( + baseType = baseType, + labelKey = labelKey, + labels = labels, + subtypes = subtypes, + fallbackJsonAdapter = fallbackJsonAdapter + ) + } + + /** + * Returns a new factory that will default to `defaultValue` upon decoding of unrecognized + * labels. The default value should be immutable. + */ + public fun withDefaultValue(defaultValue: T?): PolymorphicJsonAdapterFactory { + return withFallbackJsonAdapter(buildFallbackJsonAdapter(defaultValue)) + } + + private fun buildFallbackJsonAdapter(defaultValue: T?): JsonAdapter { + return object : JsonAdapter() { + override fun fromJson(reader: JsonReader): Any? { + reader.skipValue() + return defaultValue + } + + override fun toJson(writer: JsonWriter, value: Any?) { + throw IllegalArgumentException( + "Expected one of $subtypes but found $value, a ${value?.javaClass}. Register this subtype." + ) + } + } + } + + override fun create(type: Type, annotations: Set, moshi: Moshi): JsonAdapter<*>? { + if (type.rawType != baseType || annotations.isNotEmpty()) { + return null + } + val jsonAdapters: List> = subtypes.map(moshi::adapter) + return PolymorphicJsonAdapter(labelKey, labels, subtypes, jsonAdapters, fallbackJsonAdapter) + .nullSafe() + } + + internal class PolymorphicJsonAdapter( + private val labelKey: String, + private val labels: List, + private val subtypes: List, + private val jsonAdapters: List>, + private val fallbackJsonAdapter: JsonAdapter? + ) : JsonAdapter() { + /** Single-element options containing the label's key only. */ + private val labelKeyOptions: Options = Options.of(labelKey) + + /** Corresponds to subtypes. */ + private val labelOptions: Options = Options.of(*labels.toTypedArray()) + + override fun fromJson(reader: JsonReader): Any? { + val peeked = reader.peekJson() + peeked.setFailOnUnknown(false) + val labelIndex = peeked.use(::labelIndex) + return if (labelIndex == -1) { + fallbackJsonAdapter?.fromJson(reader) + } else { + jsonAdapters[labelIndex].fromJson(reader) + } + } + + private fun labelIndex(reader: JsonReader): Int { + reader.beginObject() + while (reader.hasNext()) { + if (reader.selectName(labelKeyOptions) == -1) { + reader.skipName() + reader.skipValue() + continue + } + val labelIndex = reader.selectString(labelOptions) + if (labelIndex == -1 && fallbackJsonAdapter == null) { + throw JsonDataException( + "Expected one of $labels for key '$labelKey' but found '${reader.nextString()}'. Register a subtype for this label." + ) + } + return labelIndex + } + throw JsonDataException("Missing label for $labelKey") + } + + @Throws(IOException::class) + override fun toJson(writer: JsonWriter, value: Any?) { + val type: Class<*> = value!!.javaClass + val labelIndex = subtypes.indexOf(type) + val adapter: JsonAdapter = if (labelIndex == -1) { + requireNotNull(fallbackJsonAdapter) { + "Expected one of $subtypes but found $value, a ${value.javaClass}. Register this subtype." + } + } else { + jsonAdapters[labelIndex] + } + writer.beginObject() + if (adapter !== fallbackJsonAdapter) { + writer.name(labelKey).value(labels[labelIndex]) + } + val flattenToken = writer.beginFlatten() + adapter.toJson(writer, value) + writer.endFlatten(flattenToken) + writer.endObject() + } + + override fun toString(): String { + return "PolymorphicJsonAdapter($labelKey)" + } + } + + public companion object { + /** + * @param baseType The base type for which this factory will create adapters. Cannot be Object. + * @param labelKey The key in the JSON object whose value determines the type to which to map the + * JSON object. + */ + @JvmStatic + @CheckReturnValue + public fun of(baseType: Class, labelKey: String): PolymorphicJsonAdapterFactory { + return PolymorphicJsonAdapterFactory( + baseType = baseType, + labelKey = labelKey, + labels = emptyList(), + subtypes = emptyList(), + fallbackJsonAdapter = null + ) + } + } +} diff --git a/moshi-adapters/src/main/java/com/squareup/moshi/adapters/Rfc3339DateJsonAdapter.java b/moshi-adapters/src/main/java/com/squareup/moshi/adapters/Rfc3339DateJsonAdapter.java deleted file mode 100644 index c97a133..0000000 --- a/moshi-adapters/src/main/java/com/squareup/moshi/adapters/Rfc3339DateJsonAdapter.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * 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 - * - * https://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.adapters; - -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.JsonReader; -import com.squareup.moshi.JsonWriter; -import java.io.IOException; -import java.util.Date; - -/** - * Formats dates using RFC 3339, which is - * formatted like {@code 2015-09-26T18:23:50.250Z}. This adapter is null-safe. To use, add this as - * an adapter for {@code Date.class} on your {@link com.squareup.moshi.Moshi.Builder Moshi.Builder}: - * - *
{@code
- * Moshi moshi = new Moshi.Builder()
- *     .add(Date.class, new Rfc3339DateJsonAdapter())
- *     .build();
- * }
- */ -public final class Rfc3339DateJsonAdapter extends JsonAdapter { - @Override - public synchronized Date fromJson(JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } - String string = reader.nextString(); - return Iso8601Utils.parse(string); - } - - @Override - public synchronized void toJson(JsonWriter writer, Date value) throws IOException { - if (value == null) { - writer.nullValue(); - } else { - String string = Iso8601Utils.format(value); - writer.value(string); - } - } -} diff --git a/moshi-adapters/src/main/java/com/squareup/moshi/adapters/Rfc3339DateJsonAdapter.kt b/moshi-adapters/src/main/java/com/squareup/moshi/adapters/Rfc3339DateJsonAdapter.kt new file mode 100644 index 0000000..96941bb --- /dev/null +++ b/moshi-adapters/src/main/java/com/squareup/moshi/adapters/Rfc3339DateJsonAdapter.kt @@ -0,0 +1,57 @@ +/* + * 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 + * + * https://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.adapters + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonReader.Token.NULL +import com.squareup.moshi.JsonWriter +import java.io.IOException +import java.util.Date + +/** + * Formats dates using [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt), which is + * formatted like `2015-09-26T18:23:50.250Z`. This adapter is null-safe. To use, add this as + * an adapter for `Date.class` on your [Moshi.Builder][com.squareup.moshi.Moshi.Builder]: + * + * ``` + * Moshi moshi = new Moshi.Builder() + * .add(Date.class, new Rfc3339DateJsonAdapter()) + * .build(); + * ``` + */ +public class Rfc3339DateJsonAdapter : JsonAdapter() { + @Synchronized + @Throws(IOException::class) + override fun fromJson(reader: JsonReader): Date? { + if (reader.peek() == NULL) { + return reader.nextNull() + } + val string = reader.nextString() + return string.parseIsoDate() + } + + @Synchronized + @Throws(IOException::class) + override fun toJson(writer: JsonWriter, value: Date?) { + if (value == null) { + writer.nullValue() + } else { + val string = value.formatIsoDate() + writer.value(string) + } + } +} diff --git a/moshi-adapters/src/test/java/com/squareup/moshi/adapters/PolymorphicJsonAdapterFactoryTest.java b/moshi-adapters/src/test/java/com/squareup/moshi/adapters/PolymorphicJsonAdapterFactoryTest.java index cbd3c91..68011b8 100644 --- a/moshi-adapters/src/test/java/com/squareup/moshi/adapters/PolymorphicJsonAdapterFactoryTest.java +++ b/moshi-adapters/src/test/java/com/squareup/moshi/adapters/PolymorphicJsonAdapterFactoryTest.java @@ -26,8 +26,8 @@ import com.squareup.moshi.Moshi; import java.io.IOException; import java.util.Collections; import java.util.Map; -import javax.annotation.Nullable; import okio.Buffer; +import org.jetbrains.annotations.Nullable; import org.junit.Test; @SuppressWarnings("CheckReturnValue")