Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,6 @@ src/main/res/
contentstack/src/androidTest/java/com/contentstack/sdk/SyncTestCase.java

# key file
key.keystore
key.keystore

contentstack/src/main/resources/assets/regions.json
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
16 changes: 14 additions & 2 deletions contentstack/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -410,4 +410,16 @@ gradle.projectsEvaluated {
ut.enabled = false
}
}
}
}
// 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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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());
Expand Down
2 changes: 2 additions & 0 deletions contentstack/src/main/java/com/contentstack/sdk/Config.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -125,6 +126,7 @@ public Config() {
public void setHost(String hostName) {
if (!TextUtils.isEmpty(hostName)) {
URL = hostName;
hostOverridden = true;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.android.volley.toolbox.Volley;

import java.io.File;
import java.util.Map;
import java.util.Objects;

/**
Expand Down Expand Up @@ -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.
*
* <p>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<String, String> 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<String, String> 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);
Expand Down
233 changes: 233 additions & 0 deletions contentstack/src/main/java/com/contentstack/sdk/Endpoint.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <h3>Resolution chain</h3>
* <ol>
* <li><b>In-memory cache</b> — populated on the first call and reused for the process lifetime
* (zero I/O on every subsequent call).</li>
* <li><b>Bundled {@code regions.json}</b> — read from the classpath resource
* {@code /assets/regions.json} that is packaged inside the SDK. Works
* fully offline with zero latency.</li>
* <li><b>Live download</b> — 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 <em>once</em> per session to avoid repeated network
* calls for genuinely invalid region strings.</li>
* </ol>
*
* <p>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.
*
* <p><b>Examples:</b>
* <pre>
* String url = Endpoint.getContentstackEndpoint("eu", "contentDelivery");
* // → "https://eu-cdn.contentstack.com"
*
* String host = Endpoint.getContentstackEndpoint("eu", "contentDelivery", true);
* // → "eu-cdn.contentstack.com"
*
* Map&lt;String, String&gt; all = Endpoint.getAllEndpoints("azure-na");
* // → {"contentDelivery": "https://azure-na-cdn.contentstack.com", ...}
* </pre>
*/
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<String, String> getAllEndpoints(String region) {
return getAllEndpoints(region, false);
}

public static Map<String, String> 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<String, String> result = new LinkedHashMap<>();
Iterator<String> 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?://", "");
}
}
Loading
Loading