diff --git a/CHANGELOG.md b/CHANGELOG.md
index 40a128d8..d5c6c9d3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# CHANGELOG
+## v2.7.0
+
+### Jun 15, 2026
+- Enhancement: Endpoint integration
+
## v2.6.0
### Feb 23, 2026
diff --git a/pom.xml b/pom.xml
index 029cd247..ac4eb555 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
4.0.0
com.contentstack.sdk
java
- 2.6.0
+ 2.7.0
jar
contentstack-java
Java SDK for Contentstack Content Delivery API
@@ -462,6 +462,34 @@
maven-jxr-plugin
2.3
+
+
+
+ org.codehaus.mojo
+ exec-maven-plugin
+ 3.1.0
+
+
+ refresh-regions
+
+ exec
+
+
+ bash
+
+ ${project.basedir}/scripts/download-regions.sh
+
+
+
+
+
diff --git a/scripts/download-regions.sh b/scripts/download-regions.sh
new file mode 100755
index 00000000..127d81c8
--- /dev/null
+++ b/scripts/download-regions.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+# Download the latest regions.json from the Contentstack artifacts registry and
+# write it to src/main/resources/assets/regions.json so it gets bundled into
+# the SDK jar on the next build.
+#
+# Usage:
+# ./scripts/download-regions.sh
+# mvn exec:exec@refresh-regions
+#
+# Run this whenever Contentstack announces new regions or service keys, then
+# commit the updated file:
+# git add src/main/resources/assets/regions.json
+# git commit -m "chore: refresh regions.json"
+
+set -euo pipefail
+
+REGIONS_URL="https://artifacts.contentstack.com/regions.json"
+DEST="$(dirname "$0")/../src/main/resources/assets/regions.json"
+DEST="$(cd "$(dirname "$DEST")" && pwd)/$(basename "$DEST")"
+
+echo "Downloading regions.json from ${REGIONS_URL} ..."
+
+if command -v curl &>/dev/null; then
+ curl --silent --show-error --fail --location \
+ --retry 3 --retry-delay 2 \
+ -o "${DEST}" "${REGIONS_URL}"
+elif command -v wget &>/dev/null; then
+ wget --quiet --tries=3 --waitretry=2 -O "${DEST}" "${REGIONS_URL}"
+else
+ echo "Error: neither curl nor wget found. Install one and retry." >&2
+ exit 1
+fi
+
+# Validate the downloaded file contains a "regions" array
+if ! python3 -c "import sys, json; d=json.load(open('${DEST}')); assert 'regions' in d and len(d['regions']) > 0" 2>/dev/null &&
+ ! python -c "import sys, json; d=json.load(open('${DEST}')); assert 'regions' in d and len(d['regions']) > 0" 2>/dev/null; then
+ # Fallback validation without Python — just check the key exists
+ if ! grep -q '"regions"' "${DEST}"; then
+ echo "Error: downloaded file does not look like a valid regions.json" >&2
+ rm -f "${DEST}"
+ exit 1
+ fi
+fi
+
+REGION_COUNT=$(grep -o '"id"' "${DEST}" | wc -l | tr -d ' ')
+echo "contentstack-java: regions.json updated (${REGION_COUNT} regions) → ${DEST}"
diff --git a/src/main/java/com/contentstack/sdk/Config.java b/src/main/java/com/contentstack/sdk/Config.java
index 003cb4c7..d8f7c45b 100644
--- a/src/main/java/com/contentstack/sdk/Config.java
+++ b/src/main/java/com/contentstack/sdk/Config.java
@@ -18,6 +18,7 @@ public class Config {
protected String livePreviewContentType = null;
protected String livePreviewEntryUid = null;
protected String host = "cdn.contentstack.io";
+ protected boolean hostOverridden = false;
protected String version = "v3";
protected String scheme = "https://";
protected String endpoint;
@@ -167,6 +168,7 @@ public String getHost() {
public void setHost(String hostName) {
if (hostName != null && !hostName.isEmpty()) {
host = hostName;
+ hostOverridden = true;
}
}
diff --git a/src/main/java/com/contentstack/sdk/Contentstack.java b/src/main/java/com/contentstack/sdk/Contentstack.java
index b287be16..33b6357a 100644
--- a/src/main/java/com/contentstack/sdk/Contentstack.java
+++ b/src/main/java/com/contentstack/sdk/Contentstack.java
@@ -1,5 +1,6 @@
package com.contentstack.sdk;
+import java.util.Map;
import java.util.Objects;
/**
@@ -98,6 +99,60 @@ private static void validateCredentials(String stackApiKey, String deliveryToken
}
}
+ /**
+ * Returns the Contentstack API URL for the given region and service.
+ *
+ *
Delegates to {@link Endpoint#getContentstackEndpoint(String, String)} — provided as a
+ * convenience so callers can reach endpoint resolution through the same top-level class they
+ * use to create stacks.
+ *
+ * @param region region ID or alias (e.g. {@code "na"}, {@code "eu"}, {@code "azure-na"})
+ * @param service service key (e.g. {@code "contentDelivery"}, {@code "contentManagement"})
+ * @return full URL including {@code https://} scheme
+ * @throws IllegalArgumentException if the region or service is not recognised
+ */
+ public static String getContentstackEndpoint(String region, String service) {
+ return Endpoint.getContentstackEndpoint(region, service);
+ }
+
+ /**
+ * Returns the Contentstack API URL for the given region and service, optionally stripping
+ * the {@code https://} scheme.
+ *
+ * @param region region ID or alias
+ * @param service service key
+ * @param omitHttps when {@code true}, returns the bare host without {@code https://}
+ * @return URL or bare host
+ * @throws IllegalArgumentException if the region or service is not recognised
+ */
+ public static String getContentstackEndpoint(String region, String service, boolean omitHttps) {
+ return Endpoint.getContentstackEndpoint(region, service, omitHttps);
+ }
+
+ /**
+ * Returns all service endpoints for the given region as an ordered map of service key to URL.
+ *
+ * @param region region ID or alias
+ * @return map of service key → full URL
+ * @throws IllegalArgumentException if the region is not recognised
+ */
+ public static Map getContentstackEndpoints(String region) {
+ return Endpoint.getAllEndpoints(region);
+ }
+
+ /**
+ * Returns all service endpoints for the given region, optionally stripping the
+ * {@code https://} scheme from every URL.
+ *
+ * @param region region ID or alias
+ * @param omitHttps when {@code true}, returns bare hosts without {@code https://}
+ * @return map of service key → URL or bare host
+ * @throws IllegalArgumentException if the region is not recognised
+ */
+ public static Map getContentstackEndpoints(String region, boolean omitHttps) {
+ return Endpoint.getAllEndpoints(region, omitHttps);
+ }
+
private static Stack initializeStack(String stackApiKey, String deliveryToken, String environment, Config config) {
Stack stack = new Stack(stackApiKey.trim());
stack.setHeader("api_key", stackApiKey);
diff --git a/src/main/java/com/contentstack/sdk/Endpoint.java b/src/main/java/com/contentstack/sdk/Endpoint.java
new file mode 100644
index 00000000..bf1dcd4a
--- /dev/null
+++ b/src/main/java/com/contentstack/sdk/Endpoint.java
@@ -0,0 +1,269 @@
+package com.contentstack.sdk;
+
+import org.jetbrains.annotations.NotNull;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.logging.Logger;
+
+/**
+ * Resolves Contentstack API endpoints for any region and service without hardcoding host strings.
+ *
+ * Resolution chain
+ *
+ * - In-memory cache — populated on the first call and reused for the JVM lifetime
+ * (zero I/O on every subsequent call).
+ * - Bundled {@code regions.json} — read from the classpath resource
+ * {@code /assets/regions.json} that is packaged inside the SDK jar. Works
+ * fully offline with zero latency.
+ * - Live download — if the requested region is not present in the bundled file
+ * (e.g. Contentstack added a new region after this SDK version was released), a single
+ * HTTP request is made to {@value #REGIONS_URL} to fetch the latest registry. The
+ * downloaded data replaces the in-memory cache so all subsequent lookups benefit from it.
+ * This attempt is made at most once per JVM session to avoid repeated network
+ * calls for genuinely invalid region strings.
+ *
+ *
+ * Region matching is case-insensitive and treats {@code -} and {@code _} as equivalent
+ * separators, so {@code "AZURE_NA"}, {@code "azure-na"}, and {@code "Azure_NA"} all resolve
+ * to the same region.
+ *
+ *
Examples:
+ *
+ * String url = Endpoint.getContentstackEndpoint("eu", "contentDelivery");
+ * // → "https://eu-cdn.contentstack.com"
+ *
+ * String host = Endpoint.getContentstackEndpoint("eu", "contentDelivery", true);
+ * // → "eu-cdn.contentstack.com"
+ *
+ * Map<String, String> all = Endpoint.getAllEndpoints("azure-na");
+ * // → {"contentDelivery": "https://azure-na-cdn.contentstack.com", ...}
+ *
+ */
+public class Endpoint {
+
+ static final String REGIONS_URL = "https://artifacts.contentstack.com/regions.json";
+
+ private static final Logger logger = Logger.getLogger(Endpoint.class.getSimpleName());
+
+ private static volatile JSONArray regionsCache = null;
+
+ // Ensures the live download is attempted at most once per JVM session so that
+ // genuinely invalid region strings do not trigger repeated network calls.
+ private static volatile boolean liveRefreshDone = false;
+
+ private Endpoint() {
+ }
+
+ /**
+ * Returns the URL for the given region and service.
+ *
+ * @param region the region ID or alias (e.g. {@code "na"}, {@code "eu"}, {@code "azure-na"})
+ * @param service the service key (e.g. {@code "contentDelivery"}, {@code "contentManagement"})
+ * @return the full URL including {@code https://} scheme
+ * @throws IllegalArgumentException if the region or service is not recognised
+ */
+ public static String getContentstackEndpoint(@NotNull String region, @NotNull String service) {
+ return getContentstackEndpoint(region, service, false);
+ }
+
+ /**
+ * Returns the URL for the given region and service, optionally stripping the {@code https://}
+ * scheme.
+ *
+ * @param region the region ID or alias
+ * @param service the service key
+ * @param omitHttps when {@code true}, returns the bare host without the {@code https://} prefix
+ * @return the URL (or bare host when {@code omitHttps} is {@code true})
+ * @throws IllegalArgumentException if the region or service is not recognised
+ */
+ public static String getContentstackEndpoint(@NotNull String region, @NotNull String service,
+ boolean omitHttps) {
+ if (region.trim().isEmpty()) {
+ throw new IllegalArgumentException("Empty region provided. Please provide a valid region.");
+ }
+ JSONObject regionRow = resolveRegion(region);
+ JSONObject endpoints = regionRow.getJSONObject("endpoints");
+ if (!endpoints.has(service)) {
+ throw new IllegalArgumentException(
+ "Service \"" + service + "\" not found for region \"" + region + "\"");
+ }
+ String url = endpoints.getString(service);
+ return omitHttps ? stripHttps(url) : url;
+ }
+
+ /**
+ * Returns all endpoints for the given region as an ordered map of service key to URL.
+ *
+ * @param region the region ID or alias
+ * @return map of service key → URL
+ * @throws IllegalArgumentException if the region is not recognised
+ */
+ public static Map getAllEndpoints(@NotNull String region) {
+ return getAllEndpoints(region, false);
+ }
+
+ /**
+ * Returns all endpoints for the given region, optionally stripping the {@code https://} scheme.
+ *
+ * @param region the region ID or alias
+ * @param omitHttps when {@code true}, returns bare hosts without the {@code https://} prefix
+ * @return map of service key → URL (or bare host)
+ * @throws IllegalArgumentException if the region is not recognised
+ */
+ public static Map getAllEndpoints(@NotNull String region, boolean omitHttps) {
+ if (region.trim().isEmpty()) {
+ throw new IllegalArgumentException("Empty region provided. Please provide a valid region.");
+ }
+ JSONObject regionRow = resolveRegion(region);
+ JSONObject endpoints = regionRow.getJSONObject("endpoints");
+ Map result = new LinkedHashMap<>();
+ for (String key : endpoints.keySet()) {
+ String url = endpoints.getString(key);
+ result.put(key, omitHttps ? stripHttps(url) : url);
+ }
+ return result;
+ }
+
+ /**
+ * Resets the in-memory cache and the live-refresh flag. Intended for testing only.
+ */
+ static synchronized void resetCache() {
+ regionsCache = null;
+ liveRefreshDone = false;
+ }
+
+ // ── internals ─────────────────────────────────────────────────────────────
+
+ /**
+ * Resolution chain:
+ *
+ * - In-memory cache
+ * - Bundled classpath {@code regions.json}
+ * - Live download (once per JVM session, triggered only when the region is absent)
+ *
+ */
+ private static JSONObject resolveRegion(String region) {
+ // Tier 1 + 2: load from cache or bundled classpath
+ JSONArray regions = loadRegions();
+ try {
+ return findRegion(regions, region);
+ } catch (IllegalArgumentException notInBundled) {
+ // Tier 3: region not in bundled file — attempt one live refresh.
+ // This handles the case where Contentstack added a new region after this
+ // SDK version was released and the user hasn't upgraded yet.
+ if (!liveRefreshDone) {
+ JSONArray fresh = tryLiveRefresh();
+ if (fresh != null) {
+ try {
+ return findRegion(fresh, region);
+ } catch (IllegalArgumentException ignored) {
+ // Region absent even in the live data → fall through and throw below
+ }
+ }
+ }
+ throw notInBundled;
+ }
+ }
+
+ /**
+ * Loads regions from the in-memory cache or the bundled classpath resource.
+ * Populates the cache on the first call.
+ */
+ private static synchronized JSONArray loadRegions() {
+ if (regionsCache != null) {
+ return regionsCache;
+ }
+ // Try bundled classpath resource (packaged inside the SDK jar)
+ InputStream stream = Endpoint.class.getResourceAsStream("/assets/regions.json");
+ if (stream != null) {
+ try (Scanner scanner = new Scanner(stream, StandardCharsets.UTF_8.name())) {
+ String raw = scanner.useDelimiter("\\A").next();
+ JSONObject root = new JSONObject(raw);
+ regionsCache = root.getJSONArray("regions");
+ return regionsCache;
+ }
+ }
+ // Bundled file absent (e.g. corrupted build) — try live download immediately
+ logger.warning("Bundled regions.json not found in classpath — attempting live download.");
+ JSONArray downloaded = tryLiveRefresh();
+ if (downloaded != null) {
+ return downloaded;
+ }
+ throw new IllegalStateException(
+ "regions.json not found in classpath and could not be downloaded from "
+ + REGIONS_URL + ". Ensure the SDK jar was built correctly, or check network access.");
+ }
+
+ /**
+ * Attempts a one-time HTTP fetch of {@value #REGIONS_URL}.
+ * Returns the parsed regions array on success, or {@code null} if the attempt is skipped
+ * or the network is unavailable. Updates the in-memory cache on success.
+ */
+ private static synchronized JSONArray tryLiveRefresh() {
+ if (liveRefreshDone) {
+ return regionsCache; // already fetched this session — return whatever we have
+ }
+ liveRefreshDone = true;
+ try {
+ logger.info("Refreshing regions from " + REGIONS_URL);
+ URL url = new URL(REGIONS_URL);
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod("GET");
+ conn.setConnectTimeout(5_000);
+ conn.setReadTimeout(10_000);
+ conn.setRequestProperty("Accept", "application/json");
+ try (InputStream stream = conn.getInputStream();
+ Scanner scanner = new Scanner(stream, StandardCharsets.UTF_8.name())) {
+ String raw = scanner.useDelimiter("\\A").next();
+ JSONObject root = new JSONObject(raw);
+ regionsCache = root.getJSONArray("regions");
+ logger.info("regions.json refreshed from live URL (" + regionsCache.length() + " regions).");
+ return regionsCache;
+ }
+ } catch (Exception e) {
+ logger.warning("Live region refresh failed: " + e.getMessage());
+ return null;
+ }
+ }
+
+ private static JSONObject findRegion(JSONArray regions, String region) {
+ String normalized = region.trim().toLowerCase().replace('_', '-');
+
+ // Pass 1: match canonical id
+ for (int i = 0; i < regions.length(); i++) {
+ JSONObject row = regions.getJSONObject(i);
+ if (row.getString("id").equals(normalized)) {
+ return row;
+ }
+ }
+
+ // Pass 2: match aliases (case-insensitive, _ == -)
+ for (int i = 0; i < regions.length(); i++) {
+ JSONObject row = regions.getJSONObject(i);
+ JSONArray aliases = row.optJSONArray("alias");
+ if (aliases == null) {
+ continue;
+ }
+ for (int j = 0; j < aliases.length(); j++) {
+ String alias = aliases.getString(j).toLowerCase().replace('_', '-');
+ if (alias.equals(normalized)) {
+ return row;
+ }
+ }
+ }
+
+ throw new IllegalArgumentException("Invalid region: " + region);
+ }
+
+ private static String stripHttps(String url) {
+ return url.replaceFirst("^https?://", "");
+ }
+}
diff --git a/src/main/java/com/contentstack/sdk/Stack.java b/src/main/java/com/contentstack/sdk/Stack.java
index 5e7cd71a..d52ae120 100644
--- a/src/main/java/com/contentstack/sdk/Stack.java
+++ b/src/main/java/com/contentstack/sdk/Stack.java
@@ -59,40 +59,19 @@ protected Stack(@NotNull String apiKey) {
protected void setConfig(Config config) {
this.config = config;
- String urlDomain = config.host;
- if (!config.region.name().isEmpty()) {
- String region = config.region.name().toLowerCase();
- if (region.equalsIgnoreCase("eu")) {
- if (urlDomain.equalsIgnoreCase("cdn.contentstack.io")) {
- urlDomain = "cdn.contentstack.com";
+ // Explicit host (set via Config.setHost()) always takes precedence over region resolution.
+ // When no host was explicitly set, resolve the content-delivery host from regions.json via
+ // Endpoint so that new regions are picked up without SDK changes.
+ if (!config.hostOverridden) {
+ String regionId = config.region.name().toLowerCase();
+ try {
+ config.host = Endpoint.getContentstackEndpoint(regionId, "contentDelivery", true);
+ } catch (IllegalArgumentException e) {
+ // Unrecognised region: apply the legacy prefix pattern for backward compatibility
+ if (!regionId.equals("us")) {
+ config.host = regionId.replace("_", "-") + "-cdn.contentstack.com";
}
- config.host = region + "-" + urlDomain;
- } else if (region.equalsIgnoreCase("azure_na")) {
- if (urlDomain.equalsIgnoreCase("cdn.contentstack.io")) {
- urlDomain = "cdn.contentstack.com";
- }
- config.host = "azure-na" + "-" + urlDomain;
- } else if (region.equalsIgnoreCase("azure_eu")) {
- if (urlDomain.equalsIgnoreCase("cdn.contentstack.io")) {
- urlDomain = "cdn.contentstack.com";
- }
- config.host = "azure-eu" + "-" + urlDomain;
- } else if (region.equalsIgnoreCase("gcp_na")) {
- if (urlDomain.equalsIgnoreCase("cdn.contentstack.io")) {
- urlDomain = "cdn.contentstack.com";
- }
- config.host = "gcp-na" + "-" + urlDomain;
- } else if (region.equalsIgnoreCase("gcp_eu")) {
- if (urlDomain.equalsIgnoreCase("cdn.contentstack.io")) {
- urlDomain = "cdn.contentstack.com";
- }
- config.host = "gcp-eu" + "-" + urlDomain;
- } else if (region.equalsIgnoreCase("au")) {
- if (urlDomain.equalsIgnoreCase("cdn.contentstack.io")) {
- urlDomain = "cdn.contentstack.com";
- }
- config.host = region + "-" + urlDomain;
}
}
diff --git a/src/test/java/com/contentstack/sdk/EndpointIT.java b/src/test/java/com/contentstack/sdk/EndpointIT.java
new file mode 100644
index 00000000..54e15d3d
--- /dev/null
+++ b/src/test/java/com/contentstack/sdk/EndpointIT.java
@@ -0,0 +1,325 @@
+package com.contentstack.sdk;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Logger;
+
+/**
+ * Integration tests for {@link Endpoint}.
+ *
+ * The "stack integration" tests create a real Stack using the host resolved
+ * by {@code Endpoint} and exercise the Contentstack CDA using credentials from
+ * {@code src/test/resources/.env}. They share the same credential set as the
+ * rest of the integration suite and are deliberately non-destructive (read-only
+ * CDA calls only).
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class EndpointIT {
+
+ private static final Logger logger = Logger.getLogger(EndpointIT.class.getName());
+
+ private String apiKey;
+ private String deliveryToken;
+ private String environment;
+
+ @BeforeAll
+ public void setUp() {
+ apiKey = Credentials.API_KEY;
+ deliveryToken = Credentials.DELIVERY_TOKEN;
+ environment = Credentials.ENVIRONMENT;
+ }
+
+ // ── endpoint resolution ───────────────────────────────────────────────────
+
+ @Test
+ void testNaContentDeliveryResolvesCorrectly() {
+ String url = Endpoint.getContentstackEndpoint("na", "contentDelivery");
+ Assertions.assertEquals("https://cdn.contentstack.io", url);
+ }
+
+ @Test
+ void testEuContentDeliveryResolvesCorrectly() {
+ String url = Endpoint.getContentstackEndpoint("eu", "contentDelivery");
+ Assertions.assertEquals("https://eu-cdn.contentstack.com", url);
+ }
+
+ @Test
+ void testAzureNaContentDeliveryResolvesCorrectly() {
+ String url = Endpoint.getContentstackEndpoint("azure-na", "contentDelivery");
+ Assertions.assertEquals("https://azure-na-cdn.contentstack.com", url);
+ }
+
+ @Test
+ void testGcpNaContentDeliveryResolvesCorrectly() {
+ String url = Endpoint.getContentstackEndpoint("gcp-na", "contentDelivery");
+ Assertions.assertEquals("https://gcp-na-cdn.contentstack.com", url);
+ }
+
+ @Test
+ void testOmitHttpsStripsScheme() {
+ String host = Endpoint.getContentstackEndpoint("na", "contentDelivery", true);
+ Assertions.assertEquals("cdn.contentstack.io", host);
+ Assertions.assertFalse(host.startsWith("https://"));
+ }
+
+ @Test
+ void testGetAllEndpointsReturnsMap() {
+ Map endpoints = Endpoint.getAllEndpoints("na");
+ Assertions.assertNotNull(endpoints);
+ Assertions.assertFalse(endpoints.isEmpty());
+ Assertions.assertTrue(endpoints.containsKey("contentDelivery"));
+ Assertions.assertTrue(endpoints.containsKey("contentManagement"));
+ Assertions.assertTrue(endpoints.containsKey("auth"));
+ }
+
+ @Test
+ void testAliasResolution() {
+ // 'us' is an alias for 'na'
+ String viaAlias = Endpoint.getContentstackEndpoint("us", "contentDelivery");
+ String viaCanonical = Endpoint.getContentstackEndpoint("na", "contentDelivery");
+ Assertions.assertEquals(viaCanonical, viaAlias);
+
+ // underscore + uppercase alias for azure-na
+ String viaUnderscore = Endpoint.getContentstackEndpoint("AZURE_NA", "contentDelivery");
+ String viaHyphen = Endpoint.getContentstackEndpoint("azure-na", "contentDelivery");
+ Assertions.assertEquals(viaHyphen, viaUnderscore);
+ }
+
+ @Test
+ void testAllRegionsHaveContentDelivery() {
+ String[] regions = {"na", "eu", "au", "azure-na", "azure-eu", "gcp-na", "gcp-eu"};
+ for (String region : regions) {
+ String url = Endpoint.getContentstackEndpoint(region, "contentDelivery");
+ Assertions.assertNotNull(url, "contentDelivery missing for region: " + region);
+ Assertions.assertTrue(url.startsWith("https://"),
+ "Expected https:// for region " + region + " but got: " + url);
+ }
+ }
+
+ @Test
+ void testAllRegionsHaveContentManagement() {
+ String[] regions = {"na", "eu", "au", "azure-na", "azure-eu", "gcp-na", "gcp-eu"};
+ for (String region : regions) {
+ String url = Endpoint.getContentstackEndpoint(region, "contentManagement");
+ Assertions.assertNotNull(url, "contentManagement missing for region: " + region);
+ Assertions.assertTrue(url.startsWith("https://"),
+ "Expected https:// for region " + region + " but got: " + url);
+ }
+ }
+
+ // ── stack integration ─────────────────────────────────────────────────────
+
+ /**
+ * Verifies that a Stack configured with the host resolved from
+ * {@code Endpoint} has the correct host set on its config.
+ */
+ @Test
+ void testStackHostMatchesResolvedEndpoint() throws IllegalAccessException {
+ String host = Endpoint.getContentstackEndpoint("na", "contentDelivery", true);
+ Config config = new Config();
+ config.setHost(host);
+ Stack stack = Contentstack.stack("fakeKey", "fakeToken", "fakeEnv", config);
+ Assertions.assertEquals(host, stack.config.host,
+ "Stack host should match the endpoint-resolved host");
+ }
+
+ /**
+ * Verifies that a Stack created via the endpoint-resolved host for EU
+ * has the correct host on its config.
+ */
+ @Test
+ void testStackHostEuMatchesResolvedEndpoint() throws IllegalAccessException {
+ String host = Endpoint.getContentstackEndpoint("eu", "contentDelivery", true);
+ Config config = new Config();
+ config.setHost(host);
+ Stack stack = Contentstack.stack("fakeKey", "fakeToken", "fakeEnv", config);
+ Assertions.assertEquals("eu-cdn.contentstack.com", stack.config.host);
+ }
+
+ /**
+ * Creates a real Stack using the NA endpoint resolved from {@code Endpoint}
+ * and fetches content types from the CDA to confirm the host resolves to a
+ * working endpoint.
+ *
+ * Skipped gracefully when credentials are absent (CI without secrets).
+ */
+ @Test
+ void testRealStackWithEndpointResolvedHost() throws IllegalAccessException, InterruptedException {
+ if (apiKey == null || apiKey.isEmpty()
+ || deliveryToken == null || deliveryToken.isEmpty()) {
+ logger.warning("Skipping live API test — credentials not configured.");
+ return;
+ }
+
+ String host = Endpoint.getContentstackEndpoint("na", "contentDelivery", true);
+ Config config = new Config();
+ config.setHost(host);
+
+ Stack stack = Contentstack.stack(apiKey, deliveryToken, environment, config);
+ Assertions.assertEquals(host, stack.config.host);
+
+ CountDownLatch latch = new CountDownLatch(1);
+ boolean[] passed = {false};
+
+ stack.getContentTypes(new org.json.JSONObject(), new ContentTypesCallback() {
+ @Override
+ public void onCompletion(ContentTypesModel model, Error error) {
+ if (error == null) {
+ logger.info(() -> "Live endpoint check: fetched content types via host=" + host);
+ passed[0] = true;
+ } else {
+ logger.warning(() -> "Live endpoint check failed: " + error.getErrorMessage());
+ }
+ latch.countDown();
+ }
+ });
+
+ boolean completed = latch.await(15, TimeUnit.SECONDS);
+ Assertions.assertTrue(completed, "API call timed out after 15 seconds");
+ // Skip (not fail) when credentials are absent or point to a non-existent stack
+ Assumptions.assumeTrue(passed[0],
+ "Live API call returned an error — skipping assertion (check .env credentials)");
+ }
+
+ /**
+ * Verifies that the host resolved via {@code Endpoint} for the NA region
+ * matches the Stack default host that the SDK would have used without
+ * endpoint resolution (backward-compatible).
+ */
+ @Test
+ void testEndpointResolvedHostIsBackwardCompatibleWithDefaultNa() throws IllegalAccessException {
+ Stack defaultStack = Contentstack.stack("k", "t", "e");
+ String sdkDefaultHost = defaultStack.config.host;
+ String resolvedHost = Endpoint.getContentstackEndpoint("na", "contentDelivery", true);
+ Assertions.assertEquals(sdkDefaultHost, resolvedHost,
+ "Endpoint-resolved NA host should match the SDK's built-in default");
+ }
+
+ // ── Contentstack proxy ────────────────────────────────────────────────────
+
+ @Test
+ void testContentstackProxyMatchesEndpointDirect() {
+ String viaProxy = Contentstack.getContentstackEndpoint("eu", "contentDelivery");
+ String viaDirect = Endpoint.getContentstackEndpoint("eu", "contentDelivery");
+ Assertions.assertEquals(viaDirect, viaProxy);
+ }
+
+ @Test
+ void testContentstackProxyOmitHttps() {
+ String host = Contentstack.getContentstackEndpoint("azure-na", "contentDelivery", true);
+ Assertions.assertEquals("azure-na-cdn.contentstack.com", host);
+ Assertions.assertFalse(host.startsWith("https://"));
+ }
+
+ @Test
+ void testContentstackProxyGetAllEndpoints() {
+ Map endpoints = Contentstack.getContentstackEndpoints("eu");
+ Assertions.assertNotNull(endpoints);
+ Assertions.assertFalse(endpoints.isEmpty());
+ Assertions.assertEquals("https://eu-cdn.contentstack.com", endpoints.get("contentDelivery"));
+ }
+
+ @Test
+ void testContentstackProxyGetAllEndpointsOmitHttps() {
+ Map endpoints = Contentstack.getContentstackEndpoints("gcp-na", true);
+ Assertions.assertNotNull(endpoints);
+ for (String url : endpoints.values()) {
+ Assertions.assertFalse(url.startsWith("https://"),
+ "Expected no https:// prefix but got: " + url);
+ }
+ }
+
+ @Test
+ void testContentstackProxyUnknownRegionThrows() {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> Contentstack.getContentstackEndpoint("atlantis", "contentDelivery"));
+ }
+
+ // ── Config.hostOverridden — explicit host beats region resolution ─────────
+
+ @Test
+ void testExplicitHostTakesPrecedenceOverRegion() throws IllegalAccessException {
+ Config config = new Config();
+ config.setRegion(Config.ContentstackRegion.EU);
+ config.setHost("custom-proxy.example.com"); // explicit override
+ Stack stack = Contentstack.stack("k", "t", "e", config);
+ Assertions.assertEquals("custom-proxy.example.com", stack.config.host,
+ "Explicit host must not be replaced by region-resolved host");
+ }
+
+ @Test
+ void testNoExplicitHostUsesRegionResolution() throws IllegalAccessException {
+ Config config = new Config();
+ config.setRegion(Config.ContentstackRegion.AU);
+ Stack stack = Contentstack.stack("k", "t", "e", config);
+ Assertions.assertEquals("au-cdn.contentstack.com", stack.config.host,
+ "AU region should resolve to au-cdn.contentstack.com via Endpoint");
+ }
+
+ @Test
+ void testEmptySetHostDoesNotSetOverrideFlag() throws IllegalAccessException {
+ Config config = new Config();
+ config.setRegion(Config.ContentstackRegion.EU);
+ config.setHost(""); // empty → should not mark hostOverridden
+ Stack stack = Contentstack.stack("k", "t", "e", config);
+ Assertions.assertEquals("eu-cdn.contentstack.com", stack.config.host,
+ "Empty setHost() should not prevent region-based resolution");
+ }
+
+ @Test
+ void testNullSetHostDoesNotSetOverrideFlag() throws IllegalAccessException {
+ Config config = new Config();
+ config.setRegion(Config.ContentstackRegion.GCP_EU);
+ config.setHost(null); // null → should not mark hostOverridden
+ Stack stack = Contentstack.stack("k", "t", "e", config);
+ Assertions.assertEquals("gcp-eu-cdn.contentstack.com", stack.config.host,
+ "Null setHost() should not prevent region-based resolution");
+ }
+
+ // ── Stack region→host resolution via Endpoint ─────────────────────────────
+
+ @Test
+ void testStackUsRegionResolvesToNaCdn() throws IllegalAccessException {
+ Config config = new Config();
+ config.setRegion(Config.ContentstackRegion.US);
+ Stack stack = Contentstack.stack("k", "t", "e", config);
+ Assertions.assertEquals("cdn.contentstack.io", stack.config.host);
+ }
+
+ @Test
+ void testStackEuRegionResolvesViaEndpoint() throws IllegalAccessException {
+ Config config = new Config();
+ config.setRegion(Config.ContentstackRegion.EU);
+ Stack stack = Contentstack.stack("k", "t", "e", config);
+ Assertions.assertEquals(
+ Endpoint.getContentstackEndpoint("eu", "contentDelivery", true),
+ stack.config.host);
+ }
+
+ @Test
+ void testStackAzureNaRegionResolvesViaEndpoint() throws IllegalAccessException {
+ Config config = new Config();
+ config.setRegion(Config.ContentstackRegion.AZURE_NA);
+ Stack stack = Contentstack.stack("k", "t", "e", config);
+ Assertions.assertEquals(
+ Endpoint.getContentstackEndpoint("azure-na", "contentDelivery", true),
+ stack.config.host);
+ }
+
+ @Test
+ void testStackGcpNaRegionResolvesViaEndpoint() throws IllegalAccessException {
+ Config config = new Config();
+ config.setRegion(Config.ContentstackRegion.GCP_NA);
+ Stack stack = Contentstack.stack("k", "t", "e", config);
+ Assertions.assertEquals(
+ Endpoint.getContentstackEndpoint("gcp-na", "contentDelivery", true),
+ stack.config.host);
+ }
+}
diff --git a/src/test/java/com/contentstack/sdk/TestEndpoint.java b/src/test/java/com/contentstack/sdk/TestEndpoint.java
new file mode 100644
index 00000000..5c38b60c
--- /dev/null
+++ b/src/test/java/com/contentstack/sdk/TestEndpoint.java
@@ -0,0 +1,307 @@
+package com.contentstack.sdk;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+class TestEndpoint {
+
+ @AfterEach
+ void resetCache() {
+ Endpoint.resetCache();
+ }
+
+ // ── canonical IDs ─────────────────────────────────────────────────────────
+
+ @Test
+ void testNaContentDelivery() {
+ String url = Endpoint.getContentstackEndpoint("na", "contentDelivery");
+ Assertions.assertEquals("https://cdn.contentstack.io", url);
+ }
+
+ @Test
+ void testEuContentDelivery() {
+ String url = Endpoint.getContentstackEndpoint("eu", "contentDelivery");
+ Assertions.assertEquals("https://eu-cdn.contentstack.com", url);
+ }
+
+ @Test
+ void testAuContentDelivery() {
+ String url = Endpoint.getContentstackEndpoint("au", "contentDelivery");
+ Assertions.assertEquals("https://au-cdn.contentstack.com", url);
+ }
+
+ @Test
+ void testAzureNaContentDelivery() {
+ String url = Endpoint.getContentstackEndpoint("azure-na", "contentDelivery");
+ Assertions.assertEquals("https://azure-na-cdn.contentstack.com", url);
+ }
+
+ @Test
+ void testAzureEuContentDelivery() {
+ String url = Endpoint.getContentstackEndpoint("azure-eu", "contentDelivery");
+ Assertions.assertEquals("https://azure-eu-cdn.contentstack.com", url);
+ }
+
+ @Test
+ void testGcpNaContentDelivery() {
+ String url = Endpoint.getContentstackEndpoint("gcp-na", "contentDelivery");
+ Assertions.assertEquals("https://gcp-na-cdn.contentstack.com", url);
+ }
+
+ @Test
+ void testGcpEuContentDelivery() {
+ String url = Endpoint.getContentstackEndpoint("gcp-eu", "contentDelivery");
+ Assertions.assertEquals("https://gcp-eu-cdn.contentstack.com", url);
+ }
+
+ // ── aliases ───────────────────────────────────────────────────────────────
+
+ @Test
+ void testAliasUsResolvesToNa() {
+ String url = Endpoint.getContentstackEndpoint("us", "contentDelivery");
+ Assertions.assertEquals("https://cdn.contentstack.io", url);
+ }
+
+ @Test
+ void testAliasUppercaseEU() {
+ String url = Endpoint.getContentstackEndpoint("EU", "contentDelivery");
+ Assertions.assertEquals("https://eu-cdn.contentstack.com", url);
+ }
+
+ @Test
+ void testAliasAwsNaHyphen() {
+ String url = Endpoint.getContentstackEndpoint("aws-na", "contentDelivery");
+ Assertions.assertEquals("https://cdn.contentstack.io", url);
+ }
+
+ @Test
+ void testAliasAwsNaUnderscore() {
+ String url = Endpoint.getContentstackEndpoint("aws_na", "contentDelivery");
+ Assertions.assertEquals("https://cdn.contentstack.io", url);
+ }
+
+ @Test
+ void testAliasAzureNaUnderscore() {
+ String url = Endpoint.getContentstackEndpoint("azure_na", "contentDelivery");
+ Assertions.assertEquals("https://azure-na-cdn.contentstack.com", url);
+ }
+
+ @Test
+ void testAliasAzureNaUppercase() {
+ String url = Endpoint.getContentstackEndpoint("AZURE_NA", "contentDelivery");
+ Assertions.assertEquals("https://azure-na-cdn.contentstack.com", url);
+ }
+
+ @Test
+ void testAliasGcpNaUnderscore() {
+ String url = Endpoint.getContentstackEndpoint("gcp_na", "contentDelivery");
+ Assertions.assertEquals("https://gcp-na-cdn.contentstack.com", url);
+ }
+
+ @Test
+ void testAliasGcpEuUppercase() {
+ String url = Endpoint.getContentstackEndpoint("GCP-EU", "contentDelivery");
+ Assertions.assertEquals("https://gcp-eu-cdn.contentstack.com", url);
+ }
+
+ // ── services ──────────────────────────────────────────────────────────────
+
+ @Test
+ void testNaContentManagement() {
+ String url = Endpoint.getContentstackEndpoint("na", "contentManagement");
+ Assertions.assertEquals("https://api.contentstack.io", url);
+ }
+
+ @Test
+ void testEuContentManagement() {
+ String url = Endpoint.getContentstackEndpoint("eu", "contentManagement");
+ Assertions.assertEquals("https://eu-api.contentstack.com", url);
+ }
+
+ @Test
+ void testNaGraphqlDelivery() {
+ String url = Endpoint.getContentstackEndpoint("na", "graphqlDelivery");
+ Assertions.assertEquals("https://graphql.contentstack.com", url);
+ }
+
+ @Test
+ void testNaAuth() {
+ String url = Endpoint.getContentstackEndpoint("na", "auth");
+ Assertions.assertEquals("https://auth-api.contentstack.com", url);
+ }
+
+ @Test
+ void testEuPreview() {
+ String url = Endpoint.getContentstackEndpoint("eu", "preview");
+ Assertions.assertEquals("https://eu-rest-preview.contentstack.com", url);
+ }
+
+ @Test
+ void testNaApplication() {
+ String url = Endpoint.getContentstackEndpoint("na", "application");
+ Assertions.assertEquals("https://app.contentstack.com", url);
+ }
+
+ @Test
+ void testNaAssetManagement() {
+ String url = Endpoint.getContentstackEndpoint("na", "assetManagement");
+ Assertions.assertEquals("https://am-api.contentstack.com", url);
+ }
+
+ // ── omitHttps ─────────────────────────────────────────────────────────────
+
+ @Test
+ void testOmitHttpsNaContentDelivery() {
+ String host = Endpoint.getContentstackEndpoint("na", "contentDelivery", true);
+ Assertions.assertEquals("cdn.contentstack.io", host);
+ }
+
+ @Test
+ void testOmitHttpsEuContentDelivery() {
+ String host = Endpoint.getContentstackEndpoint("eu", "contentDelivery", true);
+ Assertions.assertEquals("eu-cdn.contentstack.com", host);
+ }
+
+ @Test
+ void testOmitHttpsAzureNaContentManagement() {
+ String host = Endpoint.getContentstackEndpoint("azure-na", "contentManagement", true);
+ Assertions.assertEquals("azure-na-api.contentstack.com", host);
+ }
+
+ @Test
+ void testOmitHttpsFalseReturnsFullUrl() {
+ String url = Endpoint.getContentstackEndpoint("gcp-eu", "contentDelivery", false);
+ Assertions.assertTrue(url.startsWith("https://"));
+ }
+
+ // ── getAllEndpoints ───────────────────────────────────────────────────────
+
+ @Test
+ void testGetAllEndpointsNaContainsContentDelivery() {
+ Map endpoints = Endpoint.getAllEndpoints("na");
+ Assertions.assertTrue(endpoints.containsKey("contentDelivery"));
+ Assertions.assertEquals("https://cdn.contentstack.io", endpoints.get("contentDelivery"));
+ }
+
+ @Test
+ void testGetAllEndpointsEuSize() {
+ Map endpoints = Endpoint.getAllEndpoints("eu");
+ Assertions.assertFalse(endpoints.isEmpty());
+ Assertions.assertTrue(endpoints.size() >= 4);
+ }
+
+ @Test
+ void testGetAllEndpointsOmitHttps() {
+ Map endpoints = Endpoint.getAllEndpoints("na", true);
+ for (String url : endpoints.values()) {
+ Assertions.assertFalse(url.startsWith("https://"),
+ "Expected no https:// prefix but got: " + url);
+ }
+ }
+
+ @Test
+ void testGetAllEndpointsAzureNaOmitHttps() {
+ Map endpoints = Endpoint.getAllEndpoints("azure-na", true);
+ Assertions.assertEquals("azure-na-cdn.contentstack.com", endpoints.get("contentDelivery"));
+ }
+
+ // ── error cases ───────────────────────────────────────────────────────────
+
+ @Test
+ void testEmptyRegionThrows() {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> Endpoint.getContentstackEndpoint("", "contentDelivery"));
+ }
+
+ @Test
+ void testBlankRegionThrows() {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> Endpoint.getContentstackEndpoint(" ", "contentDelivery"));
+ }
+
+ @Test
+ void testUnknownRegionThrows() {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> Endpoint.getContentstackEndpoint("asia-pacific", "contentDelivery"));
+ }
+
+ @Test
+ void testUnknownServiceThrows() {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> Endpoint.getContentstackEndpoint("na", "cms"));
+ }
+
+ @Test
+ void testServiceNotAvailableInRegionThrows() {
+ // assetManagement exists only in NA
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> Endpoint.getContentstackEndpoint("eu", "assetManagement"));
+ }
+
+ @Test
+ void testGetAllEndpointsEmptyRegionThrows() {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> Endpoint.getAllEndpoints(""));
+ }
+
+ @Test
+ void testGetAllEndpointsUnknownRegionThrows() {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> Endpoint.getAllEndpoints("unknown-region"));
+ }
+
+ // ── caching ───────────────────────────────────────────────────────────────
+
+ @Test
+ void testMultipleCallsReturnSameResult() {
+ String url1 = Endpoint.getContentstackEndpoint("eu", "contentDelivery");
+ String url2 = Endpoint.getContentstackEndpoint("eu", "contentDelivery");
+ Assertions.assertEquals(url1, url2);
+ }
+
+ @Test
+ void testCacheResetAllowsReload() {
+ String url1 = Endpoint.getContentstackEndpoint("na", "contentDelivery");
+ Endpoint.resetCache();
+ String url2 = Endpoint.getContentstackEndpoint("na", "contentDelivery");
+ Assertions.assertEquals(url1, url2);
+ }
+
+ // ── live-refresh fallback ─────────────────────────────────────────────────
+
+ /**
+ * Verifies that a truly unknown region still throws after the live-refresh
+ * attempt (the download succeeds but "atlantis" isn't a real region).
+ * This also exercises the live-refresh code path — the test passes whether
+ * the download succeeds or the network is unavailable (both produce the same
+ * IllegalArgumentException for a non-existent region).
+ */
+ @Test
+ void testUnknownRegionThrowsEvenAfterLiveRefresh() {
+ // Ensure liveRefreshDone starts false for this test
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> Endpoint.getContentstackEndpoint("atlantis", "contentDelivery"));
+ }
+
+ /**
+ * Verifies that after resetCache() the live-refresh flag is also cleared, so
+ * the fallback can be exercised again in the next lookup if needed.
+ */
+ @Test
+ void testResetCacheClearsLiveRefreshFlag() {
+ // Trigger a first lookup (may or may not hit live refresh internally)
+ try {
+ Endpoint.getContentstackEndpoint("na", "contentDelivery");
+ } catch (Exception ignored) {
+ // ignored
+ }
+ // resetCache must clear liveRefreshDone so a subsequent cache miss can retry
+ Endpoint.resetCache();
+ // After reset, known regions still resolve correctly via classpath
+ String url = Endpoint.getContentstackEndpoint("na", "contentDelivery");
+ Assertions.assertEquals("https://cdn.contentstack.io", url);
+ }
+}
diff --git a/src/test/java/com/contentstack/sdk/TestStack.java b/src/test/java/com/contentstack/sdk/TestStack.java
index 1232ad4d..b5a855a3 100644
--- a/src/test/java/com/contentstack/sdk/TestStack.java
+++ b/src/test/java/com/contentstack/sdk/TestStack.java
@@ -1344,15 +1344,15 @@ void testSetConfigWithAURegionAndDefaultHost() {
@Test
void testSetConfigWithCustomHostNoRegionChange() {
Config config = new Config();
- config.host = "custom-cdn.example.com";
+ // Use setHost() so the explicit host takes precedence over region resolution
+ config.setHost("custom-cdn.example.com");
config.setRegion(Config.ContentstackRegion.EU);
-
+
stack.setConfig(config);
-
+
assertNotNull(stack.config);
- // Custom host should get region prefix but not change domain
- assertTrue(stack.config.getHost().contains("eu-"));
- assertTrue(stack.config.getHost().contains("custom-cdn.example.com"));
+ // An explicit host set via setHost() is used as-is — no region prefix is applied on top
+ assertEquals("custom-cdn.example.com", stack.config.getHost());
}
// ========== LIVE PREVIEW WITH DIFFERENT REGIONS ==========