diff --git a/changelog.md b/changelog.md
index 222ab339..375a3af0 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,5 +1,11 @@
# Changelog
+## v1.9.0
+
+### Oct 13, 2025
+
+- Feature : Variant Group support
+
## v1.8.0
### Sep 15, 2025
diff --git a/pom.xml b/pom.xml
index f2e0e305..0b0ceaee 100644
--- a/pom.xml
+++ b/pom.xml
@@ -7,7 +7,7 @@
cms
jar
contentstack-management-java
- 1.8.0
+ 1.9.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/stack/Stack.java b/src/main/java/com/contentstack/cms/stack/Stack.java
index 35af7ccd..19dbe043 100644
--- a/src/main/java/com/contentstack/cms/stack/Stack.java
+++ b/src/main/java/com/contentstack/cms/stack/Stack.java
@@ -377,6 +377,29 @@ public GlobalField globalField(@NotNull String globalFiledUid) {
return new GlobalField(this.client,this.headers,globalFiledUid);
}
+ /**
+ * Creates a new instance of VariantGroup for managing variant groups.
+ * This method is used when you want to create a new variant group.
+ *
+ * @return A new VariantGroup instanceroup variantGroup() {
+ return new VariantGroup(this.client,this.headers);
+ }
+ */
+ public VariantGroup variantGroup() {
+ return new VariantGroup(this.client,this.headers);
+ }
+
+ /**
+ * Creates a new instance of VariantGroup for managing a specific variant group.
+ * This method is used when you want to work with an existing variant group.
+ *
+ * @param variantGroupUid The UID of the variant group to manage
+ * @return A new VariantGroup instance configured for the specified variant group
+ */
+ public VariantGroup variantGroup(@NotNull String variantGroupUid) {
+ return new VariantGroup(this.client,this.headers,variantGroupUid);
+ }
+
/**
* Contentstack has a sophisticated multilingual capability. It allows you to
* create and publish entries in any
diff --git a/src/main/java/com/contentstack/cms/stack/VariantGroup.java b/src/main/java/com/contentstack/cms/stack/VariantGroup.java
new file mode 100644
index 00000000..17b2f557
--- /dev/null
+++ b/src/main/java/com/contentstack/cms/stack/VariantGroup.java
@@ -0,0 +1,236 @@
+package com.contentstack.cms.stack;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.jetbrains.annotations.NotNull;
+import org.json.simple.JSONArray;
+import org.json.simple.JSONObject;
+
+import com.contentstack.cms.BaseImplementation;
+
+import okhttp3.ResponseBody;
+import retrofit2.Call;
+import retrofit2.Retrofit;
+
+/**
+ * The VariantGroup class provides functionality to manage variant groups in Contentstack.
+ * Variant groups allow you to manage different versions of your content for various use cases,
+ * such as A/B testing, localization, or personalization.
+ */
+public class VariantGroup implements BaseImplementation {
+ protected final VariantsService service;
+ protected final Map headers;
+ protected Map params;
+ private final Retrofit instance;
+ private String variantGroupUid;
+ private List branches;
+
+ /**
+ * Creates a new VariantGroup instance without a specific variant group UID.
+ * This constructor is used when creating new variant groups.
+ *
+ * @param instance The Retrofit instance for making API calls
+ * @param headers The headers to be included in API requests
+ */
+ protected VariantGroup(Retrofit instance, Map headers) {
+ this.headers = new HashMap<>();
+ this.headers.putAll(headers);
+ this.params = new HashMap<>();
+ this.instance = instance;
+ this.service = instance.create(VariantsService.class);
+ this.branches = Arrays.asList("main"); // Default to main branch
+ }
+
+ /**
+ * Creates a new VariantGroup instance with a specific variant group UID.
+ * This constructor is used when working with existing variant groups.
+ *
+ * @param instance The Retrofit instance for making API calls
+ * @param headers The headers to be included in API requests
+ * @param variantGroupUid The unique identifier of the variant group
+ */
+ protected VariantGroup(Retrofit instance, Map headers, String variantGroupUid) {
+ this.headers = new HashMap<>();
+ this.headers.putAll(headers);
+ this.params = new HashMap<>();
+ this.instance = instance;
+ this.variantGroupUid = variantGroupUid;
+ this.service = instance.create(VariantsService.class);
+ this.branches = Arrays.asList("main"); // Default to main branch
+ }
+
+ /**
+ * Validates that the variant group UID is not null or empty.
+ * This method is called before operations that require a valid variant group UID.
+ *
+ * @throws IllegalAccessError if the variant group UID is null or empty
+ */
+ void validate() {
+ if (this.variantGroupUid == null || this.variantGroupUid.isEmpty())
+ throw new IllegalAccessError("Variant group uid can not be null or empty");
+ }
+
+ /**
+ * Sets the branches for the variant group using a List of branch names.
+ * These branches will be used when linking or unlinking content types to the variant group.
+ *
+ * @param branches A List of String values representing the branch names
+ * @return The current VariantGroup instance for method chaining
+ */
+ public VariantGroup setBranches(List branches) {
+ this.branches = branches;
+ return this;
+ }
+
+ /**
+ * Sets the branches for the variant group using varargs (variable number of arguments).
+ * This is a convenience method that allows passing branch names directly as arguments.
+ * These branches will be used when linking or unlinking content types to the variant group.
+ *
+ * @param branches Variable number of String arguments representing branch names
+ * @return The current VariantGroup instance for method chaining
+ */
+ public VariantGroup setBranches(String... branches) {
+ this.branches = Arrays.asList(branches);
+ return this;
+ }
+
+ /**
+ * @param key A string representing the key of the parameter. It cannot be
+ * null and must be
+ * provided as a non-null value.
+ * @param value The "value" parameter is of type Object, which means it can
+ * accept any type of
+ * object as its value.
+ * @return instance of VariantGroup
+ */
+ @Override
+ public VariantGroup addParam(@NotNull String key, @NotNull Object value) {
+ this.params.put(key, value);
+ return this;
+ }
+
+ /**
+ * @param key The key parameter is a string that represents the name or
+ * identifier of the header.
+ * It is used to specify the type of information being sent in the
+ * header.
+ * @param value The value parameter is a string that represents the value of the
+ * header.
+ * @return instance of VariantGroup
+ */
+ @Override
+ public VariantGroup addHeader(@NotNull String key, @NotNull String value) {
+ this.headers.put(key, value);
+ return this;
+ }
+
+ /**
+ * @param headers A HashMap containing key-value pairs of headers, where the key
+ * is a String
+ * representing the header name and the value is a String
+ * representing the header value.
+ * @return instance of VariantGroup
+ */
+ @Override
+ public VariantGroup addHeaders(@NotNull HashMap headers) {
+ this.headers.putAll(headers);
+ return this;
+ }
+
+
+ /**
+ * @param headers The "params" parameter is a HashMap that maps String keys to
+ * Object values. It is
+ * annotated with @NotNull, indicating that it cannot be null.
+ * @return instance of VariantGroup
+ */
+ @Override
+ public VariantGroup addParams(@NotNull HashMap headers) {
+ this.params.putAll(headers);
+ return this;
+ }
+
+
+ /**
+ * clears all params in the request
+ */
+ protected void clearParams() {
+ this.params.clear();
+ }
+
+ /**
+ * Retrieves a list of all variant groups.
+ * This method does not require a variant group UID to be set.
+ *
+ * @return A Call object that can be executed to perform the API request to fetch all variant groups
+ */
+ public Call find() {
+ return this.service.fetchVariantGroups(this.headers, this.params);
+ }
+
+ /**
+ * Links content types to the variant group.
+ *
+ * @param contentTypeUids Array of content type UIDs to link to the variant group
+ * @return A Call object that can be executed to perform the API request
+ * @throws IllegalAccessError if the variant group UID is not set
+ * @throws IllegalArgumentException if contentTypeUids is empty
+ */
+ public Call linkContentTypes(@NotNull String... contentTypeUids) {
+ if (contentTypeUids.length == 0) {
+ throw new IllegalArgumentException("Content type UIDs cannot be empty");
+ }
+ return updateContentTypeLinks(contentTypeUids, true);
+ }
+
+ /**
+ * Unlinks content types from the variant group.
+ *
+ * @param contentTypeUids Array of content type UIDs to unlink from the variant group
+ * @return A Call object that can be executed to perform the API request
+ * @throws IllegalAccessError if the variant group UID is not set
+ * @throws IllegalArgumentException if contentTypeUids is empty
+ */
+ public Call unlinkContentTypes(@NotNull String... contentTypeUids) {
+ if (contentTypeUids.length == 0) {
+ throw new IllegalArgumentException("Content type UIDs cannot be empty");
+ }
+ return updateContentTypeLinks(contentTypeUids, false);
+ }
+
+ /**
+ * Updates the linking status of content types to a variant group.
+ * This private method handles both linking and unlinking operations.
+ *
+ * @param contentTypeUids Array of content type UIDs to update
+ * @param isLink true to link content types, false to unlink
+ * @return A Call object that can be executed to perform the API request
+ * @throws IllegalAccessError if the variant group UID is not set
+ */
+ @SuppressWarnings("unchecked")
+ private Call updateContentTypeLinks(String[] contentTypeUids, boolean isLink) {
+ validate();
+
+ // Construct the request body
+ JSONObject requestBody = new JSONObject();
+ JSONArray contentTypes = new JSONArray();
+ JSONArray branches = new JSONArray();
+ for (String branch : this.branches) {
+ branches.add(branch);
+ }
+
+ for (String uid : contentTypeUids) {
+ JSONObject contentType = new JSONObject();
+ contentType.put("uid", uid);
+ contentType.put("status", isLink ? "linked" : "unlinked");
+ contentTypes.add(contentType);
+ }
+ requestBody.put("uid", this.variantGroupUid);
+ requestBody.put("branches", branches);
+ requestBody.put("content_types", contentTypes);
+ return this.service.updateVariantGroupContentTypes(this.headers, this.variantGroupUid, this.params, requestBody);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/contentstack/cms/stack/VariantsService.java b/src/main/java/com/contentstack/cms/stack/VariantsService.java
new file mode 100644
index 00000000..43bdba5c
--- /dev/null
+++ b/src/main/java/com/contentstack/cms/stack/VariantsService.java
@@ -0,0 +1,32 @@
+package com.contentstack.cms.stack;
+
+import java.util.Map;
+
+import org.json.simple.JSONObject;
+
+import okhttp3.ResponseBody;
+import retrofit2.Call;
+import retrofit2.http.Body;
+import retrofit2.http.GET;
+import retrofit2.http.HeaderMap;
+import retrofit2.http.PUT;
+import retrofit2.http.Path;
+import retrofit2.http.QueryMap;
+
+/**
+ * Service interface for variant group related API endpoints.
+ */
+public interface VariantsService {
+
+ @GET("variant_groups")
+ Call fetchVariantGroups(
+ @HeaderMap Map headers,
+ @QueryMap Map queryParam);
+
+ @PUT("variant_groups/{variant_group_uid}/variants")
+ Call updateVariantGroupContentTypes(
+ @HeaderMap Map headers,
+ @Path("variant_group_uid") String variantGroupUid,
+ @QueryMap Map queryParam,
+ @Body JSONObject body);
+}
\ No newline at end of file
diff --git a/src/test/java/com/contentstack/cms/TestClient.java b/src/test/java/com/contentstack/cms/TestClient.java
index 37444610..ec5484a3 100644
--- a/src/test/java/com/contentstack/cms/TestClient.java
+++ b/src/test/java/com/contentstack/cms/TestClient.java
@@ -17,12 +17,15 @@ public class TestClient {
public final static String USER_ID = (env.get("userId") != null) ? env.get("userId") : "c11e668e0295477f";
public final static String OWNERSHIP = (env.get("ownershipToken") != null) ? env.get("ownershipToken")
: "ownershipTokenId";
+ // file deepcode ignore NonCryptoHardcodedSecret/test:
public final static String API_KEY = (env.get("apiKey") != null) ? env.get("apiKey") : "apiKey99999999";
public final static String MANAGEMENT_TOKEN = (env.get("managementToken") != null) ? env.get("managementToken")
: "managementToken99999999";
public final static String DEV_HOST = "api.contentstack.io";
// (env.get("dev_host") != null) ? env.get("dev_host") : "api.contentstack.io";
+ public final static String VARIANT_GROUP_UID = (env.get("variantGroupUid") != null) ? env.get("variantGroupUid")
+ : "variantGroupUid99999999";
private static Contentstack instance;
private static Stack stackInstance;
diff --git a/src/test/java/com/contentstack/cms/models/LoginDetailTest.java b/src/test/java/com/contentstack/cms/models/LoginDetailTest.java
index b73ec23f..5e070556 100644
--- a/src/test/java/com/contentstack/cms/models/LoginDetailTest.java
+++ b/src/test/java/com/contentstack/cms/models/LoginDetailTest.java
@@ -102,6 +102,7 @@ void getterSetterUserModelLastName() {
@Test
void getterSetterUserModelUsername() {
UserModel userModel = new UserModel();
+ // deepcode ignore NoHardcodedCredentials/test:
userModel.setUsername("***REMOVED***");
Assertions.assertEquals("***REMOVED***",
userModel.getUsername());
diff --git a/src/test/java/com/contentstack/cms/stack/APISanityTestSuite.java b/src/test/java/com/contentstack/cms/stack/APISanityTestSuite.java
index afd01049..8aa24dce 100644
--- a/src/test/java/com/contentstack/cms/stack/APISanityTestSuite.java
+++ b/src/test/java/com/contentstack/cms/stack/APISanityTestSuite.java
@@ -19,9 +19,11 @@
StackAPITest.class,
TokenAPITest.class,
OrgApiTests.class,
- GlobalFieldAPITest.class
+ GlobalFieldAPITest.class,
+ VariantGroupAPITest.class,
+ VariantGroupTest.class
- })
+})
public class APISanityTestSuite {
}
\ No newline at end of file
diff --git a/src/test/java/com/contentstack/cms/stack/VariantGroupAPITest.java b/src/test/java/com/contentstack/cms/stack/VariantGroupAPITest.java
new file mode 100644
index 00000000..7bd7c062
--- /dev/null
+++ b/src/test/java/com/contentstack/cms/stack/VariantGroupAPITest.java
@@ -0,0 +1,43 @@
+package com.contentstack.cms.stack;
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import com.contentstack.cms.TestClient;
+
+import okhttp3.Request;
+
+class VariantGroupAPITest {
+
+ private static final String API_KEY = TestClient.API_KEY;
+ private static final String MANAGEMENT_TOKEN = TestClient.MANAGEMENT_TOKEN;
+ private static final String VARIANT_GROUP_UID = TestClient.VARIANT_GROUP_UID;
+ private final VariantGroup variantGroup = TestClient.getClient().stack(API_KEY, MANAGEMENT_TOKEN).variantGroup();
+
+ @Test
+ void testFetchVariantGroups() throws IOException, InterruptedException {
+ variantGroup.addParam("include_count", true);
+ variantGroup.addParam("include_variant_info", true);
+ Request request = variantGroup.find().request();
+ Assertions.assertEquals("GET", request.method());
+ Assertions.assertEquals("https://api.contentstack.io/v3/variant_groups?include_variant_info=true&include_count=true", request.url().toString());
+ }
+
+ @Test
+ void testLinkContentTypes() throws IOException, InterruptedException {
+ VariantGroup variantGroupWithUID = TestClient.getClient().stack(API_KEY, MANAGEMENT_TOKEN).variantGroup(VARIANT_GROUP_UID);
+ Request request = variantGroupWithUID.linkContentTypes("author", "page").request();
+ Assertions.assertEquals("PUT", request.method());
+ Assertions.assertEquals("https://api.contentstack.io/v3/variant_groups/" + VARIANT_GROUP_UID + "/variants", request.url().toString());
+ }
+
+ @Test
+ void testUnlinkContentTypes() throws IOException, InterruptedException {
+ VariantGroup variantGroupWithUID = TestClient.getClient().stack(API_KEY, MANAGEMENT_TOKEN).variantGroup(VARIANT_GROUP_UID);
+ Request request = variantGroupWithUID.unlinkContentTypes("author", "page").request();
+ Assertions.assertEquals("PUT", request.method());
+ Assertions.assertEquals("https://api.contentstack.io/v3/variant_groups/" + VARIANT_GROUP_UID + "/variants", request.url().toString());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/contentstack/cms/stack/VariantGroupTest.java b/src/test/java/com/contentstack/cms/stack/VariantGroupTest.java
new file mode 100644
index 00000000..5bb5ca03
--- /dev/null
+++ b/src/test/java/com/contentstack/cms/stack/VariantGroupTest.java
@@ -0,0 +1,170 @@
+package com.contentstack.cms.stack;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import com.contentstack.cms.TestClient;
+
+import okhttp3.Request;
+import okio.Buffer;
+
+class VariantGroupTest {
+
+ private static final String API_KEY = TestClient.API_KEY;
+ private static final String MANAGEMENT_TOKEN = TestClient.MANAGEMENT_TOKEN;
+ private static final String VARIANT_GROUP_UID = TestClient.VARIANT_GROUP_UID;
+ private VariantGroup variantGroup;
+
+ @BeforeEach
+ void setUp() {
+ variantGroup = TestClient.getClient().stack(API_KEY, MANAGEMENT_TOKEN).variantGroup(VARIANT_GROUP_UID);
+ }
+
+ @Test
+ void testValidate_WithValidUID() {
+ Assertions.assertDoesNotThrow(() -> variantGroup.validate());
+ }
+
+ @Test
+ void testValidate_WithNullUID() {
+ VariantGroup group = TestClient.getClient().stack(API_KEY, MANAGEMENT_TOKEN).variantGroup();
+ IllegalAccessError exception = Assertions.assertThrows(IllegalAccessError.class, group::validate);
+ Assertions.assertNotNull(exception.getMessage());
+ }
+
+ @Test
+ void testValidate_WithEmptyUID() {
+ VariantGroup group = TestClient.getClient().stack(API_KEY, MANAGEMENT_TOKEN).variantGroup("");
+ IllegalAccessError exception = Assertions.assertThrows(IllegalAccessError.class, group::validate);
+ Assertions.assertNotNull(exception.getMessage());
+ }
+
+ @Test
+ void testLinkContentTypes_WithEmptyArray() {
+ IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class,
+ () -> variantGroup.linkContentTypes());
+ Assertions.assertNotNull(exception.getMessage());
+ }
+
+ @Test
+ void testUnlinkContentTypes_WithEmptyArray() {
+ IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class,
+ () -> variantGroup.unlinkContentTypes());
+ Assertions.assertNotNull(exception.getMessage());
+ }
+
+ @Test
+ void testLinkContentTypes_SingleContentType() throws IOException {
+ Request request = variantGroup.linkContentTypes("test_content_type").request();
+ Assertions.assertEquals("PUT", request.method());
+ Assertions.assertEquals(
+ "https://api.contentstack.io/v3/variant_groups/" + VARIANT_GROUP_UID + "/variants",
+ request.url().toString()
+ );
+
+ Assertions.assertNotNull(request.body(), "Request body should not be null");
+ Buffer buffer = new Buffer();
+ request.body().writeTo(buffer);
+ String requestBody = buffer.readUtf8();
+
+ // Verify the request body contains all required fields
+ Assertions.assertTrue(requestBody.contains("\"uid\":\"" + VARIANT_GROUP_UID + "\""), "Request body should contain variant group UID");
+ Assertions.assertTrue(requestBody.contains("\"content_types\":["), "Request body should contain content_types array");
+ Assertions.assertTrue(requestBody.contains("\"uid\":\"test_content_type\""), "Request body should contain content type UID");
+ Assertions.assertTrue(requestBody.contains("\"status\":\"linked\""), "Request body should contain linked status");
+ Assertions.assertTrue(requestBody.contains("\"branches\":["), "Request body should contain branches array");
+ Assertions.assertTrue(requestBody.contains("\"main\""), "Request body should contain main branch");
+ }
+
+ @Test
+ void testUnlinkContentTypes_MultipleContentTypes() throws IOException {
+ Request request = variantGroup.unlinkContentTypes("type1", "type2", "type3").request();
+ Assertions.assertEquals("PUT", request.method());
+ Assertions.assertEquals(
+ "https://api.contentstack.io/v3/variant_groups/" + VARIANT_GROUP_UID + "/variants",
+ request.url().toString()
+ );
+
+ Assertions.assertNotNull(request.body(), "Request body should not be null");
+ Buffer buffer = new Buffer();
+ request.body().writeTo(buffer);
+ String requestBody = buffer.readUtf8();
+
+ // Verify the request body contains all required fields
+ Assertions.assertTrue(requestBody.contains("\"uid\":\"" + VARIANT_GROUP_UID + "\""), "Request body should contain variant group UID");
+ Assertions.assertTrue(requestBody.contains("\"content_types\":["), "Request body should contain content_types array");
+
+ // Verify each content type is included with unlinked status
+ Assertions.assertTrue(requestBody.contains("\"uid\":\"type1\""), "Request body should contain first content type");
+ Assertions.assertTrue(requestBody.contains("\"uid\":\"type2\""), "Request body should contain second content type");
+ Assertions.assertTrue(requestBody.contains("\"uid\":\"type3\""), "Request body should contain third content type");
+ Assertions.assertTrue(requestBody.contains("\"status\":\"unlinked\""), "Request body should contain unlinked status");
+
+ // Verify branches
+ Assertions.assertTrue(requestBody.contains("\"branches\":["), "Request body should contain branches array");
+ Assertions.assertTrue(requestBody.contains("\"main\""), "Request body should contain main branch");
+ }
+
+ @Test
+ void testFind_CallsCorrectEndpoint() throws IOException {
+ variantGroup.addParam("include_count", true);
+ Request request = variantGroup.find().request();
+ Assertions.assertEquals("GET", request.method());
+ Assertions.assertTrue(
+ request.url().toString().contains("include_count=true"),
+ "URL should include the added parameter"
+ );
+ }
+
+ @Test
+ void testSetBranches_WithList() throws IOException {
+ List testBranches = Arrays.asList("main", "development", "staging");
+ variantGroup.setBranches(testBranches);
+
+ Request request = variantGroup.linkContentTypes("test_content_type").request();
+ Assertions.assertNotNull(request.body(), "Request body should not be null");
+ Buffer buffer = new Buffer();
+ request.body().writeTo(buffer);
+ String requestBody = buffer.readUtf8();
+
+ for (String branch : testBranches) {
+ Assertions.assertTrue(
+ requestBody.contains(branch),
+ "Request body should contain branch: " + branch
+ );
+ }
+ }
+
+ @Test
+ void testSetBranches_WithVarargs() throws IOException {
+ variantGroup.setBranches("main", "feature-1", "feature-2");
+
+ Request request = variantGroup.linkContentTypes("test_content_type").request();
+ Assertions.assertNotNull(request.body(), "Request body should not be null");
+ Buffer buffer = new Buffer();
+ request.body().writeTo(buffer);
+ String requestBody = buffer.readUtf8();
+
+ Assertions.assertTrue(requestBody.contains("main"));
+ Assertions.assertTrue(requestBody.contains("feature-1"));
+ Assertions.assertTrue(requestBody.contains("feature-2"));
+ }
+
+ @Test
+ void testDefaultBranch() throws IOException {
+ VariantGroup newGroup = TestClient.getClient().stack(API_KEY, MANAGEMENT_TOKEN).variantGroup("test_uid");
+ Request request = newGroup.linkContentTypes("test_content_type").request();
+ Assertions.assertNotNull(request.body(), "Request body should not be null");
+ Buffer buffer = new Buffer();
+ request.body().writeTo(buffer);
+ String requestBody = buffer.readUtf8();
+
+ Assertions.assertTrue(requestBody.contains("main"), "Default branch should be 'main'");
+ }
+
+}
\ No newline at end of file