From f90c590b186e1ce8e919e2e4fb691c8d01022a5e Mon Sep 17 00:00:00 2001 From: Thorsrud22 <229304553+Thorsrud22@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:12:06 +0200 Subject: [PATCH 1/3] Add mapBlankStringToNull option to JsonNullableModule and JsonNullableJackson3Module Blank strings sent for a non-String JsonNullable deserialize to JsonNullable.undefined(), which silently drops a client's intent to clear a value in a PATCH request (#125). Add an opt-in module option that maps them to JsonNullable.of(null) instead. Default stays false, so nothing changes for existing users, and String targets are never affected. This carries #126 by krangerich forward onto the post-#117 code base: the option is applied to both the Jackson 2 and Jackson 3 deserializer stacks, threaded through withResolved() so it survives contextualization, and the tests run against both generations via the existing JsonProcessor parameterization. The previous public constructors are kept and delegate with the option off. Co-authored-by: krangerich <7890659+krangerich@users.noreply.github.com> --- README.md | 205 ++++++++++-------- .../JsonNullableJackson2Deserializer.java | 12 +- .../JsonNullableJackson2Deserializers.java | 14 +- .../JsonNullableJackson3Deserializer.java | 12 +- .../JsonNullableJackson3Deserializers.java | 14 +- .../nullable/JsonNullableJackson3Module.java | 22 +- .../jackson/nullable/JsonNullableModule.java | 22 +- .../jackson/nullable/Jackson2Processor.java | 7 +- .../jackson/nullable/Jackson3Processor.java | 7 +- .../nullable/JsonNullWithEmptyTest.java | 52 +++++ .../jackson/nullable/JsonProcessor.java | 2 + 11 files changed, 264 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index 07088ce..3ab09d2 100644 --- a/README.md +++ b/README.md @@ -1,95 +1,110 @@ -## This project is looking for maintainers. Please refer to the [announcement](https://github.com/OpenAPITools/jackson-databind-nullable/issues/71) for more information. - -# jackson-databind-nullable - -[![Build Status](https://api.travis-ci.com/OpenAPITools/jackson-databind-nullable.svg?branch=master&status=passed)](https://app.travis-ci.com/github/OpenAPITools/jackson-databind-nullable) - -This module provides a `JsonNullable` wrapper class and a Jackson module to serialize/deserialize it. -The `JsonNullable` wrapper shall be used to wrap Java bean fields for which it is important to distinguish between an explicit `"null"` and the field not being present. -A typical usage is when implementing [Json Merge Patch](https://tools.ietf.org/html/rfc7386) where an explicit `"null"` has the meaning "set this field to null / remove this field" whereas a non-present field has the meaning "don't change the value of this field". - -The module comes with an integrated `ValueExtractor` that automatically unwraps the contained value of the `JsonNullable` if used together with javax.validation Bean validation (JSR 380). - -Note: a lot of people use `Optional` to bring this behavior. -Although it kinda works, it's not a good idea because: -* Beans shouldn't have `Optional` fields. - `Optional` was designed to be used only as method return value. -* `Optional` should never be null. - The goal of `Optional` is to wrap the `null` and prevent NPE so the code should be designed to never assign `null` to an `Optional`. - A code invoking a method returning an Optional should be confident that this Optional is not null. - -## Installation - -The module is compatible with JDK8+ -``` -./mvnw clean install -``` - -## Usage - -`JsonNullable` shall primarily be used in bean fields. - -If we have the following class -```java -public static class Pet { - - @Size(max = 10) - public JsonNullable name = JsonNullable.undefined(); - - public Pet name(JsonNullable name) { - this.name = name; - return this; - } -} - -``` -And we instantiate the mapper either for JSON -```java -import com.fasterxml.jackson.databind.ObjectMapper; - -// ... - -ObjectMapper mapper = new ObjectMapper(); -mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); -mapper.registerModule(new JsonNullableModule()); -``` -or for XML -```java -import com.fasterxml.jackson.dataformat.xml.XmlMapper; - -// ... - -XmlMapper xmlMapper = new XmlMapper(); -xmlMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); -xmlMapper.registerModule(new JsonNullableModule()); -``` -Then we can serialize -```java -assertEquals("{}", mapper.writeValueAsString(new Pet().name(JsonNullable.undefined()))); -assertEquals("{\"name\":null}", mapper.writeValueAsString(new Pet().name(JsonNullable.of(null)))); -assertEquals("{\"name\":\"Rex\"}", mapper.writeValueAsString(new Pet().name(JsonNullable.of("Rex")))); - -``` -and deserialize -```java -assertEquals(JsonNullable.of("Rex"), mapper.readValue("{\"name\":\"Rex\"}", Pet.class).name); -assertEquals(JsonNullable.of(null), mapper.readValue("{\"name\":null}", Pet.class).name); -assertEquals(JsonNullable.undefined(), mapper.readValue("{}", Pet.class).name); - -``` - -`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`. - -The `ValueExtractor` is registered automatically via Java Service loader mechanism. The example class above will validate as follows -```java -// instantiate javax.validation.Validator -Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); -Pet myPet = new Pet().name(JsonNullable.of("My Pet's really long name")); -Set> validationResult = validator.validate(myPet); -assertEquals(1, validationResult.size()); -``` - -## Limitations - -* Doesn't work with `@JsonUnwrapped`. +## This project is looking for maintainers. Please refer to the [announcement](https://github.com/OpenAPITools/jackson-databind-nullable/issues/71) for more information. + +# jackson-databind-nullable + +[![Build Status](https://api.travis-ci.com/OpenAPITools/jackson-databind-nullable.svg?branch=master&status=passed)](https://app.travis-ci.com/github/OpenAPITools/jackson-databind-nullable) + +This module provides a `JsonNullable` wrapper class and a Jackson module to serialize/deserialize it. +The `JsonNullable` wrapper shall be used to wrap Java bean fields for which it is important to distinguish between an explicit `"null"` and the field not being present. +A typical usage is when implementing [Json Merge Patch](https://tools.ietf.org/html/rfc7386) where an explicit `"null"` has the meaning "set this field to null / remove this field" whereas a non-present field has the meaning "don't change the value of this field". + +The module comes with an integrated `ValueExtractor` that automatically unwraps the contained value of the `JsonNullable` if used together with javax.validation Bean validation (JSR 380). + +Note: a lot of people use `Optional` to bring this behavior. +Although it kinda works, it's not a good idea because: +* Beans shouldn't have `Optional` fields. + `Optional` was designed to be used only as method return value. +* `Optional` should never be null. + The goal of `Optional` is to wrap the `null` and prevent NPE so the code should be designed to never assign `null` to an `Optional`. + A code invoking a method returning an Optional should be confident that this Optional is not null. + +## Installation + +The module is compatible with JDK8+ +``` +./mvnw clean install +``` + +## Usage + +`JsonNullable` shall primarily be used in bean fields. + +If we have the following class +```java +public static class Pet { + + @Size(max = 10) + public JsonNullable name = JsonNullable.undefined(); + + public Pet name(JsonNullable name) { + this.name = name; + return this; + } +} + +``` +And we instantiate the mapper either for JSON +```java +import com.fasterxml.jackson.databind.ObjectMapper; + +// ... + +ObjectMapper mapper = new ObjectMapper(); +mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); +mapper.registerModule(new JsonNullableModule()); +``` +or for XML +```java +import com.fasterxml.jackson.dataformat.xml.XmlMapper; + +// ... + +XmlMapper xmlMapper = new XmlMapper(); +xmlMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); +xmlMapper.registerModule(new JsonNullableModule()); +``` +Then we can serialize +```java +assertEquals("{}", mapper.writeValueAsString(new Pet().name(JsonNullable.undefined()))); +assertEquals("{\"name\":null}", mapper.writeValueAsString(new Pet().name(JsonNullable.of(null)))); +assertEquals("{\"name\":\"Rex\"}", mapper.writeValueAsString(new Pet().name(JsonNullable.of("Rex")))); + +``` +and deserialize +```java +assertEquals(JsonNullable.of("Rex"), mapper.readValue("{\"name\":\"Rex\"}", Pet.class).name); +assertEquals(JsonNullable.of(null), mapper.readValue("{\"name\":null}", Pet.class).name); +assertEquals(JsonNullable.undefined(), mapper.readValue("{}", Pet.class).name); + +``` + +### 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.builder().addModule(new JsonNullableJackson3Module().mapBlankStringToNull(true)) + +// 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`. + +The `ValueExtractor` is registered automatically via Java Service loader mechanism. The example class above will validate as follows +```java +// instantiate javax.validation.Validator +Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); +Pet myPet = new Pet().name(JsonNullable.of("My Pet's really long name")); +Set> validationResult = validator.validate(myPet); +assertEquals(1, validationResult.size()); +``` + +## Limitations + +* Doesn't work with `@JsonUnwrapped`. 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..4889900 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") @@ -50,6 +53,41 @@ 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()); + } + + @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 +102,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); From 107887a142391cf4847b1032bf9cd06b435acbf3 Mon Sep 17 00:00:00 2001 From: Thorsrud22 <229304553+Thorsrud22@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:23:22 +0200 Subject: [PATCH 2/3] Test enum and POJO targets with mapBlankStringToNull, keep README CRLF Enum and POJO content deserializers throw on a blank string, so these cases only yield a present null because the guard runs before super.deserialize(). Pin that down on both Jackson generations. Restore the README's original CRLF line endings. --- README.md | 220 +++++++++--------- .../nullable/JsonNullWithEmptyTest.java | 35 +++ 2 files changed, 145 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index 3ab09d2..6393236 100644 --- a/README.md +++ b/README.md @@ -1,110 +1,110 @@ -## This project is looking for maintainers. Please refer to the [announcement](https://github.com/OpenAPITools/jackson-databind-nullable/issues/71) for more information. - -# jackson-databind-nullable - -[![Build Status](https://api.travis-ci.com/OpenAPITools/jackson-databind-nullable.svg?branch=master&status=passed)](https://app.travis-ci.com/github/OpenAPITools/jackson-databind-nullable) - -This module provides a `JsonNullable` wrapper class and a Jackson module to serialize/deserialize it. -The `JsonNullable` wrapper shall be used to wrap Java bean fields for which it is important to distinguish between an explicit `"null"` and the field not being present. -A typical usage is when implementing [Json Merge Patch](https://tools.ietf.org/html/rfc7386) where an explicit `"null"` has the meaning "set this field to null / remove this field" whereas a non-present field has the meaning "don't change the value of this field". - -The module comes with an integrated `ValueExtractor` that automatically unwraps the contained value of the `JsonNullable` if used together with javax.validation Bean validation (JSR 380). - -Note: a lot of people use `Optional` to bring this behavior. -Although it kinda works, it's not a good idea because: -* Beans shouldn't have `Optional` fields. - `Optional` was designed to be used only as method return value. -* `Optional` should never be null. - The goal of `Optional` is to wrap the `null` and prevent NPE so the code should be designed to never assign `null` to an `Optional`. - A code invoking a method returning an Optional should be confident that this Optional is not null. - -## Installation - -The module is compatible with JDK8+ -``` -./mvnw clean install -``` - -## Usage - -`JsonNullable` shall primarily be used in bean fields. - -If we have the following class -```java -public static class Pet { - - @Size(max = 10) - public JsonNullable name = JsonNullable.undefined(); - - public Pet name(JsonNullable name) { - this.name = name; - return this; - } -} - -``` -And we instantiate the mapper either for JSON -```java -import com.fasterxml.jackson.databind.ObjectMapper; - -// ... - -ObjectMapper mapper = new ObjectMapper(); -mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); -mapper.registerModule(new JsonNullableModule()); -``` -or for XML -```java -import com.fasterxml.jackson.dataformat.xml.XmlMapper; - -// ... - -XmlMapper xmlMapper = new XmlMapper(); -xmlMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); -xmlMapper.registerModule(new JsonNullableModule()); -``` -Then we can serialize -```java -assertEquals("{}", mapper.writeValueAsString(new Pet().name(JsonNullable.undefined()))); -assertEquals("{\"name\":null}", mapper.writeValueAsString(new Pet().name(JsonNullable.of(null)))); -assertEquals("{\"name\":\"Rex\"}", mapper.writeValueAsString(new Pet().name(JsonNullable.of("Rex")))); - -``` -and deserialize -```java -assertEquals(JsonNullable.of("Rex"), mapper.readValue("{\"name\":\"Rex\"}", Pet.class).name); -assertEquals(JsonNullable.of(null), mapper.readValue("{\"name\":null}", Pet.class).name); -assertEquals(JsonNullable.undefined(), mapper.readValue("{}", Pet.class).name); - -``` - -### 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.builder().addModule(new JsonNullableJackson3Module().mapBlankStringToNull(true)) - -// 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`. - -The `ValueExtractor` is registered automatically via Java Service loader mechanism. The example class above will validate as follows -```java -// instantiate javax.validation.Validator -Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); -Pet myPet = new Pet().name(JsonNullable.of("My Pet's really long name")); -Set> validationResult = validator.validate(myPet); -assertEquals(1, validationResult.size()); -``` - -## Limitations - -* Doesn't work with `@JsonUnwrapped`. +## This project is looking for maintainers. Please refer to the [announcement](https://github.com/OpenAPITools/jackson-databind-nullable/issues/71) for more information. + +# jackson-databind-nullable + +[![Build Status](https://api.travis-ci.com/OpenAPITools/jackson-databind-nullable.svg?branch=master&status=passed)](https://app.travis-ci.com/github/OpenAPITools/jackson-databind-nullable) + +This module provides a `JsonNullable` wrapper class and a Jackson module to serialize/deserialize it. +The `JsonNullable` wrapper shall be used to wrap Java bean fields for which it is important to distinguish between an explicit `"null"` and the field not being present. +A typical usage is when implementing [Json Merge Patch](https://tools.ietf.org/html/rfc7386) where an explicit `"null"` has the meaning "set this field to null / remove this field" whereas a non-present field has the meaning "don't change the value of this field". + +The module comes with an integrated `ValueExtractor` that automatically unwraps the contained value of the `JsonNullable` if used together with javax.validation Bean validation (JSR 380). + +Note: a lot of people use `Optional` to bring this behavior. +Although it kinda works, it's not a good idea because: +* Beans shouldn't have `Optional` fields. + `Optional` was designed to be used only as method return value. +* `Optional` should never be null. + The goal of `Optional` is to wrap the `null` and prevent NPE so the code should be designed to never assign `null` to an `Optional`. + A code invoking a method returning an Optional should be confident that this Optional is not null. + +## Installation + +The module is compatible with JDK8+ +``` +./mvnw clean install +``` + +## Usage + +`JsonNullable` shall primarily be used in bean fields. + +If we have the following class +```java +public static class Pet { + + @Size(max = 10) + public JsonNullable name = JsonNullable.undefined(); + + public Pet name(JsonNullable name) { + this.name = name; + return this; + } +} + +``` +And we instantiate the mapper either for JSON +```java +import com.fasterxml.jackson.databind.ObjectMapper; + +// ... + +ObjectMapper mapper = new ObjectMapper(); +mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); +mapper.registerModule(new JsonNullableModule()); +``` +or for XML +```java +import com.fasterxml.jackson.dataformat.xml.XmlMapper; + +// ... + +XmlMapper xmlMapper = new XmlMapper(); +xmlMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); +xmlMapper.registerModule(new JsonNullableModule()); +``` +Then we can serialize +```java +assertEquals("{}", mapper.writeValueAsString(new Pet().name(JsonNullable.undefined()))); +assertEquals("{\"name\":null}", mapper.writeValueAsString(new Pet().name(JsonNullable.of(null)))); +assertEquals("{\"name\":\"Rex\"}", mapper.writeValueAsString(new Pet().name(JsonNullable.of("Rex")))); + +``` +and deserialize +```java +assertEquals(JsonNullable.of("Rex"), mapper.readValue("{\"name\":\"Rex\"}", Pet.class).name); +assertEquals(JsonNullable.of(null), mapper.readValue("{\"name\":null}", Pet.class).name); +assertEquals(JsonNullable.undefined(), mapper.readValue("{}", Pet.class).name); + +``` + +### 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.builder().addModule(new JsonNullableJackson3Module().mapBlankStringToNull(true)) + +// 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`. + +The `ValueExtractor` is registered automatically via Java Service loader mechanism. The example class above will validate as follows +```java +// instantiate javax.validation.Validator +Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); +Pet myPet = new Pet().name(JsonNullable.of("My Pet's really long name")); +Set> validationResult = validator.validate(myPet); +assertEquals(1, validationResult.size()); +``` + +## Limitations + +* Doesn't work with `@JsonUnwrapped`. diff --git a/src/test/java/org/openapitools/jackson/nullable/JsonNullWithEmptyTest.java b/src/test/java/org/openapitools/jackson/nullable/JsonNullWithEmptyTest.java index 4889900..c7db26a 100644 --- a/src/test/java/org/openapitools/jackson/nullable/JsonNullWithEmptyTest.java +++ b/src/test/java/org/openapitools/jackson/nullable/JsonNullWithEmptyTest.java @@ -31,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(); @@ -80,6 +95,26 @@ void testBooleanWithEmptyWithMapBlankStringToNull() throws Exception { 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); From 1de12ad30f739f9d8883ac07cd23c385036cb90f Mon Sep 17 00:00:00 2001 From: Thorsrud22 <229304553+Thorsrud22@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:30:31 +0200 Subject: [PATCH 3/3] README: build and keep the Jackson 3 mapper in the blank-string example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6393236..ca5914c 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ send a blank string to mean "clear this value" (common with PATCH requests), ena `mapBlankStringToNull` so it deserializes to `JsonNullable.of(null)` instead: ```java mapper.registerModule(new JsonNullableModule().mapBlankStringToNull(true)); -// Jackson 3: JsonMapper.builder().addModule(new JsonNullableJackson3Module().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);