Andrey Andrey - 4 months ago 166
Java Question

How to configure Jackson to deserialize named types with default typing?

Consider the following example:

package com.example;

import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;
import com.fasterxml.jackson.annotation.JsonTypeName;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectMapper.DefaultTyping;

public class JacksonDeserializationOfNamedTypes {
public static void main(String[] args) throws Exception {
ObjectMapper jackson = new ObjectMapper();
jackson.enableDefaultTypingAsProperty(DefaultTyping.JAVA_LANG_OBJECT, "@type");

Balloon redBalloon = new Balloon("red");

String json = jackson.writeValueAsString(redBalloon); //{"@type":"Balloon","color":"red"}
//assume the JSON could be anything
Object deserialized = jackson.readValue(json, Object.class);

assert deserialized instanceof Balloon;
assert redBalloon.equals(deserialized);
}

@JsonTypeName("Balloon")
@JsonTypeInfo(use = Id.NAME)
public static final class Balloon {
private final String color;

//for deserialization
private Balloon() {
this.color = null;
}

public Balloon(final String color) {
this.color = color;
}

public String getColor() {
return color;
}

@Override
public boolean equals(final Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
final Balloon other = (Balloon) obj;
return this.color.equals(other.color);
}

@Override
public int hashCode() {
int result = color.hashCode();
result = 31 * result + color.hashCode();
return result;
}

@Override
public String toString() {
return color + " balloon";
}
}
}


The deserialization fails at runtime with the following exception:
Exception in thread "main" java.lang.IllegalArgumentException: Invalid type id 'Balloon' (for id type 'Id.class'): no such class found


The produced JSON certainly has all the information Jackson needs to determine the type correctly, so how can I configure the ObjectMapper to properly map
"Balloon"
to
com.example.JacksonDeserializationOfNamedTypes$Balloon
?

Answer

My current solution involves a combination of a custom deserializer and a manually formed map of type names to Java types:

package com.example.jackson;

import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonTypeName;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.ObjectCodec;
import com.fasterxml.jackson.databind.DeserializationContext;
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 java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

public class JacksonNamedTypesDeserializer extends StdDeserializer<Object> {
    private final Map<String, Class<?>> typesByName;
    private final String typeProperty;

    private JacksonNamedTypesDeserializer(final Map<String, Class<?>> typesByName, final String typeProperty) {
        super(Object.class);

        this.typesByName = typesByName;
        this.typeProperty = typeProperty;
    }

    @Override
    public Object deserialize(final JsonParser parser, final DeserializationContext context) throws IOException, JsonProcessingException {
        final ObjectCodec codec = parser.getCodec();
        final JsonNode root = parser.readValueAsTree();
        final String typeName = root.get(typeProperty).asText();

        return Optional
            .ofNullable(typesByName.get(typeName))
            .map(type -> parseOrNull(root, type, codec))
            .orElse(null);
    }

    private <T> T parseOrNull(final JsonNode root, final Class<T> type, final ObjectCodec codec) {
        try {
            return root.traverse(codec).readValueAs(type);
        } catch (IOException e) {
            return null;
        }
    }

    public static void main(String[] args) throws Exception {
        final Map<String, Class<?>> typesByName = scanForNamedTypes();

        final SimpleModule namedTypesModule = new SimpleModule("my-named-types-module");
        namedTypesModule.addDeserializer(Object.class, new JacksonNamedTypesDeserializer(typesByName, JsonTypeInfo.Id.NAME.getDefaultPropertyName()));

        final Car pinto = new Car("Ford", "Pinto", 1971);
        final Balloon sharik = new Balloon("blue");
        final ObjectMapper mapper = new ObjectMapper().registerModule(namedTypesModule);
        System.out.println(mapper.readValue(mapper.writeValueAsString(pinto), Object.class).getClass());
        System.out.println(mapper.readValue(mapper.writeValueAsString(sharik), Object.class).getClass());
    }

    @JsonTypeName("Balloon")
    @JsonTypeInfo(use = JsonTypeInfo.Id.NAME)
    public static final class Balloon {
        public String color;

        private Balloon() {}

        public Balloon(final String color) {
            this.color = color;
        }
    }

    @JsonTypeName("Car")
    @JsonTypeInfo(use = JsonTypeInfo.Id.NAME)
    public static final class Car {
        public String make;
        public String model;
        public int year;

        private Car() {}

        public Car(final String make, final String model, final int year) {
            this.make = make;
            this.model = model;
            this.year = year;
        }
    }

    static Map<String, Class<?>> scanForNamedTypes() {
        //in reality, i'd be using a framework (e.g. Reflections) to scan the classpath
        //for classes tagged with @JsonTypeName to avoid maintaining manual mappings
        final Map<String, Class<?>> typesByName = new HashMap<>();
        typesByName.put("Balloon", Balloon.class);
        typesByName.put("Car", Car.class);
        return Collections.unmodifiableMap(typesByName);
    }
}
Comments