diff --git a/README.md b/README.md index 07088ce..ca5914c 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,21 @@ assertEquals(JsonNullable.undefined(), mapper.readValue("{}", Pet.class) ``` +### Blank strings + +By default a blank string (`""` or whitespace only) sent for a non-String `JsonNullable` +deserializes to `JsonNullable.undefined()`, as if the property were absent. If your clients +send a blank string to mean "clear this value" (common with PATCH requests), enable +`mapBlankStringToNull` so it deserializes to `JsonNullable.of(null)` instead: +```java +mapper.registerModule(new JsonNullableModule().mapBlankStringToNull(true)); +// Jackson 3: JsonMapper mapper = JsonMapper.builder().addModule(new JsonNullableJackson3Module().mapBlankStringToNull(true)).build(); + +// given a bean with a JsonNullable age property: +assertEquals(JsonNullable.of(null), mapper.readValue("{\"age\":\"\"}", Person.class).age); +``` +`JsonNullable` properties are never affected: a blank string is a valid string value. + `JsonNullable` can also be used as a `@JsonCreator` constructor parameter. An absent property is passed to the constructor as `JsonNullable.undefined()` rather than as `null`, so it stays distinguishable from an explicit `null`. diff --git a/src/main/java/org/openapitools/jackson/nullable/JsonNullableJackson2Deserializer.java b/src/main/java/org/openapitools/jackson/nullable/JsonNullableJackson2Deserializer.java index 01eb04a..7c7aa9a 100644 --- a/src/main/java/org/openapitools/jackson/nullable/JsonNullableJackson2Deserializer.java +++ b/src/main/java/org/openapitools/jackson/nullable/JsonNullableJackson2Deserializer.java @@ -19,6 +19,7 @@ public class JsonNullableJackson2Deserializer extends ReferenceTypeDeserializer< private static final long serialVersionUID = 1L; private boolean isStringDeserializer = false; + private final boolean mapBlankStringToNull; /* /********************************************************** @@ -27,7 +28,14 @@ public class JsonNullableJackson2Deserializer extends ReferenceTypeDeserializer< */ public JsonNullableJackson2Deserializer(JavaType fullType, ValueInstantiator inst, TypeDeserializer typeDeser, JsonDeserializer deser) { + this(fullType, inst, typeDeser, deser, false); + } + + public JsonNullableJackson2Deserializer(JavaType fullType, ValueInstantiator inst, + TypeDeserializer typeDeser, JsonDeserializer deser, + boolean mapBlankStringToNull) { super(fullType, inst, typeDeser, deser); + this.mapBlankStringToNull = mapBlankStringToNull; if (fullType instanceof ReferenceType && ((ReferenceType) fullType).getReferencedType() != null) { this.isStringDeserializer = ((ReferenceType) fullType).getReferencedType().isTypeOrSubTypeOf(String.class); } @@ -45,7 +53,7 @@ public JsonNullable deserialize(JsonParser p, DeserializationContext ctx if (t == JsonToken.VALUE_STRING && !isStringDeserializer) { String str = p.getText().trim(); if (str.isEmpty()) { - return JsonNullable.undefined(); + return mapBlankStringToNull ? JsonNullable.of(null) : JsonNullable.undefined(); } } return super.deserialize(p, ctxt); @@ -54,7 +62,7 @@ public JsonNullable deserialize(JsonParser p, DeserializationContext ctx @Override public JsonNullableJackson2Deserializer withResolved(TypeDeserializer typeDeser, JsonDeserializer valueDeser) { return new JsonNullableJackson2Deserializer(_fullType, _valueInstantiator, - typeDeser, valueDeser); + typeDeser, valueDeser, mapBlankStringToNull); } @Override diff --git a/src/main/java/org/openapitools/jackson/nullable/JsonNullableJackson2Deserializers.java b/src/main/java/org/openapitools/jackson/nullable/JsonNullableJackson2Deserializers.java index 54a5d3b..748dbbe 100644 --- a/src/main/java/org/openapitools/jackson/nullable/JsonNullableJackson2Deserializers.java +++ b/src/main/java/org/openapitools/jackson/nullable/JsonNullableJackson2Deserializers.java @@ -9,10 +9,22 @@ public class JsonNullableJackson2Deserializers extends Deserializers.Base { + private final boolean mapBlankStringToNull; + + public JsonNullableJackson2Deserializers() { + this(false); + } + + public JsonNullableJackson2Deserializers(boolean mapBlankStringToNull) { + this.mapBlankStringToNull = mapBlankStringToNull; + } + @Override public JsonDeserializer findReferenceDeserializer(ReferenceType refType, DeserializationConfig config, BeanDescription beanDesc, TypeDeserializer contentTypeDeserializer, JsonDeserializer contentDeserializer) { - return (refType.hasRawClass(JsonNullable.class)) ? new JsonNullableJackson2Deserializer(refType, null, contentTypeDeserializer,contentDeserializer) : null; + return (refType.hasRawClass(JsonNullable.class)) + ? new JsonNullableJackson2Deserializer(refType, null, contentTypeDeserializer, contentDeserializer, mapBlankStringToNull) + : null; } } diff --git a/src/main/java/org/openapitools/jackson/nullable/JsonNullableJackson3Deserializer.java b/src/main/java/org/openapitools/jackson/nullable/JsonNullableJackson3Deserializer.java index a01ac80..9859cae 100644 --- a/src/main/java/org/openapitools/jackson/nullable/JsonNullableJackson3Deserializer.java +++ b/src/main/java/org/openapitools/jackson/nullable/JsonNullableJackson3Deserializer.java @@ -17,6 +17,7 @@ public class JsonNullableJackson3Deserializer extends ReferenceTypeDeserializer< private boolean isStringDeserializer = false; + private final boolean mapBlankStringToNull; /* /********************************************************** @@ -25,7 +26,14 @@ public class JsonNullableJackson3Deserializer extends ReferenceTypeDeserializer< */ public JsonNullableJackson3Deserializer(JavaType fullType, ValueInstantiator inst, TypeDeserializer typeDeser, ValueDeserializer deser) { + this(fullType, inst, typeDeser, deser, false); + } + + public JsonNullableJackson3Deserializer(JavaType fullType, ValueInstantiator inst, + TypeDeserializer typeDeser, ValueDeserializer deser, + boolean mapBlankStringToNull) { super(fullType, inst, typeDeser, deser); + this.mapBlankStringToNull = mapBlankStringToNull; if (fullType instanceof ReferenceType && ((ReferenceType) fullType).getReferencedType() != null) { this.isStringDeserializer = ((ReferenceType) fullType).getReferencedType().isTypeOrSubTypeOf(String.class); } @@ -43,7 +51,7 @@ public JsonNullable deserialize(JsonParser p, DeserializationContext ctx if (t == JsonToken.VALUE_STRING && !isStringDeserializer) { String str = p.getString().trim(); if (str.isEmpty()) { - return JsonNullable.undefined(); + return mapBlankStringToNull ? JsonNullable.of(null) : JsonNullable.undefined(); } } return super.deserialize(p, ctxt); @@ -52,7 +60,7 @@ public JsonNullable deserialize(JsonParser p, DeserializationContext ctx @Override protected ReferenceTypeDeserializer> withResolved(TypeDeserializer typeDeser, ValueDeserializer valueDeser) { return new JsonNullableJackson3Deserializer(_fullType, _valueInstantiator, - typeDeser, valueDeser); + typeDeser, valueDeser, mapBlankStringToNull); } @Override diff --git a/src/main/java/org/openapitools/jackson/nullable/JsonNullableJackson3Deserializers.java b/src/main/java/org/openapitools/jackson/nullable/JsonNullableJackson3Deserializers.java index 7ef853c..a414a3c 100644 --- a/src/main/java/org/openapitools/jackson/nullable/JsonNullableJackson3Deserializers.java +++ b/src/main/java/org/openapitools/jackson/nullable/JsonNullableJackson3Deserializers.java @@ -9,11 +9,23 @@ public class JsonNullableJackson3Deserializers extends Deserializers.Base { + private final boolean mapBlankStringToNull; + + public JsonNullableJackson3Deserializers() { + this(false); + } + + public JsonNullableJackson3Deserializers(boolean mapBlankStringToNull) { + this.mapBlankStringToNull = mapBlankStringToNull; + } + @Override public ValueDeserializer findReferenceDeserializer(ReferenceType refType, DeserializationConfig config, Supplier beanDescRef, TypeDeserializer contentTypeDeserializer, ValueDeserializer contentDeserializer) { - return (refType.hasRawClass(JsonNullable.class)) ? new JsonNullableJackson3Deserializer(refType, null, contentTypeDeserializer,contentDeserializer) : null; + return (refType.hasRawClass(JsonNullable.class)) + ? new JsonNullableJackson3Deserializer(refType, null, contentTypeDeserializer, contentDeserializer, mapBlankStringToNull) + : null; } @Override diff --git a/src/main/java/org/openapitools/jackson/nullable/JsonNullableJackson3Module.java b/src/main/java/org/openapitools/jackson/nullable/JsonNullableJackson3Module.java index e9abf49..276fd43 100644 --- a/src/main/java/org/openapitools/jackson/nullable/JsonNullableJackson3Module.java +++ b/src/main/java/org/openapitools/jackson/nullable/JsonNullableJackson3Module.java @@ -7,11 +7,31 @@ public class JsonNullableJackson3Module extends JacksonModule { private final String NAME = "JsonNullableModule"; + private boolean mapBlankStringToNull = false; + + /** + * Configures whether blank strings (for example {@code ""} or {@code " "}) deserialized + * into a non-String {@code JsonNullable} are mapped to {@code JsonNullable.of(null)} + * instead of {@code JsonNullable.undefined()}. + * + *

This matters for PATCH semantics: a blank string sent by a client expresses an + * explicit intent to clear the value, which {@code undefined()} silently swallows. + * String targets are never affected. + * + *

Default is {@code false} for backwards compatibility. + * + * @param state {@code true} to map blank strings to {@code JsonNullable.of(null)} + * @return this module, for chaining + */ + public JsonNullableJackson3Module mapBlankStringToNull(boolean state) { + this.mapBlankStringToNull = state; + return this; + } @Override public void setupModule(SetupContext context) { context.addSerializers(new JsonNullableJackson3Serializers()); - context.addDeserializers(new JsonNullableJackson3Deserializers()); + context.addDeserializers(new JsonNullableJackson3Deserializers(mapBlankStringToNull)); // Modify type info for JsonNullable context.addTypeModifier(new JsonNullableJackson3TypeModifier()); context.addSerializerModifier(new JsonNullableJackson3ValueSerializerModifier()); diff --git a/src/main/java/org/openapitools/jackson/nullable/JsonNullableModule.java b/src/main/java/org/openapitools/jackson/nullable/JsonNullableModule.java index 0b9fdd0..ec77edd 100644 --- a/src/main/java/org/openapitools/jackson/nullable/JsonNullableModule.java +++ b/src/main/java/org/openapitools/jackson/nullable/JsonNullableModule.java @@ -7,11 +7,31 @@ public class JsonNullableModule extends Module { private final String NAME = "JsonNullableModule"; + private boolean mapBlankStringToNull = false; + + /** + * Configures whether blank strings (for example {@code ""} or {@code " "}) deserialized + * into a non-String {@code JsonNullable} are mapped to {@code JsonNullable.of(null)} + * instead of {@code JsonNullable.undefined()}. + * + *

This matters for PATCH semantics: a blank string sent by a client expresses an + * explicit intent to clear the value, which {@code undefined()} silently swallows. + * String targets are never affected. + * + *

Default is {@code false} for backwards compatibility. + * + * @param state {@code true} to map blank strings to {@code JsonNullable.of(null)} + * @return this module, for chaining + */ + public JsonNullableModule mapBlankStringToNull(boolean state) { + this.mapBlankStringToNull = state; + return this; + } @Override public void setupModule(SetupContext context) { context.addSerializers(new JsonNullableJackson2Serializers()); - context.addDeserializers(new JsonNullableJackson2Deserializers()); + context.addDeserializers(new JsonNullableJackson2Deserializers(mapBlankStringToNull)); // Modify type info for JsonNullable context.addTypeModifier(new JsonNullableJackson2TypeModifier()); context.addBeanSerializerModifier(new JsonNullableJackson2BeanSerializerModifier()); diff --git a/src/test/java/org/openapitools/jackson/nullable/Jackson2Processor.java b/src/test/java/org/openapitools/jackson/nullable/Jackson2Processor.java index 65a94e5..81dc3c1 100644 --- a/src/test/java/org/openapitools/jackson/nullable/Jackson2Processor.java +++ b/src/test/java/org/openapitools/jackson/nullable/Jackson2Processor.java @@ -27,8 +27,13 @@ public Jackson2Processor() { @Override public JsonProcessor mapperWithModule() { + return mapperWithModule(false); + } + + @Override + public JsonProcessor mapperWithModule(boolean mapBlankStringToNull) { mapper = new ObjectMapper(); - mapper.registerModule(new JsonNullableModule()); + mapper.registerModule(new JsonNullableModule().mapBlankStringToNull(mapBlankStringToNull)); return this; } diff --git a/src/test/java/org/openapitools/jackson/nullable/Jackson3Processor.java b/src/test/java/org/openapitools/jackson/nullable/Jackson3Processor.java index e7788f7..9773c3f 100644 --- a/src/test/java/org/openapitools/jackson/nullable/Jackson3Processor.java +++ b/src/test/java/org/openapitools/jackson/nullable/Jackson3Processor.java @@ -26,7 +26,12 @@ public Jackson3Processor() { @Override public JsonProcessor mapperWithModule() { - builder = JsonMapper.builder().addModule(new JsonNullableJackson3Module()); + return mapperWithModule(false); + } + + @Override + public JsonProcessor mapperWithModule(boolean mapBlankStringToNull) { + builder = JsonMapper.builder().addModule(new JsonNullableJackson3Module().mapBlankStringToNull(mapBlankStringToNull)); return this; } diff --git a/src/test/java/org/openapitools/jackson/nullable/JsonNullWithEmptyTest.java b/src/test/java/org/openapitools/jackson/nullable/JsonNullWithEmptyTest.java index 69907e1..c7db26a 100644 --- a/src/test/java/org/openapitools/jackson/nullable/JsonNullWithEmptyTest.java +++ b/src/test/java/org/openapitools/jackson/nullable/JsonNullWithEmptyTest.java @@ -7,8 +7,11 @@ import org.junit.jupiter.params.ParameterizedClass; import org.junit.jupiter.params.provider.MethodSource; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; @ParameterizedClass @MethodSource("jsonProcessors") @@ -28,6 +31,21 @@ public BooleanBean(Boolean b) { } } + enum Color { RED, GREEN } + + static class EnumBean { + public JsonNullable value; + } + + static class Point { + public int x; + public int y; + } + + static class PojoBean { + public JsonNullable value; + } + @BeforeEach void setup() { jsonProcessor.mapperWithModule(); @@ -50,6 +68,61 @@ void testBooleanWithEmpty() throws Exception { assertFalse(b.value.isPresent()); } + // mapBlankStringToNull(true): blank strings for non-String targets become a present null + + @Test + void testJsonNullableFromEmptyWithMapBlankStringToNull() throws Exception { + jsonProcessor.mapperWithModule(true); + JsonNullable value = jsonProcessor.readValue(quote(""), TypeReferences.INTEGER.getType(jsonProcessor)); + assertTrue(value.isPresent()); + assertNull(value.get()); + } + + @Test + void testJsonNullableFromBlankWithMapBlankStringToNull() throws Exception { + jsonProcessor.mapperWithModule(true); + JsonNullable value = jsonProcessor.readValue(quote(" "), TypeReferences.INTEGER.getType(jsonProcessor)); + assertTrue(value.isPresent()); + assertNull(value.get()); + } + + @Test + void testBooleanWithEmptyWithMapBlankStringToNull() throws Exception { + jsonProcessor.mapperWithModule(true); + BooleanBean b = jsonProcessor.readValue(aposToQuotes("{'value':''}"), BooleanBean.class); + assertNotNull(b.value); + assertTrue(b.value.isPresent()); + assertNull(b.value.get()); + } + + // The guard runs before the content deserializer, so enum and POJO targets never + // see the blank string. Without it these would throw instead of yielding a present null. + @Test + void testEnumWithEmptyWithMapBlankStringToNull() throws Exception { + jsonProcessor.mapperWithModule(true); + EnumBean b = jsonProcessor.readValue(aposToQuotes("{'value':''}"), EnumBean.class); + assertNotNull(b.value); + assertTrue(b.value.isPresent()); + assertNull(b.value.get()); + } + + @Test + void testPojoWithEmptyWithMapBlankStringToNull() throws Exception { + jsonProcessor.mapperWithModule(true); + PojoBean b = jsonProcessor.readValue(aposToQuotes("{'value':''}"), PojoBean.class); + assertNotNull(b.value); + assertTrue(b.value.isPresent()); + assertNull(b.value.get()); + } + + @Test + void testStringTargetUnaffectedByMapBlankStringToNull() throws Exception { + jsonProcessor.mapperWithModule(true); + JsonNullable value = jsonProcessor.readValue(quote(""), TypeReferences.STRING.getType(jsonProcessor)); + assertTrue(value.isPresent()); + assertEquals("", value.get()); + } + private enum TypeReferences { INTEGER { @Override @@ -64,6 +137,20 @@ public Object getType(JsonProcessor jsonProcessor) { } throw new RuntimeException("jsonProcessor type not implemented"); } + }, + STRING { + @Override + public Object getType(JsonProcessor jsonProcessor) { + if (jsonProcessor instanceof Jackson2Processor) { + return new TypeReference>() { + }; + } + if (jsonProcessor instanceof Jackson3Processor) { + return new tools.jackson.core.type.TypeReference>() { + }; + } + throw new RuntimeException("jsonProcessor type not implemented"); + } }; public abstract Object getType(JsonProcessor jsonProcessor); diff --git a/src/test/java/org/openapitools/jackson/nullable/JsonProcessor.java b/src/test/java/org/openapitools/jackson/nullable/JsonProcessor.java index 857e519..a21adc4 100644 --- a/src/test/java/org/openapitools/jackson/nullable/JsonProcessor.java +++ b/src/test/java/org/openapitools/jackson/nullable/JsonProcessor.java @@ -7,6 +7,8 @@ public interface JsonProcessor { JsonProcessor mapperWithModule(); + JsonProcessor mapperWithModule(boolean mapBlankStringToNull); + JsonProcessor setDateFormat(SimpleDateFormat simpleDateFormat); JsonProcessor setDefaultPropertyInclusion(JsonInclude.Include incl);