diff --git a/changelog.md b/changelog.md index 8543ea85..0f211e6a 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,11 @@ # Changelog +## v1.16.0 + +### Oct 28, 2026 + +- Feat: Branch create accepts an optional `branch.publish_config.environments` list of environment names, so a branch can clone only the environments it needs. `[]` clones none; omitting `publish_config` clones all environments, as before. The list is sent unchanged, the SDK adds no length limit, and an unknown name (or a mix) returns the API's 422 error body unchanged with no branch created. + ## v1.15.0 ### Oct 21, 2026 diff --git a/pom.xml b/pom.xml index da74f14a..69b3a27d 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ cms jar contentstack-management-java - 1.15.0 + 1.16.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/Branch.java b/src/main/java/com/contentstack/cms/stack/Branch.java index fdf93b52..629351f2 100644 --- a/src/main/java/com/contentstack/cms/stack/Branch.java +++ b/src/main/java/com/contentstack/cms/stack/Branch.java @@ -159,6 +159,32 @@ public Call fetch() { /** * The Create a branch request creates a new branch in a particular stack of * your organization. + *

+ * Optionally set {@code branch.publish_config.environments} to a list of + * environment names (not UIDs) to choose which environments the branch + * clones: + *

+ * The list is sent unchanged. The SDK does not validate the names or limit the + * list length. An unknown name, or a mix of known and unknown names, is + * rejected by the API with a 422; the API's error body is returned unchanged + * and no branch is created. + *

+ * Example body: + *

{@code
+     * JSONObject publishConfig = new JSONObject();
+     * publishConfig.put("environments", Arrays.asList("development", "staging"));
+     * JSONObject branch = new JSONObject();
+     * branch.put("uid", "branch_uid");
+     * branch.put("source", "main");
+     * branch.put("publish_config", publishConfig);
+     * JSONObject body = new JSONObject();
+     * body.put("branch", branch);
+     * stack.branch().create(body).execute();
+     * }
* * @param body the request body * @return Call diff --git a/src/test/java/com/contentstack/cms/UnitTestSuite.java b/src/test/java/com/contentstack/cms/UnitTestSuite.java index 5b84fb52..1fdf6b50 100644 --- a/src/test/java/com/contentstack/cms/UnitTestSuite.java +++ b/src/test/java/com/contentstack/cms/UnitTestSuite.java @@ -3,6 +3,7 @@ import com.contentstack.cms.core.AuthInterceptorTest; import com.contentstack.cms.core.EndpointTest; import com.contentstack.cms.stack.AssetUnitTest; +import com.contentstack.cms.stack.BranchUnitTest; import com.contentstack.cms.stack.EntryVariantUnitTest; import com.contentstack.cms.stack.EnvironmentUnitTest; import com.contentstack.cms.stack.GlobalFieldUnitTests; @@ -33,6 +34,7 @@ AssetUnitTest.class, EntryVariantUnitTest.class, EnvironmentUnitTest.class, + BranchUnitTest.class, GlobalFieldUnitTests.class, LocaleUnitTest.class, ReleaseUnitTest.class, diff --git a/src/test/java/com/contentstack/cms/stack/APISanityTestSuite.java b/src/test/java/com/contentstack/cms/stack/APISanityTestSuite.java index 06404419..13248ca0 100644 --- a/src/test/java/com/contentstack/cms/stack/APISanityTestSuite.java +++ b/src/test/java/com/contentstack/cms/stack/APISanityTestSuite.java @@ -11,6 +11,7 @@ EndpointAPITest.class, TaxonomyAPITest.class, AssetAPITest.class, + BranchAPITest.class, ContentTypeAPITest.class, EntryFieldsAPITest.class, EnvironmentAPITest.class, diff --git a/src/test/java/com/contentstack/cms/stack/BranchAPITest.java b/src/test/java/com/contentstack/cms/stack/BranchAPITest.java new file mode 100644 index 00000000..336efc8e --- /dev/null +++ b/src/test/java/com/contentstack/cms/stack/BranchAPITest.java @@ -0,0 +1,169 @@ +package com.contentstack.cms.stack; + +import com.contentstack.cms.TestClient; + +import okhttp3.ResponseBody; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.junit.jupiter.api.*; +import retrofit2.Response; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Live checks for {@code publish_config.environments} on branch create (DX-31564). + *

+ * Needs a real stack in {@code .env}; without credentials the whole class is skipped. + * The environment names {@value #ENV_DEVELOPMENT} and {@value #ENV_PRODUCTION} must exist + * on the test stack. The source branch {@value #SOURCE} must exist too. + */ +@Tag("api") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class BranchAPITest { + + static final String ENV_DEVELOPMENT = "development"; + static final String ENV_PRODUCTION = "production"; + static final String UNKNOWN_ENV = "no_such_env_dx31564"; + static final String SOURCE = "main"; + + /** + * Unique per run, so reruns on the same stack do not collide. Branch UIDs allow at most + * 15 characters, lowercase letters and digits only: "b" plus the last 5 base-36 digits of + * the current time, then one letter per UID below (7 characters in total). + */ + private static final String RUN = runId(); + private static final String UID_TWO = RUN + "a"; + private static final String UID_EMPTY = RUN + "b"; + private static final String UID_UNKNOWN = RUN + "c"; + private static final String UID_MIXED = RUN + "d"; + + private static String runId() { + String time = Long.toString(System.currentTimeMillis(), 36); + return "b" + time.substring(time.length() - 5); + } + + /** Branches this class created, deleted in {@link #cleanUp()}. */ + private static final List CREATED = new ArrayList<>(); + + private static Stack stack; + + @BeforeAll + static void setup() { + try { + stack = TestClient.getStack(); + } catch (ExceptionInInitializerError | NoClassDefFoundError e) { + // TestClient loads .env in its static initializer; without it the class cannot load. + Assumptions.assumeTrue(false, "Skipping: TestClient could not load .env"); + } + Assumptions.assumeFalse(TestClient.isUsingDefaultStackCredentials(), + "Skipping: stack credentials not set in .env"); + } + + @AfterAll + static void cleanUp() { + for (String uid : CREATED) { + try { + stack.branch(uid).delete().execute(); + } catch (IOException e) { + System.out.println("Cleanup failed for branch " + uid + ": " + e.getLocalizedMessage()); + } + } + } + + @Test + @Order(1) + void createWithTwoEnvironmentNames() throws IOException { + Response response = stack.branch() + .create(body(UID_TWO, Arrays.asList(ENV_DEVELOPMENT, ENV_PRODUCTION))).execute(); + CREATED.add(UID_TWO); + assertTrue(response.isSuccessful(), "create with two environment names should succeed"); + } + + @Test + @Order(2) + void fetchBranchCarriesTwoEnvironmentNames() throws Exception { + Response response = stack.branch(UID_TWO).fetch().execute(); + assertTrue(response.isSuccessful(), "fetch of the created branch should succeed"); + assertEquals(new HashSet<>(Arrays.asList(ENV_DEVELOPMENT, ENV_PRODUCTION)), + new HashSet<>(environmentsOf(response))); + } + + @Test + @Order(3) + void createWithEmptyListClonesNoEnvironments() throws Exception { + Response response = stack.branch() + .create(body(UID_EMPTY, Collections.emptyList())).execute(); + CREATED.add(UID_EMPTY); + assertTrue(response.isSuccessful(), "create with an empty list should succeed"); + + Response fetched = stack.branch(UID_EMPTY).fetch().execute(); + assertTrue(fetched.isSuccessful(), "fetch of the created branch should succeed"); + assertTrue(environmentsOf(fetched).isEmpty(), "an empty list must come back empty"); + } + + @Test + @Order(4) + void createWithUnknownNameIsRejectedAndCreatesNoBranch() throws Exception { + Response response = stack.branch() + .create(body(UID_UNKNOWN, Collections.singletonList(UNKNOWN_ENV))).execute(); + CREATED.add(UID_UNKNOWN); + assertEquals(422, response.code(), "an unknown environment name should give the API's 422"); + assertNotNull(response.errorBody(), "the API's error body should be present"); + assertTrue(response.errorBody().string().contains("publish_config.environments"), + "the 422 body should name publish_config.environments"); + + Response fetched = stack.branch(UID_UNKNOWN).fetch().execute(); + assertFalse(fetched.isSuccessful(), "no branch should exist after a rejected create"); + } + + @Test + @Order(5) + void createWithMixedNamesIsRejectedAndCreatesNoBranch() throws Exception { + Response response = stack.branch() + .create(body(UID_MIXED, Arrays.asList(ENV_DEVELOPMENT, UNKNOWN_ENV))).execute(); + CREATED.add(UID_MIXED); + assertEquals(422, response.code(), "a mix with an unknown name should give the API's 422"); + assertNotNull(response.errorBody(), "the API's error body should be present"); + assertTrue(response.errorBody().string().contains("publish_config.environments"), + "the 422 body should name publish_config.environments"); + + Response fetched = stack.branch(UID_MIXED).fetch().execute(); + assertFalse(fetched.isSuccessful(), "no branch should exist after a rejected create"); + } + + /** Builds {@code {"branch": {uid, source, publish_config: {environments}}}}. */ + private static JSONObject body(String uid, List environments) { + JSONObject publishConfig = new JSONObject(); + publishConfig.put("environments", environments); + JSONObject branch = new JSONObject(); + branch.put("uid", uid); + branch.put("source", SOURCE); + branch.put("publish_config", publishConfig); + JSONObject root = new JSONObject(); + root.put("branch", branch); + return root; + } + + /** Reads {@code branch.publish_config.environments} from a fetch response. */ + private static List environmentsOf(Response response) throws Exception { + JSONObject json = (JSONObject) new JSONParser().parse(response.body().string()); + JSONObject branch = (JSONObject) json.get("branch"); + assertNotNull(branch, "the fetch response should carry a 'branch' object"); + JSONObject publishConfig = (JSONObject) branch.get("publish_config"); + assertNotNull(publishConfig, "the branch should carry a 'publish_config' object"); + Object environments = publishConfig.get("environments"); + assertNotNull(environments, "publish_config should carry an 'environments' list"); + List names = new ArrayList<>(); + for (Object name : (List) environments) { + names.add((String) name); + } + return names; + } +} diff --git a/src/test/java/com/contentstack/cms/stack/BranchUnitTest.java b/src/test/java/com/contentstack/cms/stack/BranchUnitTest.java index 6120bc27..20d88865 100644 --- a/src/test/java/com/contentstack/cms/stack/BranchUnitTest.java +++ b/src/test/java/com/contentstack/cms/stack/BranchUnitTest.java @@ -3,19 +3,29 @@ import com.contentstack.cms.TestClient; import okhttp3.Request; import okhttp3.ResponseBody; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; import org.json.simple.JSONObject; import org.json.simple.parser.JSONParser; import org.json.simple.parser.ParseException; import org.junit.jupiter.api.*; import retrofit2.Call; - +import retrofit2.Response; +import retrofit2.Retrofit; +import retrofit2.converter.gson.GsonConverterFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import static org.junit.jupiter.api.Assertions.*; @Tag("unit") @TestMethodOrder(MethodOrderer.OrderAnnotation.class) -class BranchUnitTest { +public class BranchUnitTest { protected static String AUTHTOKEN = TestClient.AUTHTOKEN; protected static Branch branch; @@ -310,4 +320,134 @@ void testCompare() { assertNotNull(request.url()); } + // --- publish_config.environments on branch create (DX-31564) ----------------- + // These run against a MockWebServer through a bare Retrofit, because the public + // Contentstack.Builder only speaks https. Retrofit and the Gson converter match + // what the client builds, so the request body is serialized the same way. + + private MockWebServer server; + + @BeforeEach + void startServer() throws IOException { + server = new MockWebServer(); + server.start(); + } + + @AfterEach + void stopServer() throws IOException { + server.shutdown(); + } + + private Branch mockedBranch(String uid) { + Retrofit retrofit = new Retrofit.Builder() + .baseUrl(server.url("/v3/")) + .addConverterFactory(GsonConverterFactory.create()) + .build(); + return new Branch(retrofit, new HashMap(), uid); + } + + private static JSONObject bodyWithEnvironments(List environments) { + JSONObject publishConfig = new JSONObject(); + publishConfig.put("environments", environments); + JSONObject branchJson = new JSONObject(); + branchJson.put("uid", "dev"); + branchJson.put("source", "main"); + branchJson.put("publish_config", publishConfig); + JSONObject root = new JSONObject(); + root.put("branch", branchJson); + return root; + } + + private static JSONObject parse(String json) throws ParseException { + return (JSONObject) new JSONParser().parse(json); + } + + private static JSONObject publishConfigSent(JSONObject sent) { + return (JSONObject) ((JSONObject) sent.get("branch")).get("publish_config"); + } + + @Test + void createSendsEnvironmentNamesAsGiven() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"branch\":{\"uid\":\"dev\"}}")); + Response response = mockedBranch("dev") + .create(bodyWithEnvironments(Arrays.asList("development", "staging"))).execute(); + assertTrue(response.isSuccessful()); + JSONObject sent = parse(server.takeRequest().getBody().readUtf8()); + assertEquals(Arrays.asList("development", "staging"), publishConfigSent(sent).get("environments")); + } + + @Test + void createSendsEmptyEnvironmentListAsEmptyArray() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"branch\":{\"uid\":\"dev\"}}")); + mockedBranch("dev").create(bodyWithEnvironments(Collections.emptyList())).execute(); + JSONObject sent = parse(server.takeRequest().getBody().readUtf8()); + assertTrue(publishConfigSent(sent).containsKey("environments")); + assertEquals(Collections.emptyList(), publishConfigSent(sent).get("environments")); + } + + @Test + void createSendsDuplicateEnvironmentNamesAsGiven() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"branch\":{\"uid\":\"dev\"}}")); + List duplicates = Arrays.asList("development", "development"); + mockedBranch("dev").create(bodyWithEnvironments(duplicates)).execute(); + JSONObject sent = parse(server.takeRequest().getBody().readUtf8()); + assertEquals(duplicates, publishConfigSent(sent).get("environments")); + } + + @Test + void createSendsOneHundredFiftyEnvironmentNamesUnchanged() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"branch\":{\"uid\":\"dev\"}}")); + List many = new ArrayList<>(); + for (int i = 0; i < 150; i++) { + many.add("env_" + i); + } + mockedBranch("dev").create(bodyWithEnvironments(many)).execute(); + JSONObject sent = parse(server.takeRequest().getBody().readUtf8()); + List sentNames = (List) publishConfigSent(sent).get("environments"); + assertEquals(150, sentNames.size()); + assertEquals(many, sentNames); + } + + @Test + void createSendsMixedCaseAndSpacedNamesUnchanged() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"branch\":{\"uid\":\"dev\"}}")); + List names = Arrays.asList("Development", " staging ", "PRODUCTION"); + mockedBranch("dev").create(bodyWithEnvironments(names)).execute(); + JSONObject sent = parse(server.takeRequest().getBody().readUtf8()); + assertEquals(names, publishConfigSent(sent).get("environments")); + } + + @Test + void createWithoutEnvironmentListSendsBodyUnchanged() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"branch\":{\"uid\":\"release\"}}")); + mockedBranch("release").create(parse(theJsonString)).execute(); + JSONObject sent = parse(server.takeRequest().getBody().readUtf8()); + assertEquals(parse(theJsonString), sent); + assertFalse(((JSONObject) sent.get("branch")).containsKey("publish_config")); + } + + @Test + void createRejectedWith422ReturnsApiErrorBodyUnchanged() throws Exception { + String apiError = "{\"error_message\":\"Environment not found.\",\"error_code\":422," + + "\"errors\":{\"publish_config.environments\":[\"staging_typo\"]}}"; + server.enqueue(new MockResponse().setResponseCode(422).setBody(apiError)); + Response response = mockedBranch("dev") + .create(bodyWithEnvironments(Arrays.asList("development", "staging_typo"))).execute(); + assertEquals(422, response.code()); + assertFalse(response.isSuccessful()); + assertEquals(apiError, response.errorBody().string()); + JSONObject sent = parse(server.takeRequest().getBody().readUtf8()); + assertEquals(Arrays.asList("development", "staging_typo"), publishConfigSent(sent).get("environments")); + } + + @Test + void fetchReturnsEnvironmentListUnchanged() throws Exception { + String fetched = "{\"branch\":{\"uid\":\"dev\",\"source\":\"main\"," + + "\"publish_config\":{\"environments\":[\"development\",\"staging\"]}}}"; + server.enqueue(new MockResponse().setResponseCode(200).setBody(fetched)); + Response response = mockedBranch("dev").fetch().execute(); + assertEquals("/v3/stacks/branches/dev", server.takeRequest().getPath()); + assertEquals(fetched, response.body().string()); + } + }