Custom Serialization and Deserialization by Annotation

Last modified: 27/07/2020

The typical use case where you want to (de-)serialize some field in a request/response is when you map to a graphical user id.

Things are rather easy if you just want to map from A to B but become more complicated if you need to pass an argument, e.g. for a SALT:

@Guid(FOO_SALT)
Long id;

Fortunately, there is a way of writing a clean meta-annotation for this:

@Target({FIELD, PARAMETER})
@Retention(RUNTIME)
@JacksonAnnotationsInside
@JsonSerialize(using = ResponseSerializer.class)
@JsonDeserialize(using = RequestDeserializer.class)
public @interface Guid {

    String value();
}

In my case I use the hashids library with a certain SALT for de-/encoding GUIDs:

public class HashId {

    public static String encode(Long id, String salt) {
        // ...
    }

    public static Long decode(String guid, String salt) {
        // ...
    }
}

With the @Guid annotation I can trigger the deserialization on fields processed by Jackson like this:

public class RequestDeserializer extends StdDeserializer<Long> implements ContextualDeserializer {

    private String value;

    public RequestDeserializer() {
        super(Long.class);
    }

    public RequestDeserializer(String value) {
        super(Long.class);
        this.value = value;
    }

    @Override
    public Long deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        return HashId.decode(p.getText(), this.value);
    }

    @Override
    public JsonDeserializer<?> createContextual(DeserializationContext ctxt, BeanProperty property) {
        String value = null;
        Salt annotation = null;

        if (property != null) {
            annotation = property.getAnnotation(Guid.class);
        }
        if (annotation != null) {
            value = annotation.value();
        }
        return new RequestDeserializer(value);
    }
}

And the serialization on fields processed by Jackson like this:

public class ResponseSerializer extends StdSerializer<Long> implements ContextualSerializer {

    private String value;

    public ResponseSerializer() {
        super(Long.class);
    }

    public ResponseSerializer(String value) {
        super(Long.class);
        this.value = value;
    }

    @Override
    public void serialize(Long value, JsonGenerator generator, SerializerProvider provider) throws IOException {
        generator.writeString(HashId.encode(value, this.value));
    }

    @Override
    public JsonSerializer<?> createContextual(SerializerProvider provider, BeanProperty property) {
        String value = null;
        Salt annotation = null;

        if (property != null) {
            annotation = property.getAnnotation(Guid.class);
        }
        if (annotation != null) {
            value = annotation.value();
        }
        return new ResponseSerializer(value);
    }
}

Plus I can use it on path variables to automatically decode them with Spring's argument resolver:

@EnableWebMvc
@Configuration
public class WebMvc implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new DecodeArgument());
    }
}
public class DecodeArgument extends PathVariableMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(Guid.class);
    }

    @Override
    protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) {
        return new NamedValueInfo("", false, null);
    }

    @Override
    protected Object resolveName(String name, MethodParameter parameter, NativeWebRequest request) throws Exception {
        var value = super.resolveName(name, parameter, request);
        var annotation = parameter.getParameterAnnotation(Guid.class);

        if (value != null && annotation != null) {
            return HashId.decode((String) value, annotation.value());
        }
        throw new HashIdException("Annotation doesn't specify SALT");
    }
}

Which makes the following code work automatically:

@GetMapping("{fooId}")
public ResponseEntity<FooResponse> getFoo(@Guid(FOO_SALT) Long fooId) {
    // ...
}