3

I'm using Jackson with Kotlin to serialize and deserialize a variety of Kotlin data classes.

My data classes are quite simple objects which can easily be serialized and deserialized using the standard Jackson ObjectMapper, except that I want to ensure that some post-validation is done as part of the deserialization. For example, I want to ensure that Thing.someField >= 0:

data class InnerThing(
    val foo: String
);

data class Thing(
    val someField: Int,   // must not be negative
    val innerThing: InnerThing
);

The simplest way to implement this would seem to be to override the StdDeserializer for the class(es) in question.

class ThingDeserializer : StdDeserializer<Thing>(Thing::class.java) {
    override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Thing {
        // Defer to the superclass to do the actual deserialization
        // DOES NOT WORK because StdDeserializer.deserialize() is abstract
        val t: Thing = super.deserialize(p, ctxt);

        if (thing.someField < 0) {
            throw RuntimeException("someField value must be >= 0");
        }

        return t;
    }
}

… but that does not work because StdDeserializer.deserialize() is abstract. Which leads me to this related question. It appears that it's amazingly difficult to defer to the default deserialization behavior from within a custom deserializer.

From what I can tell, the most straightforward way to defer to the default deserialization behavior is to create an entirely separate ObjectMapper (!!!), use that to read the class, and then do the post-validation.

Leading me to this…

class ThingDeserializer : StdDeserializer<Thing>(Thing::class.java) {

    // Create a whole separate ObjectMapper that doesn't override
    // the deserializer for Thing:
    private val defaultMapper = jacksonObjectMapper();

    init {
        // Need to reregister modules for things like ISO8601
        // timestamp parsing:
        defaultMapper.registerModule(JavaTimeModule());
    }    

    override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Thing {
        // Use the non-overridden ObjectMapper to deserialize the object:
        val t: Thing = defaultMapper.readValue(p, Thing::class.java);

        // ... and then do validation afterwards
        if (thing.someField < 0) {
            throw RuntimeException("someField value must be >= 0");
        }

        return t;
    }
}

fun main(args: Array<String>) {
    val mapper = jacksonObjectMapper();
    mapper.registerModule(JavaTimeModule());
    mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);

    mapper.registerModule(
        SimpleModule().apply {
          addDeserializer(Thing::class.java, ThingDeserializer()) 
        }
    );

    val test: Thing = mapper.readValue("""{
      "someField": -1,
      "innerThing": { "foo": "bar" }
    }""");
}

This does work; it throws a RuntimeException for the unwanted value of someField in the test case.

This seems wrong

Is the above really a reasonable way to do this validation?

It has a very bad code smell. I'm creating a whole new ObjectMapper, and I have to re-register modules for things like timestamp deserialization.

Question

It feels like there must be a saner, less redundant way to deserialize a Kotlin data class (or Java POJO) and then do post-validation of the contents of the object, without rewriting the deserialization machinery entirely.

Is there? I can't figure out a simple way to defer to the default deserialization behavior of ObjectMapper from within the deserializer.

Dan Lenski
  • 76,929
  • 13
  • 76
  • 124
  • 2
    Does it make sense to have instance of `Thing` with negative `someField` if it was created not via deserialization? Maybe you just need to change this field type to [UInt](https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/-u-int/) or move this check to `init` block? – Михаил Нафталь Nov 10 '20 at 14:10
  • Thanks @МихаилНафталь. In this case, because of a fairly complex versioning of the whole data structure, included nested elements therein, it would be easier to validate the whole structure in one piece, rather than as the individual `data class`es get instantiated. But yeah… some of the validation can be done in the `init` blocks of those classes. – Dan Lenski Nov 10 '20 at 17:34
  • The simple machinery by @oddbjorn in the answer below allows for quite sane validation (minimal code). Once the required setup is in place, all you need is a `validate()` method in your classes.. – oligofren Sep 15 '21 at 09:16

2 Answers2

3

For automatic validation during deserialization, using a @JsonCreator constructor is probably the most correct way of doing this, but if your data classes have many fields the constructor signature will end up being rather long.

With a bit of leg work you can do this in a fairly clean fashion and still keep your data classes compact and neat. The trick is to hook into Jacksons deserialization process by means of the combination of the BeanDeserializerModifier, StdDelegatingDeserializer and Converter classes. Hooking these up correctly, you can have the BeanDeserializerModifier delegate to a StdDelegatingDeserializer that calls a Converter that validates the deserialization result object before returning it unchanged.

Begin by defining your Validatable interface:

public interface Validatable {
    void validate();
}

And then define the Converter class that simply validates:

import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.type.TypeFactory;
import com.fasterxml.jackson.databind.util.Converter;

import java.util.Objects;

public class ValidatingConverter implements Converter<Object, Object> {

    private final JavaType type;

    public ValidatingConverter(JavaType type) {
        Objects.requireNonNull(this.type = type);
    }

    @Override
    public Object convert(Object value) {
        if (value instanceof Validatable validatable) {
            validatable.validate();
        }
        return value;
    }

    @Override
    public JavaType getInputType(TypeFactory typeFactory) {
        return type;
    }

    @Override
    public JavaType getOutputType(TypeFactory typeFactory) {
        return type;
    }
}

Then, the ValidatingConverter can be used by a StdDelegatingDeserializer:

import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier;
import com.fasterxml.jackson.databind.deser.std.StdDelegatingDeserializer;
import com.fasterxml.jackson.databind.type.*;

import java.io.IOException;

public class ValidatingBeanDeserializerModifier extends BeanDeserializerModifier {

    @Override
    public JsonDeserializer<?> modifyDeserializer(DeserializationConfig config, BeanDescription beanDesc, JsonDeserializer<?> deserializer) {
        return createDelegate(beanDesc.getType(), deserializer);
    }

    @Override
    public JsonDeserializer<?> modifyEnumDeserializer(DeserializationConfig config, JavaType type, BeanDescription beanDesc, JsonDeserializer<?> deserializer) {
        return createDelegate(type, deserializer);
    }

    @Override
    public JsonDeserializer<?> modifyReferenceDeserializer(DeserializationConfig config, ReferenceType type, BeanDescription beanDesc, JsonDeserializer<?> deserializer) {
        return createDelegate(type, deserializer);
    }

    @Override
    public JsonDeserializer<?> modifyArrayDeserializer(DeserializationConfig config, ArrayType type, BeanDescription beanDesc, JsonDeserializer<?> deserializer) {
        return createDelegate(type, deserializer);
    }

    @Override
    public JsonDeserializer<?> modifyCollectionDeserializer(DeserializationConfig config, CollectionType type, BeanDescription beanDesc, JsonDeserializer<?> deserializer) {
        return createDelegate(type, deserializer);
    }

    @Override
    public JsonDeserializer<?> modifyCollectionLikeDeserializer(DeserializationConfig config, CollectionLikeType type, BeanDescription beanDesc, JsonDeserializer<?> deserializer) {
        return createDelegate(type, deserializer);
    }

    @Override
    public JsonDeserializer<?> modifyMapDeserializer(DeserializationConfig config, MapType type, BeanDescription beanDesc, JsonDeserializer<?> deserializer) {
        return createDelegate(type, deserializer);
    }

    @Override
    public JsonDeserializer<?> modifyMapLikeDeserializer(DeserializationConfig config, MapLikeType type, BeanDescription beanDesc, JsonDeserializer<?> deserializer) {
        return createDelegate(type, deserializer);
    }

    @Override
    public KeyDeserializer modifyKeyDeserializer(DeserializationConfig config, JavaType type, KeyDeserializer deserializer) {
        return new KeyDeserializer() {
            @Override
            public Object deserializeKey(String key, DeserializationContext ctxt) throws IOException {
                final var deserializedKey = deserializer.deserializeKey(key, ctxt);
                if (deserializedKey instanceof Validatable validatable) {
                    validatable.validate();
                }
                return deserializedKey;
            }
        };
    }

    private JsonDeserializer<?> createDelegate(JavaType type, JsonDeserializer<?> target) {
        return new StdDelegatingDeserializer<>(new ValidatingConverter(type), type, target);
    }
}

Then, ValidatingBeanDeserializerModifier can be used from a Jackson module:

import com.fasterxml.jackson.databind.module.SimpleModule;

public class ValidationModule extends SimpleModule {
    public ValidationModule() {
        this.setDeserializerModifier(new ValidatingBeanDeserializerModifier());
    }
}

Finally, we can verify that it all works by first creating a Validatable class (or even a record):

import java.util.List;
import java.util.Map;
import java.util.Objects;

public class Person implements Validatable {

    public String name;
    public int age;
    public Person spouse;
    public Map<String, Person> parents;
    public List<Person> children;

    @Override
    public void validate() {
        Objects.requireNonNull(name, "Person.name must be non-null");
        if (age < 0) {
            throw new IllegalArgumentException("Person.age must be >= 0");
        }
    }
}

And run the whole setup in a test class:

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.intellij.lang.annotations.Language;

public class Test {

    public static void main(String[] args) throws JsonProcessingException {
        final var objectMapper = new ObjectMapper().registerModule(new ValidationModule());
        @Language("JSON") final var json = """
                {
                  "name": "John",
                  "age": 50,
                  "spouse": {
                    "name": "Anne",
                    "age": 50
                  },
                  "parents": {
                    "father": {
                      "name": "Greg",
                      "age": 75
                    },
                    "mother": {
                      "name": "Hilda",
                      "age": 75
                    }
                  },
                  "children": [
                    {
                      "name": "Bob",
                      "age": 25
                    },
                    {
                      "name": "Wilma",
                      "age": 20
                    }
                  ]
                }""";
        final var person = objectMapper.readValue(json, Person.class);
        System.out.println(person);
    }

}

Try setting the age of one of JSON Person objects to a negative integer and you will get an exception similar to the following: com.fasterxml.jackson.databind.JsonMappingException: Person.age must be >= 0 (through reference chain: Person["children"]->java.util.ArrayList[1])

Jackson version used: 2.12.2

oddbjorn
  • 359
  • 2
  • 11
1

A general facility for doing things validation has been requested:

https://github.com/FasterXML/jackson-databind/issues/2045

although not yet implemented. Presumably this would not be limited to be just annotation-based but would allow pluggable post-processing (plus one can always "fake" annotations by sub-classing AnnotationIntrospector anyway).

Beyond this (which is not yet available), for POJOs use of @JsonCreator works well if you can pass all properties through it (constructor or factory method). But not so well for validating 3rd party types.

StaxMan
  • 113,358
  • 34
  • 211
  • 239