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