springjacksondeclarative-programming

serializing annotations as well as fields to JSON


I have a spring boot app, and I want to send DTO validation constraints as well as field value to the client.

Having DTO

class PetDTO {
  @Length(min=5, max=15)
  String name;
}

where name happens to be 'Leviathan', should result in this JSON being sent to client:

{
    name: 'Leviathan'
    name_constraint: { type: 'length', min:5, max: 15},
}

Reasoning is to have single source of truth for validations. Can this be done with reasonable amount of work?


Solution

  • To extend Frederik's answer I'll show a little sample code that convers an object to map and serializes it.

    So here is the User pojo:

    import org.hibernate.validator.constraints.Length;
    
    public class User {
    
        private String name;
    
        public User(String name) {
            this.name = name;
        }
    
        @Length(min = 5, max = 15)
        public String getName() {
            return name;
        }
    }
    

    Then the actual serializer:

    import com.fasterxml.jackson.core.JsonGenerator;
    import com.fasterxml.jackson.databind.SerializerProvider;
    import com.fasterxml.jackson.databind.ser.std.StdSerializer;
    import org.springframework.util.ReflectionUtils;
    
    import java.beans.IntrospectionException;
    import java.beans.Introspector;
    import java.io.IOException;
    import java.lang.annotation.Annotation;
    import java.lang.reflect.Method;
    import java.lang.reflect.Proxy;
    import java.util.*;
    import java.util.stream.Stream;
    
    import static java.util.stream.Collectors.toMap;
    
    public class UserSerializer extends StdSerializer<User> {
    
        public UserSerializer(){
            this(User.class);
        }
    
        private UserSerializer(Class t) {
            super(t);
        }
    
        @Override
        public void serialize(User bean, JsonGenerator gen, SerializerProvider provider) throws IOException {
            Map<String, Object> properties = beanProperties(bean);
            gen.writeStartObject();
            for (Map.Entry<String, Object> entry : properties.entrySet()) {
                gen.writeObjectField(entry.getKey(), entry.getValue());
            }
            gen.writeEndObject();
        }
    
        private static Map<String, Object> beanProperties(Object bean) {
            try {
                return Arrays.stream(Introspector.getBeanInfo(bean.getClass(), Object.class).getPropertyDescriptors())
                        .filter(descriptor -> Objects.nonNull(descriptor.getReadMethod()))
                        .flatMap(descriptor -> {
                            String name = descriptor.getName();
                            Method getter = descriptor.getReadMethod();
                            Object value = ReflectionUtils.invokeMethod(getter, bean);
                            Property originalProperty = new Property(name, value);
    
                            Stream<Property> constraintProperties = Stream.of(getter.getAnnotations())
                                    .map(anno -> new Property(name + "_constraint", annotationProperties(anno)));
    
                            return Stream.concat(Stream.of(originalProperty), constraintProperties);
                        })
                        .collect(toMap(Property::getName, Property::getValue));
            } catch (Exception e) {
                return Collections.emptyMap();
            }
        }
    
        // Methods from Annotation.class
        private static List<String> EXCLUDED_ANNO_NAMES = Arrays.asList("toString", "equals", "hashCode", "annotationType");
    
        private static Map<String, Object> annotationProperties(Annotation anno) {
            try {
                Stream<Property> annoProps = Arrays.stream(Introspector.getBeanInfo(anno.getClass(), Proxy.class).getMethodDescriptors())
                        .filter(descriptor -> !EXCLUDED_ANNO_NAMES.contains(descriptor.getName()))
                        .map(descriptor -> {
                            String name = descriptor.getName();
                            Method method = descriptor.getMethod();
                            Object value = ReflectionUtils.invokeMethod(method, anno);
                            return new Property(name, value);
                        });
                Stream<Property> type = Stream.of(new Property("type", anno.annotationType().getName()));
                return Stream.concat(type, annoProps).collect(toMap(Property::getName, Property::getValue));
            } catch (IntrospectionException e) {
                return Collections.emptyMap();
            }
        }
    
        private static class Property {
            private String name;
            private Object value;
    
            public Property(String name, Object value) {
                this.name = name;
                this.value = value;
            }
    
            public String getName() {
                return name;
            }
    
            public Object getValue() {
                return value;
            }
        }
    }
    

    And finally we need to register this serializer to be used by Jackson:

    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.context.annotation.Bean;
    import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @SpringBootApplication(scanBasePackages = "sample.spring.serialization")
    public class SerializationApp {
    
        @Bean
        public Jackson2ObjectMapperBuilder mapperBuilder(){
            Jackson2ObjectMapperBuilder jackson2ObjectMapperBuilder = new Jackson2ObjectMapperBuilder();
            jackson2ObjectMapperBuilder.serializers(new UserSerializer());
            return jackson2ObjectMapperBuilder;
        }
    
        public static void main(String[] args) {
            SpringApplication.run(SerializationApp.class, args);
        }
    }
    
    @RestController
    class SerializationController {
        @GetMapping("/user")
        public User user() {
            return new User("sample");
        }
    }
    

    The Json that will be emitted:

    {  
       "name_constraint":{  
          "min":5,
          "max":15,
          "payload":[],
          "groups":[],
          "message":"{org.hibernate.validator.constraints.Length.message}",
          "type":"org.hibernate.validator.constraints.Length"
       },
       "name":"sample"
    }
    

    Hope this helps. Good luck.