Open mhendrikxce opened 1 year ago
We're currently fighting with this as well. In our case, we are serializing to a JsonNullable<SomeEnum>
and want to throw an exception if the user passes an empty string, as we see this as an invalid enum value.
My current attempt at solving this looks like this (using Spring Boot 3):
import java.io.IOException;
import org.openapitools.jackson.nullable.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.deser.ValueInstantiator;
import com.fasterxml.jackson.databind.jsontype.TypeDeserializer;
import com.fasterxml.jackson.databind.type.ReferenceType;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
@Configuration
public class ObjectMapperConfiguration {
@Bean
public Jackson2ObjectMapperBuilder jackson2ObjectMapperBuilder() {
return new Jackson2ObjectMapperBuilder()
.modules(new JavaTimeModule(), new CustomJsonNullableModule());
}
public class CustomJsonNullableModule extends JsonNullableModule {
@Override
public void setupModule(SetupContext context) {
context.addSerializers(new JsonNullableSerializers());
context.addDeserializers(new CustomJsonNullableDeserializers());
// Modify type info for JsonNullable
context.addTypeModifier(new JsonNullableTypeModifier());
context.addBeanSerializerModifier(new JsonNullableBeanSerializerModifier());
}
}
public class CustomJsonNullableDeserializers extends JsonNullableDeserializers {
@Override
public JsonDeserializer<?> findReferenceDeserializer(ReferenceType refType, DeserializationConfig config,
BeanDescription beanDesc, TypeDeserializer contentTypeDeserializer,
JsonDeserializer<?> contentDeserializer) {
return (refType.hasRawClass(JsonNullable.class))
? new CustomJsonNullableDeserializer(refType, null, contentTypeDeserializer, contentDeserializer)
: null;
}
}
public class CustomJsonNullableDeserializer extends JsonNullableDeserializer {
private boolean isStringDeserializer = false;
CustomJsonNullableDeserializer(JavaType fullType, ValueInstantiator inst, TypeDeserializer typeDeser,
JsonDeserializer<?> deser) {
super(fullType, inst, typeDeser, deser);
}
@Override
public JsonNullable<Object> deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JsonToken t = p.getCurrentToken();
if (t == JsonToken.VALUE_STRING && !isStringDeserializer) {
String str = p.getText().trim();
if (str.isEmpty()) {
throw new RuntimeException("empty string is not allowed");
}
}
return super.deserialize(p, ctxt);
}
}
}
However, this isn't quite working. For some reason it still jumps into the regular JsonNullableDeserializer#deserialize
method and not the custom implementation, even though the module is correctly registered in the ObjectMapper
and I can't quite figure out why.
@mhendrikxce did you maybe find a way to customize the behavior?
That is what we ultimately implemented at the time. Note the isPassEmptyStringToDeserializer, you will need to customize that part for your use-case.
This worked fine for my specific use-case. I did not open a PR as it contains use-case specific code. I did not go the extra mile to verify it does not break other functionality that I do not use.
public class P6CustomJsonNullableModule extends JsonNullableModule {
@Override
public void setupModule(SetupContext context) {
context.addSerializers(new JsonNullableSerializers());
context.addDeserializers(new P6CustomJsonNullableDeserializers());
// Modify type info for JsonNullable
context.addTypeModifier(new JsonNullableTypeModifier());
context.addBeanSerializerModifier(new JsonNullableBeanSerializerModifier());
}
}
public class P6CustomJsonNullableDeserializers extends JsonNullableDeserializers {
@Override
public JsonDeserializer<?> findReferenceDeserializer(ReferenceType refType,
DeserializationConfig config, BeanDescription beanDesc,
TypeDeserializer contentTypeDeserializer, JsonDeserializer<?> contentDeserializer) {
return refType.hasRawClass(JsonNullable.class)
? new P6CustomJsonNullableDeserializer(refType, null, contentTypeDeserializer,contentDeserializer)
: null;
}
}
public class P6CustomJsonNullableDeserializer extends ReferenceTypeDeserializer<JsonNullable<Object>> {
private boolean isPassEmptyStringToDeserializer;
public P6CustomJsonNullableDeserializer(JavaType fullType, ValueInstantiator inst,
TypeDeserializer typeDeser, JsonDeserializer<?> deser) {
super(fullType, inst, typeDeser, deser);
if (fullType instanceof ReferenceType) {
JavaType javaType = fullType.getReferencedType();
// you want to check your own specific types here
this.isPassEmptyStringToDeserializer = javaType.isTypeOrSubTypeOf(String.class)
|| javaType.isTypeOrSubTypeOf(EnumType.class) || javaType.isTypeOrSubTypeOf(Date.class)
|| javaType.isTypeOrSubTypeOf(Percentage.class);
}
}
@Override
public JsonNullable<Object> deserialize(JsonParser parser, DeserializationContext ctxt) throws IOException {
JsonToken t = parser.getCurrentToken();
if (t == JsonToken.VALUE_STRING && !isPassEmptyStringToDeserializer) {
String str = parser.getText().trim();
if (str.isEmpty()) {
return JsonNullable.undefined();
}
}
return super.deserialize(parser, ctxt);
}
@Override
public P6CustomJsonNullableDeserializer withResolved(TypeDeserializer typeDeser, JsonDeserializer<?> valueDeser) {
return new P6CustomJsonNullableDeserializer(_fullType, _valueInstantiator,
typeDeser, valueDeser);
}
@Override
public Object getAbsentValue(DeserializationContext ctxt) {
return JsonNullable.undefined();
}
@Override
public JsonNullable<Object> getNullValue(DeserializationContext ctxt) {
return JsonNullable.of(null);
}
@Override
public Object getEmptyValue(DeserializationContext ctxt) {
return JsonNullable.undefined();
}
@Override
public JsonNullable<Object> referenceValue(Object contents) {
return JsonNullable.of(contents);
}
@Override
public Object getReferenced(JsonNullable<Object> reference) {
return reference.isPresent()
? reference.get()
: null;
}
@Override
public JsonNullable<Object> updateReference(JsonNullable<Object> reference, Object contents) {
return JsonNullable.of(contents);
}
@Override
public Boolean supportsUpdate(DeserializationConfig config) {
// yes; regardless of value deserializer reference itself may be updated
return Boolean.TRUE;
}
}
Thank you so much for the quick reply @mhendrikx89! It helped out a ton.
What I was missing in my implementation above was the override of the JsonNullableDeserializer#withResolved
method.
My final implementation looks like this:
import java.io.IOException;
import org.openapitools.jackson.nullable.*;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.deser.ValueInstantiator;
import com.fasterxml.jackson.databind.jsontype.TypeDeserializer;
import com.fasterxml.jackson.databind.type.ReferenceType;
@Configuration
public class ObjectMapperConfiguration {
@Bean
public Jackson2ObjectMapperBuilderCustomizer customOctopusJacksonObjectMapperBuilderCustomizer() {
return builder -> {
builder.modulesToInstall(consumer -> {
// we replace the regular auto-configured JsonNullableModule with our custom one
consumer.removeIf(module -> module instanceof JsonNullableModule);
consumer.add(new CustomJsonNullableModule());
});
};
}
public class CustomJsonNullableModule extends JsonNullableModule {
@Override
public void setupModule(SetupContext context) {
context.addSerializers(new JsonNullableSerializers());
// Replace the default deserializers with our custom ones.
// The rest of the setup stays unchanged.
context.addDeserializers(new CustomJsonNullableDeserializers());
context.addTypeModifier(new JsonNullableTypeModifier());
context.addBeanSerializerModifier(new JsonNullableBeanSerializerModifier());
}
}
public class CustomJsonNullableDeserializers extends JsonNullableDeserializers {
@Override
public JsonDeserializer<?> findReferenceDeserializer(ReferenceType refType, DeserializationConfig config,
BeanDescription beanDesc, TypeDeserializer contentTypeDeserializer,
JsonDeserializer<?> contentDeserializer) {
// return our custom deserializer for JsonNullable
return (refType.hasRawClass(JsonNullable.class))
? new CustomJsonNullableDeserializer(refType, null, contentTypeDeserializer, contentDeserializer)
: null;
}
}
public class CustomJsonNullableDeserializer extends JsonNullableDeserializer {
private boolean isStringDeserializer = false;
private final String EMPTY_STRING_MESSAGE = "Empty string is not a valid value";
// the constructor is copied from the original JsonNullableDeserializer
public CustomJsonNullableDeserializer(JavaType fullType, ValueInstantiator inst,
TypeDeserializer typeDeser, JsonDeserializer<?> deser) {
super(fullType, inst, typeDeser, deser);
if (fullType instanceof ReferenceType && ((ReferenceType) fullType).getReferencedType() != null) {
this.isStringDeserializer = ((ReferenceType) fullType).getReferencedType()
.isTypeOrSubTypeOf(String.class);
}
}
@Override
public JsonNullable<Object> deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JsonToken t = p.getCurrentToken();
if (t == JsonToken.VALUE_STRING && !isStringDeserializer) {
String str = p.getText().trim();
// this is the only change we want to make in the module
if (str.isEmpty()) {
throw new JsonParseException(p, EMPTY_STRING_MESSAGE);
}
}
return super.deserialize(p, ctxt);
}
@Override
public JsonNullableDeserializer withResolved(TypeDeserializer typeDeser, JsonDeserializer<?> valueDeser) {
// required so the custom serializer is used for JsonNullable
return new CustomJsonNullableDeserializer(_fullType, _valueInstantiator,
typeDeser, valueDeser);
}
@Override
public Object getEmptyValue(DeserializationContext ctxt) {
if (!isStringDeserializer) {
throw new IllegalArgumentException(EMPTY_STRING_MESSAGE);
}
return JsonNullable.of("");
}
}
}
In our scenario, we have an API that for a field reports an empty String. The empty String should be deserialized to an enum value that represents the scenario of emptry String: "" -> ConstaintType.NONE (yes, I know this is weird).
I noticed that it however did not end up in our custom deserializer that handles this mapping. The reason for that is that JsonNullableDeserializer#deserialize(JsonParser, DeserializationContext) catches the scenario for empty string and maps it to JsonNullable.undefined(). This logic currently happens if the deserializer is not a String deserializer (there is an open PR that extends it to CharSequence https://github.com/OpenAPITools/jackson-databind-nullable/pull/45).
In my opinion, it is being a bit too smart here. I would expect that it always delegates to super; or at least only return JsonNullable.undefined for the standard Java types and delegate to super.deserialize for the custom user defined Java classes? At minimum, it would be nice if we could easily override this behaviour?