23

Lets say I have JSON of the following format:

{
    "type" : "Foo"
    "data" : {
        "object" : {
            "id" : "1"
            "fizz" : "bizz"
            ...
        },
        "metadata" : {
            ...
        },
        "owner" : {
            "name" : "John"
            ...
        }
    }
}

I am trying to avoid custom deserializer and attempting to deserialize the above JSON (called Wrapper.java) into Java POJOs. The "type" field dictates the "object" deserialization ie. type = foo means the deserialize the "object" field using the Foo.java. (if type = Bar, use Bar.java to deserialize the object field). Metadata/owner will always deserialize the same way using a simple Jackson annotated Java class for each. Is there a way to accomplish this using annotations? If not, how can this be done using a custom deserializer?

John Baum
  • 3,183
  • 11
  • 42
  • 90

4 Answers4

38

Annotations-only approach

Alternatively to the custom deserializer approach, you can have the following for an annotations-only solution (similar to the one described in Spunc's answer, but using type as an external property):

public abstract class AbstractData {

    private Owner owner;

    private Metadata metadata;

    // Getters and setters
}
public static final class FooData extends AbstractData {

    private Foo object;

    // Getters and setters
}
public static final class BarData extends AbstractData {

    private Bar object;

    // Getters and setters
}
public class Wrapper {

    private String type;

    @JsonTypeInfo(use = Id.NAME, property = "type", include = As.EXTERNAL_PROPERTY)
    @JsonSubTypes(value = { 
            @JsonSubTypes.Type(value = FooData.class, name = "Foo"),
            @JsonSubTypes.Type(value = BarData.class, name = "Bar") 
    })
    private AbstractData data;

    // Getters and setters
}

In this approach, @JsonTypeInfo is set to use type as an external property to determine the right class to map the data property.

The JSON document can be deserialized as following:

ObjectMapper mapper = new ObjectMapper();
Wrapper wrapper = mapper.readValue(json, Wrapper.class);  
David Renner
  • 454
  • 3
  • 15
cassiomolin
  • 124,154
  • 35
  • 280
  • 359
  • 1
    The problem I see with this approach is that BarData and FooData now are tied to this very specific AbstractData class. Imagine that FooData and BarData are to be used in a totally different part of the codebase that doesnt care at all how it was constructed (from JSON). – John Baum May 23 '17 at 04:32
  • 1
    Slightly misread your code, looks like this approach could definitely work. The remaining pain point for me is that each type of object (Foo, Bar) will now need a corresponding FooData and BarData wrapper class and will lead to 2x classes being created for each type of type "object" being parsed. Any way to use typed generics or similar to get around that? – John Baum May 23 '17 at 04:45
  • @JohnBaum Generics would do. However, as far as I know, you would need to define a `JavaType` or `TypeReference` when deserializing or handle it in a custom deserializer. My advice: don't overcomplicate it. – cassiomolin May 23 '17 at 10:08
  • [My solution](https://stackoverflow.com/a/44123978/715660) only needs one superclass and only one class for each type of object. Generics would be overkill. – Spunc May 23 '17 at 12:57
  • How to stop the type info showing the root dynamically , knowing i have this defined `@Generated("com.robohorse.robopojogenerator") @Entity @Table(name = "message") @JsonTypeInfo(use = Id.NAME, include = As.EXTERNAL_PROPERTY, property = "data") @JsonPropertyOrder({"id", "status", "numberOfUnits", "cost", "balance", "recipient", "dataCreated"}) @JsonSerialize(using = JacksonCustomMessageSerializer.class) @JsonDeserialize(using = JacksonCustomMessageDeserializer.class)` – shareef Jun 11 '18 at 06:03
  • @cassiomolin I tried this solution for my code, the Jackson was successfully able to convert the AbstractData to one of the type, but somehow the value of `type` was null – Deepak Patankar Jun 19 '20 at 14:12
  • will it work for json which is not serialized by this serializer? – Bhushan Karmarkar Aug 09 '22 at 05:26
19

Custom deserializer approach

You could use a custom deserializer that checks the type property to parse the object property into the most suitable class.

First define an interface that will be implemented by Foo and Bar classes:

public interface Model {

}
public class Foo implements Model {

    // Fields, getters and setters
}
public class Bar implements Model {

    // Fields, getters and setters
}

Then define your Wrapper and Data classes:

public class Wrapper {

    private String type;

    private Data data;

    // Getters and setters
}
public class Data {

    @JsonDeserialize(using = ModelDeserializer.class)
    private Model object;

    private Metadata metadata;

    private Owner owner;

    // Getters and setters
}

The object field is annotated with @JsonDeserialize, indicating the deserializer that will be used for the object property.

The deserializer is defined as following:

public class ModelDeserializer extends JsonDeserializer<Model> {

    @Override
    public Model deserialize(JsonParser jp, DeserializationContext ctxt)
        throws IOException, JsonMappingException {

        // Get reference to ObjectCodec
        ObjectCodec codec = jp.getCodec();

        // Parse "object" node into Jackson's tree model
        JsonNode node = codec.readTree(jp);

        // Get value of the "type" property
        String type = ((Wrapper) jp.getParsingContext().getParent()
            .getCurrentValue()).getType();

        // Check the "type" property and map "object" to the suitable class
        switch (type) {

            case "Foo":
                return codec.treeToValue(node, Foo.class);

            case "Bar":
                return codec.treeToValue(node, Bar.class);

            default:
                throw new JsonMappingException(jp, 
                    "Invalid value for the \"type\" property");
        }
    }
}

The JSON document can be deserialized as following:

ObjectMapper mapper = new ObjectMapper();
Wrapper wrapper = mapper.readValue(json, Wrapper.class);  

Alternatively to this custom deserializer, consider an annotations-only approach.

cassiomolin
  • 124,154
  • 35
  • 280
  • 359
  • Note that I had to annotate the `Foo` and `Bar` classes with the `@JsonDeserialize(using=JsonDeserializer.None.class)`. Otherwise, I ended up in an infinite loop. Note also that I used the newer Java `record` syntax. See also https://stackoverflow.com/a/40921263/3757139 – Samuel Jul 18 '23 at 08:45
15

All this can be done by means of annotations.

Create an abstract superclass with the common fields like "metadata" and "owner" and their getters/setters. This class needs to be annotated with @JsonTypeInfo. It should look like:

@JsonTypeInfo(use = Id.CLASS, include = As.PROPERTY, property = "type")

With the parameter property = "type" you specify that the class identifier will be serialized under the field type in your JSON document.

The value of the class identifier can be specified with use. Id.CLASS uses the fully-qualified Java class name. You can also use Id.MINIMAL_CLASS which is an abbreviated Java class name. To have your own identifier, use Id.NAME. In this case, you need to declare the subtypes:

@JsonTypeInfo(use = Id.NAME, include = As.PROPERTY, property = "type")
@JsonSubTypes({
    @JsonSubTypes.Type(value = Foo.class, name = "Foo"),
    @JsonSubTypes.Type(value = Bar.class, name = "Bar")
})

Implement your classes Foo and Bar by extending from the abstract superclass.

Jackson's ObjectMapper will use the additional field "type" of the JSON document for serialization and deserialization. E. g. when you deserialise a JSON string into a super class reference, it will be of the appropriate subclass:

ObjectMapper om = new ObjectMapper();
AbstractBase x = om.readValue(json, AbstractBase.class);
// x will be instanceof Foo or Bar


Complete code example (I used public fields as shortcut to not need to write getters/setters):

package test;

import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.annotation.JsonTypeInfo.As;

import java.io.IOException;

import com.fasterxml.jackson.annotation.JsonSubTypes;

@JsonTypeInfo(use = Id.NAME, include = As.PROPERTY, property = "type")
@JsonSubTypes({
    @JsonSubTypes.Type(value = Foo.class, name = "Foo"),
    @JsonSubTypes.Type(value = Bar.class, name = "Bar")
})
public abstract class AbstractBase {

    public MetaData metaData;
    public Owner owner;
    @Override
    public String toString() {
        return "metaData=" + metaData + "; owner=" + owner;
    }

    public static void main(String[] args) throws IOException {

        // Common fields
        Owner owner = new Owner();
        owner.name = "Richard";
        MetaData metaData = new MetaData();
        metaData.data = "Some data";

        // Foo
        Foo foo = new Foo();
        foo.owner = owner;
        foo.metaData = metaData;
        CustomObject customObject = new CustomObject();
        customObject.id = 20l;
        customObject.fizz = "Example";
        Data data = new Data();
        data.object = customObject;
        foo.data = data;
        System.out.println("Foo: " + foo);

        // Bar
        Bar bar = new Bar();
        bar.owner = owner;
        bar.metaData = metaData;
        bar.data = "A String in Bar";

        ObjectMapper om = new ObjectMapper();

        // Test Foo:
        String foojson = om.writeValueAsString(foo);
        System.out.println(foojson);
        AbstractBase fooDeserialised = om.readValue(foojson, AbstractBase.class);
        System.out.println(fooDeserialised);

        // Test Bar:
        String barjson = om.writeValueAsString(bar);
        System.out.println(barjson);
        AbstractBase barDeserialised = om.readValue(barjson, AbstractBase.class);
        System.out.println(barDeserialised);

    }

}

class Foo extends AbstractBase {
    public Data data;
    @Override
    public String toString() {
        return "Foo[" + super.toString() + "; data=" + data + ']';
    }
}

class Bar extends AbstractBase {
    public String data;
    public String toString() {
        return "Bar[" + super.toString() + "; data=" + data + ']';
    }
}


class Data {
    public CustomObject object;
    @Override
    public String toString() {
        return "Data[object=" + object + ']';
    }
}

class CustomObject {
    public long id;
    public String fizz;
    @Override
    public String toString() {
        return "CustomObject[id=" + id + "; fizz=" + fizz + ']';
    }
}

class MetaData {
    public String data;
    @Override
    public String toString() {
        return "MetaData[data=" + data + ']';
    }
}

class Owner {
    public String name;
    @Override
    public String toString() {
        return "Owner[name=" + name + ']';
    }
}
Spunc
  • 338
  • 1
  • 14
  • 1
    Looks like it does not apply to the OP situation because the `type` is not defined under `object`. Hence `@JsonTypeInfo` won't work. – cassiomolin May 23 '17 at 00:32
  • Already used in production (and unit tested there, of course). The property ```type``` will be introduced by Jackson. It won't work if you have a ```type``` field in the class itself. – Spunc May 23 '17 at 00:33
  • If possible, I would be interested in seeing an example with `@JsonTypeInfo` that works with the situation mentioned by the OP. – cassiomolin May 23 '17 at 00:40
  • `@JsonTypeInfo(use = Id.NAME, property = "type", include = As.EXTERNAL_PROPERTY)` could work. I will try it when I have the chance to. – cassiomolin May 23 '17 at 00:47
  • I got your idea. For test purposes, I managed to create an example with `@JsonTypeInfo(use = Id.NAME, property = "type", include = As.EXTERNAL_PROPERTY)`. Let me show it. – cassiomolin May 23 '17 at 01:06
  • [Here's](https://stackoverflow.com/a/44124343/1426227) how I manage to do it with `@JsonTypeInfo(use = Id.NAME, property = "type", include = As.EXTERNAL_PROPERTY)`. – cassiomolin May 23 '17 at 01:25
  • 1
    Looks like your code produces `{"type":"Foo","metaData":{"data":"Some data"},"owner":{"name":"Richard"},"data":{"object":{"id":20,"fizz":"Example"}}}`, which is a slightly different JSON from the one shown in the question. It could be fixed by moving `metaData` and `owner` members from `AbstractBase` to `Data` class. – cassiomolin May 23 '17 at 01:43
1

I think it is rather straight-forward. You probably have a super class that has properties for metadata and owner, so rather than making it truly generic, you could substitute T for your super class. But basically, you will have to parse the name of the class from the actual JSON string, which in your example would look something like this:

int start = jsonString.indexOf("type");
int end = jsonString.indexOf("data");
Class actualClass = Class.forName(jsonString.substring(start + 4, end - 2)); // that of course, is approximate - based on how you format JSON

and overall code could be something like this:

public static <T> T deserialize(String xml, Object obj)
        throws JAXBException {

    T result = null;

    try {

        int start = jsonString.indexOf("type");
        int end = jsonString.indexOf("data");
        Class actualClass = Class.forName(jsonString.substring(start + 4, end - 2)); 

        JAXBContextFactory factory = JAXBContextFactory.getInstance();
        JAXBContext jaxbContext = factory.getJaxBContext(actualClass);

        Unmarshaller jaxbUnmarshaller = jaxbContext.createUnmarshaller();

        // this will create Java object
        try (StringReader reader = new StringReader(xml)) {
            result = (T) jaxbUnmarshaller.unmarshal(reader);
        }

    } catch (JAXBException e) {
        log.error(String
                .format("Exception while deserialising the object[JAXBException] %s\n\r%s",
                        e.getMessage()));
    }

    return result;
}
Renats Stozkovs
  • 2,549
  • 10
  • 22
  • 26