From 0679339c31e91884ad6628ac55b2fd8099a28b1d Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Fri, 5 Jun 2026 13:54:08 +0530 Subject: [PATCH 1/3] feat: Add dynamic endpoint resolution and region management - Introduced dynamic endpoint resolution via `Endpoint.getContentstackEndpoint()` - Added convenience method `Contentstack.Builder.setRegion(String)` for region targeting - Implemented static proxy methods for ad-hoc URL resolution in `Contentstack` - Added `Contentstack.refreshRegions()` to refresh regions registry from live source - Bundled `regions.json` in JAR and auto-refreshed during build - Added public accessors `Contentstack.getHost()` and `Contentstack.getBaseUrl()` - Created `Endpoint` class to manage endpoint resolution and caching - Implemented tests for endpoint resolution and region management --- changelog.md | 11 + pom.xml | 22 +- scripts/download-regions.sh | 48 ++ .../com/contentstack/cms/Contentstack.java | 110 +++++ .../com/contentstack/cms/core/Endpoint.java | 238 ++++++++++ .../java/com/contentstack/cms/TestClient.java | 1 + .../com/contentstack/cms/UnitTestSuite.java | 2 + .../contentstack/cms/core/EndpointTest.java | 411 ++++++++++++++++++ .../cms/stack/APISanityTestSuite.java | 3 +- .../cms/stack/EndpointAPITest.java | 212 +++++++++ 10 files changed, 1056 insertions(+), 2 deletions(-) create mode 100755 scripts/download-regions.sh create mode 100644 src/main/java/com/contentstack/cms/core/Endpoint.java create mode 100644 src/test/java/com/contentstack/cms/core/EndpointTest.java create mode 100644 src/test/java/com/contentstack/cms/stack/EndpointAPITest.java diff --git a/changelog.md b/changelog.md index 74e2cc78..d6e917fd 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,16 @@ # Changelog +## v1.12.0 + +### Jun 05, 2026 + +- Feature: Dynamic endpoint resolution via `Endpoint.getContentstackEndpoint()` backed by the Contentstack Regions Registry (`https://artifacts.contentstack.com/regions.json`). Resolves the correct API URL for any of the 7 supported regions (NA, EU, AU, Azure NA, Azure EU, GCP NA, GCP EU) and 18 service keys without hardcoding host strings. +- Feature: `Contentstack.Builder.setRegion(String)` — convenience method to target a region directly (e.g. `.setRegion("eu")`), automatically resolving the correct Content Management API host. +- Feature: `Contentstack.getContentstackEndpoint(region, service)` and `Contentstack.getContentstackEndpoints(region)` static proxy methods for ad-hoc URL resolution. +- Feature: `Contentstack.refreshRegions()` — forces a live download of the regions registry and refreshes the in-memory cache, so newly published regions or service URLs are available without upgrading the SDK. +- Feature: `regions.json` bundled in the JAR and auto-refreshed at build time via `scripts/download-regions.sh` (invoked on the Maven `generate-resources` phase). +- Feature: `Contentstack.getHost()` and `Contentstack.getBaseUrl()` public accessors on the client instance. + ## v1.11.2 ### Jun 01, 2026 diff --git a/pom.xml b/pom.xml index 1ff53c24..1f203bed 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ cms jar contentstack-management-java - 1.11.2 + 1.12.0 Contentstack Java Management SDK for Content Management API, Contentstack is a headless CMS with an API-first approach @@ -278,6 +278,26 @@ + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + download-regions + generate-resources + + exec + + + bash + + ${project.basedir}/scripts/download-regions.sh + + + + + org.apache.maven.plugins maven-source-plugin diff --git a/scripts/download-regions.sh b/scripts/download-regions.sh new file mode 100755 index 00000000..60a76418 --- /dev/null +++ b/scripts/download-regions.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# +# Downloads the Contentstack regions registry from the official source and +# saves it to src/main/resources/regions.json. +# +# Invoked automatically by Maven on the generate-resources phase, and +# manually via: bash scripts/download-regions.sh +# +# Requires: curl (preferred) or wget as fallback + +set -euo pipefail + +URL="https://artifacts.contentstack.com/regions.json" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEST="${SCRIPT_DIR}/../src/main/resources/regions.json" +DIR="$(dirname "$DEST")" + +mkdir -p "$DIR" + +data="" + +# --- Attempt 1: curl (preferred) -------------------------------------------- +if command -v curl &>/dev/null; then + data=$(curl --silent --fail --location --max-time 30 "$URL") || data="" +fi + +# --- Attempt 2: wget fallback ----------------------------------------------- +if [[ -z "$data" ]] && command -v wget &>/dev/null; then + data=$(wget --quiet --timeout=30 -O - "$URL") || data="" +fi + +# --- Validate and write ------------------------------------------------------ +if [[ -z "$data" ]]; then + echo "contentstack/cms: Warning — could not download regions.json." >&2 + echo " The SDK will attempt to download it at runtime on first use." >&2 + exit 0 # non-fatal: runtime fallback in Endpoint.java handles it +fi + +# Basic validation: must contain a "regions" key +if ! echo "$data" | grep -q '"regions"'; then + echo "contentstack/cms: Warning — downloaded data is not valid regions.json." >&2 + exit 0 +fi + +echo "$data" > "$DEST" + +region_count=$(echo "$data" | grep -o '"id"' | wc -l | tr -d ' ') +echo "contentstack/cms: regions.json downloaded (${region_count} regions)." diff --git a/src/main/java/com/contentstack/cms/Contentstack.java b/src/main/java/com/contentstack/cms/Contentstack.java index 9c497a9e..2603b760 100644 --- a/src/main/java/com/contentstack/cms/Contentstack.java +++ b/src/main/java/com/contentstack/cms/Contentstack.java @@ -14,6 +14,7 @@ import org.jetbrains.annotations.NotNull; import com.contentstack.cms.core.AuthInterceptor; +import com.contentstack.cms.core.Endpoint; import com.contentstack.cms.core.Util; import static com.contentstack.cms.core.Util.API_KEY; import static com.contentstack.cms.core.Util.AUTHORIZATION; @@ -561,6 +562,81 @@ public CompletableFuture oauthLogout() { return oauthLogout(false); } + /** + * Forces a live download of the Contentstack regions registry and replaces + * the in-memory cache. Useful when new regions or service URLs have been + * published since the SDK JAR was built. + * + *
{@code
+     * int count = Contentstack.refreshRegions();
+     * // now getContentstackEndpoint() resolves from the freshly downloaded registry
+     * }
+ * + * @return the number of regions loaded from the live registry + * @throws RuntimeException if the download fails + */ + public static int refreshRegions() { + return Endpoint.refresh(); + } + + /** + * Resolves a Contentstack service endpoint URL for the given region. + * + *
{@code
+     * // Full URL
+     * String url = Contentstack.getContentstackEndpoint("eu", "contentManagement");
+     * // → "https://eu-api.contentstack.com"
+     *
+     * // Host only (for Builder.setHost)
+     * String host = Contentstack.getContentstackEndpoint("eu", "contentManagement", true);
+     * // → "eu-api.contentstack.com"
+     * }
+ * + * @param region region ID or alias (e.g. {@code na}, {@code eu}, {@code azure-na}) + * @param service service key (e.g. {@code contentManagement}, {@code contentDelivery}) + * @param omitHttps when {@code true}, strips {@code https://} from the returned URL + * @return the resolved URL string + * @throws IllegalArgumentException if region or service is unknown + */ + public static String getContentstackEndpoint(String region, String service, boolean omitHttps) { + return Endpoint.getContentstackEndpoint(region, service, omitHttps); + } + + /** + * Resolves a Contentstack service endpoint URL for the given region (with scheme). + * + * @param region region ID or alias + * @param service service key + * @return the resolved URL including {@code https://} + * @throws IllegalArgumentException if region or service is unknown + */ + public static String getContentstackEndpoint(String region, String service) { + return Endpoint.getContentstackEndpoint(region, service); + } + + /** + * Returns all endpoint URLs for the given region as an ordered map. + * + * @param region region ID or alias + * @return map of service name → URL (includes {@code https://}) + * @throws IllegalArgumentException if region is unknown or empty + */ + public static java.util.Map getContentstackEndpoints(String region) { + return Endpoint.getContentstackEndpoints(region); + } + + /** + * Returns all endpoint URLs for the given region as an ordered map. + * + * @param region region ID or alias + * @param omitHttps when {@code true}, strips {@code https://} from every URL + * @return map of service name → URL + * @throws IllegalArgumentException if region is unknown or empty + */ + public static java.util.Map getContentstackEndpoints(String region, boolean omitHttps) { + return Endpoint.getContentstackEndpoints(region, omitHttps); + } + public Contentstack(Builder builder) { this.host = builder.hostname; this.port = builder.port; @@ -577,6 +653,16 @@ public Contentstack(Builder builder) { this.retryConfig = builder.retryConfig; } + /** Returns the API hostname this client is configured to target. */ + public String getHost() { + return host; + } + + /** Returns the full Retrofit base URL (e.g. {@code https://eu-api.contentstack.com/v3/}). */ + public String getBaseUrl() { + return instance.baseUrl().toString(); + } + public RetryConfig getRetryConfig() { return retryConfig; } @@ -665,6 +751,30 @@ public Builder setHost(@NotNull String hostname) { return this; } + /** + * Configures the client to target a specific Contentstack region by resolving + * the correct Content Management API host from the bundled regions registry. + * + *

This is a convenience alternative to calling {@link #setHost(String)} with + * a manually constructed hostname. + * + *

{@code
+         * Contentstack client = new Contentstack.Builder()
+         *     .setAuthtoken("authtoken")
+         *     .setRegion(ContentstackRegion.EU)
+         *     .build();
+         * }
+ * + * @param region region ID or alias (e.g. {@code "na"}, {@code "eu"}, {@code "azure-na"}). + * Use constants from {@link com.contentstack.cms.core.ContentstackRegion}. + * @return this Builder + * @throws IllegalArgumentException if the region is unknown or empty + */ + public Builder setRegion(@NotNull String region) { + this.hostname = Endpoint.getContentstackEndpoint(region, "contentManagement", true); + return this; + } + /** * Set port for client instance * diff --git a/src/main/java/com/contentstack/cms/core/Endpoint.java b/src/main/java/com/contentstack/cms/core/Endpoint.java new file mode 100644 index 00000000..1d5afabb --- /dev/null +++ b/src/main/java/com/contentstack/cms/core/Endpoint.java @@ -0,0 +1,238 @@ +package com.contentstack.cms.core; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Resolves Contentstack API endpoints for any region and service. + * + *

Endpoint data is loaded from the bundled {@code regions.json} classpath resource. + * The parsed result is cached for the lifetime of the JVM process. + * If the bundled file is absent, a live download from + * {@code https://artifacts.contentstack.com/regions.json} is attempted as a fallback. + * + *

{@code
+ * // Get a specific service URL
+ * String cdnUrl = Endpoint.getContentstackEndpoint("eu", "contentDelivery");
+ * // → "https://eu-cdn.contentstack.com"
+ *
+ * // Get the host without the https:// scheme (for use with Builder.setHost)
+ * String host = Endpoint.getContentstackEndpoint("eu", "contentDelivery", true);
+ * // → "eu-cdn.contentstack.com"
+ *
+ * // Get all endpoints for a region
+ * Map all = Endpoint.getContentstackEndpoints("eu");
+ * }
+ */ +public class Endpoint { + + private static final String REGIONS_URL = "https://artifacts.contentstack.com/regions.json"; + private static final String REGIONS_RESOURCE = "regions.json"; + + private static volatile JsonArray regionsData = null; + + private Endpoint() {} + + /** + * Returns the URL for a specific service in the given region. + * + * @param region canonical region ID ({@code na}, {@code eu}, {@code au}, {@code azure-na}, + * {@code azure-eu}, {@code gcp-na}, {@code gcp-eu}) or any accepted alias. + * Case-insensitive; {@code -} and {@code _} separators are equivalent. + * @param service service name (e.g. {@code contentManagement}, {@code contentDelivery}) + * @return full URL including {@code https://} + * @throws IllegalArgumentException if region or service is unknown, or region is empty + * @throws RuntimeException if {@code regions.json} cannot be loaded + */ + public static String getContentstackEndpoint(String region, String service) { + return getContentstackEndpoint(region, service, false); + } + + /** + * Returns the URL for a specific service in the given region. + * + * @param region canonical region ID or alias + * @param service service name + * @param omitHttps when {@code true}, strips {@code https://} from the result + * @return URL string, with or without scheme depending on {@code omitHttps} + * @throws IllegalArgumentException if region or service is unknown, or region is empty + * @throws RuntimeException if {@code regions.json} cannot be loaded + */ + public static String getContentstackEndpoint(String region, String service, boolean omitHttps) { + if (service == null || service.trim().isEmpty()) { + throw new IllegalArgumentException( + "Service must not be empty. Use getContentstackEndpoints(region) to retrieve all endpoints."); + } + JsonObject regionRow = resolveRegion(region); + JsonObject endpoints = regionRow.getAsJsonObject("endpoints"); + if (endpoints == null || !endpoints.has(service)) { + throw new IllegalArgumentException( + "Service \"" + service + "\" not found for region \"" + regionRow.get("id").getAsString() + "\""); + } + String url = endpoints.get(service).getAsString(); + return omitHttps ? stripHttps(url) : url; + } + + /** + * Returns all endpoint URLs for the given region as an ordered map. + * + * @param region canonical region ID or alias + * @return map of service name → URL (includes {@code https://}) + * @throws IllegalArgumentException if region is unknown or empty + * @throws RuntimeException if {@code regions.json} cannot be loaded + */ + public static Map getContentstackEndpoints(String region) { + return getContentstackEndpoints(region, false); + } + + /** + * Returns all endpoint URLs for the given region as an ordered map. + * + * @param region canonical region ID or alias + * @param omitHttps when {@code true}, strips {@code https://} from every URL + * @return map of service name → URL + * @throws IllegalArgumentException if region is unknown or empty + * @throws RuntimeException if {@code regions.json} cannot be loaded + */ + public static Map getContentstackEndpoints(String region, boolean omitHttps) { + JsonObject regionRow = resolveRegion(region); + JsonObject endpoints = regionRow.getAsJsonObject("endpoints"); + Map result = new LinkedHashMap<>(); + if (endpoints != null) { + for (Map.Entry entry : endpoints.entrySet()) { + String url = entry.getValue().getAsString(); + result.put(entry.getKey(), omitHttps ? stripHttps(url) : url); + } + } + return result; + } + + // ── internal ────────────────────────────────────────────────────────────── + + private static JsonObject resolveRegion(String region) { + if (region == null || region.trim().isEmpty()) { + throw new IllegalArgumentException("Empty region provided. Please provide a valid region."); + } + JsonArray regions = loadRegions(); + String normalized = region.trim().toLowerCase().replace('_', '-'); + + // First pass: exact match on canonical id + for (int i = 0; i < regions.size(); i++) { + JsonObject row = regions.get(i).getAsJsonObject(); + if (row.get("id").getAsString().equals(normalized)) { + return row; + } + } + + // Second pass: match on alias list (case-insensitive, normalised separators) + for (int i = 0; i < regions.size(); i++) { + JsonObject row = regions.get(i).getAsJsonObject(); + JsonArray aliases = row.getAsJsonArray("alias"); + if (aliases != null) { + for (int j = 0; j < aliases.size(); j++) { + String alias = aliases.get(j).getAsString().toLowerCase().replace('_', '-'); + if (alias.equals(normalized)) { + return row; + } + } + } + } + + throw new IllegalArgumentException("Invalid region: " + region); + } + + private static synchronized JsonArray loadRegions() { + if (regionsData != null) { + return regionsData; + } + + // Try bundled classpath resource first + InputStream is = Endpoint.class.getClassLoader().getResourceAsStream(REGIONS_RESOURCE); + if (is != null) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + String json = reader.lines().collect(Collectors.joining("\n")); + regionsData = JsonParser.parseString(json).getAsJsonObject().getAsJsonArray("regions"); + return regionsData; + } catch (Exception e) { + // fall through to live download + } + } + + // Fallback: download from Contentstack + try { + String json = downloadRegions(); + regionsData = JsonParser.parseString(json).getAsJsonObject().getAsJsonArray("regions"); + return regionsData; + } catch (Exception e) { + throw new RuntimeException( + "contentstack/cms: regions.json not found and could not be downloaded. " + + "Ensure the JAR was built correctly or network access is available.", e); + } + } + + private static String downloadRegions() throws IOException { + URL url = new URL(REGIONS_URL); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(10_000); + try (InputStream is = conn.getInputStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + return reader.lines().collect(Collectors.joining("\n")); + } finally { + conn.disconnect(); + } + } + + /** + * Forces a live download of the regions registry from + * {@code https://artifacts.contentstack.com/regions.json} and replaces + * the in-memory cache. + * + *

Call this if you suspect new Contentstack regions or service URLs have + * been published since the SDK JAR was built, without needing to upgrade + * the SDK version. + * + *

{@code
+     * int count = Endpoint.refresh();
+     * System.out.println("Loaded " + count + " regions from live registry.");
+     * }
+ * + * @return the number of regions loaded from the live registry + * @throws RuntimeException if the download fails or the response is not valid JSON + */ + public static synchronized int refresh() { + try { + String json = downloadRegions(); + JsonArray fresh = JsonParser.parseString(json).getAsJsonObject().getAsJsonArray("regions"); + regionsData = fresh; + return fresh.size(); + } catch (Exception e) { + throw new RuntimeException( + "contentstack/cms: Failed to refresh regions.json from " + REGIONS_URL + ". " + + "Check network connectivity and try again.", e); + } + } + + private static String stripHttps(String url) { + return url.replaceAll("^https?://", ""); + } + + /** Clears the in-memory cache. For use in tests only. */ + static void resetCache() { + regionsData = null; + } +} diff --git a/src/test/java/com/contentstack/cms/TestClient.java b/src/test/java/com/contentstack/cms/TestClient.java index 2503e39b..e246501b 100644 --- a/src/test/java/com/contentstack/cms/TestClient.java +++ b/src/test/java/com/contentstack/cms/TestClient.java @@ -23,6 +23,7 @@ public class TestClient { : "managementToken99999999"; public final static String DEV_HOST = (env.get("dev_host") != null) ? env.get("dev_host").trim() : "api.contentstack.io"; + public final static String REGION = (env.get("region") != null) ? env.get("region").trim() : "na"; public final static String VARIANT_GROUP_UID = (env.get("variantGroupUid") != null) ? env.get("variantGroupUid") : "variantGroupUid99999999"; private static Contentstack instance; diff --git a/src/test/java/com/contentstack/cms/UnitTestSuite.java b/src/test/java/com/contentstack/cms/UnitTestSuite.java index 97df7a51..6c514051 100644 --- a/src/test/java/com/contentstack/cms/UnitTestSuite.java +++ b/src/test/java/com/contentstack/cms/UnitTestSuite.java @@ -1,6 +1,7 @@ package com.contentstack.cms; import com.contentstack.cms.core.AuthInterceptorTest; +import com.contentstack.cms.core.EndpointTest; import com.contentstack.cms.stack.EnvironmentUnitTest; import com.contentstack.cms.stack.GlobalFieldUnitTests; import com.contentstack.cms.stack.LocaleUnitTest; @@ -23,6 +24,7 @@ // Core tests AuthInterceptorTest.class, ContentstackUnitTest.class, + EndpointTest.class, // Stack module tests (only public classes) EnvironmentUnitTest.class, diff --git a/src/test/java/com/contentstack/cms/core/EndpointTest.java b/src/test/java/com/contentstack/cms/core/EndpointTest.java new file mode 100644 index 00000000..8584ef1c --- /dev/null +++ b/src/test/java/com/contentstack/cms/core/EndpointTest.java @@ -0,0 +1,411 @@ +package com.contentstack.cms.core; + +import com.contentstack.cms.Contentstack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +public class EndpointTest { + + @BeforeEach + @AfterEach + void resetCache() { + Endpoint.resetCache(); + } + + // ── canonical IDs — contentDelivery ─────────────────────────────────────── + + @Test + void testNaContentDelivery() { + assertEquals("https://cdn.contentstack.io", + Endpoint.getContentstackEndpoint("na", "contentDelivery")); + } + + @Test + void testNaContentManagement() { + assertEquals("https://api.contentstack.io", + Endpoint.getContentstackEndpoint("na", "contentManagement")); + } + + @Test + void testEuContentDelivery() { + assertEquals("https://eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("eu", "contentDelivery")); + } + + @Test + void testEuContentManagement() { + assertEquals("https://eu-api.contentstack.com", + Endpoint.getContentstackEndpoint("eu", "contentManagement")); + } + + @Test + void testAuContentDelivery() { + assertEquals("https://au-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("au", "contentDelivery")); + } + + @Test + void testAzureNaContentDelivery() { + assertEquals("https://azure-na-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("azure-na", "contentDelivery")); + } + + @Test + void testAzureEuContentDelivery() { + assertEquals("https://azure-eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("azure-eu", "contentDelivery")); + } + + @Test + void testGcpNaContentDelivery() { + assertEquals("https://gcp-na-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("gcp-na", "contentDelivery")); + } + + @Test + void testGcpEuContentDelivery() { + assertEquals("https://gcp-eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("gcp-eu", "contentDelivery")); + } + + // ── region aliases resolve to the same endpoint ─────────────────────────── + + @ParameterizedTest + @ValueSource(strings = {"na", "us", "aws-na", "aws_na", "NA", "US", "AWS-NA", "AWS_NA"}) + void testNaAliasesAllResolveToNaCdn(String alias) { + assertEquals("https://cdn.contentstack.io", + Endpoint.getContentstackEndpoint(alias, "contentDelivery")); + } + + @ParameterizedTest + @ValueSource(strings = {"eu", "aws-eu", "aws_eu", "EU", "AWS-EU", "AWS_EU"}) + void testEuAliasesAllResolveToEuCdn(String alias) { + assertEquals("https://eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint(alias, "contentDelivery")); + } + + @ParameterizedTest + @ValueSource(strings = {"au", "aws-au", "aws_au", "AU", "AWS-AU", "AWS_AU"}) + void testAuAliasesAllResolveToAuCdn(String alias) { + assertEquals("https://au-cdn.contentstack.com", + Endpoint.getContentstackEndpoint(alias, "contentDelivery")); + } + + @ParameterizedTest + @ValueSource(strings = {"azure-na", "azure_na", "AZURE-NA", "AZURE_NA"}) + void testAzureNaAliasesResolve(String alias) { + assertEquals("https://azure-na-cdn.contentstack.com", + Endpoint.getContentstackEndpoint(alias, "contentDelivery")); + } + + @ParameterizedTest + @ValueSource(strings = {"azure-eu", "azure_eu", "AZURE-EU", "AZURE_EU"}) + void testAzureEuAliasesResolve(String alias) { + assertEquals("https://azure-eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint(alias, "contentDelivery")); + } + + @ParameterizedTest + @ValueSource(strings = {"gcp-na", "gcp_na", "GCP-NA", "GCP_NA"}) + void testGcpNaAliasesResolve(String alias) { + assertEquals("https://gcp-na-cdn.contentstack.com", + Endpoint.getContentstackEndpoint(alias, "contentDelivery")); + } + + @ParameterizedTest + @ValueSource(strings = {"gcp-eu", "gcp_eu", "GCP-EU", "GCP_EU"}) + void testGcpEuAliasesResolve(String alias) { + assertEquals("https://gcp-eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint(alias, "contentDelivery")); + } + + // ── omitHttps ───────────────────────────────────────────────────────────── + + @Test + void testOmitHttpsFalseKeepsScheme() { + String url = Endpoint.getContentstackEndpoint("na", "contentDelivery", false); + assertTrue(url.startsWith("https://")); + } + + @Test + void testOmitHttpsTrueStripsScheme() { + String host = Endpoint.getContentstackEndpoint("na", "contentDelivery", true); + assertFalse(host.startsWith("https://")); + assertEquals("cdn.contentstack.io", host); + } + + @Test + void testOmitHttpsEuCdn() { + assertEquals("eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("eu", "contentDelivery", true)); + } + + @Test + void testOmitHttpsAzureNaApi() { + assertEquals("azure-na-api.contentstack.com", + Endpoint.getContentstackEndpoint("azure-na", "contentManagement", true)); + } + + @Test + void testOmitHttpsGcpEuCdn() { + assertEquals("gcp-eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("gcp-eu", "contentDelivery", true)); + } + + // ── various service keys ─────────────────────────────────────────────────── + + @Test + void testNaAuth() { + assertEquals("https://auth-api.contentstack.com", + Endpoint.getContentstackEndpoint("na", "auth")); + } + + @Test + void testNaGraphqlDelivery() { + assertEquals("https://graphql.contentstack.com", + Endpoint.getContentstackEndpoint("na", "graphqlDelivery")); + } + + @Test + void testNaPreview() { + assertEquals("https://rest-preview.contentstack.com", + Endpoint.getContentstackEndpoint("na", "preview")); + } + + @Test + void testNaApplication() { + assertEquals("https://app.contentstack.com", + Endpoint.getContentstackEndpoint("na", "application")); + } + + @Test + void testNaAssetManagement() { + assertEquals("https://am-api.contentstack.com", + Endpoint.getContentstackEndpoint("na", "assetManagement")); + } + + @Test + void testNaDeveloperHub() { + assertEquals("https://developerhub-api.contentstack.com", + Endpoint.getContentstackEndpoint("na", "developerHub")); + } + + @Test + void testEuAutomate() { + assertEquals("https://eu-prod-automations-api.contentstack.com", + Endpoint.getContentstackEndpoint("eu", "automate")); + } + + @Test + void testNaLaunch() { + assertEquals("https://launch-api.contentstack.com", + Endpoint.getContentstackEndpoint("na", "launch")); + } + + // ── all-endpoints map ────────────────────────────────────────────────────── + + @Test + void testGetAllEndpointsForNaNotEmpty() { + Map endpoints = Endpoint.getContentstackEndpoints("na"); + assertNotNull(endpoints); + assertFalse(endpoints.isEmpty()); + } + + @Test + void testGetAllEndpointsForNaContainsExpectedKeys() { + Map endpoints = Endpoint.getContentstackEndpoints("na"); + assertEquals("https://cdn.contentstack.io", endpoints.get("contentDelivery")); + assertEquals("https://api.contentstack.io", endpoints.get("contentManagement")); + assertEquals("https://auth-api.contentstack.com", endpoints.get("auth")); + } + + @Test + void testGetAllEndpointsForEu() { + Map endpoints = Endpoint.getContentstackEndpoints("eu"); + assertEquals("https://eu-cdn.contentstack.com", endpoints.get("contentDelivery")); + assertEquals("https://eu-api.contentstack.com", endpoints.get("contentManagement")); + } + + @Test + void testGetAllEndpointsOmitHttps() { + Map hosts = Endpoint.getContentstackEndpoints("eu", true); + assertEquals("eu-cdn.contentstack.com", hosts.get("contentDelivery")); + assertEquals("eu-api.contentstack.com", hosts.get("contentManagement")); + hosts.values().forEach(v -> assertFalse(v.startsWith("https://"), + "Expected no scheme but got: " + v)); + } + + @Test + void testGetAllEndpointsWithSchemePresentByDefault() { + Map endpoints = Endpoint.getContentstackEndpoints("na"); + endpoints.values().forEach(v -> assertTrue(v.startsWith("https://"), + "Expected https:// but got: " + v)); + } + + // ── error handling ──────────────────────────────────────────────────────── + + @Test + void testEmptyRegionThrows() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> Endpoint.getContentstackEndpoint("", "contentDelivery")); + assertTrue(ex.getMessage().contains("Empty region")); + } + + @Test + void testNullRegionThrows() { + assertThrows(IllegalArgumentException.class, + () -> Endpoint.getContentstackEndpoint(null, "contentDelivery")); + } + + @Test + void testInvalidRegionThrows() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> Endpoint.getContentstackEndpoint("asia-pacific", "contentDelivery")); + assertTrue(ex.getMessage().contains("Invalid region")); + assertTrue(ex.getMessage().contains("asia-pacific")); + } + + @Test + void testUnknownServiceThrows() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> Endpoint.getContentstackEndpoint("na", "cms")); + assertTrue(ex.getMessage().contains("cms")); + assertTrue(ex.getMessage().contains("na")); + } + + @Test + void testEmptyServiceThrows() { + assertThrows(IllegalArgumentException.class, + () -> Endpoint.getContentstackEndpoint("na", "")); + } + + @Test + void testNullServiceThrows() { + assertThrows(IllegalArgumentException.class, + () -> Endpoint.getContentstackEndpoint("na", null)); + } + + // ── Contentstack class proxy ─────────────────────────────────────────────── + + @Test + void testContentstackProxyWithScheme() { + assertEquals("https://eu-api.contentstack.com", + Contentstack.getContentstackEndpoint("eu", "contentManagement")); + } + + @Test + void testContentstackProxyOmitHttps() { + assertEquals("eu-api.contentstack.com", + Contentstack.getContentstackEndpoint("eu", "contentManagement", true)); + } + + // ── Builder.setRegion() ──────────────────────────────────────────────────── + + @Test + void testBuilderSetRegionEuResolvesCorrectHost() { + Contentstack client = new Contentstack.Builder() + .setRegion("eu") + .build(); + assertEquals("eu-api.contentstack.com", client.getHost()); + } + + @Test + void testBuilderSetRegionNaResolvesCorrectHost() { + Contentstack client = new Contentstack.Builder() + .setRegion("na") + .build(); + assertEquals("api.contentstack.io", client.getHost()); + } + + @Test + void testBuilderSetRegionAzureNaResolvesCorrectHost() { + Contentstack client = new Contentstack.Builder() + .setRegion("azure-na") + .build(); + assertEquals("azure-na-api.contentstack.com", client.getHost()); + } + + @Test + void testBuilderSetRegionGcpEuResolvesCorrectHost() { + Contentstack client = new Contentstack.Builder() + .setRegion("gcp-eu") + .build(); + assertEquals("gcp-eu-api.contentstack.com", client.getHost()); + } + + @Test + void testBuilderSetRegionInvalidThrows() { + assertThrows(IllegalArgumentException.class, + () -> new Contentstack.Builder().setRegion("invalid-region")); + } + + @Test + void testBuilderSetRegionWithAuthtoken() { + Contentstack client = new Contentstack.Builder() + .setAuthtoken("fake_authtoken") + .setRegion("au") + .build(); + assertEquals("au-api.contentstack.com", client.getHost()); + } + + // ── caching — second call returns same result without re-parsing ─────────── + + @Test + void testCachingReturnsSameResult() { + String first = Endpoint.getContentstackEndpoint("na", "contentDelivery"); + String second = Endpoint.getContentstackEndpoint("na", "contentDelivery"); + assertEquals(first, second); + } + + // ── refresh() — forces live download and replaces cache ─────────────────── + + @Test + void testRefreshReturnsPositiveRegionCount() { + int count = Endpoint.refresh(); + assertTrue(count > 0, "refresh() should return at least one region"); + } + + @Test + void testRefreshLoadsAllKnownRegions() { + int count = Endpoint.refresh(); + assertEquals(7, count, "Expected 7 regions from the live registry"); + } + + @Test + void testRefreshUpdatesCache() { + // prime the cache from the bundled file + Endpoint.getContentstackEndpoint("na", "contentDelivery"); + + // force refresh from live + Endpoint.refresh(); + + // subsequent resolution should still work correctly + assertEquals("https://cdn.contentstack.io", + Endpoint.getContentstackEndpoint("na", "contentDelivery")); + } + + @Test + void testContentstackRefreshRegionsProxy() { + int count = Contentstack.refreshRegions(); + assertTrue(count > 0); + } + + @Test + void testRefreshThenResolveAllRegions() { + Endpoint.refresh(); + // verify all 7 canonical region IDs still resolve after a live refresh + String[] regions = {"na", "eu", "au", "azure-na", "azure-eu", "gcp-na", "gcp-eu"}; + for (String region : regions) { + assertDoesNotThrow(() -> Endpoint.getContentstackEndpoint(region, "contentManagement"), + "Should resolve contentManagement for region: " + region); + } + } +} diff --git a/src/test/java/com/contentstack/cms/stack/APISanityTestSuite.java b/src/test/java/com/contentstack/cms/stack/APISanityTestSuite.java index 4b027193..b6da2582 100644 --- a/src/test/java/com/contentstack/cms/stack/APISanityTestSuite.java +++ b/src/test/java/com/contentstack/cms/stack/APISanityTestSuite.java @@ -8,7 +8,8 @@ @SuppressWarnings("deprecation") @RunWith(JUnitPlatform.class) @SelectClasses({ - TaxonomyAPITest.class, + EndpointAPITest.class, + TaxonomyAPITest.class, AssetAPITest.class, ContentTypeAPITest.class, EntryFieldsAPITest.class, diff --git a/src/test/java/com/contentstack/cms/stack/EndpointAPITest.java b/src/test/java/com/contentstack/cms/stack/EndpointAPITest.java new file mode 100644 index 00000000..f5498475 --- /dev/null +++ b/src/test/java/com/contentstack/cms/stack/EndpointAPITest.java @@ -0,0 +1,212 @@ +package com.contentstack.cms.stack; + +import com.contentstack.cms.Contentstack; +import com.contentstack.cms.TestClient; +import com.contentstack.cms.core.Endpoint; +import com.contentstack.cms.core.Util; +import okhttp3.Request; +import org.junit.jupiter.api.*; +import retrofit2.Response; + +import java.io.IOException; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * API-level tests for the endpoint integration feature. + * + *

URL-structure tests verify the resolved host is wired into every outgoing + * request. Live-call tests make real network requests to the NA management API + * using credentials from {@code .env}. + */ +@Tag("api") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class EndpointAPITest { + + private static final String API_KEY = TestClient.API_KEY; + private static final String MANAGEMENT_TOKEN = TestClient.MANAGEMENT_TOKEN; + private static final String REGION = TestClient.REGION; + + // ── URL structure — all 7 regions ──────────────────────────────────────── + + @Order(1) + @Test + void testNaRegionRequestUrlIsCorrect() { + Stack stack = clientForRegion("na").stack(API_KEY, MANAGEMENT_TOKEN); + Request req = stack.contentType("test").find().request(); + assertTrue(req.url().isHttps()); + assertEquals("api.contentstack.io", req.url().host()); + assertEquals("v3", req.url().pathSegments().get(0)); + } + + @Order(2) + @Test + void testEuRegionRequestUrlIsCorrect() { + Stack stack = clientForRegion("eu").stack(API_KEY, MANAGEMENT_TOKEN); + Request req = stack.contentType("test").find().request(); + assertTrue(req.url().isHttps()); + assertEquals("eu-api.contentstack.com", req.url().host()); + } + + @Order(3) + @Test + void testAuRegionRequestUrlIsCorrect() { + Stack stack = clientForRegion("au").stack(API_KEY, MANAGEMENT_TOKEN); + Request req = stack.contentType("test").find().request(); + assertTrue(req.url().isHttps()); + assertEquals("au-api.contentstack.com", req.url().host()); + } + + @Order(4) + @Test + void testAzureNaRegionRequestUrlIsCorrect() { + Stack stack = clientForRegion("azure-na").stack(API_KEY, MANAGEMENT_TOKEN); + Request req = stack.contentType("test").find().request(); + assertTrue(req.url().isHttps()); + assertEquals("azure-na-api.contentstack.com", req.url().host()); + } + + @Order(5) + @Test + void testAzureEuRegionRequestUrlIsCorrect() { + Stack stack = clientForRegion("azure-eu").stack(API_KEY, MANAGEMENT_TOKEN); + Request req = stack.contentType("test").find().request(); + assertTrue(req.url().isHttps()); + assertEquals("azure-eu-api.contentstack.com", req.url().host()); + } + + @Order(6) + @Test + void testGcpNaRegionRequestUrlIsCorrect() { + Stack stack = clientForRegion("gcp-na").stack(API_KEY, MANAGEMENT_TOKEN); + Request req = stack.contentType("test").find().request(); + assertTrue(req.url().isHttps()); + assertEquals("gcp-na-api.contentstack.com", req.url().host()); + } + + @Order(7) + @Test + void testGcpEuRegionRequestUrlIsCorrect() { + Stack stack = clientForRegion("gcp-eu").stack(API_KEY, MANAGEMENT_TOKEN); + Request req = stack.contentType("test").find().request(); + assertTrue(req.url().isHttps()); + assertEquals("gcp-eu-api.contentstack.com", req.url().host()); + } + + // ── region aliases are wired correctly ──────────────────────────────────── + + @Order(8) + @Test + void testUsAliasResolvesToNaHost() { + Stack stack = clientForRegion("us").stack(API_KEY, MANAGEMENT_TOKEN); + assertEquals("api.contentstack.io", stack.contentType("t").find().request().url().host()); + } + + @Order(9) + @Test + void testAwsEuAliasResolvesToEuHost() { + Stack stack = clientForRegion("aws-eu").stack(API_KEY, MANAGEMENT_TOKEN); + assertEquals("eu-api.contentstack.com", stack.contentType("t").find().request().url().host()); + } + + @Order(10) + @Test + void testAzureNaUnderscoreAliasResolvesCorrectly() { + Stack stack = clientForRegion("azure_na").stack(API_KEY, MANAGEMENT_TOKEN); + assertEquals("azure-na-api.contentstack.com", stack.contentType("t").find().request().url().host()); + } + + @Order(11) + @Test + void testUppercaseRegionAliasResolvesCorrectly() { + Stack stack = clientForRegion("GCP-EU").stack(API_KEY, MANAGEMENT_TOKEN); + assertEquals("gcp-eu-api.contentstack.com", stack.contentType("t").find().request().url().host()); + } + + // ── setRegion wires host into Retrofit base URL ─────────────────────────── + + @Order(12) + @Test + void testSetRegionWiresCorrectBaseUrl() { + Contentstack client = new Contentstack.Builder() + .setRegion("eu") + .build(); + assertTrue(client.getBaseUrl().contains("eu-api.contentstack.com"), + "Base URL should contain EU CMA host"); + } + + @Order(13) + @Test + void testSetRegionNaWiresDefaultHost() { + Contentstack client = new Contentstack.Builder() + .setRegion("na") + .build(); + assertEquals("api.contentstack.io", client.getHost()); + assertTrue(client.getBaseUrl().contains("api.contentstack.io")); + } + + // ── getContentstackEndpoints() returns full map ─────────────────────────── + + @Order(14) + @Test + void testGetAllEndpointsForRegionNotEmpty() { + Map endpoints = Contentstack.getContentstackEndpoints(REGION); + assertNotNull(endpoints); + assertFalse(endpoints.isEmpty()); + assertTrue(endpoints.containsKey("contentManagement")); + assertTrue(endpoints.containsKey("contentDelivery")); + assertTrue(endpoints.containsKey("auth")); + } + + @Order(15) + @Test + void testGetAllEndpointsOmitHttps() { + Map hosts = Contentstack.getContentstackEndpoints("eu", true); + hosts.values().forEach(v -> + assertFalse(v.startsWith("https://"), "Expected no scheme but got: " + v)); + assertEquals("eu-api.contentstack.com", hosts.get("contentManagement")); + } + + // ── live API call — uses env region + credentials ───────────────────────── + + @Order(16) + @Test + void testLiveCallWithSetRegionReturnsResponse() throws IOException { + Contentstack client = new Contentstack.Builder() + .setAuthtoken(TestClient.AUTHTOKEN) + .setRegion(REGION) + .build(); + + String resolvedHost = Endpoint.getContentstackEndpoint(REGION, "contentManagement", true); + assertEquals(resolvedHost, client.getHost()); + + Response response = client.user().getUser().execute(); + // 200 = valid authtoken, 401 = invalid but network + endpoint resolution worked + assertTrue(response.code() == 200 || response.code() == 401, + "Expected 200 or 401, got: " + response.code()); + } + + @Order(17) + @Test + void testLiveStackCallWithSetRegion() throws IOException { + Stack stack = new Contentstack.Builder() + .setAuthtoken(TestClient.AUTHTOKEN) + .setRegion(REGION) + .build() + .stack(API_KEY, MANAGEMENT_TOKEN); + + Request req = stack.contentType("").find().request(); + String expectedHost = Endpoint.getContentstackEndpoint(REGION, "contentManagement", true); + assertEquals(expectedHost, req.url().host()); + assertTrue(req.url().isHttps()); + assertEquals(Util.VERSION, req.url().pathSegments().get(0)); + } + + // ── helper ──────────────────────────────────────────────────────────────── + + private Contentstack clientForRegion(String region) { + return new Contentstack.Builder().setRegion(region).build(); + } +} From 0e3109cdb0765263fad4fe3d28d66ea76b08e0d2 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Mon, 8 Jun 2026 10:56:44 +0530 Subject: [PATCH 2/3] feat: Update changelog and improve region parameter documentation in Contentstack.Builder --- changelog.md | 7 +------ src/main/java/com/contentstack/cms/Contentstack.java | 6 +++--- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/changelog.md b/changelog.md index d6e917fd..cc612d9d 100644 --- a/changelog.md +++ b/changelog.md @@ -4,12 +4,7 @@ ### Jun 05, 2026 -- Feature: Dynamic endpoint resolution via `Endpoint.getContentstackEndpoint()` backed by the Contentstack Regions Registry (`https://artifacts.contentstack.com/regions.json`). Resolves the correct API URL for any of the 7 supported regions (NA, EU, AU, Azure NA, Azure EU, GCP NA, GCP EU) and 18 service keys without hardcoding host strings. -- Feature: `Contentstack.Builder.setRegion(String)` — convenience method to target a region directly (e.g. `.setRegion("eu")`), automatically resolving the correct Content Management API host. -- Feature: `Contentstack.getContentstackEndpoint(region, service)` and `Contentstack.getContentstackEndpoints(region)` static proxy methods for ad-hoc URL resolution. -- Feature: `Contentstack.refreshRegions()` — forces a live download of the regions registry and refreshes the in-memory cache, so newly published regions or service URLs are available without upgrading the SDK. -- Feature: `regions.json` bundled in the JAR and auto-refreshed at build time via `scripts/download-regions.sh` (invoked on the Maven `generate-resources` phase). -- Feature: `Contentstack.getHost()` and `Contentstack.getBaseUrl()` public accessors on the client instance. +- Feature: Dynamic endpoint resolution via `Endpoint.getContentstackEndpoint()` and `Builder.setRegion()` backed by the Contentstack Regions Registry. ## v1.11.2 diff --git a/src/main/java/com/contentstack/cms/Contentstack.java b/src/main/java/com/contentstack/cms/Contentstack.java index 2603b760..69295d04 100644 --- a/src/main/java/com/contentstack/cms/Contentstack.java +++ b/src/main/java/com/contentstack/cms/Contentstack.java @@ -761,12 +761,12 @@ public Builder setHost(@NotNull String hostname) { *

{@code
          * Contentstack client = new Contentstack.Builder()
          *     .setAuthtoken("authtoken")
-         *     .setRegion(ContentstackRegion.EU)
+         *     .setRegion("eu")
          *     .build();
          * }
* - * @param region region ID or alias (e.g. {@code "na"}, {@code "eu"}, {@code "azure-na"}). - * Use constants from {@link com.contentstack.cms.core.ContentstackRegion}. + * @param region region ID or alias (e.g. {@code "na"}, {@code "eu"}, {@code "azure-na"}, + * {@code "azure-eu"}, {@code "gcp-na"}, {@code "gcp-eu"}, {@code "au"}). * @return this Builder * @throws IllegalArgumentException if the region is unknown or empty */ From 2231ff05c1cc9433f0d16a0b4e8817dcb0f34f44 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Mon, 8 Jun 2026 15:20:09 +0530 Subject: [PATCH 3/3] fix: update release date for v1.12.0 in changelog --- changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index cc612d9d..5f5c3c64 100644 --- a/changelog.md +++ b/changelog.md @@ -2,7 +2,7 @@ ## v1.12.0 -### Jun 05, 2026 +### Jun 15, 2026 - Feature: Dynamic endpoint resolution via `Endpoint.getContentstackEndpoint()` and `Builder.setRegion()` backed by the Contentstack Regions Registry.