diff --git a/.gitignore b/.gitignore index 7e8a92bc..d1bfb78a 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,6 @@ src/main/res/ contentstack/src/androidTest/java/com/contentstack/sdk/SyncTestCase.java # key file -key.keystore \ No newline at end of file +key.keystore + +contentstack/src/main/resources/assets/regions.json \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e2a7f703..93222deb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # CHANGELOG +## Version 4.3.0 + +### Date: 29-Jun-2026 + +### Enhancement + +- Feature: Dynamic endpoint resolution via `Endpoint.getContentstackEndpoint()` and `Builder.setRegion()` backed by the Contentstack Regions Registry. + ## Version 4.2.2 ### Date: 01-Jun-2026 diff --git a/contentstack/build.gradle b/contentstack/build.gradle index 798afd6e..7f9d7353 100755 --- a/contentstack/build.gradle +++ b/contentstack/build.gradle @@ -7,7 +7,7 @@ plugins { ext { PUBLISH_GROUP_ID = 'com.contentstack.sdk' PUBLISH_ARTIFACT_ID = 'android' - PUBLISH_VERSION = '4.2.2' + PUBLISH_VERSION = '4.3.0' } android { @@ -410,4 +410,16 @@ gradle.projectsEvaluated { ut.enabled = false } } -} \ No newline at end of file +} +// Refresh the bundled regions.json from the Contentstack artifact registry. +// Run whenever Contentstack adds new regions or service keys, then commit the +// updated contentstack/src/main/resources/assets/regions.json. +// +// Usage: +// ./gradlew :contentstack:refreshRegions +tasks.register('refreshRegions', Exec) { + description = 'Download the latest regions.json from the Contentstack artifact registry.' + group = 'contentstack' + workingDir = rootProject.projectDir + commandLine 'bash', "${rootProject.projectDir}/scripts/download-regions.sh" +} diff --git a/contentstack/src/androidTest/java/com/contentstack/sdk/AssetTestCase.java b/contentstack/src/androidTest/java/com/contentstack/sdk/AssetTestCase.java index 1f807b6e..3332df7c 100644 --- a/contentstack/src/androidTest/java/com/contentstack/sdk/AssetTestCase.java +++ b/contentstack/src/androidTest/java/com/contentstack/sdk/AssetTestCase.java @@ -162,9 +162,8 @@ public void test_AZURE_NA() throws Exception { String DEFAULT_API_KEY = BuildConfig.APIKey; String DEFAULT_DELIVERY_TOKEN = BuildConfig.deliveryToken; String DEFAULT_ENV = BuildConfig.environment; - String DEFAULT_HOST = BuildConfig.host; - config.setHost(DEFAULT_HOST); config.setRegion(Config.ContentstackRegion.AZURE_NA); + // Host is resolved from the region; no explicit setHost() so region resolution applies Context appContext = InstrumentationRegistry.getTargetContext(); stack = Contentstack.stack(appContext, DEFAULT_API_KEY, DEFAULT_DELIVERY_TOKEN, DEFAULT_ENV, config); assertEquals("azure-na-cdn.contentstack.com", config.getHost()); @@ -189,9 +188,8 @@ public void test_GCP_NA() throws Exception { String DEFAULT_API_KEY = BuildConfig.APIKey; String DEFAULT_DELIVERY_TOKEN = BuildConfig.deliveryToken; String DEFAULT_ENV = BuildConfig.environment; - String DEFAULT_HOST = BuildConfig.host; - config.setHost(DEFAULT_HOST); config.setRegion(Config.ContentstackRegion.GCP_NA); + // Host is resolved from the region; no explicit setHost() so region resolution applies Context appContext = InstrumentationRegistry.getTargetContext(); stack = Contentstack.stack(appContext, DEFAULT_API_KEY, DEFAULT_DELIVERY_TOKEN, DEFAULT_ENV, config); assertEquals("gcp-na-cdn.contentstack.com", config.getHost()); diff --git a/contentstack/src/main/java/com/contentstack/sdk/Config.java b/contentstack/src/main/java/com/contentstack/sdk/Config.java index 0cd845ee..875862b8 100755 --- a/contentstack/src/main/java/com/contentstack/sdk/Config.java +++ b/contentstack/src/main/java/com/contentstack/sdk/Config.java @@ -17,6 +17,7 @@ public class Config { protected String PROTOCOL = "https://"; protected String URL = "cdn.contentstack.io"; + protected boolean hostOverridden = false; protected String VERSION = "v3"; protected String environment = null; protected String branch = null; @@ -125,6 +126,7 @@ public Config() { public void setHost(String hostName) { if (!TextUtils.isEmpty(hostName)) { URL = hostName; + hostOverridden = true; } } diff --git a/contentstack/src/main/java/com/contentstack/sdk/Contentstack.java b/contentstack/src/main/java/com/contentstack/sdk/Contentstack.java index 5d10650e..ff859589 100755 --- a/contentstack/src/main/java/com/contentstack/sdk/Contentstack.java +++ b/contentstack/src/main/java/com/contentstack/sdk/Contentstack.java @@ -8,6 +8,7 @@ import com.android.volley.toolbox.Volley; import java.io.File; +import java.util.Map; import java.util.Objects; /** @@ -90,6 +91,60 @@ public static Stack stack(Context context, String apiKey, 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(Context appContext, String apiKey, String deliveryToken, Config config) { Stack stack = new Stack(apiKey.trim()); stack.setHeader("api_key", apiKey); diff --git a/contentstack/src/main/java/com/contentstack/sdk/Endpoint.java b/contentstack/src/main/java/com/contentstack/sdk/Endpoint.java new file mode 100644 index 00000000..9d02c6e5 --- /dev/null +++ b/contentstack/src/main/java/com/contentstack/sdk/Endpoint.java @@ -0,0 +1,233 @@ +package com.contentstack.sdk; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.Proxy; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Iterator; +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

+ *
    + *
  1. In-memory cache — populated on the first call and reused for the process lifetime + * (zero I/O on every subsequent call).
  2. + *
  3. Bundled {@code regions.json} — read from the classpath resource + * {@code /assets/regions.json} that is packaged inside the SDK. Works + * fully offline with zero latency.
  4. + *
  5. 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 session to avoid repeated network + * calls for genuinely invalid region strings.
  6. + *
+ * + *

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; + + private static volatile boolean liveRefreshDone = false; + + private Endpoint() { + } + + public static String getContentstackEndpoint(String region, String service) { + return getContentstackEndpoint(region, service, false); + } + + public static String getContentstackEndpoint(String region, String service, boolean omitHttps) { + return getContentstackEndpoint(region, service, omitHttps, null); + } + + /** + * Internal variant that routes the live-refresh fallback through the given {@code proxy} + * (typically the one configured on {@link Config}). Used by {@link Stack} so region + * resolution still works in proxy-only / VPN environments. A {@code null} proxy uses a + * direct connection. + */ + static String getContentstackEndpoint(String region, String service, boolean omitHttps, Proxy proxy) { + if (region == null || region.trim().isEmpty()) { + throw new IllegalArgumentException("Empty region provided. Please provide a valid region."); + } + JSONObject regionRow = resolveRegion(region, proxy); + try { + 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; + } catch (JSONException e) { + throw new IllegalStateException("Malformed regions.json: " + e.getMessage(), e); + } + } + + public static Map getAllEndpoints(String region) { + return getAllEndpoints(region, false); + } + + public static Map getAllEndpoints(String region, boolean omitHttps) { + if (region == null || region.trim().isEmpty()) { + throw new IllegalArgumentException("Empty region provided. Please provide a valid region."); + } + JSONObject regionRow = resolveRegion(region, null); + try { + JSONObject endpoints = regionRow.getJSONObject("endpoints"); + Map result = new LinkedHashMap<>(); + Iterator keys = endpoints.keys(); + while (keys.hasNext()) { + String key = keys.next(); + String url = endpoints.getString(key); + result.put(key, omitHttps ? stripHttps(url) : url); + } + return result; + } catch (JSONException e) { + throw new IllegalStateException("Malformed regions.json: " + e.getMessage(), e); + } + } + + static synchronized void resetCache() { + regionsCache = null; + liveRefreshDone = false; + } + + private static JSONObject resolveRegion(String region, Proxy proxy) { + JSONArray regions = loadRegions(proxy); + try { + return findRegion(regions, region); + } catch (IllegalArgumentException notInBundled) { + if (!liveRefreshDone) { + JSONArray fresh = tryLiveRefresh(proxy); + if (fresh != null) { + try { + return findRegion(fresh, region); + } catch (IllegalArgumentException ignored) { + // fall through to re-throw the original error below + } + } + } + throw notInBundled; + } + } + + private static synchronized JSONArray loadRegions(Proxy proxy) { + if (regionsCache != null) { + return regionsCache; + } + 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; + } catch (JSONException e) { + throw new IllegalStateException("Bundled regions.json is corrupt: " + e.getMessage(), e); + } + } + logger.warning("Bundled regions.json not found in classpath — attempting live download."); + JSONArray downloaded = tryLiveRefresh(proxy); + 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 was built correctly, or check network access."); + } + + private static synchronized JSONArray tryLiveRefresh(Proxy proxy) { + if (liveRefreshDone) { + return regionsCache; + } + liveRefreshDone = true; + try { + logger.info("Refreshing regions from " + REGIONS_URL); + URL url = new URL(REGIONS_URL); + HttpURLConnection conn = (HttpURLConnection) (proxy != null + ? url.openConnection(proxy) + : 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('_', '-'); + + try { + for (int i = 0; i < regions.length(); i++) { + JSONObject row = regions.getJSONObject(i); + if (row.getString("id").equals(normalized)) { + return row; + } + } + + 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; + } + } + } + } catch (JSONException e) { + throw new IllegalStateException("Malformed regions.json: " + e.getMessage(), e); + } + + throw new IllegalArgumentException("Invalid region: " + region); + } + + private static String stripHttps(String url) { + return url.replaceFirst("^https?://", ""); + } +} diff --git a/contentstack/src/main/java/com/contentstack/sdk/Stack.java b/contentstack/src/main/java/com/contentstack/sdk/Stack.java index 43aa73fb..1a7b1727 100755 --- a/contentstack/src/main/java/com/contentstack/sdk/Stack.java +++ b/contentstack/src/main/java/com/contentstack/sdk/Stack.java @@ -78,22 +78,19 @@ protected void setConfig(Config config) { if (!TextUtils.isEmpty(config.environment)) { setHeader("environment", config.environment); } - // Handle region setting first before any host overrides - if (!config.region.name().isEmpty()) { - String region = config.region.name().toLowerCase(); - if (!region.equalsIgnoreCase("us")) { - if (region.equalsIgnoreCase("azure_na")) { - config.setHost("azure-na-cdn.contentstack.com"); - } else if (region.equalsIgnoreCase("azure_eu")) { - config.setHost("azure-eu-cdn.contentstack.com"); - } else if (region.equalsIgnoreCase("gcp_na")) { - config.setHost("gcp-na-cdn.contentstack.com"); - } else if (region.equalsIgnoreCase("gcp_eu")) { - config.setHost("gcp-eu-cdn.contentstack.com"); - } else if (region.equalsIgnoreCase("au")) { - config.setHost("au-cdn.contentstack.com"); - } else { - config.setHost(region + "-cdn.contentstack.io"); + // 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 && !config.region.name().isEmpty()) { + String regionId = config.region.name().toLowerCase(); + try { + // Route the live-refresh fallback through any configured proxy so region + // resolution still works in proxy-only / VPN environments. + config.URL = Endpoint.getContentstackEndpoint(regionId, "contentDelivery", true, config.getProxy()); + } catch (IllegalArgumentException e) { + // Unrecognised region: apply the legacy prefix pattern for backward compatibility + if (!regionId.equalsIgnoreCase("us")) { + config.URL = regionId.replace("_", "-") + "-cdn.contentstack.com"; } } } diff --git a/contentstack/src/test/java/com/contentstack/sdk/TestEndpoint.java b/contentstack/src/test/java/com/contentstack/sdk/TestEndpoint.java new file mode 100644 index 00000000..9726c2b8 --- /dev/null +++ b/contentstack/src/test/java/com/contentstack/sdk/TestEndpoint.java @@ -0,0 +1,287 @@ +package com.contentstack.sdk; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.Map; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28, manifest = Config.NONE) +public class TestEndpoint { + + @After + public void resetCache() { + Endpoint.resetCache(); + } + + // ── canonical IDs ───────────────────────────────────────────────────────── + + @Test + public void testNaContentDelivery() { + assertEquals("https://cdn.contentstack.io", + Endpoint.getContentstackEndpoint("na", "contentDelivery")); + } + + @Test + public void testEuContentDelivery() { + assertEquals("https://eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("eu", "contentDelivery")); + } + + @Test + public void testAuContentDelivery() { + assertEquals("https://au-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("au", "contentDelivery")); + } + + @Test + public void testAzureNaContentDelivery() { + assertEquals("https://azure-na-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("azure-na", "contentDelivery")); + } + + @Test + public void testAzureEuContentDelivery() { + assertEquals("https://azure-eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("azure-eu", "contentDelivery")); + } + + @Test + public void testGcpNaContentDelivery() { + assertEquals("https://gcp-na-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("gcp-na", "contentDelivery")); + } + + @Test + public void testGcpEuContentDelivery() { + assertEquals("https://gcp-eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("gcp-eu", "contentDelivery")); + } + + // ── aliases ─────────────────────────────────────────────────────────────── + + @Test + public void testAliasUsResolvesToNa() { + assertEquals("https://cdn.contentstack.io", + Endpoint.getContentstackEndpoint("us", "contentDelivery")); + } + + @Test + public void testAliasUppercaseEU() { + assertEquals("https://eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("EU", "contentDelivery")); + } + + @Test + public void testAliasAwsNaHyphen() { + assertEquals("https://cdn.contentstack.io", + Endpoint.getContentstackEndpoint("aws-na", "contentDelivery")); + } + + @Test + public void testAliasAwsNaUnderscore() { + assertEquals("https://cdn.contentstack.io", + Endpoint.getContentstackEndpoint("aws_na", "contentDelivery")); + } + + @Test + public void testAliasAzureNaUnderscore() { + assertEquals("https://azure-na-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("azure_na", "contentDelivery")); + } + + @Test + public void testAliasAzureNaUppercase() { + assertEquals("https://azure-na-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("AZURE_NA", "contentDelivery")); + } + + @Test + public void testAliasGcpNaUnderscore() { + assertEquals("https://gcp-na-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("gcp_na", "contentDelivery")); + } + + @Test + public void testAliasGcpEuUppercase() { + assertEquals("https://gcp-eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("GCP-EU", "contentDelivery")); + } + + // ── services ────────────────────────────────────────────────────────────── + + @Test + public void testNaContentManagement() { + assertEquals("https://api.contentstack.io", + Endpoint.getContentstackEndpoint("na", "contentManagement")); + } + + @Test + public void testEuContentManagement() { + assertEquals("https://eu-api.contentstack.com", + Endpoint.getContentstackEndpoint("eu", "contentManagement")); + } + + @Test + public void testNaGraphqlDelivery() { + assertEquals("https://graphql.contentstack.com", + Endpoint.getContentstackEndpoint("na", "graphqlDelivery")); + } + + @Test + public void testNaAuth() { + assertEquals("https://auth-api.contentstack.com", + Endpoint.getContentstackEndpoint("na", "auth")); + } + + @Test + public void testEuPreview() { + assertEquals("https://eu-rest-preview.contentstack.com", + Endpoint.getContentstackEndpoint("eu", "preview")); + } + + @Test + public void testNaApplication() { + assertEquals("https://app.contentstack.com", + Endpoint.getContentstackEndpoint("na", "application")); + } + + @Test + public void testNaAssetManagement() { + assertEquals("https://am-api.contentstack.com", + Endpoint.getContentstackEndpoint("na", "assetManagement")); + } + + // ── omitHttps ───────────────────────────────────────────────────────────── + + @Test + public void testOmitHttpsNaContentDelivery() { + assertEquals("cdn.contentstack.io", + Endpoint.getContentstackEndpoint("na", "contentDelivery", true)); + } + + @Test + public void testOmitHttpsEuContentDelivery() { + assertEquals("eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("eu", "contentDelivery", true)); + } + + @Test + public void testOmitHttpsAzureNaContentManagement() { + assertEquals("azure-na-api.contentstack.com", + Endpoint.getContentstackEndpoint("azure-na", "contentManagement", true)); + } + + @Test + public void testOmitHttpsFalseReturnsFullUrl() { + String url = Endpoint.getContentstackEndpoint("gcp-eu", "contentDelivery", false); + assertTrue(url.startsWith("https://")); + } + + // ── getAllEndpoints ─────────────────────────────────────────────────────── + + @Test + public void testGetAllEndpointsNaContainsContentDelivery() { + Map endpoints = Endpoint.getAllEndpoints("na"); + assertTrue(endpoints.containsKey("contentDelivery")); + assertEquals("https://cdn.contentstack.io", endpoints.get("contentDelivery")); + } + + @Test + public void testGetAllEndpointsEuSize() { + Map endpoints = Endpoint.getAllEndpoints("eu"); + assertFalse(endpoints.isEmpty()); + assertTrue(endpoints.size() >= 4); + } + + @Test + public void testGetAllEndpointsOmitHttps() { + Map endpoints = Endpoint.getAllEndpoints("na", true); + for (String url : endpoints.values()) { + assertFalse("Expected no https:// prefix but got: " + url, url.startsWith("https://")); + } + } + + @Test + public void testGetAllEndpointsAzureNaOmitHttps() { + Map endpoints = Endpoint.getAllEndpoints("azure-na", true); + assertEquals("azure-na-cdn.contentstack.com", endpoints.get("contentDelivery")); + } + + // ── error cases ─────────────────────────────────────────────────────────── + + @Test + public void testEmptyRegionThrows() { + assertThrows(IllegalArgumentException.class, + () -> Endpoint.getContentstackEndpoint("", "contentDelivery")); + } + + @Test + public void testBlankRegionThrows() { + assertThrows(IllegalArgumentException.class, + () -> Endpoint.getContentstackEndpoint(" ", "contentDelivery")); + } + + @Test + public void testUnknownServiceThrows() { + assertThrows(IllegalArgumentException.class, + () -> Endpoint.getContentstackEndpoint("na", "cms")); + } + + @Test + public void testServiceNotAvailableInRegionThrows() { + // assetManagement exists only in NA + assertThrows(IllegalArgumentException.class, + () -> Endpoint.getContentstackEndpoint("eu", "assetManagement")); + } + + @Test + public void testGetAllEndpointsEmptyRegionThrows() { + assertThrows(IllegalArgumentException.class, + () -> Endpoint.getAllEndpoints("")); + } + + // ── caching ─────────────────────────────────────────────────────────────── + + @Test + public void testMultipleCallsReturnSameResult() { + String url1 = Endpoint.getContentstackEndpoint("eu", "contentDelivery"); + String url2 = Endpoint.getContentstackEndpoint("eu", "contentDelivery"); + assertEquals(url1, url2); + } + + @Test + public void testCacheResetAllowsReload() { + String url1 = Endpoint.getContentstackEndpoint("na", "contentDelivery"); + Endpoint.resetCache(); + String url2 = Endpoint.getContentstackEndpoint("na", "contentDelivery"); + assertEquals(url1, url2); + } + + @Test + public void testResetCacheClearsLiveRefreshFlag() { + Endpoint.resetCache(); + String url = Endpoint.getContentstackEndpoint("na", "contentDelivery"); + assertEquals("https://cdn.contentstack.io", url); + } + + // ── proxy-aware resolution ──────────────────────────────────────────────── + + @Test + public void testProxyOverloadResolvesBundledRegion() { + // A bundled region never triggers the live download, so the proxy is unused but the + // proxy-aware overload must still resolve correctly. + java.net.Proxy proxy = new java.net.Proxy( + java.net.Proxy.Type.HTTP, new java.net.InetSocketAddress("127.0.0.1", 8080)); + assertEquals("eu-cdn.contentstack.com", + Endpoint.getContentstackEndpoint("eu", "contentDelivery", true, proxy)); + } +} diff --git a/contentstack/src/test/java/com/contentstack/sdk/TestStack.java b/contentstack/src/test/java/com/contentstack/sdk/TestStack.java index f6ec85b1..c4ba9a15 100644 --- a/contentstack/src/test/java/com/contentstack/sdk/TestStack.java +++ b/contentstack/src/test/java/com/contentstack/sdk/TestStack.java @@ -382,7 +382,7 @@ public void testAzureNaRegionSetsCorrectURL() throws Exception { @Test public void testNonUsRegionsSetsCorrectStackURL() throws Exception { Map expectedHosts = new HashMap<>(); - expectedHosts.put(com.contentstack.sdk.Config.ContentstackRegion.EU, "eu-cdn.contentstack.io"); + expectedHosts.put(com.contentstack.sdk.Config.ContentstackRegion.EU, "eu-cdn.contentstack.com"); expectedHosts.put(com.contentstack.sdk.Config.ContentstackRegion.AU, "au-cdn.contentstack.com"); expectedHosts.put(com.contentstack.sdk.Config.ContentstackRegion.AZURE_NA, "azure-na-cdn.contentstack.com"); expectedHosts.put(com.contentstack.sdk.Config.ContentstackRegion.AZURE_EU, "azure-eu-cdn.contentstack.com"); diff --git a/scripts/download-regions.sh b/scripts/download-regions.sh new file mode 100755 index 00000000..0c79d1f8 --- /dev/null +++ b/scripts/download-regions.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Download the latest regions.json from the Contentstack artifacts registry and +# write it to contentstack/src/main/resources/assets/regions.json so it gets +# bundled into the SDK on the next build. +# +# Usage: +# ./scripts/download-regions.sh +# ./gradlew refreshRegions +# +# Run this whenever Contentstack announces new regions or service keys, then +# commit the updated file: +# git add contentstack/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")/../contentstack/src/main/resources/assets/regions.json" +mkdir -p "$(dirname "$DEST")" +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-android: regions.json updated (${REGION_COUNT} regions) → ${DEST}"