How can a Jackson converter be written to handle generic collection types?

Viewed 2168

I've created a Jackson Converter which orders Set elements in ascending sorted order, so that the resulting JSON array is output in sorted order, rather than whatever arbitrary iteration order happens to be used by the given Set implementation. It extended the standard StdConverter class in Jackson, as recommended by the Converter documentation.

NOTE: implementors are strongly encouraged to extend StdConverter instead of directly implementing Converter, since that can help with default implementation of typically boiler-plate code.

public class OrderedSetConverter
        extends StdConverter<Set<DayOfWeek>, Set<DayOfWeek>> {
    @Override
    public Set<DayOfWeek> convert(Set<DayOfWeek> value) {
        return value == null ? null : value.stream()
                .sorted(Comparator.nullsLast(Comparator.naturalOrder()))
                .collect(Collectors.toCollection(LinkedHashSet::new));
    }
}

public class MyType {
    @JsonSerialize(converter = OrderedSetConverter.class)
    private Set<DayOfWeek> myValues;
}

This works well when the Converter is for a specific type of elements to convert (DayOfWeek in this example). However, there is nothing in this implementation that is specific to a particular type; it would work equally well for any Comparable type. Therefore, I'd prefer to have a generic implementation of this converter that can be used for any Set of comparables.

I've attempted to implement this by using a straightforward usage of generics:

public class OrderedSetConverter<E extends Comparable<? super E>>
        extends StdConverter<Set<E>, Set<E>> {
    @Override
    public Set<E> convert(Set<E> value) {
        return value == null ? null : value.stream()
                .sorted(Comparator.nullsLast(Comparator.naturalOrder()))
                .collect(Collectors.toCollection(LinkedHashSet::new));
    }
}

This does not work as I would hope, since Jackson tries (and fails) to convert the set's element type to Comparable, rather than the actual type (e.g. DayOfWeek).

Exception in thread "main" com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `java.lang.Comparable` (no Creators, like default constructor, exist): abstract types either need to be mapped to concrete types, have custom deserializer, or contain additional type information
 at [Source: (String)"{"daysOfWeek":["MONDAY","TUESDAY","WEDNESDAY","THURSDAY","FRIDAY","SATURDAY","SUNDAY"]}"; line: 1, column: 16] (through reference chain: com.example.MyType["daysOfWeek"]->java.util.HashSet[0])
    at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:67)
    at com.fasterxml.jackson.databind.DeserializationContext.reportBadDefinition(DeserializationContext.java:1764)
    at com.fasterxml.jackson.databind.DatabindContext.reportBadDefinition(DatabindContext.java:400)
    at com.fasterxml.jackson.databind.DeserializationContext.handleMissingInstantiator(DeserializationContext.java:1209)
    at com.fasterxml.jackson.databind.deser.AbstractDeserializer.deserialize(AbstractDeserializer.java:274)
    at com.fasterxml.jackson.databind.deser.std.CollectionDeserializer._deserializeFromArray(CollectionDeserializer.java:347)
    at com.fasterxml.jackson.databind.deser.std.CollectionDeserializer.deserialize(CollectionDeserializer.java:244)
    at com.fasterxml.jackson.databind.deser.std.CollectionDeserializer.deserialize(CollectionDeserializer.java:28)
    at com.fasterxml.jackson.databind.deser.std.StdDelegatingDeserializer.deserialize(StdDelegatingDeserializer.java:175)
    at com.fasterxml.jackson.databind.deser.impl.MethodProperty.deserializeAndSet(MethodProperty.java:129)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.vanillaDeserialize(BeanDeserializer.java:324)
    at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:187)
    at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:322)
    at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4593)
    at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3548)
    at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3516)

About the best I've been able to come up with is to use this as an abstract superclass, and have a Converter of each specific subtype for each type that's actually used:

public abstract class OrderedSetConverter<E extends Comparable<? super E>>
        extends StdConverter<Set<E>, Set<E>> {
    @Override
    public Set<E> convert(Set<E> value) {
        return value == null ? null : value.stream()
                .sorted(Comparator.nullsLast(Comparator.naturalOrder()))
                .collect(Collectors.toCollection(LinkedHashSet::new));
    }
}

public class DayOfWeekSetConverter extends OrderedSetConverter<DayOfWeek> { }

public class MyType {
    @JsonSerialize(converter = DayOfWeekSetConverter.class)
    private Set<DayOfWeek> myValues;
}

How can I write a converter for a generic collection type and have Jackson figure out the element type?

2 Answers

OrderedSetConverter does not know which type must be instantiated during deserialisation process. We need to instruct deserialisation process which type to use. If you want to keep converter generic you need to provide this information when converter is created. To extend default behaviour you need to implement custom com.fasterxml.jackson.databind.cfg.HandlerInstantiator:

class ConverterHandlerInstantiator extends HandlerInstantiator {

    @Override
    public Converter<?, ?> converterInstance(MapperConfig<?> config, Annotated annotated, Class<?> implClass) {
        if (config instanceof DeserializationConfig) {
            JsonDeserialize jsonDeserialize = annotated.getAnnotation(JsonDeserialize.class);
            Class contentAs = jsonDeserialize.contentAs();
            if (contentAs != Void.class) {
                return new OrderedSetConverter<>(contentAs);
            }
        }
        return super.converterInstance(config, annotated, implClass);
    }

    @Override
    public JsonDeserializer<?> deserializerInstance(DeserializationConfig config, Annotated annotated, Class<?> deserClass) {
        return null;
    }

    @Override
    public KeyDeserializer keyDeserializerInstance(DeserializationConfig config, Annotated annotated, Class<?> keyDeserClass) {
        return null;
    }

    @Override
    public JsonSerializer<?> serializerInstance(SerializationConfig config, Annotated annotated, Class<?> serClass) {
        return null;
    }

    @Override
    public TypeResolverBuilder<?> typeResolverBuilderInstance(MapperConfig<?> config, Annotated annotated, Class<?> builderClass) {
        return null;
    }

    @Override
    public TypeIdResolver typeIdResolverInstance(MapperConfig<?> config, Annotated annotated, Class<?> resolverClass) {
        return null;
    }
}

As you noticed above code uses contentAs property from JsonDeserialize annotation. We need to specify it in your class:

class MyType {
    @JsonSerialize(converter = OrderedSetConverter.class)
    @JsonDeserialize(converter = OrderedSetConverter.class, contentAs = DayOfWeek.class)
    private Set<DayOfWeek> myValues;
}

This value is not needed when we serialise objects, so, we can skip it. Now, we need to register our custom instantiator:

ObjectMapper mapper = new ObjectMapper();
mapper.setHandlerInstantiator(new ConverterHandlerInstantiator());

At the end, our generic converter:

class OrderedSetConverter<E extends Comparable<? super E>> extends StdConverter<Set<E>, Set<E>> {
    private final Class<E> contentClass;

    OrderedSetConverter() {
        this(null);
    }

    public OrderedSetConverter(Class<E> contentClass) {
        this.contentClass = contentClass;
    }

    @Override
    public Set<E> convert(Set<E> value) {
        return value == null ? null : value.stream()
                .sorted(Comparator.nullsLast(Comparator.naturalOrder()))
                .collect(Collectors.toCollection(LinkedHashSet::new));
    }

    @Override
    public JavaType getInputType(TypeFactory typeFactory) {
        if (contentClass == null) {
            return super.getInputType(typeFactory);
        }
        return typeFactory.constructCollectionType(Set.class, contentClass);
    }
}

Another answer pointed out that a custom Jackson HandlerInstantiator could be used to set a type variable within the Converter at object construction time. That answer required manually specifying the collection type using @JsonDeserialize.contentAs. After experimenting with this approach, I found a way to automatically deduce the type variable within the HandlerInstantiator:

HandlerInstantiator

public class OrderedSetConverterHandlerInstantiator extends HandlerInstantiator {
    @Override
    public Converter<?, ?> converterInstance(
            MapperConfig<?> config, Annotated annotated, Class<?> implClass) {
        if (OrderedSetJacksonConverter.class.equals(implClass)) {
            return new OrderedSetJacksonConverter<>(
                    getSetContentType(config, annotated));
        } else {
            return null;
        }
    }

    private JavaType getSetContentType(MapperConfig<?> config, Annotated annotated) {
        Type setType = getPropertyType(config, annotated);
        return TypeFactory.defaultInstance().constructType(setType).getContentType();
    }

    private Type getPropertyType(MapperConfig<?> config, Annotated annotated) {
        // TODO: Enhance to handle other cases, such as annotated constructor parameter
        BeanPropertyDefinition beanPropertyDefinition =
                SimpleBeanPropertyDefinition.construct(config, 
                        (AnnotatedMember) annotated);
        AnnotatedMember setter = beanPropertyDefinition.getSetter();
        AnnotatedMember getter = beanPropertyDefinition.getGetter();
        AnnotatedMember field = beanPropertyDefinition.getField();

        Type setType;
        if (setter != null) {
            setType = ((Method) setter.getMember()).getGenericParameterTypes()[0];
        } else if (getter != null) {
            setType = ((Method) getter.getMember()).getGenericReturnType();
        } else if (field != null) {
            setType = ((Field) field.getMember()).getGenericType();
        } else {
            throw new UnsupportedOperationException(
                    "Annotated type not yet supported: " + annotated.getClass());
        }
        return setType;
    }
    
    // Implement other required methods such as serializerInstance to return null
}

Converter

public class OrderedSetConverter<T extends Comparable<? super T>> 
        extends StdConverter<Set<T>, Set<T>> {
    private final JavaType contentType;

    public OrderedSetJacksonConverter(JavaType contentType) {
        this.contentType = contentType;
    }

    @Override
    public Set<T> convert(Set<T> value) {
        return value == null ? null : value.stream()
                .sorted(Comparator.nullsLast(Comparator.naturalOrder()))
                .collect(Collectors.toCollection(LinkedHashSet::new));
    }

    @Override
    public JavaType getInputType(TypeFactory typeFactory) {
        return typeFactory.constructCollectionType(Set.class, contentType);
    }

    @Override
    public JavaType getOutputType(TypeFactory typeFactory) {
        return typeFactory.constructCollectionType(Set.class, contentType);
    }
}

ObjectMapper configuration

JsonMapper jsonMapper = JsonMapper.builder()
            .handlerInstantiator(new OrderedSetConverterHandlerInstantiator())

One clunky thing about the solution is the way the property type is being determined, as it's manually iterating over all the possible annotated element types the Annotated value might be (getter/setter/field) to determine the element type. Hopefully there's a cleaner, more idiomatic approach, though if there is I haven't found it yet.


Registering the converter in the Spring framework

Spring Web ships with its own HandlerInstantiator, SpringHandlerInstantiator, which is automatically applied to the ObjectMapper created by Jackson2ObjectMapperBuilder. This handler uses any relevant beans in the classpath for the types supplied by the HandlerInstantiator.

In order to retain this behavior while still using the custom type, a HandlerInstantiator class could be written which uses the custom converter when appropriate, otherwise fall back to the default SpringHandlerInstantiator. One way to implement this is as follows:

public class DelegatingHandlerInstantiator extends SpringHandlerInstantiator {
    private final OrderedSetConverterHandlerInstantiator handler =
            new OrderedSetConverterHandlerInstantiator();

    public DelegatingHandlerInstantiator(AutowireCapableBeanFactory beanFactory) {
        super(beanFactory);
    }

    @Override
    public Converter<?, ?> converterInstance(
            MapperConfig<?> config, Annotated annotated, Class<?> implClass) {
        Converter<?, ?> converter =
                handler.converterInstance(config, annotated, implClass);
        return Objects.requireNonNullElseGet(converter,
                () -> super.converterInstance(config, annotated, implClass));
    }
}

The Jackson2ObjectMapperBuilder used by the application should then be configured to use this handler. If the application is a Spring Boot application, a Jackson2ObjectMapperBuilderCustomizer bean can be created to set the HandlerInstantiator in the Jackson2ObjectMapperBuilder bean.

@Configuration
public class JacksonConfig {
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer(
            AutowireCapableBeanFactory beanFactory) {
        return b -> b.handlerInstantiator(
                new DelegatingHandlerInstantiator(beanFactory));
    }
}
Related