Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save serj-lotutovici/a50bef81bf6a31252453f5f302d32606 to your computer and use it in GitHub Desktop.
Save serj-lotutovici/a50bef81bf6a31252453f5f302d32606 to your computer and use it in GitHub Desktop.
A Moshi JsonAdapter.Factory that creates Polymorphic JsonAdapter. Requires Moshi 1.4.0. (Tests written in kotlin)
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.LinkedHashMap;
import java.util.Map;
import java.util.Set;
/**
* A {@link JsonAdapter.Factory} that can create a polymorphic {@link JsonAdapter} which will parse
* a given type based on the value under the name set via {@link Builder#setKey(String)}.
*
* <p>Usage:
* <pre>{@code
* JsonAdapter.Factory polymorphicAdapterFactory =
* new GenericPolymorphicJsonAdapterFactory.Builder()
* .setKey("the-key-which-will-provide-the-value")
* .map("some_value", YourObject.class)
* .map("some_other_value", YourOtherObject.class)
* .build();
*
* Moshi moshi = new Moshi.Builder()
* .add(polymorphicAdapterFactory)
* .build();
* }</pre>
*
* <p>The adapter can be restricted to a specific <strong>parent</strong> type. This will allow to
* minimize the amount of adapters created for each object. See {@link Builder} for more info.
*/
public final class GenericPolymorphicJsonAdapterFactory implements JsonAdapter.Factory {
private final Map<String, Class<?>> kindToClass;
private final Class<?> parent;
private final String key;
private final boolean lenient;
GenericPolymorphicJsonAdapterFactory(Builder builder) {
this.kindToClass = builder.valueToClass;
this.parent = builder.parent;
this.key = builder.key;
this.lenient = builder.lenient;
}
@Override public JsonAdapter<?> create(
Type type, Set<? extends Annotation> annotations, Moshi moshi) {
if (!annotations.isEmpty()) return null;
if (typeSupported(type)) {
Map<String, JsonAdapter<?>> nameToAdapter = new LinkedHashMap<>();
Map<Class<?>, JsonAdapter<?>> classToAdapter = new LinkedHashMap<>();
for (Map.Entry<String, Class<?>> classEntry : kindToClass.entrySet()) {
JsonAdapter<Object> adapter =
moshi.nextAdapter(this, classEntry.getValue(), annotations);
nameToAdapter.put(classEntry.getKey(), adapter);
classToAdapter.put(classEntry.getValue(), adapter);
}
GenericPolymorphicJsonAdapter adapter =
new GenericPolymorphicJsonAdapter(key, nameToAdapter, classToAdapter);
return lenient ? adapter.lenient() : adapter;
}
return null;
}
private boolean typeSupported(Type type) {
if (parent != null) {
Class<?> rawType = Types.getRawType(type);
return parent.isAssignableFrom(rawType);
}
for (Class<?> cls : kindToClass.values()) if (type == cls) return true;
return false;
}
private static final class GenericPolymorphicJsonAdapter extends JsonAdapter<Object> {
private final String kindKey;
private final Map<String, JsonAdapter<?>> nameToAdapter;
private final Map<Class<?>, JsonAdapter<?>> classToAdapter;
GenericPolymorphicJsonAdapter(String kindKey, Map<String, JsonAdapter<?>> nameToAdapter,
Map<Class<?>, JsonAdapter<?>> classToAdapter) {
this.kindKey = kindKey;
this.nameToAdapter = nameToAdapter;
this.classToAdapter = classToAdapter;
}
@Override public Object fromJson(JsonReader reader) throws IOException {
Object jsonObject = reader.readJsonValue();
if (jsonObject instanceof Map) {
// readJsonValue() can only return one type of map.
//noinspection unchecked
Map<String, Object> json = (Map<String, Object>) jsonObject;
Object kind = json.get(kindKey);
if (kind instanceof String) {
JsonAdapter<?> jsonAdapter = nameToAdapter.get(kind);
if (jsonAdapter != null) {
return jsonAdapter.fromJsonValue(jsonObject);
}
return nullIfLenient(reader,
"No adapter registered for " + kind + " at path " + reader.getPath());
}
return nullIfLenient(reader, "Expected KIND to be a string, but found "
+ (kind != null ? kind.getClass().getSimpleName() : null)
+ " at path " + reader.getPath());
}
return nullIfLenient(reader, "Expected Map, but found "
+ (jsonObject != null ? jsonObject.getClass().getSimpleName() : null)
+ " at path " + reader.getPath());
}
private Object nullIfLenient(JsonReader reader, String message) {
if (reader.isLenient()) return null;
throw new JsonDataException(message);
}
@Override public void toJson(JsonWriter writer, Object object) throws IOException {
if (object != null) {
//noinspection unchecked
JsonAdapter<Object> adapter = (JsonAdapter<Object>) classToAdapter.get(object.getClass());
if (adapter != null) {
adapter.toJson(writer, object);
} else {
if (writer.isLenient()) {
writer.nullValue();
} else {
throw new JsonDataException(
"No adapter registered for " + object.getClass().getSimpleName());
}
}
} else {
writer.nullValue();
}
}
}
/** Constructs a {@link GenericPolymorphicJsonAdapterFactory}. */
public static final class Builder {
Map<String, Class<?>> valueToClass = new LinkedHashMap<>();
final Class<?> parent;
String key;
boolean lenient = false;
/**
* Creates a new builder, which is not opinionated regarding the parent of the supported types.
*/
public Builder() {
this(null);
}
/**
* Creates a new builder, that will force each type to be a descendant of {@code parent}.
*/
public Builder(Class<?> parent) {
this.parent = parent;
}
/**
* Set the key/name of the {@link String} value that will be used to distinguish types from one
* another.
*/
public Builder setKey(String key) {
if (key == null) throw new NullPointerException("key == null");
this.key = key;
return this;
}
public Builder setLenient(boolean lenient) {
this.lenient = lenient;
return this;
}
/** Map a value to a specific type. */
public Builder map(String value, Class<?> toType) {
if (value == null) throw new NullPointerException("value == null");
if (toType == null) throw new NullPointerException("toType == null");
if (parent != null) {
if (!parent.isAssignableFrom(toType)) {
throw new IllegalArgumentException(toType + " must inherit from " + parent);
}
}
valueToClass.put(value, toType);
return this;
}
public GenericPolymorphicJsonAdapterFactory build() {
if (key == null) throw new IllegalStateException("key not set!");
if (valueToClass.isEmpty()) {
throw new IllegalStateException("No kind -> type mapping registered.");
}
return new GenericPolymorphicJsonAdapterFactory(this);
}
}
}
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.Moshi
import org.junit.Test
import org.assertj.core.api.Assertions.assertThat
import org.junit.Assert.fail
import kotlin.test.assertNull
class GenericPolymorphicJsonAdapterTest {
@Test fun builderFailsOnInvalidValues() {
val builder = GenericPolymorphicJsonAdapterFactory.Builder()
assertFailing(
block = { builder.setKey(null) },
clazz = NullPointerException::class.java,
message = "key == null")
assertFailing(
block = { builder.map(null, String::class.java) },
clazz = NullPointerException::class.java,
message = "value == null")
assertFailing(
block = { builder.map("kind", null) },
clazz = NullPointerException::class.java,
message = "toType == null")
}
@Test fun builderForcesRequiredValues() {
val builder = GenericPolymorphicJsonAdapterFactory.Builder()
assertFailing(
block = { builder.build() },
clazz = IllegalStateException::class.java,
message = "key not set!")
builder.setKey("some-kind")
assertFailing(
block = { builder.build() },
clazz = IllegalStateException::class.java,
message = "No kind -> type mapping registered.")
builder.map("kindOne", String::class.java)
builder.build()
}
@Test fun builderForcesToRespectParent() {
val builder = GenericPolymorphicJsonAdapterFactory.Builder(Polymorphic::class.java)
assertFailing(
block = { builder.map("some-kind", Third::class.java) },
clazz = IllegalArgumentException::class.java,
message = "class Third must inherit from interface Polymorphic")
}
@Test fun polymorphicWithParent() {
val moshi = Moshi.Builder().apply {
add(GenericPolymorphicJsonAdapterFactory.Builder(Polymorphic::class.java).apply {
setKey("kind")
map("first", First::class.java)
map("second", Second::class.java)
}.build())
}.build()
val adapter = moshi.adapter(Polymorphic::class.java)
val shouldBeFirst = adapter.fromJson(
"""
{
"kind": "first",
"count": 42
}
""")
assertThat(shouldBeFirst)
.isInstanceOf(First::class.java)
.isEqualTo(First(count = 42))
assertThat(adapter.toJson(shouldBeFirst))
.isEqualTo("{\"count\":42}")
val shouldBeSecond = adapter.fromJson(
"""
{
"kind": "second",
"message": "Yo!"
}
""")
assertThat(shouldBeSecond)
.isInstanceOf(Second::class.java)
.isEqualTo(Second(message = "Yo!"))
assertThat(adapter.toJson(shouldBeSecond))
.isEqualTo("{\"message\":\"Yo!\"}")
assertThat(adapter.toJson(null)).isEqualTo("null")
}
@Test fun polymorphicNoParent() {
val moshi = buildStrictAllInMoshi()
val firstAdapter = moshi.adapter(First::class.java)
val first = firstAdapter.fromJson(
"""
{
"kind": "first",
"count": 45
}
""")
assertThat(first).isEqualTo(First(count = 45))
assertThat(firstAdapter.toJson(first)).isEqualTo("{\"count\":45}")
assertThat(firstAdapter.toJson(null)).isEqualTo("null")
val thirdAdapter = moshi.adapter(Third::class.java)
val third = thirdAdapter.fromJson(
"""
{
"kind": "third",
"flag": true
}
""")
assertThat(third).isEqualTo(Third(flag = true))
assertThat(thirdAdapter.toJson(third)).isEqualTo("{\"flag\":true}")
assertThat(thirdAdapter.toJson(null)).isEqualTo("null")
}
@Test fun nonLenientThrowsIfNotObjectOrNull() {
val adapter = buildStrictAllInMoshi().adapter(First::class.java)
assertFailing(
block = { adapter.fromJson("[]") },
clazz = JsonDataException::class.java,
message = "Expected Map, but found ArrayList at path $")
assertFailing(
block = { adapter.fromJson("null") },
clazz = JsonDataException::class.java,
message = "Expected Map, but found null at path $")
}
@Test fun nonLenientThrowsIfKindNotString() {
val adapter = buildStrictAllInMoshi().adapter(First::class.java)
assertFailing(
block = { adapter.fromJson("{\"kind\":1}") },
clazz = JsonDataException::class.java,
message = "Expected KIND to be a string, but found Double at path $")
assertFailing(
block = { adapter.fromJson("{\"kind\":null}") },
clazz = JsonDataException::class.java,
message = "Expected KIND to be a string, but found null at path $")
}
@Test fun nonLenientThrowsIfNoAdapterRegistered() {
val moshi = Moshi.Builder().apply {
add(GenericPolymorphicJsonAdapterFactory.Builder(Polymorphic::class.java).apply {
setKey("kind")
map("first", First::class.java)
}.build())
}.build()
val adapter = moshi.adapter(Polymorphic::class.java)
assertFailing(
block = { adapter.fromJson("{\"kind\":\"fourth\"}") },
clazz = JsonDataException::class.java,
message = "No adapter registered for fourth at path $")
assertFailing(
block = { adapter.toJson(Second("You shall fail")) },
clazz = JsonDataException::class.java,
message = "No adapter registered for Second")
}
@Test fun lenientParsesAsNullIfSomethingIsWrong() {
val moshi = Moshi.Builder().apply {
add(GenericPolymorphicJsonAdapterFactory.Builder().apply {
setKey("kind")
map("first", First::class.java)
setLenient(true)
}.build())
}.build()
val adapter = moshi.adapter(First::class.java)
assertNull(adapter.fromJson("[]"))
assertNull(adapter.fromJson("null"))
assertNull(adapter.fromJson("{\"kind\":1}"))
assertNull(adapter.fromJson("{\"kind\":null}"))
assertNull(adapter.fromJson("{\"kind\":\"second\"}"))
}
@Test fun lenientWritesNulls() {
val moshi = Moshi.Builder().apply {
add(GenericPolymorphicJsonAdapterFactory.Builder(Polymorphic::class.java).apply {
setKey("kind")
map("first", First::class.java)
setLenient(true)
}.build())
}.build()
val adapter = moshi.adapter(Polymorphic::class.java).serializeNulls()
assertThat(adapter.toJson(Second("null we go"))).isEqualTo("null")
}
private fun buildStrictAllInMoshi() = Moshi.Builder().apply {
add(GenericPolymorphicJsonAdapterFactory.Builder().apply {
setKey("kind")
map("first", First::class.java)
map("second", Second::class.java)
map("third", Third::class.java)
}.build())
}.build()
private fun assertFailing(block: () -> Any, clazz: Class<out Any>, message: String) {
try {
block()
fail("The block must fail!")
} catch (ex: Exception) {
assertThat(ex).isInstanceOf(clazz).hasMessage(message)
}
}
}
interface Polymorphic
data class First(val count: Int) : Polymorphic
data class Second(val message: String) : Polymorphic
data class Third(val flag: Boolean)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment