OpenAPITools / jackson-databind-nullable

JsonNullable wrapper class and Jackson module to support meaningful null values
Apache License 2.0
102 stars 30 forks source link

Custom deserializer and empty string treatment #46

Open mhendrikxce opened 1 year ago

mhendrikxce commented 1 year ago

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?

luispflamminger commented 2 months 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?

mhendrikx89 commented 2 months ago

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;
    }
}
luispflamminger commented 2 months ago

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("");
        }
    }
}