javaspring

deserialize an abstract class with enum in spring


Let's say we have a class InstantMessage, which has an abstract child IMContent content.

public class InstantMessage {
    // other fields
    private MessageType type;
    private IMContent content;
}

public enum MessageType {
    TEXT, IMAGE, VIDEO, PRODUCT, SHOPPING_ORDER, SYSTEM;
}

public abstract class IMContent {
    public abstract String toText();
}

IMContent has a lot of implementations, like IMTextContent, IMImageContent, which depends on the type of message.

When using InstantMessage in the @RequestBody and @RequestPart, Spring cannot deserialize the IMContent.

I have tried several methods but they all have some obvious flaws.

Firstly, I tried adding @JsonDeserialize(using = IMContentDeserialzer.class) to the content, but that requires me to deserialize other fields manually besides content, which is every inconvenient and easy to forget when adding another field. I can accept that I write the deserialization of IMContent manually but not the entire InstantMessage. I wish there is a simple way to deserialize "non-content" fields.

My second attempt was using @JsonTypeInfo and @JsonSubTypes, but that seems to require me to move the type from InstantMessage to the IMContent. It is also inconvenient when I need to access the type from InstantMessage directly or just access the text content directly. For example if I need to search the text content, I need to flatten the content into a String in the database and when I fetch the InstantMessage out of db I need to resemble the content again.

I also have some workarounds, like using Map instead of the IMContent, or deserializing content into IMContent on the fly, or making InstantMessage abstract, but they are not elegant I think.

What's the easiest way to solve this problem in this case?

@JsonTypeInfo(
        use = JsonTypeInfo.Id.NAME,
        include = JsonTypeInfo.As.PROPERTY,
        property = "type",
        visible = true
)
@JsonSubTypes({
        @JsonSubTypes.Type(value = IMTextContent.class, name = "TEXT"),
        @JsonSubTypes.Type(value = IMImageContent.class, name = "IMAGE"),
        @JsonSubTypes.Type(value = IMProductContent.class, name = "PRODUCT"),
        @JsonSubTypes.Type(value = IMShoppingOrderContent.class, name = "SHOPPING_ORDER"),
})
public abstract class IMContent {

    public abstract String toText();

}

Solution

  • Usage of @JsonTypeInfo and @JsonSubTypes don't require moving the type from InstantMessage to the IMContent.

    This requires telling how to distinguish between different types of IMContent when deserializing, so you need to have some kind of property, like type or whatever you want to call it, to associate it with a specific implementation of IMContent class.

    In other words, InstantMessage can have a business! attribute MessageType type property that describes the type of InstantMessage, and IMContent can have another type property that describes the type of IMContent in order to deserialize it.

    But if MessageType type describes from business point of view which content! this is a type, then fmpov it must belong to IMContent and you don't need an InstantMessage wrapper:

        @JsonTypeInfo(
                use = JsonTypeInfo.Id.NAME,
                include = JsonTypeInfo.As.PROPERTY,
                property = "type",
                visible = true
        )
        @JsonSubTypes({
                @JsonSubTypes.Type(value = IMTextContent.class, name = "TEXT"),
                @JsonSubTypes.Type(value = IMImageContent.class, name = "IMAGE"),
                @JsonSubTypes.Type(value = IMProductContent.class, name = "PRODUCT"),
                @JsonSubTypes.Type(value = IMShoppingOrderContent.class, name = "SHOPPING_ORDER"),
        })
        public abstract class IMContent {
        
            protected MessageType type;
        
            public abstract String toText();
    
            // type getter/setter
        }
    
        public IMTextContent extends IMContent {
    
        // further IMTextContent properties
        // type is available via getter from IMContent
        
        }
    

    EDIT: You can achieve desired behaviour using JsonCreator:

    import com.fasterxml.jackson.annotation.JsonCreator;
    import com.fasterxml.jackson.annotation.JsonProperty;
    import com.fasterxml.jackson.core.JsonProcessingException;
    import com.fasterxml.jackson.databind.JsonNode;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import lombok.Getter;
    import lombok.Setter;
    import lombok.ToString;
    
    @Getter
    @Setter
    @ToString
    public class InstantMessage {
    
        private Long id;
    
        private MessageType type;
    
        private IMContent imContent;
    
        @JsonCreator
        public InstantMessage(@JsonProperty("id") Long id,
                              @JsonProperty("type") MessageType type,
                              @JsonProperty("imContent") JsonNode imContent) {
            this.id = id;
            this.type = type;
            this.imContent = deserializeContent(type, imContent);
        }
    
        // move this in separate class
        private IMContent deserializeContent(MessageType type, JsonNode imContentNode) {
            try {
                if (type == MessageType.TEXT) {
                    String text = imContentNode.get("text").asText(); // specific property for IMTextContent
                    // further specific properties
    
                    IMTextContent imTextContent = new IMTextContent();
                    imTextContent.setText(text);
                    // set further specific properties
    
                    return imTextContent;
                } else if (type == MessageType.IMAGE) {
                    String image = imContentNode.get("image").asText();
                    // extract further specific properties
    
                    IMImageContent imImageContent = new IMImageContent();
                    imImageContent.setImage(image);
                    // set further specific properties
    
                    return imImageContent;
                }
            } catch (Exception e) {
                throw new RuntimeException(e); // handle failed to deserialize
            }
    
            return null; // also handle case if message type unknown
        }
    
        public static void main(String[] args) throws JsonProcessingException {
            String json = """
                    {
                    "id": "44",
                    "type":"TEXT",
                    "imContent": {
                       "text": "bla"
                      }
                    }
                    """;
    
            InstantMessage instantMessage = new ObjectMapper().readValue(json, InstantMessage.class);
    
            System.out.println(instantMessage); // InstantMessage(id=44, type=TEXT, imContent=IMTextContent(text=bla))
            System.out.println(instantMessage.getImContent().getClass().getSimpleName()); // IMTextContent
        }
    }