Moshi DateTime Adapter With Multiple Format Support

April 11, 2018

If you are using ThreeTenABP (JSR-310/java.time backport for Android) with Moshi, it will generate the following json format for LocalDateTime

{"date":{"day":10,"month":4,"year":2018},"time":{"hour":3,"minute":34,"nano":115000000,"second":18}}

I assume the above would not happend if you are usung java.time, but I think it’s best for us to use a standardize format for datetime such as RFC 3339 / ISO 8601 to ensure compatibility.

We shall write a Moshi adapter for LocalDateTime which support ISO string format.

class LocalDateTimeAdapter {
    @ToJson
    fun toJson(value: LocalDateTime): String {
        return FORMATTER.format(value)
    }

    @FromJson
    fun fromJson(value: String): LocalDateTime {
        return FORMATTER.parse(value, LocalDateTime.FROM)
    }

    companion object {
        private val FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME
    }
}

Using the adapter.

val moshi = Moshi.Builder()
        .add(KotlinJsonAdapterFactory())
        .add(LocalDateTimeAdapter())
        .build()
val jsonAdapter = moshi.adapter(LocalDateTime::class.java) 

val now = LocalDateTime.now(ZoneOffset.UTC)
var jsonString = jsonAdapter.toJson(now)
// Output: 2018-04-10T03:34:18.115
// Output without LocalDateTimeAdapter: {"date":{"day":10,"month":4,"year":2018},"time":{"hour":3,"minute":34,"nano":115000000,"second":18}}

val newNow = jsonAdapter.fromJson(jsonString)
Log.d(TAG, "$now == $newNow = ${now == newNow}")

There might be cases where we want to support both format, such as an existing format is already exist in production.

To support multiple format, we need to make changes to LocalDateTimeAdapter to use JsonQualifier.

@Retention(AnnotationRetention.RUNTIME)
@JsonQualifier
internal annotation class DateString

class LocalDateTimeAdapter {
    @ToJson
    fun toJson(@DateString value: LocalDateTime): String {
        return FORMATTER.format(value)
    }

    @FromJson
    @DateString
    fun fromJson(value: String): LocalDateTime {
        return FORMATTER.parse(value, LocalDateTime.FROM)
    }

    companion object {
        private val FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME
    }
}

Then we need to create another adapter to support multiple format.

If json input is String, LocalDateTimeAdapter shall be used, else the default adapter is used.

By default, we use LocalDateTimeAdapter to write to json.

class MultipleFormatsDateAdapter {
    @ToJson
    fun toJson(writer: JsonWriter, value: LocalDateTime,
                        @DateString stringAdapter: JsonAdapter<LocalDateTime>) {
        // default use LocalDateTimeAdapter to write
        stringAdapter.toJson(writer, value)
    }

    @FromJson
    fun fromJson(reader: JsonReader, @DateString stringAdapter: JsonAdapter<LocalDateTime>,
                          defaultAdapter: JsonAdapter<LocalDateTime>): LocalDateTime? {
        return if (reader.peek() === JsonReader.Token.STRING) {
            stringAdapter.fromJson(reader)
        } else {
            defaultAdapter.fromJson(reader)
        }
    }
}

I might not fully understand the dynamics of Moshi at play here, but the following is the explanation by Jesse Wilson

This works because Moshi lets you declare multiple JsonAdapter arguments to the @ToJson and @FromJson methods, and these arguments may be annotated.

It also relies on the way this feature works if the types are the same. Here we’re making a JsonAdapter by delegating to another JsonAdapter. When the types are the same Moshi uses its nextAdapter() feature for composition.

To use the above adapter

val moshi = Moshi.Builder()
        .add(KotlinJsonAdapterFactory())
        .add(LocalDateTimeAdapter())
        .add(MultipleFormatsDateAdapter())
        .build()
val jsonAdapter = moshi.adapter(LocalDateTime::class.java) 

val now = LocalDateTime.now(ZoneOffset.UTC)
var jsonString = jsonAdapter.toJson(now)
// Output ISO String: 2018-04-10T03:34:18.115

// Support ISO String as input
var newNow = jsonAdapter.fromJson(jsonString)
Log.d(TAG, "$now == $newNow = ${now == newNow}")

// Support JSON format as input
jsonString = "{\"date\":{\"day\":10,\"month\":4,\"year\":2018},\"time\":{\"hour\":3,\"minute\":34,\"nano\":115000000,\"second\":18}}"
newNow = jsonAdapter.fromJson(jsonString)
Log.d(TAG, "$now == $newNow = ${now == newNow}")

References:

This work is licensed under a
Creative Commons Attribution-NonCommercial 4.0 International License.