OneMoreError
OneMoreError

Reputation: 7728

Jackson polymorphic deserialisation without using annotations

I have an Animal class accepting a generic type parameter T, as shown below:

public class Animal<T> {
    private String type;
    private T details;

    // getters and setters
}

The type parameter can be Dog or Cat.

public class Dog {
    private String name;
    private boolean goodBoy;

    // no-arg, all-args constructors, getters and setters
}

public class Cat {
    private String name;
    private boolean naughty;

    // no-arg, all-args constructors, getters and setters
}

I am trying to deserialise the following JSON.

{
    "type": "dog",
    "details": {
        "name": "Marley",
        "goodBoy": true
    }
}

However, when I deserialise, the field details always gets deserialised as a LinkedHashMap and not the particular implementation of the class.

public static void main(String[] args) throws IOException {
    ObjectMapper mapper = new ObjectMapper();

    Animal<Dog> dog = new Animal<>();
    dog.setType("dog");
    dog.setDetails(new Dog("Marley", true));

    String dogJson = mapper.writeValueAsString(dog);
    Animal dogDeserialized = mapper.readValue(dogJson, Animal.class);

    // dogDeserialized's details is LinkedHashMap
}

I cannot change the above classes, and so can't use annotations on the field. Is there a way I can specify the list of classes which the ObjectMapper may deserialise the details field to ?

Note that the value of the type field is set to "dog" or "cat" for respective Dog or Cat classes.

Upvotes: 4

Views: 696

Answers (2)

Michał Ziober
Michał Ziober

Reputation: 38625

Because you can not change POJO model you need to implement custom deserialiser and handle types manually. Custom deserialiser could look like below:

class AnimalJsonDeserializer extends JsonDeserializer<Animal> {

    private Map<String, Class> availableTypes = new HashMap<>();

    public AnimalJsonDeserializer() {
        availableTypes.put("cat", Cat.class);
        availableTypes.put("dog", Dog.class);
    }

    @Override
    public Animal deserialize(JsonParser parser, DeserializationContext ctxt) throws IOException {
        ObjectNode root = parser.readValueAsTree();
        JsonNode type = getProperty(parser, root, "type");

        Animal<Object> animal = new Animal<>();
        animal.setType(type.asText());

        Class<?> pojoClass = availableTypes.get(animal.getType());
        if (pojoClass == null) {
            throw new JsonMappingException(parser, "Class is not found for " + animal.getType());
        }

        JsonNode details = getProperty(parser, root, "details");
        animal.setDetails(parser.getCodec().treeToValue(details, pojoClass));

        return animal;
    }

    private JsonNode getProperty(JsonParser parser, ObjectNode root, String property) throws JsonMappingException {
        JsonNode value = root.get(property);
        if (value.isMissingNode() || value.isNull()) {
            throw new JsonMappingException(parser, "No " + property + " field!");
        }

        return value;
    }
}

We need to use SimpleModule class and register deserialiser for Animal class. Example usage:

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.node.ObjectNode;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

public class JsonApp {

    public static void main(String[] args) throws Exception {
        SimpleModule animalModule = new SimpleModule();
        animalModule.addDeserializer(Animal.class, new AnimalJsonDeserializer());

        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(animalModule);

        Animal<Dog> dog = new Animal<>();
        dog.setType("dog");
        dog.setDetails(new Dog("Marley", true));

        Animal<Cat> cat = new Animal<>();
        cat.setType("cat");
        cat.setDetails(new Cat("Tom", false));

        Animal<Dog> husky = new Animal<>();
        husky.setType("husky");
        husky.setDetails(new Dog("Sib", true));

        for (Animal animal : new Animal[]{dog, cat, husky}) {
            String json = mapper.writeValueAsString(animal);
            System.out.println("JSON: " + json);
            System.out.println("Deserialized: " + mapper.readValue(json, Animal.class));
            System.out.println();
        }
    }
}

Above code prints:

JSON: {"type":"dog","details":{"name":"Marley","goodBoy":true}}
Deserialized: Animal{type='dog', details=Dog{name='Marley', goodBoy=true}}

JSON: {"type":"cat","details":{"name":"Tom","naughty":false}}
Deserialized: Animal{type='cat', details=Cat{name='Tom', naughty=false}}

JSON: {"type":"husky","details":{"name":"Sib","goodBoy":true}}
Exception in thread "main" com.fasterxml.jackson.databind.JsonMappingException: Class is not found for husky
 at [Source: (String)"{"type":"husky","details":{"name":"Sib","goodBoy":true}}"; line: 1, column: 56]
    at AnimalJsonDeserializer.deserialize(JsonApp.java:69)
    at AnimalJsonDeserializer.deserialize(JsonApp.java:50)
    at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4013)
    at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3004)
    at com.celoxity.JsonApp.main(JsonApp.java:44)

MixIn feature could be possible if you could change classes.

If you can not change source classes you can always use Mix-in feature which allows to create new interface with similar method and annotate all required classes appropriately. In your case we need to remove also type property which will be handled automatically by Jackson. Your example, after changes, could look like below:

import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;

public class JsonApp {

    public static void main(String[] args) throws Exception {
        ObjectMapper mapper = new ObjectMapper();
        mapper.addMixIn(Animal.class, AnimalMixIn.class);

        Animal<Dog> dog = new Animal<>();
        dog.setDetails(new Dog("Marley", true));

        String dogJson = mapper.writeValueAsString(dog);
        System.out.println(dogJson);
        Animal dogDeserialized = mapper.readValue(dogJson, Animal.class);
        System.out.println(dogDeserialized);
    }
}

interface AnimalMixIn {

    @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", include = JsonTypeInfo.As.EXTERNAL_PROPERTY)
    @JsonSubTypes(value = {
            @JsonSubTypes.Type(value = Dog.class, name = "dog"),
            @JsonSubTypes.Type(value = Cat.class, name = "cat")})
    Object getDetails();
}

class Animal<T> {

    private T details;

    public T getDetails() {
        return details;
    }

    public void setDetails(T details) {
        this.details = details;
    }

    @Override
    public String toString() {
        return "Animal{details=" + details + '}';
    }
}

Cat and Dogs classes stay the same. Above code prints:

{"details":{"name":"Marley","goodBoy":true},"type":"dog"}
Animal{details=Dog{name='Marley', goodBoy=true}}

See also:

Upvotes: 0

Ryuzaki L
Ryuzaki L

Reputation: 39978

Use TypeReference to specify the type to Deserialized

Animal<Dog> dogDeserialized = mapper.readValue(
    dogJson, new TypeReference<Animal<Dog>>() {});

Or instead of Dog and Cat you can have only one class with all the properties

public class AnimalDetails {

  private String name;
  private Boolean goodBoy;
  private Boolean naughty;

 }

And set ObjectMapper to ignore unknown properties in the JSON:

 ObjectMapper mapper = new ObjectMapper()
  .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

So if it is Dog class naughty will be null and if it is Cat class goodBoy will be null

Upvotes: 1

Related Questions