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.

Comment From: bfreitastgtg

Extending the Jackson2ObjectMapperBuilder with this sort of did the trick:

.annotationIntrospector(new JacksonAnnotationIntrospector() {
        @Override
        public JsonSetter.Value findSetterInfo(Annotated a) {
            var setterInfo = super.findSetterInfo(a);
            // Allow override with explicit @JsonSetter on field
            if (setterInfo != null && (setterInfo.getValueNulls() != Nulls.DEFAULT ||
                    setterInfo.getContentNulls() != Nulls.DEFAULT)) {
                return setterInfo;
            }
            var annotation = a.getAnnotation(Nullable.class);
            return JsonSetter.Value.empty()
                    .withValueNulls(annotation == null ? Nulls.FAIL : Nulls.DEFAULT);
        }
        })

I say "sort of", because it only does what I want if the class has a constructor with all parameters. This one does not throw if I try deserializing the {} empty json, prop will be null:

public class SomeClass {
    private String prop;

    public SomeClass() {
    }

    public String getProp() {
        return prop;
    }

    public void setProp(String prop) {
        this.prop = prop;
    }
}

This one does throw on deserialization:

public class AnotherClass {
    private String prop;

    @ConstructorProperties({"prop"})
    public AnotherClass(String prop) {
        this.prop = prop;
    }

    public String getProp() {
        return prop;
    }

    public void setProp(String prop) {
        this.prop = prop;
    }
}

But that's something that can be validated with proper testing on my end.

Comment From: cowtowncoder

Yes, there is a well-known limitation on checks for "required" status:

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

so basically missing properties are only checked if they go through parameter-taking Creator (constructor, factory method). But not if set via Field or passed via Setter Method.

Comment From: bfreitastgtg

Going to close this issue (thanks for the help). At the end, it also made sense to allow Optional to deserialize to null, even if not annotated with @Nullable. So, the final implementation was this:

          @Override
          public JsonSetter.Value findSetterInfo(Annotated a) {
            // Allow override with explicit @JsonSetter on field:
            var setterInfoOverride = super.findSetterInfo(a);
            if (setterInfoOverride != null && !JsonSetter.Value.empty().equals(setterInfoOverride)) {
              return setterInfoOverride;
            }
            var nullsStrategy = a.getAnnotation(Nullable.class) == null && !a.getRawType().equals(Optional.class)
                ? Nulls.FAIL : Nulls.DEFAULT;
            return JsonSetter.Value.forValueNulls(nullsStrategy);
          }

Comment From: cowtowncoder

Thank you for sharing @bfreitastgtg !