Skip to content

Instantly share code, notes, and snippets.

@nikclayton
Created February 21, 2024 21:51
Show Gist options
  • Save nikclayton/31743dcc11be8ccd27adf472a2639406 to your computer and use it in GitHub Desktop.
Save nikclayton/31743dcc11be8ccd27adf472a2639406 to your computer and use it in GitHub Desktop.
/*
* Copyright 2024 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.core.network.json
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.JsonQualifier
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import java.lang.reflect.Type
/**
* A [JsonQualifier] for use with [Enum] declarations to indicate that incoming
* JSON values that are not valid enum constants should be mapped to a default
* value instead of throwing a [JsonDataException].
*
* Usage:
* ```
* val moshi = Moshi.Builder()
* .add(DefaultEnum.Factory())
* .build()
*
* @DefaultEnum(name = "FOO")
* enum class SomeEnum { FOO, BAR }
*
* @JsonClass(generateAdapter = true)
* data class Data(
* @Json(name = "some_enum") someEnum: SomeEnum
* )
* ```
*
* JSON of the form `{ "some_enum": "unknown" }` will parse to a
*
* ```
* Data(someEnum = SomeEnum.FOO)
* ```
*
* because of the default.
*
* This is similar to Moshi's existing [com.squareup.moshi.adapters.EnumJsonAdapter]
* which also supports fallbacks. The primary difference is that you define the
* default value at the point where the `enum class` is declared, not at the point
* where the Moshi instance is created.
*
* Because annotations are not generic you must also specify the default as a
* string, not the specific enum constant.
*/
@Retention(AnnotationRetention.RUNTIME)
@JsonQualifier
annotation class DefaultEnum(val name: String) {
class Factory : JsonAdapter.Factory {
override fun create(
type: Type,
annotations: MutableSet<out Annotation>,
moshi: Moshi,
): JsonAdapter<*>? {
if (annotations.isNotEmpty()) return null
val rawType = Types.getRawType(type)
if (!rawType.isEnum) return null
val annotation = rawType.getAnnotation(DefaultEnum::class.java) ?: return null
val delegateAnnotations = Types.nextAnnotations(
annotations,
DefaultEnum::class.java,
) ?: emptySet()
val delegate = moshi.nextAdapter<Any>(
this,
type,
delegateAnnotations,
)
val enumType = rawType as Class<out Enum<*>>
enumType.enumConstants?.firstOrNull { it.name == annotation.name }
val fallbackConstant = enumType.enumConstants?.firstOrNull { it.name == annotation.name }
?: throw AssertionError("Missing ${annotation.name} in ${enumType.name}")
return Adapter(delegate, fallbackConstant)
}
private class Adapter<T : Enum<T>>(
private val delegate: JsonAdapter<Any>,
val default: Enum<*>,
) : JsonAdapter<T>() {
override fun fromJson(reader: JsonReader): T {
val peeked = reader.peekJson()
val result = try {
delegate.fromJson(peeked) as T
} catch (_: JsonDataException) {
default
} finally {
peeked.close()
}
reader.skipValue()
return result as T
}
override fun toJson(writer: JsonWriter, value: T?) = delegate.toJson(writer, value)
}
}
}
// Tests below here, put in DefaultEnumTest.kt
/*
* Copyright 2024 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.core.network.json
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import kotlin.test.fail
import org.junit.Test
@OptIn(ExperimentalStdlibApi::class)
class DefaultEnumTest {
private val moshi = Moshi.Builder()
.add(DefaultEnum.Factory())
.build()
enum class NoAnnotation { FOO, BAR }
@DefaultEnum(name = "FOO")
enum class Annotated { FOO, BAR, }
@Test
fun `unannotated enum parses`() {
assertThat(moshi.adapter<NoAnnotation>().fromJson("\"FOO\"")).isEqualTo(NoAnnotation.FOO)
}
@Test
fun `unannotated enum throws an exception`() {
try {
moshi.adapter<NoAnnotation>().fromJson("\"UNKNOWN\"")
fail()
} catch (e: Exception) {
assertThat(e).isInstanceOf(JsonDataException::class.java)
}
}
@Test
fun `annotated enum parses as normal`() {
val adapter = moshi.adapter<Annotated>()
assertThat(adapter.fromJson("\"FOO\"")).isEqualTo(Annotated.FOO)
assertThat(adapter.fromJson("\"BAR\"")).isEqualTo(Annotated.BAR)
}
@Test
fun `annotated enum with unknown value parses as FOO`() {
val adapter = moshi.adapter<Annotated>()
assertThat(adapter.fromJson("\"unknown\"")).isEqualTo(Annotated.FOO)
}
@Test
fun `annotated enum emits correct JSON for valid values`() {
val adapter = moshi.adapter<Annotated>()
assertThat(adapter.toJson(Annotated.FOO)).isEqualTo("\"FOO\"")
assertThat(adapter.toJson(Annotated.BAR)).isEqualTo("\"BAR\"")
}
@JsonClass(generateAdapter = true)
data class Data(@Json(name = "some_enum") val someEnum: Annotated)
@Test
fun `annotated enum as property of class with unknown value parses as FOO`() {
val adapter = moshi.adapter<Data>()
assertThat(adapter.fromJson("{ \"some_enum\": \"unknown\" }")).isEqualTo(
Data(someEnum = Annotated.FOO),
)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment