Search code examples
jacksonjson-deserialization

Value Dependent Deserialization with Jackson


I want to deserialize into a data structure. Dependent on the version of the JSON data I want to deserialize into different implementations of the same interface. And this works so far with a custom deserializer.

However, in the data structure I use references. And I expect that when undefined references are encountered an exception is thrown. The way I programmed it, this does not work together with the interface.

I created a small example with a (currently not passing) test case to show the desired behavior.

Additional Information: In the test case, when I use concrete classes (instead of the interface) in readValue the desired behavior occurs. That is, when I write mapper.readValue(buggy, Database2.class); instead of mapper.readValue(buggy, DatabaseI.class);. But then I lose the ability to abstract from the particular content of the JSON data.

import static org.junit.jupiter.api.Assertions.assertThrows;

import com.btc.adt.pop.scen.objectstreams.Person;
import com.fasterxml.jackson.core.JacksonException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.node.IntNode;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;


public class Example {

  @Test
  public void test() throws JsonProcessingException {

    ObjectMapper mapper =
        new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
            .configure(JsonParser.Feature.ALLOW_SINGLE_QUOTES, true);

    SimpleModule module = new SimpleModule();
    module.addDeserializer(DatabaseI.class, new ToyDeserializer());
    mapper.registerModule(module);

    String correct = "{'version':1,'people':[{'id':'a','friends':['b','c']},{'id':'b','friends':['c']},{'id':'c','friends':['b']}]}";
    DatabaseI deserCorrect = mapper.readValue(correct, DatabaseI.class);
    System.out.println(mapper.writeValueAsString(deserCorrect));

    String buggy = "{'version':2,'people':[{'id':'a','friends':['b','c']},{'id':'b','friends':['c']},{'id':'c','friends':['FOO']}]}";
    assertThrows(Exception.class, () -> {
      mapper.readValue(buggy, DatabaseI.class);
    }, "The reference FOO is undefined. An Exception should be thrown.");
  }
}

class Person {

  @JsonProperty("id")
  private String id;

  @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class,
      property = "id")
  @JsonIdentityReference(alwaysAsId = true)
  private List<Person> friends = new ArrayList<>();


  public Person() {
  }

  public String getId() {
    return id;
  }

  public void setId(String id) {
    this.id = id;
  }

  public List<Person> getFriends() {
    return friends;
  }

  public void setFriends(List<Person> friends) {
    this.friends = friends;
  }
  
}

interface DatabaseI {

}

class Database1 implements DatabaseI {

  private int version;
  private List<Person> people = new ArrayList<>();

  public Database1() {
  }

  public List<Person> getPeople() {
    return people;
  }

  public void setPeople(List<Person> people) {
    this.people = people;
  }

  public int getVersion() {
    return version;
  }

  public void setVersion(int version) {
    this.version = version;
  }
}

class Database2 implements DatabaseI {

  private String version;
  private List<Person> people = new ArrayList<>();

  public Database2() {
  }

  public List<Person> getPeople() {
    return people;
  }

  public void setPeople(List<Person> people) {
    this.people = people;
  }

  public String getVersion() {
    return version;
  }

  public void setVersion(String version) {
    this.version = version;
  }
}

class ToyDeserializer extends StdDeserializer<DatabaseI> {

  protected ToyDeserializer(Class<?> vc) {
    super(vc);
  }

  public ToyDeserializer() {
    this(null);
  }

  @Override
  public DatabaseI deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException, JacksonException {
    ObjectMapper mapper = (ObjectMapper) jp.getCodec();
    JsonNode node = mapper.readTree(jp);

    int version = (Integer) ((IntNode) node.get("version")).numberValue();
    if (version == 1) {
      return mapper.treeToValue(node, Database1.class);
    } else {
      return mapper.treeToValue(node, Database2.class);
    }
  }
}


Solution

  • This very good question! If you want to understand why no exception is thrown, your class Person must look like this:

    @JsonIdentityInfo(
            generator = ObjectIdGenerators.PropertyGenerator.class,
            property = "id",
            scope = Person.class,
            resolver = SimpleObjectIdResolverThrowsException.class
    )
    @JsonIdentityReference
    class Person {
    
        String id;
        List<Person> friends = new ArrayList<>();
    
        @ConstructorProperties({"id"})
        public Person(String id) {
            this.id = id;
        }
    
        public String getId() {
            return id;
        }
    
        public void setId(String id) {
            this.id = id;
        }
    
        public List<Person> getFriends() {
            return friends;
        }
    
        public void setFriends(List<Person> friends) {
            this.friends = friends;
        }
    }
    
    class SimpleObjectIdResolverThrowsException extends SimpleObjectIdResolver {
    
        public SimpleObjectIdResolverThrowsException() {
            super();
        }
    
        @Override
        public Object resolveId(ObjectIdGenerator.IdKey id) {
            if (this._items == null) {
                return null;
            }
    
            Object obj = this._items.get(id);
            if (obj == null) {
                throw new RuntimeException("Unresolved reference for: " + id);
            }
    
            return obj;
        }
    
        @Override
        public ObjectIdResolver newForDeserialization(Object context) {
            return new SimpleObjectIdResolverThrowsException();
        }
    }
    

    Now you can set break point in the method resolveId and see what happens when we de-serialize the string "{'version':1,'people':[{'id':'a','friends':['b','c']},{'id':'b','friends':['c']},{'id':'c','friends':['b']}]}":

    enter image description here

    The problem is that the objects are processed one after the other and the references from the friends list are not resolved at that time.