Andrey
Andrey

Reputation: 9072

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?

Upvotes: 5

Views: 21430

Answers (5)

firegate666
firegate666

Reputation: 108

SWince I just stumbled across the same issue, I want to add to the solution aboce to provide the fully qualified name, that you don't need to type it everytime, but you can do it like this:

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

@JsonTypeInfo(use = Id.CLASS, include = As.WRAPPER_OBJECT, property = "@class")
public interface JsonStorable {}

Then you can setup your mapper

ObjectMapper objectMapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);

and that was all for me to do. It even handles polymorphic classes. Instead of the WRAPPER_OBJECT you can also use the property mentioned above. The result is like this

{
  "de.firegate.jsonDb.User" : {
    "username" : "user",
    "password" : "pass",
    "createdAt" : "1970-01-01T12:00:00",
    "usergroups" : [ "Editor", "Author" ],
    "pets" : [ {
      "de.firegate.jsonDb.Cat" : {
        "name" : "Maunz",
        "favoriteToy" : "Bell"
      }
    }, {
      "de.firegate.jsonDb.Dog" : {
        "name" : "Wuff",
        "breed" : "Sheppard",
        "medals" : 7
      }
    }, {
      "de.firegate.jsonDb.special.Bird" : {
        "name" : "Butschi"
      }
    } ]
  }
}

Upvotes: 0

storm
storm

Reputation: 21

One more way to achieve dynamic type resolution is using annotation @JsonTypeIdResolver and custom implementation of interface TypeIdResolver (there is base abstract implementation com.fasterxml.jackson.databind.jsontype.impl.TypeIdResolverBase).

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "jsonPropertyWithSomeIdToReferClass")
@JsonTypeIdResolver(YourTypeIdResolver.class)
class YourBaseClass{}

Examples:

  • Tutorial
  • implementation in Jackson library: com.fasterxml.jackson.databind.jsontype.impl.TypeNameIdResolver

Also reflection library can help to find classes in class path:

Reflections reflections = new Reflections("my.project");
Set<Class<? extends SomeType>> subTypes = reflections.getSubTypesOf(SomeType.class);
Set<Class<?>> annotated = reflections.getTypesAnnotatedWith(SomeAnnotation.class);

To avoid adding vulnerabilities to your code related to polymorphic Jackson features can help articles like this.

Upvotes: 1

Andrey
Andrey

Reputation: 9072

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.JsonMappingException;
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 JacksonDeserializerOfNamedTypes extends StdDeserializer<Object> {
    private final Map<String, Class<?>> typesByName;
    private final String typeProperty;

    private JacksonDeserializerOfNamedTypes(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 JsonNode typeNameNodeOrNull = root.get(typeProperty);
        if (typeNameNodeOrNull == null) {
            throw new JsonMappingException(parser, "Unable to determine Java type of JSON: " + root);
        } else {
            final String typeName = typeNameNodeOrNull.asText();
            return Optional
                .ofNullable(typesByName.get(typeName))
                .map(type -> parseOrNull(root, type, codec))
                .orElseThrow(() ->
                    new JsonMappingException(parser, String.format(
                        "Unsupported type name '%s' in JSON: %s", typeName, root)));
        }
    }

    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 JacksonDeserializerOfNamedTypes(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);
    }
}

Upvotes: 3

Sotirios Delimanolis
Sotirios Delimanolis

Reputation: 279880

The produced JSON certainly has all the information Jackson needs to determine the type correctly

You've provided Jackson with the following

Object deserialized = jackson.readValue(json, Object.class);

Object is the supertype of all Java reference types. This is known. Not very useful to Jackson. Your JSON also contains

{"@type":"Balloon","color":"red"}

Given that and thanks to

jackson.enableDefaultTypingAsProperty(DefaultTyping.JAVA_LANG_OBJECT, "@type");

Jackson can deduce that it can use the value for the @type element. However, what can it do with the name Balloon? Jackson doesn't know about all types on the classpath. Do you have a type with the fully qualified name Balloon? Do you have a type named com.example.Balloon or one named org.company.toys.Balloon? How should Jackson choose?

The @JsonTypeInfo and family of annotations are typically used for deserialization of inheritance hierarchies. For example

@JsonTypeInfo(use = Id.NAME)
@JsonSubTypes(value = @Type(value = Balloon.class))
public abstract static class Toy {

}

@JsonTypeName("Balloon")
public static final class Balloon extends Toy {

and

Object deserialized = jackson.readValue(json, Toy.class);

Now Jackson can look up the Toy class and its metadata which identifies its subclass(es) as Balloon which it can also check for the name Balloon.

If you're not trying to model an inheritance hierarchy, the simplest solution, here, would be to use the fully qualified name of your Balloon class in the @JsonTypeName annotation.

@JsonTypeName("com.example.JacksonDeserializationOfNamedTypes$Balloon")
@JsonTypeInfo(use = Id.NAME)
public static final class Balloon {

That name will then appear in the JSON and Jackson will use it to determine the target class.

Upvotes: 4

Matyas
Matyas

Reputation: 13702

In short (and too late :) )

You should either:

  • provide the fully qualified className so Jackson can find the class to deserialize to, like:

    @JsonTypeName("com.example.JacksonDeserializationOfNamedTypes$Balloon")
    
  • or add a custom deserializer to handle your Balloon Type

Upvotes: -1

Related Questions