Skip to content

Commit d616d42

Browse files
authored
Merge pull request #114 from contentstack/development
DX | 29-06-2026 | Release
2 parents a4014b4 + 72d849c commit d616d42

11 files changed

Lines changed: 665 additions & 24 deletions

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,6 @@ src/main/res/
4747
contentstack/src/androidTest/java/com/contentstack/sdk/SyncTestCase.java
4848

4949
# key file
50-
key.keystore
50+
key.keystore
51+
52+
contentstack/src/main/resources/assets/regions.json

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# CHANGELOG
22

3+
## Version 4.3.0
4+
5+
### Date: 29-Jun-2026
6+
7+
### Enhancement
8+
9+
- Feature: Dynamic endpoint resolution via `Endpoint.getContentstackEndpoint()` and `Builder.setRegion()` backed by the Contentstack Regions Registry.
10+
311
## Version 4.2.2
412

513
### Date: 01-Jun-2026

contentstack/build.gradle

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ plugins {
77
ext {
88
PUBLISH_GROUP_ID = 'com.contentstack.sdk'
99
PUBLISH_ARTIFACT_ID = 'android'
10-
PUBLISH_VERSION = '4.2.2'
10+
PUBLISH_VERSION = '4.3.0'
1111
}
1212

1313
android {
@@ -410,4 +410,16 @@ gradle.projectsEvaluated {
410410
ut.enabled = false
411411
}
412412
}
413-
}
413+
}
414+
// Refresh the bundled regions.json from the Contentstack artifact registry.
415+
// Run whenever Contentstack adds new regions or service keys, then commit the
416+
// updated contentstack/src/main/resources/assets/regions.json.
417+
//
418+
// Usage:
419+
// ./gradlew :contentstack:refreshRegions
420+
tasks.register('refreshRegions', Exec) {
421+
description = 'Download the latest regions.json from the Contentstack artifact registry.'
422+
group = 'contentstack'
423+
workingDir = rootProject.projectDir
424+
commandLine 'bash', "${rootProject.projectDir}/scripts/download-regions.sh"
425+
}

contentstack/src/androidTest/java/com/contentstack/sdk/AssetTestCase.java

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,8 @@ public void test_AZURE_NA() throws Exception {
162162
String DEFAULT_API_KEY = BuildConfig.APIKey;
163163
String DEFAULT_DELIVERY_TOKEN = BuildConfig.deliveryToken;
164164
String DEFAULT_ENV = BuildConfig.environment;
165-
String DEFAULT_HOST = BuildConfig.host;
166-
config.setHost(DEFAULT_HOST);
167165
config.setRegion(Config.ContentstackRegion.AZURE_NA);
166+
// Host is resolved from the region; no explicit setHost() so region resolution applies
168167
Context appContext = InstrumentationRegistry.getTargetContext();
169168
stack = Contentstack.stack(appContext, DEFAULT_API_KEY, DEFAULT_DELIVERY_TOKEN, DEFAULT_ENV, config);
170169
assertEquals("azure-na-cdn.contentstack.com", config.getHost());
@@ -189,9 +188,8 @@ public void test_GCP_NA() throws Exception {
189188
String DEFAULT_API_KEY = BuildConfig.APIKey;
190189
String DEFAULT_DELIVERY_TOKEN = BuildConfig.deliveryToken;
191190
String DEFAULT_ENV = BuildConfig.environment;
192-
String DEFAULT_HOST = BuildConfig.host;
193-
config.setHost(DEFAULT_HOST);
194191
config.setRegion(Config.ContentstackRegion.GCP_NA);
192+
// Host is resolved from the region; no explicit setHost() so region resolution applies
195193
Context appContext = InstrumentationRegistry.getTargetContext();
196194
stack = Contentstack.stack(appContext, DEFAULT_API_KEY, DEFAULT_DELIVERY_TOKEN, DEFAULT_ENV, config);
197195
assertEquals("gcp-na-cdn.contentstack.com", config.getHost());

contentstack/src/main/java/com/contentstack/sdk/Config.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
public class Config {
1818
protected String PROTOCOL = "https://";
1919
protected String URL = "cdn.contentstack.io";
20+
protected boolean hostOverridden = false;
2021
protected String VERSION = "v3";
2122
protected String environment = null;
2223
protected String branch = null;
@@ -125,6 +126,7 @@ public Config() {
125126
public void setHost(String hostName) {
126127
if (!TextUtils.isEmpty(hostName)) {
127128
URL = hostName;
129+
hostOverridden = true;
128130
}
129131
}
130132

contentstack/src/main/java/com/contentstack/sdk/Contentstack.java

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import com.android.volley.toolbox.Volley;
99

1010
import java.io.File;
11+
import java.util.Map;
1112
import java.util.Objects;
1213

1314
/**
@@ -90,6 +91,60 @@ public static Stack stack(Context context, String apiKey, String deliveryToken,
9091
}
9192

9293

94+
/**
95+
* Returns the Contentstack API URL for the given region and service.
96+
*
97+
* <p>Delegates to {@link Endpoint#getContentstackEndpoint(String, String)} — provided as a
98+
* convenience so callers can reach endpoint resolution through the same top-level class they
99+
* use to create stacks.
100+
*
101+
* @param region region ID or alias (e.g. {@code "na"}, {@code "eu"}, {@code "azure-na"})
102+
* @param service service key (e.g. {@code "contentDelivery"}, {@code "contentManagement"})
103+
* @return full URL including {@code https://} scheme
104+
* @throws IllegalArgumentException if the region or service is not recognised
105+
*/
106+
public static String getContentstackEndpoint(String region, String service) {
107+
return Endpoint.getContentstackEndpoint(region, service);
108+
}
109+
110+
/**
111+
* Returns the Contentstack API URL for the given region and service, optionally stripping
112+
* the {@code https://} scheme.
113+
*
114+
* @param region region ID or alias
115+
* @param service service key
116+
* @param omitHttps when {@code true}, returns the bare host without {@code https://}
117+
* @return URL or bare host
118+
* @throws IllegalArgumentException if the region or service is not recognised
119+
*/
120+
public static String getContentstackEndpoint(String region, String service, boolean omitHttps) {
121+
return Endpoint.getContentstackEndpoint(region, service, omitHttps);
122+
}
123+
124+
/**
125+
* Returns all service endpoints for the given region as an ordered map of service key to URL.
126+
*
127+
* @param region region ID or alias
128+
* @return map of service key → full URL
129+
* @throws IllegalArgumentException if the region is not recognised
130+
*/
131+
public static Map<String, String> getContentstackEndpoints(String region) {
132+
return Endpoint.getAllEndpoints(region);
133+
}
134+
135+
/**
136+
* Returns all service endpoints for the given region, optionally stripping the
137+
* {@code https://} scheme from every URL.
138+
*
139+
* @param region region ID or alias
140+
* @param omitHttps when {@code true}, returns bare hosts without {@code https://}
141+
* @return map of service key → URL or bare host
142+
* @throws IllegalArgumentException if the region is not recognised
143+
*/
144+
public static Map<String, String> getContentstackEndpoints(String region, boolean omitHttps) {
145+
return Endpoint.getAllEndpoints(region, omitHttps);
146+
}
147+
93148
private static Stack initializeStack(Context appContext, String apiKey, String deliveryToken, Config config) {
94149
Stack stack = new Stack(apiKey.trim());
95150
stack.setHeader("api_key", apiKey);
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
package com.contentstack.sdk;
2+
3+
import org.json.JSONArray;
4+
import org.json.JSONException;
5+
import org.json.JSONObject;
6+
7+
import java.io.InputStream;
8+
import java.net.HttpURLConnection;
9+
import java.net.Proxy;
10+
import java.net.URL;
11+
import java.nio.charset.StandardCharsets;
12+
import java.util.Iterator;
13+
import java.util.LinkedHashMap;
14+
import java.util.Map;
15+
import java.util.Scanner;
16+
import java.util.logging.Logger;
17+
18+
/**
19+
* Resolves Contentstack API endpoints for any region and service without hardcoding host strings.
20+
*
21+
* <h3>Resolution chain</h3>
22+
* <ol>
23+
* <li><b>In-memory cache</b> — populated on the first call and reused for the process lifetime
24+
* (zero I/O on every subsequent call).</li>
25+
* <li><b>Bundled {@code regions.json}</b> — read from the classpath resource
26+
* {@code /assets/regions.json} that is packaged inside the SDK. Works
27+
* fully offline with zero latency.</li>
28+
* <li><b>Live download</b> — if the requested region is not present in the bundled file
29+
* (e.g. Contentstack added a new region after this SDK version was released), a single
30+
* HTTP request is made to {@value #REGIONS_URL} to fetch the latest registry. The
31+
* downloaded data replaces the in-memory cache so all subsequent lookups benefit from it.
32+
* This attempt is made at most <em>once</em> per session to avoid repeated network
33+
* calls for genuinely invalid region strings.</li>
34+
* </ol>
35+
*
36+
* <p>Region matching is case-insensitive and treats {@code -} and {@code _} as equivalent
37+
* separators, so {@code "AZURE_NA"}, {@code "azure-na"}, and {@code "Azure_NA"} all resolve
38+
* to the same region.
39+
*
40+
* <p><b>Examples:</b>
41+
* <pre>
42+
* String url = Endpoint.getContentstackEndpoint("eu", "contentDelivery");
43+
* // → "https://eu-cdn.contentstack.com"
44+
*
45+
* String host = Endpoint.getContentstackEndpoint("eu", "contentDelivery", true);
46+
* // → "eu-cdn.contentstack.com"
47+
*
48+
* Map&lt;String, String&gt; all = Endpoint.getAllEndpoints("azure-na");
49+
* // → {"contentDelivery": "https://azure-na-cdn.contentstack.com", ...}
50+
* </pre>
51+
*/
52+
public class Endpoint {
53+
54+
static final String REGIONS_URL = "https://artifacts.contentstack.com/regions.json";
55+
56+
private static final Logger logger = Logger.getLogger(Endpoint.class.getSimpleName());
57+
58+
private static volatile JSONArray regionsCache = null;
59+
60+
private static volatile boolean liveRefreshDone = false;
61+
62+
private Endpoint() {
63+
}
64+
65+
public static String getContentstackEndpoint(String region, String service) {
66+
return getContentstackEndpoint(region, service, false);
67+
}
68+
69+
public static String getContentstackEndpoint(String region, String service, boolean omitHttps) {
70+
return getContentstackEndpoint(region, service, omitHttps, null);
71+
}
72+
73+
/**
74+
* Internal variant that routes the live-refresh fallback through the given {@code proxy}
75+
* (typically the one configured on {@link Config}). Used by {@link Stack} so region
76+
* resolution still works in proxy-only / VPN environments. A {@code null} proxy uses a
77+
* direct connection.
78+
*/
79+
static String getContentstackEndpoint(String region, String service, boolean omitHttps, Proxy proxy) {
80+
if (region == null || region.trim().isEmpty()) {
81+
throw new IllegalArgumentException("Empty region provided. Please provide a valid region.");
82+
}
83+
JSONObject regionRow = resolveRegion(region, proxy);
84+
try {
85+
JSONObject endpoints = regionRow.getJSONObject("endpoints");
86+
if (!endpoints.has(service)) {
87+
throw new IllegalArgumentException(
88+
"Service \"" + service + "\" not found for region \"" + region + "\"");
89+
}
90+
String url = endpoints.getString(service);
91+
return omitHttps ? stripHttps(url) : url;
92+
} catch (JSONException e) {
93+
throw new IllegalStateException("Malformed regions.json: " + e.getMessage(), e);
94+
}
95+
}
96+
97+
public static Map<String, String> getAllEndpoints(String region) {
98+
return getAllEndpoints(region, false);
99+
}
100+
101+
public static Map<String, String> getAllEndpoints(String region, boolean omitHttps) {
102+
if (region == null || region.trim().isEmpty()) {
103+
throw new IllegalArgumentException("Empty region provided. Please provide a valid region.");
104+
}
105+
JSONObject regionRow = resolveRegion(region, null);
106+
try {
107+
JSONObject endpoints = regionRow.getJSONObject("endpoints");
108+
Map<String, String> result = new LinkedHashMap<>();
109+
Iterator<String> keys = endpoints.keys();
110+
while (keys.hasNext()) {
111+
String key = keys.next();
112+
String url = endpoints.getString(key);
113+
result.put(key, omitHttps ? stripHttps(url) : url);
114+
}
115+
return result;
116+
} catch (JSONException e) {
117+
throw new IllegalStateException("Malformed regions.json: " + e.getMessage(), e);
118+
}
119+
}
120+
121+
static synchronized void resetCache() {
122+
regionsCache = null;
123+
liveRefreshDone = false;
124+
}
125+
126+
private static JSONObject resolveRegion(String region, Proxy proxy) {
127+
JSONArray regions = loadRegions(proxy);
128+
try {
129+
return findRegion(regions, region);
130+
} catch (IllegalArgumentException notInBundled) {
131+
if (!liveRefreshDone) {
132+
JSONArray fresh = tryLiveRefresh(proxy);
133+
if (fresh != null) {
134+
try {
135+
return findRegion(fresh, region);
136+
} catch (IllegalArgumentException ignored) {
137+
// fall through to re-throw the original error below
138+
}
139+
}
140+
}
141+
throw notInBundled;
142+
}
143+
}
144+
145+
private static synchronized JSONArray loadRegions(Proxy proxy) {
146+
if (regionsCache != null) {
147+
return regionsCache;
148+
}
149+
InputStream stream = Endpoint.class.getResourceAsStream("/assets/regions.json");
150+
if (stream != null) {
151+
try (Scanner scanner = new Scanner(stream, StandardCharsets.UTF_8.name())) {
152+
String raw = scanner.useDelimiter("\\A").next();
153+
JSONObject root = new JSONObject(raw);
154+
regionsCache = root.getJSONArray("regions");
155+
return regionsCache;
156+
} catch (JSONException e) {
157+
throw new IllegalStateException("Bundled regions.json is corrupt: " + e.getMessage(), e);
158+
}
159+
}
160+
logger.warning("Bundled regions.json not found in classpath — attempting live download.");
161+
JSONArray downloaded = tryLiveRefresh(proxy);
162+
if (downloaded != null) {
163+
return downloaded;
164+
}
165+
throw new IllegalStateException(
166+
"regions.json not found in classpath and could not be downloaded from "
167+
+ REGIONS_URL + ". Ensure the SDK was built correctly, or check network access.");
168+
}
169+
170+
private static synchronized JSONArray tryLiveRefresh(Proxy proxy) {
171+
if (liveRefreshDone) {
172+
return regionsCache;
173+
}
174+
liveRefreshDone = true;
175+
try {
176+
logger.info("Refreshing regions from " + REGIONS_URL);
177+
URL url = new URL(REGIONS_URL);
178+
HttpURLConnection conn = (HttpURLConnection) (proxy != null
179+
? url.openConnection(proxy)
180+
: url.openConnection());
181+
conn.setRequestMethod("GET");
182+
conn.setConnectTimeout(5_000);
183+
conn.setReadTimeout(10_000);
184+
conn.setRequestProperty("Accept", "application/json");
185+
try (InputStream stream = conn.getInputStream();
186+
Scanner scanner = new Scanner(stream, StandardCharsets.UTF_8.name())) {
187+
String raw = scanner.useDelimiter("\\A").next();
188+
JSONObject root = new JSONObject(raw);
189+
regionsCache = root.getJSONArray("regions");
190+
logger.info("regions.json refreshed from live URL (" + regionsCache.length() + " regions).");
191+
return regionsCache;
192+
}
193+
} catch (Exception e) {
194+
logger.warning("Live region refresh failed: " + e.getMessage());
195+
return null;
196+
}
197+
}
198+
199+
private static JSONObject findRegion(JSONArray regions, String region) {
200+
String normalized = region.trim().toLowerCase().replace('_', '-');
201+
202+
try {
203+
for (int i = 0; i < regions.length(); i++) {
204+
JSONObject row = regions.getJSONObject(i);
205+
if (row.getString("id").equals(normalized)) {
206+
return row;
207+
}
208+
}
209+
210+
for (int i = 0; i < regions.length(); i++) {
211+
JSONObject row = regions.getJSONObject(i);
212+
JSONArray aliases = row.optJSONArray("alias");
213+
if (aliases == null) {
214+
continue;
215+
}
216+
for (int j = 0; j < aliases.length(); j++) {
217+
String alias = aliases.getString(j).toLowerCase().replace('_', '-');
218+
if (alias.equals(normalized)) {
219+
return row;
220+
}
221+
}
222+
}
223+
} catch (JSONException e) {
224+
throw new IllegalStateException("Malformed regions.json: " + e.getMessage(), e);
225+
}
226+
227+
throw new IllegalArgumentException("Invalid region: " + region);
228+
}
229+
230+
private static String stripHttps(String url) {
231+
return url.replaceFirst("^https?://", "");
232+
}
233+
}

0 commit comments

Comments
 (0)