Skip to content

Commit fac561d

Browse files
authored
Handle null values in serialization when the missing properties workaround is enabled (#1235)
* Handle null values in serialization when the missing properties workaround is enabled (#557) * add unit tests for NullSafeJsonGenerator * address review: extend DelegatingJsonGenerator, broaden test coverage * use random mapper for Map and POJO serialization tests
1 parent c26273e commit fac561d

4 files changed

Lines changed: 269 additions & 2 deletions

File tree

java-client/src/main/java/co/elastic/clients/json/JsonpUtils.java

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import co.elastic.clients.json.jackson.JacksonJsonProvider;
2323
import co.elastic.clients.util.AllowForbiddenApis;
24+
import co.elastic.clients.util.ApiTypeHelper;
2425
import jakarta.json.JsonException;
2526
import jakarta.json.JsonObject;
2627
import jakarta.json.JsonString;
@@ -498,7 +499,12 @@ public void close() {
498499
}
499500
};
500501

501-
try(JsonGenerator generator = mapper.jsonProvider().createGenerator(writer)) {
502+
try(JsonGenerator rawGenerator = mapper.jsonProvider().createGenerator(writer)) {
503+
// When the missing-properties workaround is active, wrap the generator so that null
504+
// reference values are serialized as JSON null instead of throwing NPE. See #557.
505+
JsonGenerator generator = ApiTypeHelper.requiredPropertiesCheckDisabled()
506+
? new NullSafeJsonGenerator(rawGenerator)
507+
: rawGenerator;
502508
value.serialize(generator, mapper);
503509
} catch (ToStringTooLongException e) {
504510
// Ignore
@@ -508,7 +514,12 @@ public void close() {
508514

509515
public static String toJsonString(Object value, JsonpMapper mapper) {
510516
StringWriter writer = new StringWriter();
511-
JsonGenerator generator = mapper.jsonProvider().createGenerator(writer);
517+
JsonGenerator rawGenerator = mapper.jsonProvider().createGenerator(writer);
518+
// When the missing-properties workaround is active, wrap the generator so that null
519+
// reference values are serialized as JSON null instead of throwing NPE. See #557.
520+
JsonGenerator generator = ApiTypeHelper.requiredPropertiesCheckDisabled()
521+
? new NullSafeJsonGenerator(rawGenerator)
522+
: rawGenerator;
512523
mapper.serialize(value, generator);
513524
generator.close();
514525
return writer.toString();
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package co.elastic.clients.json;
21+
22+
import jakarta.json.JsonValue;
23+
import jakarta.json.stream.JsonGenerator;
24+
25+
import java.math.BigDecimal;
26+
import java.math.BigInteger;
27+
28+
/**
29+
* A {@link DelegatingJsonGenerator} that translates {@code write(...)} calls with a {@code null} reference value
30+
* into a {@link JsonGenerator#writeNull() writeNull} call. The {@code write(name, value)} overloads in
31+
* {@link DelegatingJsonGenerator} are {@code final} and split into {@code writeKey(name)} + {@code write(value)},
32+
* so overriding the value-only methods here is enough to also cover the name+value variants via virtual dispatch.
33+
*
34+
* <p>This is used as a safety net when {@link co.elastic.clients.util.ApiTypeHelper#DANGEROUS_disableRequiredPropertiesCheck(boolean)}
35+
* is active: with the workaround enabled, deserialization can leave required reference fields as {@code null},
36+
* and the generated {@code serializeInternal} methods would otherwise call e.g. {@code generator.write((String) null)},
37+
* which throws {@link NullPointerException} in JSON-P providers such as Parsson.
38+
*
39+
* <p>See <a href="https://github.com/elastic/elasticsearch-java/issues/557">#557</a>.
40+
*/
41+
class NullSafeJsonGenerator extends DelegatingJsonGenerator {
42+
43+
NullSafeJsonGenerator(JsonGenerator delegate) {
44+
super(delegate);
45+
}
46+
47+
@Override
48+
public JsonGenerator write(String value) {
49+
if (value == null) {
50+
generator.writeNull();
51+
return this;
52+
}
53+
return super.write(value);
54+
}
55+
56+
@Override
57+
public JsonGenerator write(BigDecimal value) {
58+
if (value == null) {
59+
generator.writeNull();
60+
return this;
61+
}
62+
return super.write(value);
63+
}
64+
65+
@Override
66+
public JsonGenerator write(BigInteger value) {
67+
if (value == null) {
68+
generator.writeNull();
69+
return this;
70+
}
71+
return super.write(value);
72+
}
73+
74+
@Override
75+
public JsonGenerator write(JsonValue value) {
76+
if (value == null) {
77+
generator.writeNull();
78+
return this;
79+
}
80+
return super.write(value);
81+
}
82+
}

java-client/src/test/java/co/elastic/clients/elasticsearch/model/BehaviorsTest.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,4 +468,38 @@ public void testDangerousDisablePropertyCheckPrimitive(){
468468
assertTrue(healthResponse.toString()!=null);
469469
}
470470
}
471+
472+
@Test
473+
public void testDangerousDisablePropertyCheckReferenceSerialization() {
474+
// Repro for #557: with the workaround enabled, a null required reference
475+
// field (here: clusterName, a required String) causes an NPE during
476+
// re-serialization because HealthResponseBody#serializeInternal calls
477+
// generator.write((String) null).
478+
try (ApiTypeHelper.DisabledChecksHandle h =
479+
ApiTypeHelper.DANGEROUS_disableRequiredPropertiesCheck(true)) {
480+
HealthResponse healthResponse = HealthResponse.of(hr -> hr.withJson(new StringReader("{\n" +
481+
// cluster_name (required) intentionally omitted
482+
" \"status\" : \"green\",\n" +
483+
" \"timed_out\" : false,\n" +
484+
" \"number_of_nodes\" : 3,\n" +
485+
" \"number_of_data_nodes\" : 2,\n" +
486+
" \"active_primary_shards\" : 88,\n" +
487+
" \"active_shards\" : 176,\n" +
488+
" \"relocating_shards\" : 0,\n" +
489+
" \"initializing_shards\" : 0,\n" +
490+
" \"unassigned_shards\" : 0,\n" +
491+
" \"delayed_unassigned_shards\" : 0,\n" +
492+
" \"number_of_pending_tasks\" : 0,\n" +
493+
" \"number_of_in_flight_fetch\" : 0,\n" +
494+
" \"task_max_waiting_in_queue_millis\" : 0,\n" +
495+
" \"active_shards_percent_as_number\" : 100.0\n" +
496+
"}")));
497+
498+
// deserialization succeeds — clusterName is null because the workaround is active
499+
assertTrue(healthResponse.clusterName() == null);
500+
501+
// round-trip currently fails with NullPointerException — this is bug #557
502+
assertTrue(healthResponse.toString() != null);
503+
}
504+
}
471505
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package co.elastic.clients.json;
21+
22+
import co.elastic.clients.testkit.ModelTestCase;
23+
import co.elastic.clients.util.ApiTypeHelper;
24+
import jakarta.json.JsonValue;
25+
import jakarta.json.stream.JsonGenerator;
26+
import org.junit.jupiter.api.Assumptions;
27+
import org.junit.jupiter.api.Test;
28+
29+
import java.io.StringWriter;
30+
import java.math.BigDecimal;
31+
import java.math.BigInteger;
32+
import java.util.LinkedHashMap;
33+
import java.util.Map;
34+
35+
public class NullSafeJsonGeneratorTest extends ModelTestCase {
36+
37+
@Test
38+
public void testNullReferencesAreWrittenAsJsonNull() {
39+
StringWriter writer = new StringWriter();
40+
try (JsonGenerator generator = new NullSafeJsonGenerator(mapper.jsonProvider().createGenerator(writer))) {
41+
generator.writeStartObject();
42+
43+
// writeKey + write(value) overloads with null reference values
44+
generator.writeKey("string_field");
45+
generator.write((String) null);
46+
generator.writeKey("decimal_field");
47+
generator.write((BigDecimal) null);
48+
generator.writeKey("integer_field");
49+
generator.write((BigInteger) null);
50+
generator.writeKey("json_value_field");
51+
generator.write((JsonValue) null);
52+
53+
// name+value overloads with null reference values — DelegatingJsonGenerator splits these into
54+
// writeKey(name) + write(value), so they reach the overrides above via virtual dispatch.
55+
generator.write("named_string", (String) null);
56+
generator.write("named_decimal", (BigDecimal) null);
57+
generator.write("named_integer", (BigInteger) null);
58+
generator.write("named_json_value", (JsonValue) null);
59+
60+
generator.writeEnd();
61+
}
62+
63+
assertEquals(
64+
"{" +
65+
"\"string_field\":null," +
66+
"\"decimal_field\":null," +
67+
"\"integer_field\":null," +
68+
"\"json_value_field\":null," +
69+
"\"named_string\":null," +
70+
"\"named_decimal\":null," +
71+
"\"named_integer\":null," +
72+
"\"named_json_value\":null" +
73+
"}",
74+
writer.toString()
75+
);
76+
}
77+
78+
@Test
79+
public void testNonNullValuesPassThrough() {
80+
StringWriter writer = new StringWriter();
81+
try (JsonGenerator generator = new NullSafeJsonGenerator(mapper.jsonProvider().createGenerator(writer))) {
82+
generator.writeStartObject();
83+
generator.write("string_field", "hello");
84+
generator.write("int_field", 42);
85+
generator.write("long_field", 10000000000L);
86+
generator.write("double_field", 1.5);
87+
generator.write("bool_field", true);
88+
generator.write("decimal_field", new BigDecimal("3.14"));
89+
generator.write("integer_field", new BigInteger("12345"));
90+
generator.writeEnd();
91+
}
92+
93+
assertEquals(
94+
"{" +
95+
"\"string_field\":\"hello\"," +
96+
"\"int_field\":42," +
97+
"\"long_field\":10000000000," +
98+
"\"double_field\":1.5," +
99+
"\"bool_field\":true," +
100+
"\"decimal_field\":3.14," +
101+
"\"integer_field\":12345" +
102+
"}",
103+
writer.toString()
104+
);
105+
}
106+
107+
@Test
108+
public void testToJsonStringWithMapWhileWorkaroundActive() {
109+
// SimpleJsonpMapper only serializes types registered in the Java client and doesn't support arbitrary Maps.
110+
Assumptions.assumeFalse(jsonImpl == JsonImpl.Simple, "SimpleJsonpMapper does not support arbitrary Map serialization");
111+
Map<String, Object> map = new LinkedHashMap<>();
112+
map.put("a", 1);
113+
map.put("b", "two");
114+
115+
try (ApiTypeHelper.DisabledChecksHandle h =
116+
ApiTypeHelper.DANGEROUS_disableRequiredPropertiesCheck(true)) {
117+
assertEquals("{\"a\":1,\"b\":\"two\"}", JsonpUtils.toJsonString(map, mapper));
118+
}
119+
}
120+
121+
@Test
122+
public void testToJsonStringWithPojoWhileWorkaroundActive() {
123+
Assumptions.assumeFalse(jsonImpl == JsonImpl.Simple, "SimpleJsonpMapper does not support POJO serialization");
124+
SomePojo pojo = new SomePojo();
125+
pojo.name = "test";
126+
pojo.value = 42;
127+
128+
try (ApiTypeHelper.DisabledChecksHandle h =
129+
ApiTypeHelper.DANGEROUS_disableRequiredPropertiesCheck(true)) {
130+
String json = JsonpUtils.toJsonString(pojo, mapper);
131+
assertTrue(json.contains("\"name\":\"test\""), "Expected name field, got: " + json);
132+
assertTrue(json.contains("\"value\":42"), "Expected value field, got: " + json);
133+
}
134+
}
135+
136+
public static class SomePojo {
137+
public String name;
138+
public int value;
139+
}
140+
}

0 commit comments

Comments
 (0)