All you have to do is to implement a custom Map<String, ...>
deserializer that will be triggered for maps defined using a special deserializer that's aware of the mapping rules.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@interface JsonKey {
@Nonnull
String value();
}
final class JsonKeyMapTypeAdapterFactory<V>
implements TypeAdapterFactory {
private final Class<V> superClass;
private final Map<String, Class<? extends V>> subClasses;
private final Supplier<? extends Map<String, V>> createMap;
private JsonKeyMapTypeAdapterFactory(final Class<V> superClass, final Map<String, Class<? extends V>> subClasses,
final Supplier<? extends Map<String, V>> createMap) {
this.superClass = superClass;
this.subClasses = subClasses;
this.createMap = createMap;
}
static <V> Builder<V> build(final Class<V> superClass) {
return new Builder<>(superClass);
}
@Override
@Nullable
public <T> TypeAdapter<T> create(final Gson gson, final TypeToken<T> typeToken) {
if ( !Map.class.isAssignableFrom(typeToken.getRawType()) ) {
return null;
}
final Type type = typeToken.getType();
if ( !(type instanceof ParameterizedType) ) {
return null;
}
final ParameterizedType parameterizedType = (ParameterizedType) type;
final Type[] actualTypeArguments = parameterizedType.getActualTypeArguments();
final Type valueType = actualTypeArguments[1];
if ( !(valueType instanceof Class) ) {
return null;
}
final Class<?> valueClass = (Class<?>) valueType;
if ( !superClass.isAssignableFrom(valueClass) ) {
return null;
}
final Type keyType = actualTypeArguments[0];
if ( !(keyType instanceof Class) || keyType != String.class ) {
throw new IllegalArgumentException(typeToken + " must represent a string-keyed map");
}
final Function<? super String, ? extends TypeAdapter<? extends V>> resolveTypeAdapter = subClasses.entrySet()
.stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> gson.getDelegateAdapter(this, TypeToken.get(e.getValue()))))
::get;
@SuppressWarnings("unchecked")
final TypeAdapter<T> castTypeAdapter = (TypeAdapter<T>) JsonKeyMapTypeAdapter.create(resolveTypeAdapter, createMap);
return castTypeAdapter;
}
static final class Builder<V> {
private final Class<V> superClass;
private final ImmutableMap.Builder<String, Class<? extends V>> subClasses = new ImmutableMap.Builder<>();
private Supplier<? extends Map<String, V>> createMap = LinkedHashMap::new;
private Builder(final Class<V> superClass) {
this.superClass = superClass;
}
Builder<V> register(final Class<? extends V> subClass) {
@Nullable
final JsonKey jsonKey = subClass.getAnnotation(JsonKey.class);
if ( jsonKey == null ) {
throw new IllegalArgumentException(subClass + " must be annotated with " + JsonKey.class);
}
return register(jsonKey.value(), subClass);
}
Builder<V> register(final String key, final Class<? extends V> subClass) {
if ( !superClass.isAssignableFrom(subClass) ) {
throw new IllegalArgumentException(subClass + " must be a subclass of " + superClass);
}
subClasses.put(key, subClass);
return this;
}
Builder<V> createMapWith(final Supplier<? extends Map<String, V>> createMap) {
this.createMap = createMap;
return this;
}
TypeAdapterFactory create() {
return new JsonKeyMapTypeAdapterFactory<>(superClass, subClasses.build(), createMap);
}
}
private static final class JsonKeyMapTypeAdapter<V>
extends TypeAdapter<Map<String, V>> {
private final Function<? super String, ? extends TypeAdapter<? extends V>> resolveTypeAdapter;
private final Supplier<? extends Map<String, V>> createMap;
private JsonKeyMapTypeAdapter(final Function<? super String, ? extends TypeAdapter<? extends V>> resolveTypeAdapter,
final Supplier<? extends Map<String, V>> createMap) {
this.resolveTypeAdapter = resolveTypeAdapter;
this.createMap = createMap;
}
private static <V> TypeAdapter<Map<String, V>> create(final Function<? super String, ? extends TypeAdapter<? extends V>> resolveTypeAdapter,
final Supplier<? extends Map<String, V>> createMap) {
return new JsonKeyMapTypeAdapter<>(resolveTypeAdapter, createMap)
.nullSafe();
}
@Override
public void write(final JsonWriter out, final Map<String, V> value) {
throw new UnsupportedOperationException();
}
@Override
public Map<String, V> read(final JsonReader in)
throws IOException {
in.beginObject();
final Map<String, V> map = createMap.get();
while ( in.hasNext() ) {
final String key = in.nextName();
@Nullable
final TypeAdapter<? extends V> typeAdapter = resolveTypeAdapter.apply(key);
if ( typeAdapter == null ) {
throw new JsonParseException("Unknown key " + key + " at " + in.getPath());
}
final V value = typeAdapter.read(in);
@Nullable
final V replaced = map.put(key, value);
if ( replaced != null ) {
throw new JsonParseException(value + " duplicates " + replaced + " using " + key);
}
}
in.endObject();
return map;
}
}
}
private static final Gson gson = new GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapterFactory(JsonKeyMapTypeAdapterFactory.build(Equipment.class)
.register(Engine.class)
.register(Tires.class)
.create()
)
.create();
The Gson
object above will deserialize your JSON document to a map that is toString
-ed like this (assuming Lombok is used for toString
):
{Engine=Engine(name=Ford 6.7L, cylinders=8), Tires=Tires(name=Blizzak LM32, season=winter)}