Alexey
Alexey

Reputation: 301

Jackson polymorphic deserialization with dynamic types

I have a data structure with some strongly-typed fields and some loosely-typed fields. Some of those fields are Collections that can be any deep nested.

The example JSON

{
  "prop": "Hello",              //strongly-typed
  "child1": {
    "anInt": -1
  },
  "map": {                      // here magic begins
    "JustString": "JustValue",  // we may store any Object in this map
    "Item_With_Type": {
      "@type": "MyMap",         // some of them tell their type and we need to rely on it
      "Custom": "Value"
    },
    "List_With_All_Child1": {
      "@type": "MyMap[]",       // lists define the type of all values in it in this way
      "@values": [
        {
          "Key": "Value",       // MyMap is a Map
          "Child1": {           // of <? extends Object>
             "anInt": 2
           }
        },
        {
          "Key": "Value"
        }
      ]
    }
  }
}

I want to map this on

public static class Parent {
    private String prop;
    private Child1 child1;
    private MyMap<?> map;
}

public static class Child1 {
    private int anInt;
}

public static class MyMap<T> extends HashMap<String, T> implements Map<String, T> {
}

(accessors omitted)

Basically I need kind of Data Binder which Jackson will ask about a type each time it tries to resolve a type for any field saving context, and if this data binder didn't find anything application-specific, Jackson should fallback to default type resolving.

Any ideas how to achieve this?

Upvotes: 3

Views: 4639

Answers (1)

Alexey
Alexey

Reputation: 301

After playing with Jackson for some time I came to the following solution. Works fine for me.

First we make everything polymorphic

@JsonTypeResolver(MyTypeResolver.class)
@JsonTypeIdResolver(MyTypeIdResolver.class)
@JsonTypeInfo(use = JsonTypeInfo.Id.CUSTOM, include = JsonTypeInfo.As.PROPERTY, property = "@type")
public interface ObjectMixin {

}

ObjectMapper mapper = new ObjectMapper();
mapper.configure(SerializationFeature.INDENT_OUTPUT, true);
mapper.addMixIn(Object.class, ObjectMixin.class);

We create custom TypeResolver that only handles type serialization/deserilization for java.lang.Object.

public class MyTypeResolver extends StdTypeResolverBuilder {

    @Override
    public TypeSerializer buildTypeSerializer(SerializationConfig config, JavaType baseType, Collection<NamedType> subtypes) {
        return useForType(baseType) ? super.buildTypeSerializer(config, baseType, subtypes) : null;
    }

    @Override
    public TypeDeserializer buildTypeDeserializer(DeserializationConfig config, JavaType baseType, Collection<NamedType> subtypes) {
        return useForType(baseType) ? super.buildTypeDeserializer(config, baseType, subtypes) : null;
    }

    public boolean useForType(JavaType t) {
        return t.isJavaLangObject();
    }
}

TypeIdResolver in turn handles ID magic. In this example everything is hardcoded, in a real code it's looking much more nicer of course. :)

public class MyTypeIdResolver extends TypeIdResolverBase {

    @Override
    public String idFromValue(Object value) {
        return getId(value);
    }

    @Override
    public String idFromValueAndType(Object value, Class<?> suggestedType) {
        return getId(value);
    }

    @Override
    public JsonTypeInfo.Id getMechanism() {
        return JsonTypeInfo.Id.CUSTOM;
    }

    private String getId(Object value) {
        if (value instanceof ListWrapper.MyMapListWrapper) {
            return "MyMap[]";
        }

        if (value instanceof ListWrapper.Child1ListWrapper) {
            return "Child1[]";
        }

        if (value instanceof ListWrapper && !((ListWrapper) value).getValues().isEmpty()) {
            return ((ListWrapper) value).getValues().get(0).getClass().getSimpleName() + "[]";
        }

        return value.getClass().getSimpleName();
    }

    @Override
    public JavaType typeFromId(DatabindContext context, String id) throws IOException {
        if (id.endsWith("[]")) {
            if (id.startsWith("Child1")) {
                return TypeFactory.defaultInstance().constructParametricType(ListWrapper.class, Child1.class);
            }
            if (id.startsWith("MyMap")) {
                return TypeFactory.defaultInstance().constructSpecializedType(TypeFactory.unknownType(), ListWrapper.MyMapListWrapper.class);
            }
        }
        if (id.equals("Child1")) {
            return TypeFactory.defaultInstance().constructSpecializedType(TypeFactory.unknownType(), Child1.class);
        }
        if (id.equals("MyMap")) {
            return TypeFactory.defaultInstance().constructSpecializedType(TypeFactory.unknownType(), MyMap.class);
        }

        return TypeFactory.unknownType();
    }
}

To be able to handle {"@type: "...", "@values": ...} lists I have a ListWrapper class and subclasses. Todo: reimplement this using custom deserialization logic.

public class ListWrapper<T> {    
    @JsonProperty("@values")
    private List<T> values;

    public static class MyMapListWrapper extends ListWrapper<MyMap> {
    }

    public static class Child1ListWrapper extends ListWrapper<Child1> {
    }
}

It's possible to skip creation of subclasses but then the type information is added to every single element. java.lang.* classes come without type information of course.

Models are:

public class Parent {
    private String prop;
    private Child1 child1;
    private MyMap map;
}

public class Child1 {
    private int anInt;
}

The test code:

@Test
public void shouldDoTheTrick() throws IOException {
    ObjectMapper mapper = new ObjectMapper();
    mapper.configure(SerializationFeature.INDENT_OUTPUT, true);
    mapper.addMixIn(Object.class, ObjectMixin.class);

    Parent parent = new Parent("Hello", new Child1(-1), new MyMap() {{
        put("JustString", "JustValue");
        put("List_With_All_MyMaps", new ListWrapper.MyMapListWrapper(new ArrayList<MyMap>() {{
            add(new MyMap() {{
                put("Key", "Value");
                put("object", new Child1(2));
            }});
            add(new MyMap() {{
                put("Key", "Value");
            }});
        }}));
        put("List_With_All_Child1", new ListWrapper.Child1ListWrapper(new ArrayList<Child1>() {{
            add(new Child1(41));
            add(new Child1(42));
        }}));
    }});

    String valueAsString = mapper.writeValueAsString(parent);

    Parent deser = mapper.readValue(valueAsString, Parent.class);
    assertEquals(parent, deser);
}

The JSON output:

{
  "prop" : "Hello",
  "child1" : {
    "anInt" : -1
  },
  "map" : {
    "JustString" : "JustValue",
    "List_With_All_MyMaps" : {
      "@type" : "MyMap[]",
      "@values" : [ {
        "Key" : "Value",
        "object" : {
          "@type" : "Child1",
          "anInt" : 2
        }
      }, {
        "Key" : "Value"
      } ]
    },
    "List_With_All_Child1" : {
      "@type" : "Child1[]",
      "@values" : [ {
        "anInt" : 41
      }, {
        "anInt" : 42
      } ]
    }
  }
}

UPD: real implementation example https://github.com/sdl/dxa-web-application-java/commit/7a36a9598ac2273007806285ea4d854db1434ac5

Upvotes: 3

Related Questions