Skip to content

Commit f483430

Browse files
reeshika-hclaude
andcommitted
feat: add opt-in Builder.setSerializeNulls to send explicit nulls (DX-29683)
The SDK serializes request bodies with a default Gson converter, which drops null values, so "field": null never reached the CMA and a field such as a Number on a localized entry could not be cleared. Add Contentstack.Builder.setSerializeNulls(boolean), off by default so existing behavior is unchanged. When enabled, explicit nulls are sent for every request made through the client. Bump version to 1.15.0 and add a changelog entry. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 112118f commit f483430

4 files changed

Lines changed: 103 additions & 2 deletions

File tree

‎changelog.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## v1.15.0
4+
5+
### Oct 21, 2026
6+
7+
- Feat: Added the opt-in `Contentstack.Builder.setSerializeNulls(boolean)` option. When enabled, `null` values in request bodies are sent to the API instead of being dropped, so a field's value (for example a Number field on a localized entry) can be cleared. Off by default; existing behavior is unchanged.
8+
39
## v1.14.1
410

511
### Aug 26, 2026

‎pom.xml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<artifactId>cms</artifactId>
88
<packaging>jar</packaging>
99
<name>contentstack-management-java</name>
10-
<version>1.14.1</version>
10+
<version>1.15.0</version>
1111
<description>Contentstack Java Management SDK for Content Management API, Contentstack is a headless CMS with an
1212
API-first approach
1313
</description>

‎src/main/java/com/contentstack/cms/Contentstack.java‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import com.contentstack.cms.stack.Stack;
3131
import com.contentstack.cms.user.User;
3232
import com.google.gson.Gson;
33+
import com.google.gson.GsonBuilder;
3334
import com.contentstack.cms.core.RetryConfig;
3435
import okhttp3.ConnectionPool;
3536
import okhttp3.OkHttpClient;
@@ -696,6 +697,7 @@ public static class Builder {
696697
private Integer readTimeoutSeconds;
697698
private Integer writeTimeoutSeconds;
698699
private Boolean retry = Util.RETRY_ON_FAILURE;// Default base url for contentstack
700+
private boolean serializeNulls = false;
699701
private RetryConfig retryConfig = RetryConfig.defaultConfig();
700702
/**
701703
* Default ConnectionPool holds up to 5 idle connections which will be
@@ -744,6 +746,22 @@ public Builder setRetry(@NotNull Boolean retry) {
744746
return this;
745747
}
746748

749+
/**
750+
* Controls whether {@code null} values in request bodies are sent to the API.
751+
* <p>
752+
* By default {@code null} values are dropped before the request is sent, so
753+
* {@code "field": null} behaves exactly like leaving the field out. Enable this
754+
* to send {@code null} explicitly, for example to clear a Number field's value
755+
* on an entry. It applies to every request made through this client.
756+
*
757+
* @param serializeNulls {@code true} to send {@code null} values, {@code false} (default) to drop them
758+
* @return the Builder
759+
*/
760+
public Builder setSerializeNulls(boolean serializeNulls) {
761+
this.serializeNulls = serializeNulls;
762+
return this;
763+
}
764+
747765
/**
748766
* Set host for client instance
749767
*
@@ -1015,8 +1033,12 @@ private void validateClient(Contentstack contentstack) {
10151033
// of how the hostname was set (setHost, region resolution, or default).
10161034
String validatedHost = validateHostname(this.hostname);
10171035
String baseUrl = Util.PROTOCOL + "://" + validatedHost + "/" + version + "/";
1036+
GsonBuilder gson = new GsonBuilder();
1037+
if (serializeNulls) {
1038+
gson.serializeNulls();
1039+
}
10181040
this.instance = new Retrofit.Builder().baseUrl(baseUrl)
1019-
.addConverterFactory(GsonConverterFactory.create())
1041+
.addConverterFactory(GsonConverterFactory.create(gson.create()))
10201042
.client(httpClient(contentstack, this.retry)).build();
10211043
contentstack.instance = this.instance;
10221044

‎src/test/java/com/contentstack/cms/ContentstackUnitTest.java‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
import com.contentstack.cms.core.AuthInterceptor;
44
import com.contentstack.cms.organization.Organization;
5+
import com.contentstack.cms.stack.Entry;
56
import com.contentstack.cms.stack.Stack;
7+
import com.google.gson.JsonObject;
8+
import com.google.gson.JsonParser;
69
import okhttp3.Headers;
710
import okhttp3.OkHttpClient;
811
import okhttp3.Protocol;
@@ -12,7 +15,10 @@
1215
import okhttp3.mockwebserver.MockWebServer;
1316
import okhttp3.mockwebserver.RecordedRequest;
1417
import org.junit.jupiter.api.Assertions;
18+
import okio.Buffer;
19+
import org.json.simple.JSONObject;
1520
import org.junit.jupiter.api.Test;
21+
import retrofit2.Call;
1622
import retrofit2.Response;
1723

1824
import java.io.IOException;
@@ -412,4 +418,71 @@ public void testEarlyAccessHeaderEmpty() {
412418
Assertions.assertEquals(0, client.earlyAccess.length);
413419
}
414420

421+
@Test
422+
void localizeBodyKeepsNullFieldWhenSerializeNullsEnabled() throws IOException {
423+
Contentstack client = new Contentstack.Builder().setSerializeNulls(true).build();
424+
425+
JsonObject entry = sentEntry(entryOf(client).localize(entryBody(fieldsWith("position", null)), "de-de"));
426+
427+
Assertions.assertTrue(entry.has("position"));
428+
Assertions.assertTrue(entry.get("position").isJsonNull());
429+
}
430+
431+
@Test
432+
void localizeBodyDropsNullFieldByDefault() throws IOException {
433+
Contentstack client = new Contentstack.Builder().build();
434+
435+
JsonObject entry = sentEntry(entryOf(client).localize(entryBody(fieldsWith("position", null)), "de-de"));
436+
437+
Assertions.assertFalse(entry.has("position"));
438+
Assertions.assertEquals("example", entry.get("title").getAsString());
439+
}
440+
441+
@Test
442+
void localizeBodyKeepsNullInsideGroupFieldWhenSerializeNullsEnabled() throws IOException {
443+
Contentstack client = new Contentstack.Builder().setSerializeNulls(true).build();
444+
Map<String, Object> group = new HashMap<>();
445+
group.put("label", "kept");
446+
group.put("position", null);
447+
448+
JsonObject entry = sentEntry(entryOf(client).localize(entryBody(fieldsWith("group", group)), "de-de"));
449+
450+
JsonObject sentGroup = entry.getAsJsonObject("group");
451+
Assertions.assertTrue(sentGroup.get("position").isJsonNull());
452+
Assertions.assertEquals("kept", sentGroup.get("label").getAsString());
453+
}
454+
455+
@Test
456+
void updateBodyKeepsNullFieldWhenSerializeNullsEnabled() throws IOException {
457+
Contentstack client = new Contentstack.Builder().setSerializeNulls(true).build();
458+
459+
JsonObject entry = sentEntry(entryOf(client).update(entryBody(fieldsWith("position", null))));
460+
461+
Assertions.assertTrue(entry.has("position"));
462+
Assertions.assertTrue(entry.get("position").isJsonNull());
463+
}
464+
465+
private static Map<String, Object> fieldsWith(String field, Object value) {
466+
Map<String, Object> fields = new HashMap<>();
467+
fields.put("title", "example");
468+
fields.put(field, value);
469+
return fields;
470+
}
471+
472+
private static JSONObject entryBody(Map<String, Object> fields) {
473+
Map<String, Object> body = new HashMap<>();
474+
body.put("entry", fields);
475+
return new JSONObject(body);
476+
}
477+
478+
private static Entry entryOf(Contentstack client) {
479+
return client.stack("api_key", "management_token").contentType("newspage").entry("entry_uid");
480+
}
481+
482+
private static JsonObject sentEntry(Call<ResponseBody> call) throws IOException {
483+
Buffer buffer = new Buffer();
484+
call.request().body().writeTo(buffer);
485+
return JsonParser.parseString(buffer.readUtf8()).getAsJsonObject().getAsJsonObject("entry");
486+
}
487+
415488
}

0 commit comments

Comments
 (0)