2

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?

user690954
  • 313
  • 2
  • 9

2 Answers2

1

You can always use a custom Jackson Serializer for this. Plenty of docs to do this can be found on the internet, might look something like this:

public void serialize(PetDTO value, JsonGenerator jgen, ...) {

    jgen.writeStartObject();
    jgen.writeNumberField("name", value.name);
    jgen.writeObjectField("name_consteaint", getConstraintValue(value));
}

public ConstaintDTO getConstraintValue(PetDTO value) {

    // Use reflection to check if the name field on the PetDTO is annotated
    // and extract the min, max and type values from the annotation
    return new ConstaintDTO().withMaxValue(...).withMinValue(...).ofType(...);
}

You may want to create a base-DTO class for which the converter kicks in so you don't have to create a custom converter for all your domain objects that need to expose the constraints.

By combining reflection and smart use of writing fields, you can get close. Downside is you can't take advantage of the @JsonXXX annotations on your domain objects, since you're writing the JSON yourself.

More ideal solution whould be to have Jackson convert, but have some kind of post-conversion-call to add additional XX_condtion properties to the object. Maybe start by overriding the default object-serializer (if possible)?

1

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.

Yevhenii Melnyk
  • 553
  • 4
  • 16