From f4834309916c9007ec00158822aa23f75380719e Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Wed, 21 Oct 2026 11:36:42 +0530 Subject: [PATCH] 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 --- changelog.md | 6 ++ pom.xml | 2 +- .../com/contentstack/cms/Contentstack.java | 24 +++++- .../cms/ContentstackUnitTest.java | 73 +++++++++++++++++++ 4 files changed, 103 insertions(+), 2 deletions(-) diff --git a/changelog.md b/changelog.md index 118dbb93..8543ea85 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,11 @@ # Changelog +## v1.15.0 + +### Oct 21, 2026 + +- 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. + ## v1.14.1 ### Aug 26, 2026 diff --git a/pom.xml b/pom.xml index 056f6622..da74f14a 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ cms jar contentstack-management-java - 1.14.1 + 1.15.0 Contentstack Java Management SDK for Content Management API, Contentstack is a headless CMS with an API-first approach diff --git a/src/main/java/com/contentstack/cms/Contentstack.java b/src/main/java/com/contentstack/cms/Contentstack.java index a4a75d22..9773a088 100644 --- a/src/main/java/com/contentstack/cms/Contentstack.java +++ b/src/main/java/com/contentstack/cms/Contentstack.java @@ -30,6 +30,7 @@ import com.contentstack.cms.stack.Stack; import com.contentstack.cms.user.User; import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import com.contentstack.cms.core.RetryConfig; import okhttp3.ConnectionPool; import okhttp3.OkHttpClient; @@ -696,6 +697,7 @@ public static class Builder { private Integer readTimeoutSeconds; private Integer writeTimeoutSeconds; private Boolean retry = Util.RETRY_ON_FAILURE;// Default base url for contentstack + private boolean serializeNulls = false; private RetryConfig retryConfig = RetryConfig.defaultConfig(); /** * Default ConnectionPool holds up to 5 idle connections which will be @@ -744,6 +746,22 @@ public Builder setRetry(@NotNull Boolean retry) { return this; } + /** + * Controls whether {@code null} values in request bodies are sent to the API. + *

+ * By default {@code null} values are dropped before the request is sent, so + * {@code "field": null} behaves exactly like leaving the field out. Enable this + * to send {@code null} explicitly, for example to clear a Number field's value + * on an entry. It applies to every request made through this client. + * + * @param serializeNulls {@code true} to send {@code null} values, {@code false} (default) to drop them + * @return the Builder + */ + public Builder setSerializeNulls(boolean serializeNulls) { + this.serializeNulls = serializeNulls; + return this; + } + /** * Set host for client instance * @@ -1015,8 +1033,12 @@ private void validateClient(Contentstack contentstack) { // of how the hostname was set (setHost, region resolution, or default). String validatedHost = validateHostname(this.hostname); String baseUrl = Util.PROTOCOL + "://" + validatedHost + "/" + version + "/"; + GsonBuilder gson = new GsonBuilder(); + if (serializeNulls) { + gson.serializeNulls(); + } this.instance = new Retrofit.Builder().baseUrl(baseUrl) - .addConverterFactory(GsonConverterFactory.create()) + .addConverterFactory(GsonConverterFactory.create(gson.create())) .client(httpClient(contentstack, this.retry)).build(); contentstack.instance = this.instance; diff --git a/src/test/java/com/contentstack/cms/ContentstackUnitTest.java b/src/test/java/com/contentstack/cms/ContentstackUnitTest.java index 1da49474..881913ee 100644 --- a/src/test/java/com/contentstack/cms/ContentstackUnitTest.java +++ b/src/test/java/com/contentstack/cms/ContentstackUnitTest.java @@ -2,7 +2,10 @@ import com.contentstack.cms.core.AuthInterceptor; import com.contentstack.cms.organization.Organization; +import com.contentstack.cms.stack.Entry; import com.contentstack.cms.stack.Stack; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import okhttp3.Headers; import okhttp3.OkHttpClient; import okhttp3.Protocol; @@ -12,7 +15,10 @@ import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; import org.junit.jupiter.api.Assertions; +import okio.Buffer; +import org.json.simple.JSONObject; import org.junit.jupiter.api.Test; +import retrofit2.Call; import retrofit2.Response; import java.io.IOException; @@ -412,4 +418,71 @@ public void testEarlyAccessHeaderEmpty() { Assertions.assertEquals(0, client.earlyAccess.length); } + @Test + void localizeBodyKeepsNullFieldWhenSerializeNullsEnabled() throws IOException { + Contentstack client = new Contentstack.Builder().setSerializeNulls(true).build(); + + JsonObject entry = sentEntry(entryOf(client).localize(entryBody(fieldsWith("position", null)), "de-de")); + + Assertions.assertTrue(entry.has("position")); + Assertions.assertTrue(entry.get("position").isJsonNull()); + } + + @Test + void localizeBodyDropsNullFieldByDefault() throws IOException { + Contentstack client = new Contentstack.Builder().build(); + + JsonObject entry = sentEntry(entryOf(client).localize(entryBody(fieldsWith("position", null)), "de-de")); + + Assertions.assertFalse(entry.has("position")); + Assertions.assertEquals("example", entry.get("title").getAsString()); + } + + @Test + void localizeBodyKeepsNullInsideGroupFieldWhenSerializeNullsEnabled() throws IOException { + Contentstack client = new Contentstack.Builder().setSerializeNulls(true).build(); + Map group = new HashMap<>(); + group.put("label", "kept"); + group.put("position", null); + + JsonObject entry = sentEntry(entryOf(client).localize(entryBody(fieldsWith("group", group)), "de-de")); + + JsonObject sentGroup = entry.getAsJsonObject("group"); + Assertions.assertTrue(sentGroup.get("position").isJsonNull()); + Assertions.assertEquals("kept", sentGroup.get("label").getAsString()); + } + + @Test + void updateBodyKeepsNullFieldWhenSerializeNullsEnabled() throws IOException { + Contentstack client = new Contentstack.Builder().setSerializeNulls(true).build(); + + JsonObject entry = sentEntry(entryOf(client).update(entryBody(fieldsWith("position", null)))); + + Assertions.assertTrue(entry.has("position")); + Assertions.assertTrue(entry.get("position").isJsonNull()); + } + + private static Map fieldsWith(String field, Object value) { + Map fields = new HashMap<>(); + fields.put("title", "example"); + fields.put(field, value); + return fields; + } + + private static JSONObject entryBody(Map fields) { + Map body = new HashMap<>(); + body.put("entry", fields); + return new JSONObject(body); + } + + private static Entry entryOf(Contentstack client) { + return client.stack("api_key", "management_token").contentType("newspage").entry("entry_uid"); + } + + private static JsonObject sentEntry(Call call) throws IOException { + Buffer buffer = new Buffer(); + call.request().body().writeTo(buffer); + return JsonParser.parseString(buffer.readUtf8()).getAsJsonObject().getAsJsonObject("entry"); + } + }