Skip to content

Commit dc9fb8c

Browse files
committed
feat: integrate dynamic region resolution and update regions.json handling
- Added Endpoint class for resolving Contentstack API endpoints based on region and service. - Introduced refreshRegions Gradle task to download the latest regions.json from the Contentstack artifact registry. - Updated Stack class to utilize Endpoint for region-based URL resolution. - Added TestEndpoint class to validate endpoint resolution logic. - Created download-regions.sh script for manual updates of regions.json. - Updated existing tests to reflect changes in region URL mappings.
1 parent dcf0fd0 commit dc9fb8c

9 files changed

Lines changed: 627 additions & 19 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

contentstack/build.gradle

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

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

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -78,22 +78,17 @@ protected void setConfig(Config config) {
7878
if (!TextUtils.isEmpty(config.environment)) {
7979
setHeader("environment", config.environment);
8080
}
81-
// Handle region setting first before any host overrides
82-
if (!config.region.name().isEmpty()) {
83-
String region = config.region.name().toLowerCase();
84-
if (!region.equalsIgnoreCase("us")) {
85-
if (region.equalsIgnoreCase("azure_na")) {
86-
config.setHost("azure-na-cdn.contentstack.com");
87-
} else if (region.equalsIgnoreCase("azure_eu")) {
88-
config.setHost("azure-eu-cdn.contentstack.com");
89-
} else if (region.equalsIgnoreCase("gcp_na")) {
90-
config.setHost("gcp-na-cdn.contentstack.com");
91-
} else if (region.equalsIgnoreCase("gcp_eu")) {
92-
config.setHost("gcp-eu-cdn.contentstack.com");
93-
} else if (region.equalsIgnoreCase("au")) {
94-
config.setHost("au-cdn.contentstack.com");
95-
} else {
96-
config.setHost(region + "-cdn.contentstack.io");
81+
// Explicit host (set via Config.setHost()) always takes precedence over region resolution.
82+
// When no host was explicitly set, resolve the content-delivery host from regions.json via
83+
// Endpoint so that new regions are picked up without SDK changes.
84+
if (!config.hostOverridden && !config.region.name().isEmpty()) {
85+
String regionId = config.region.name().toLowerCase();
86+
try {
87+
config.URL = Endpoint.getContentstackEndpoint(regionId, "contentDelivery", true);
88+
} catch (IllegalArgumentException e) {
89+
// Unrecognised region: apply the legacy prefix pattern for backward compatibility
90+
if (!regionId.equalsIgnoreCase("us")) {
91+
config.URL = regionId.replace("_", "-") + "-cdn.contentstack.com";
9792
}
9893
}
9994
}

0 commit comments

Comments
 (0)