Skip to content

Instantly share code, notes, and snippets.

@luciofm
Created February 7, 2018 17:25
Show Gist options
  • Save luciofm/bc2ee4c5cc17a9a69c3b88cfb64ff4c8 to your computer and use it in GitHub Desktop.
Save luciofm/bc2ee4c5cc17a9a69c3b88cfb64ff4c8 to your computer and use it in GitHub Desktop.
/*
* Copyright 2018 Lucio Maciel, Rocket.Chat
* Copyright 2016 Serj Lotutovici
*
* 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.
*/
import com.squareup.moshi.Moshi
import java.lang.annotation.Documented
/**
* Indicates that the annotated sealed class has a fallback value. The fallback must be set via
* [.name] and must have a constructor String field [.fieldName]. If no class with the provided name is declared in the
* annotated sealed class type an [assertion error][AssertionError] will be thrown.
*
*
* To leverage from [FallbackSealedClass] [FallbackSealedClassJsonAdapter.ADAPTER_FACTORY] must be added to
* your [moshi instance][Moshi]:
*
*
* <pre>`
* Moshi moshi = new Moshi.Builder()
* .add(FallbackEnum.ADAPTER_FACTORY)
* .build();
* `</pre>
*
* Declaration example:
* <pre>`
* @FallbackSealedClass(name = "Custom", fieldName = "rawType")
* sealed class RoomType {
* @Json(name = "c") class Public : RoomType()
* @Json(name = "d") class OneToOne: RoomType()
* class Custom(val rawType: String) : RoomType()
* }
* `</pre>
*/
@Documented
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FILE)
annotation class FallbackSealedClass(val name: String, val fieldName: String)
/*
* Copyright 2018 Lucio Maciel, Rocket.Chat
* Copyright 2016 Serj Lotutovici
* Copyright (C) 2014 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.
*/
import com.squareup.moshi.*
import java.io.IOException
import java.lang.reflect.Constructor
import java.lang.reflect.Field
import java.util.LinkedHashMap
/**
* [JsonAdapter] that fallbacks to a default class type declared in the sealed class annotated
* with [FallbackSealedClass].
*
*
*/
class FallbackSealedClassJsonAdapter<T>(private val classType: Class<T>,
fallback: String,
private val fieldName: String) : JsonAdapter<T>() {
private val fallbackConstant: Class<out T>
private val fallbackConstructor: Constructor<out T>
private val nameConstantMap: Map<String, Class<out T>>
private val nameStrings: Array<String?>
private val fallbackConstructorField: Field
init {
try {
var fallbackConstantIndex = -1
val classes = classType.classes
val nameMap = LinkedHashMap<String, Class<out T>>()
nameStrings = arrayOfNulls(classes.size)
for (index in classes.indices) {
val clazz = classes[index]
val annotation = clazz.getAnnotation(Json::class.java)
val name = annotation?.name ?: clazz.simpleName
nameMap[name] = clazz as Class<out T>
nameStrings[index] = name
if (fallback == clazz.simpleName) {
fallbackConstantIndex = index
}
}
if (fallbackConstantIndex != -1) {
fallbackConstant = classes[fallbackConstantIndex] as Class<out T>
fallbackConstructor = fallbackConstant.getConstructor(String::class.java)
fallbackConstructorField = fallbackConstant.getDeclaredField(fieldName)
} else {
throw NoSuchFieldException("Filed \"$fallback\" is not declared.")
}
nameConstantMap = nameMap.toMap()
} catch (e: NoSuchFieldException) {
throw AssertionError("Missing field in " + classType.name, e)
} catch (e: NoSuchMethodException) {
throw AssertionError("Missing constructor with \"String\" parameter")
} catch (e: SecurityException) {
throw AssertionError("Invalid permission for constructor")
}
}
@Throws(IOException::class)
override fun fromJson(reader: JsonReader): T? {
val name = reader.nextString()
val constant = nameConstantMap[name]
return constant?.newInstance() ?: fallbackConstructor.newInstance(name)
}
@Throws(IOException::class)
override fun toJson(writer: JsonWriter, value: T?) {
value?.let {
if (fallbackConstant.isInstance(value)) {
val accessible = fallbackConstructorField.isAccessible
fallbackConstructorField.isAccessible = true
writer.value(fallbackConstructorField.get(value) as String)
fallbackConstructorField.isAccessible = accessible
return
}
for (entry in nameConstantMap) {
if (entry.value.isInstance(value)) {
writer.value(entry.key)
return
}
}
}
}
override fun toString(): String {
return "JsonAdapter(" + classType.name + ").fallbackClass(" + fallbackConstant + ")"
}
companion object {
/**
* Builds an adapter that can process sealed classes annotated with [FallbackSealedClass].
*/
val ADAPTER_FACTORY: JsonAdapter.Factory = JsonAdapter.Factory { type, annotations, moshi ->
if (!annotations.isEmpty()) return@Factory null
val rawType = Types.getRawType(type)
val annotation = rawType.getAnnotation(FallbackSealedClass::class.java) ?: return@Factory null
return@Factory FallbackSealedClassJsonAdapter(rawType, annotation.name, annotation.fieldName)
.nullSafe()
}
}
}
import com.squareup.moshi.Moshi
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Before
import org.junit.Test
import org.mockito.MockitoAnnotations
import org.hamcrest.CoreMatchers.`is` as isEqualTo
class FallbackSealedClassJsonAdapterTest {
lateinit var moshi: Moshi
@Before
fun setup() {
MockitoAnnotations.initMocks(this)
moshi = Moshi.Builder()
.add(FallbackSealedClassJsonAdapter.ADAPTER_FACTORY)
.build()
}
@Test(expected = AssertionError::class)
fun `should fail with missing fallback field`() {
val adapter = moshi.adapter<NoFallback>(NoFallback::class.java)
val value1 = adapter.fromJson(NO_FALLBACK_TEST1)
assert(value1?.value is NoFallbackClass.value1)
}
@Test(expected = AssertionError::class)
fun `should fail with missing Fallback constructor field`(){
val adapter = moshi.adapter<MissingField>(MissingField::class.java)
val value1 = adapter.fromJson(MISSING_FIELD_TEST1)
assert(value1?.value is MissingFieldClass.value1)
}
@Test(expected = AssertionError::class)
fun `should fail with wrong Constructor field name`(){
val adapter = moshi.adapter<InvalidFieldName>(InvalidFieldName::class.java)
val value1 = adapter.fromJson(INVALID_FIELD_TEST1)
assert(value1?.value is InvalidFieldNameClass.value1)
}
@Test
fun `should parse value1 value`() {
val adapter = moshi.adapter<ValidFallback>(ValidFallback::class.java)
val value = adapter.fromJson(VALID_TEST1)
assert(value?.value is ValidFallbackClass.value1)
}
@Test
fun `should parse value2 value`() {
val adapter = moshi.adapter<ValidFallback>(ValidFallback::class.java)
val value = adapter.fromJson(VALID_TEST2)
assert(value?.value is ValidFallbackClass.value2)
}
@Test
fun `should return Fallback(value)`() {
val adapter = moshi.adapter<ValidFallback>(ValidFallback::class.java)
val value = adapter.fromJson(VALID_TEST3)!!
assert(value.value is ValidFallbackClass.Fallback)
assertThat((value.value as ValidFallbackClass.Fallback).field, isEqualTo("value"))
}
@Test
fun `should return Fallback(test)`() {
val adapter = moshi.adapter<ValidFallback>(ValidFallback::class.java)
val value = adapter.fromJson(VALID_TEST4)!!
assert(value.value is ValidFallbackClass.Fallback)
assertThat((value.value as ValidFallbackClass.Fallback).field, isEqualTo("test"))
}
@Test
fun `should parse empty value`() {
val adapter = moshi.adapter<ValidFallback>(ValidFallback::class.java)
val value = adapter.fromJson(VALID_TEST5)!!
assert(value.value is ValidFallbackClass.Fallback)
assertThat((value.value as ValidFallbackClass.Fallback).field, isEqualTo(""))
}
}
@FallbackSealedClass(name = "Fallback", fieldName = "field")
sealed class NoFallbackClass {
class value1 : NoFallbackClass()
class value2 : NoFallbackClass()
}
data class NoFallback(val value: NoFallbackClass)
const val NO_FALLBACK_TEST1 = "{\"value\":\"value1\"}"
@FallbackSealedClass(name = "Fallback", fieldName = "field")
sealed class MissingFieldClass {
class value1 : MissingFieldClass()
class value2 : MissingFieldClass()
class Fallback : MissingFieldClass()
}
data class MissingField(val value: MissingFieldClass)
const val MISSING_FIELD_TEST1 = "{\"value\":\"value1\"}"
@FallbackSealedClass(name = "Fallback", fieldName = "field")
sealed class InvalidFieldNameClass {
class value1 : InvalidFieldNameClass()
class value2 : InvalidFieldNameClass()
class Fallback(val value: String) : InvalidFieldNameClass()
}
data class InvalidFieldName(val value: InvalidFieldNameClass)
const val INVALID_FIELD_TEST1 = "{\"value\":\"value1\"}"
@FallbackSealedClass(name = "Fallback", fieldName = "field")
sealed class ValidFallbackClass {
class value1 : ValidFallbackClass()
class value2 : ValidFallbackClass()
class Fallback(val field: String) : ValidFallbackClass()
}
data class ValidFallback(val value: ValidFallbackClass)
const val VALID_TEST1 = "{\"value\":\"value1\"}"
const val VALID_TEST2 = "{\"value\":\"value2\"}"
const val VALID_TEST3 = "{\"value\":\"value\"}"
const val VALID_TEST4 = "{\"value\":\"test\"}"
const val VALID_TEST5 = "{\"value\":\"\"}"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment