Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@ assertEquals(JsonNullable.<String>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<Integer> age property:
assertEquals(JsonNullable.<Integer>of(null), mapper.readValue("{\"age\":\"\"}", Person.class).age);
```
`JsonNullable<String>` 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`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public class JsonNullableJackson2Deserializer extends ReferenceTypeDeserializer<
private static final long serialVersionUID = 1L;

private boolean isStringDeserializer = false;
private final boolean mapBlankStringToNull;

/*
/**********************************************************
Expand All @@ -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);
}
Expand All @@ -45,7 +53,7 @@ public JsonNullable<Object> 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);
Expand All @@ -54,7 +62,7 @@ public JsonNullable<Object> deserialize(JsonParser p, DeserializationContext ctx
@Override
public JsonNullableJackson2Deserializer withResolved(TypeDeserializer typeDeser, JsonDeserializer<?> valueDeser) {
return new JsonNullableJackson2Deserializer(_fullType, _valueInstantiator,
typeDeser, valueDeser);
typeDeser, valueDeser, mapBlankStringToNull);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public class JsonNullableJackson3Deserializer extends ReferenceTypeDeserializer<


private boolean isStringDeserializer = false;
private final boolean mapBlankStringToNull;

/*
/**********************************************************
Expand All @@ -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);
}
Expand All @@ -43,7 +51,7 @@ public JsonNullable<Object> 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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When enabled, a Unicode whitespace-only string such as "\u2003" bypasses this return and Jackson attempts to parse it as the target value, causing an exception instead of producing present null. Use a Unicode-aware blank predicate before this branch.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/main/java/org/openapitools/jackson/nullable/JsonNullableJackson3Deserializer.java, line 54:

<comment>When enabled, a Unicode whitespace-only string such as `"\u2003"` bypasses this return and Jackson attempts to parse it as the target value, causing an exception instead of producing present null. Use a Unicode-aware blank predicate before this branch.</comment>

<file context>
@@ -43,7 +51,7 @@ public JsonNullable<Object> deserialize(JsonParser p, DeserializationContext ctx
             String str = p.getString().trim();
             if (str.isEmpty()) {
-                return JsonNullable.undefined();
+                return mapBlankStringToNull ? JsonNullable.of(null) : JsonNullable.undefined();
             }
         }
</file context>

}
}
return super.deserialize(p, ctxt);
Expand All @@ -52,7 +60,7 @@ public JsonNullable<Object> deserialize(JsonParser p, DeserializationContext ctx
@Override
protected ReferenceTypeDeserializer<JsonNullable<Object>> withResolved(TypeDeserializer typeDeser, ValueDeserializer<?> valueDeser) {
return new JsonNullableJackson3Deserializer(_fullType, _valueInstantiator,
typeDeser, valueDeser);
typeDeser, valueDeser, mapBlankStringToNull);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()}.
*
* <p>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.
*
* <p>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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,31 @@
public class JsonNullableModule extends Module {

private final String NAME = "JsonNullableModule";
private boolean mapBlankStringToNull = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The flag is read only inside setupModule(), which runs once at registration. Calling mapBlankStringToNull() on an already-registered or shared/singleton module silently does nothing, because the deserializers were already constructed with the previous flag. Reserve the setter for pre-registration configuration (e.g. document that it must be called before registerModule) or make the module immutable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/main/java/org/openapitools/jackson/nullable/JsonNullableModule.java, line 10:

<comment>The flag is read only inside setupModule(), which runs once at registration. Calling mapBlankStringToNull() on an already-registered or shared/singleton module silently does nothing, because the deserializers were already constructed with the previous flag. Reserve the setter for pre-registration configuration (e.g. document that it must be called before registerModule) or make the module immutable.</comment>

<file context>
@@ -7,11 +7,31 @@
 public class JsonNullableModule extends Module {
 
     private final String NAME = "JsonNullableModule";
+    private boolean mapBlankStringToNull = false;
+
+    /**
</file context>


/**
* 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()}.
*
* <p>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.
*
* <p>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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -28,6 +31,21 @@ public BooleanBean(Boolean b) {
}
}

enum Color { RED, GREEN }

static class EnumBean {
public JsonNullable<Color> value;
}

static class Point {
public int x;
public int y;
}

static class PojoBean {
public JsonNullable<Point> value;
}

@BeforeEach
void setup() {
jsonProcessor.mapperWithModule();
Expand All @@ -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
Expand All @@ -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<JsonNullable<String>>() {
};
}
if (jsonProcessor instanceof Jackson3Processor) {
return new tools.jackson.core.type.TypeReference<JsonNullable<String>>() {
};
}
throw new RuntimeException("jsonProcessor type not implemented");
}
};

public abstract Object getType(JsonProcessor jsonProcessor);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
public interface JsonProcessor {
JsonProcessor mapperWithModule();

JsonProcessor mapperWithModule(boolean mapBlankStringToNull);

JsonProcessor setDateFormat(SimpleDateFormat simpleDateFormat);

JsonProcessor setDefaultPropertyInclusion(JsonInclude.Include incl);
Expand Down