Describe your Issue

Hello, Jackson devs! I have been trying to do this with Jackson but have been unable to: I want it to fail on deserialization if a field is null or absent, but only if the field is not annotated with org.springframework.lang.Nullable. Example:

My POJO:

public record TestRecord(@Nullable Boolean nullableProp, Boolean prop) {
}
// These are supposed to fail:
var serialized1 = "{\"nullableProp\": true, \"prop\": null}";
var serialized2 = "{\"nullableProp\": true}";

// These are not supposed to fail:
var serialized3 = "{\"nullableProp\": null, \"prop\": true}";
var serialized4 = "{\"prop\": true}";

var deserialized1 = objectMapper.readValue(serialized1, TestRecord.class);
var deserialized2 = objectMapper.readValue(serialized2, TestRecord.class);
var deserialized3 = objectMapper.readValue(serialized3, TestRecord.class);
var deserialized4 = objectMapper.readValue(serialized4, TestRecord.class);

I have tried 2 approaches for this, none of them worked: 1) Enable DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES and DeserializationFeature.FAIL_ON_NULL_CREATOR_PROPERTIES on my objectMapper:

var objectMapper = new Jackson2ObjectMapperBuilder()
                .featuresToEnable(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES)
                .featuresToEnable(DeserializationFeature.FAIL_ON_NULL_CREATOR_PROPERTIES)
                .build();

This will make Jackson throw for the cases where the @Nullable property is null/absent, which I don't want. This made realize Jackson doesn't care about @Nullable, so I tried the second approach which unfortunately also did not work: 2) Use a custom BeanDeserializerModifier:

public class NonNullPropertyEnforcer extends BeanDeserializerModifier {
    @Override
    public JsonDeserializer<?> modifyDeserializer(
            DeserializationConfig config,
            BeanDescription beanDesc,
            JsonDeserializer<?> deserializer
    ) {
        if (deserializer instanceof BeanDeserializer beanDeserializer) {
            Iterator<SettableBeanProperty> iterator = beanDeserializer.properties(); // no luck with creatorProperties() either
            while (iterator.hasNext()) {
                SettableBeanProperty property = iterator.next();
                if (property.getAnnotation(Nullable.class) == null) {
                    SettableBeanProperty updatedProperty = property.withNullProvider(new NullValueProvider() {
                        @Override
                        public Object getNullValue(DeserializationContext ctxt) {
                            throw new IllegalArgumentException(
                                    "Property '" + property.getName() + "' must not be null"
                            );
                        }

                        @Override
                        public AccessPattern getNullAccessPattern() {
                            return AccessPattern.DYNAMIC;
                        }
                    });
                    beanDeserializer.replaceProperty(property, updatedProperty);
                }
            }
        }
        return deserializer;
    }
}
// No deserialization features enabled this time, just a simple module:
var nullableModule = new SimpleModule();
nullableModule.setDeserializerModifier(new NonNullPropertyEnforcer());
var objectMapper = new Jackson2ObjectMapperBuilder().modules(nullableModule).build();

This time, Jackson does not throw at all for any of the test cases. I debugged a little and found out that the code inside the methods of my NullValueProvider isn't executed at all.

Thanks for any help with this!

Comment From: cowtowncoder

Quick note: we won't be adding direct dependency to Spring-specific annotations, so this would need to go in an add-on module.

Aside from that, I think it may make sense to instead look into how JacksonAnnotationIntrospector detects annotations that relate to null handling: if one of callbacks would work -- possibly this one:

public JsonSetter.Value findSetterInfo(Annotated a);

it'd be supported throughout.

But one challenge is that @Nullable is sort of inverse of required flag so this might not be easy to make work overall.