Skip to content

Swap Jackson custom serializer / deserializer during runtime

I have the following system:

enter image description here

  • I am sending MediaType.APPLICATION_JSON_VALUEs from spring controllers to my client and vice versa.
  • I also have an export/import feature of my to-be-serialized classes. The JSON File is created by using an ObjectMapper and utilizing the writeValueAsString and readValue methods. I am reading from and writing into the json file.
  • Both of those serialization paths currently utilize the same serializers/deserializers.

I use the @JsonSerialize and @JsonDeserialize annotations to define custom serialization for some of my objects. I want to serialize those objects differently for export/import.

So I want to swap the serializer / deserializer for the export/import task. Something like this:

enter image description here

If I understand the docs correctly, those two annotations only allow one using class. But I want to register multiple serializers/deserializers and use them based on some conditional logic.

Answer

This is my solution

It’s not pretty but does its job.

I left my old jackson config untouched, so the client<->server serialization stays the same. I then added this custom ObjectMapper to take care of my server<->file.

My custom ObjectMapper does the following things:

  1. It registers a new custom JacksonAnnotationIntrospector, which I configured to ignore certain annotations. I also configured it to use my selfmade annotation @TransferJsonTypeInfo whenever a property has both the @TransferJsonTypeInfo as well as the @JsonTypeInfo annotation.
  2. I registered my CustomerFileSerializer and CustomerFileDeserializer for this ObjectMapper.
@Service
public class ImportExportMapper {

    protected final ObjectMapper customObjectMapper;

    private static final JacksonAnnotationIntrospector IGNORE_JSON_ANNOTATIONS_AND_USE_TRANSFERJSONTYPEINFO = BuildImportExportJacksonAnnotationIntrospector();

    public ImportExportMapper(){
        customObjectMapper = new ObjectMapper().registerModule(new JavaTimeModule())
                .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
                .configure(SerializationFeature.WRITE_DATE_KEYS_AS_TIMESTAMPS, false);

        // emulate the default settings as described here: https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#howto-customize-the-jackson-objectmapper
        customObjectMapper.disable(MapperFeature.DEFAULT_VIEW_INCLUSION);
        customObjectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);

        SimpleModule module = new SimpleModule();
        module.addSerializer(Customer.class, new CustomerFileSerializer());
        module.addDeserializer(Customer.class, new CustomerFileDeserializer());

        customObjectMapper.setAnnotationIntrospector(IGNORE_JSON_ANNOTATIONS_AND_USE_TRANSFERJSONTYPEINFO);

        customObjectMapper.registerModule(module);
    }

    public String writeValueAsString(Object data) {
        try {
            return customObjectMapper.writeValueAsString(data);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
            throw new IllegalArgumentException();
        }
    }

    public ObjectTransferData readValue(String fileContent, Class clazz) throws JsonProcessingException {
        return customObjectMapper.readValue(fileContent, clazz);
    }

    private static JacksonAnnotationIntrospector BuildImportExportJacksonAnnotationIntrospector() {
        return new JacksonAnnotationIntrospector() {

            @Override
            protected <A extends Annotation> A _findAnnotation(final Annotated annotated, final Class<A> annoClass) {
                if (annoClass == JsonTypeInfo.class && _hasAnnotation(annotated, FileJsonTypeInfo.class)) {
                    FileJsonTypeInfo fileJsonTypeInfo = _findAnnotation(annotated, TransferJsonTypeInfo.class);
                    if(fileJsonTypeInfo != null && fileJsonTypeInfo.jsonTypeInfo() != null) {
                        return (A) fileJsonTypeInfo.jsonTypeInfo(); // this cast should be safe because we have checked the annotation class
                    }
                }
                if (ignoreJsonAnnotations(annoClass)) return null;
                return super._findAnnotation(annotated, annoClass);
            }
        };
    }

    private static <A extends Annotation> boolean ignoreJsonAnnotations(Class<A> annoClass) {
        if (annoClass == JsonSerialize.class) {
            return true;
        }
        if(annoClass == JsonDeserialize.class){
            return true;
        }
        if(annoClass == JsonIdentityReference.class){
            return true;
        }
        return annoClass == JsonIdentityInfo.class;
    }
}

My custom annotation is defined and described like this:

/**
 * This annotation inside of a annotation solution is a way to tell the importExportMapper how to serialize/deserialize
 * objects that already have a wrongly defined @JsonTypeInfo annotation (wrongly defined for the importExportMapper).
 *
 * Idea is taken from here: https://stackoverflow.com/questions/58495480/how-to-properly-override-jacksonannotationintrospector-findannotation-to-replac
 */
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface FileJsonTypeInfo {
    JsonTypeInfo jsonTypeInfo();
}

And it is used like this:

    @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
    @JsonTypeInfo(defaultImpl = Customer.class, property = "", use = JsonTypeInfo.Id.NONE)
    @TransferJsonTypeInfo(jsonTypeInfo = @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "customeridentifier"))
    @JsonIdentityReference(alwaysAsId = true)
    @JsonDeserialize(using = CustomerClientDeserializer.class)
    @JsonSerialize(using = CustomerClientSerializer.class)
    private Customer customer;