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 !