Skip to content

Commit 966bf69

Browse files
authored
Merge pull request #152 from contentstack/enh/DX-7279-endpoint-integration
Implement dynamic endpoint resolution and enhance region loading
2 parents d3a98a7 + 15771cc commit 966bf69

6 files changed

Lines changed: 682 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
A brief description of what changes project contains
44

5+
## Jun 29, 2026
6+
7+
#### v1.6.0
8+
9+
- Feature: Dynamic endpoint resolution via `Endpoint.getContentstackEndpoint()` backed by the Contentstack Regions Registry
10+
- Feature: `Utils.getContentstackEndpoint()` proxy for backward-compatible access
11+
- Feature: `regions.json` auto-downloaded at build time via `scripts/download-regions.sh` with runtime fallback
12+
513
## Jun 10, 2026
614

715
#### v1.5.1

pom.xml

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<modelVersion>4.0.0</modelVersion>
55
<groupId>com.contentstack.sdk</groupId>
66
<artifactId>utils</artifactId>
7-
<version>1.5.1</version>
7+
<version>1.6.0</version>
88
<packaging>jar</packaging>
99
<name>Contentstack-utils</name>
1010
<description>Java Utils SDK for Contentstack Content Delivery API, Contentstack is a headless CMS</description>
@@ -20,14 +20,14 @@
2020
<maven-source-plugin.version>2.2.1</maven-source-plugin.version>
2121
<maven-javadoc-plugin.version>3.1.1</maven-javadoc-plugin.version>
2222
<junit.version>4.13.2</junit.version>
23-
<jsoup.version>1.22.1</jsoup.version>
23+
<jsoup.version>1.22.2</jsoup.version>
2424
<json.simple.version>1.1.1</json.simple.version>
2525
<maven-site-plugin.version>3.3</maven-site-plugin.version>
2626
<maven-gpg-plugin.version>1.5</maven-gpg-plugin.version>
2727
<central-publishing-maven-plugin.version>0.8.0</central-publishing-maven-plugin.version>
2828
<maven-release-plugin.version>2.5.3</maven-release-plugin.version>
2929
<validation-version>2.0.1.Final</validation-version>
30-
<json-version>20251224</json-version>
30+
<json-version>20260522</json-version>
3131
<spring-web-version>7.0.8</spring-web-version>
3232
<org.apache.commons-text>1.15.0</org.apache.commons-text>
3333
</properties>
@@ -270,6 +270,26 @@
270270
<waitUntil>published</waitUntil>
271271
</configuration>
272272
</plugin>
273+
<plugin>
274+
<groupId>org.codehaus.mojo</groupId>
275+
<artifactId>exec-maven-plugin</artifactId>
276+
<version>3.1.0</version>
277+
<executions>
278+
<execution>
279+
<id>download-regions</id>
280+
<phase>generate-resources</phase>
281+
<goals>
282+
<goal>exec</goal>
283+
</goals>
284+
<configuration>
285+
<executable>bash</executable>
286+
<arguments>
287+
<argument>${project.basedir}/scripts/download-regions.sh</argument>
288+
</arguments>
289+
</configuration>
290+
</execution>
291+
</executions>
292+
</plugin>
273293
<plugin>
274294
<groupId>org.apache.maven.plugins</groupId>
275295
<artifactId>maven-compiler-plugin</artifactId>

scripts/download-regions.sh

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Downloads the Contentstack regions registry from the official source and
4+
# saves it to src/main/resources/regions.json.
5+
#
6+
# Invoked automatically by Maven on the generate-resources phase, and
7+
# manually via: bash scripts/download-regions.sh
8+
#
9+
# Requires: curl (preferred) or wget as fallback
10+
11+
set -euo pipefail
12+
13+
URL="https://artifacts.contentstack.com/regions.json"
14+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15+
DEST="${SCRIPT_DIR}/../src/main/resources/regions.json"
16+
DIR="$(dirname "$DEST")"
17+
18+
mkdir -p "$DIR"
19+
20+
data=""
21+
22+
# --- Attempt 1: curl (preferred) --------------------------------------------
23+
if command -v curl &>/dev/null; then
24+
data=$(curl --silent --fail --location --max-time 30 "$URL") || data=""
25+
fi
26+
27+
# --- Attempt 2: wget fallback -----------------------------------------------
28+
if [[ -z "$data" ]] && command -v wget &>/dev/null; then
29+
data=$(wget --quiet --timeout=30 -O - "$URL") || data=""
30+
fi
31+
32+
# --- Validate and write ------------------------------------------------------
33+
if [[ -z "$data" ]]; then
34+
echo "contentstack/utils: Warning — could not download regions.json." >&2
35+
echo " The SDK will attempt to download it at runtime on first use." >&2
36+
exit 0 # non-fatal: runtime fallback in Endpoint.java handles it
37+
fi
38+
39+
# Basic validation: must contain a "regions" key
40+
if ! echo "$data" | grep -q '"regions"'; then
41+
echo "contentstack/utils: Warning — downloaded data is not valid regions.json." >&2
42+
exit 0
43+
fi
44+
45+
echo "$data" > "$DEST"
46+
47+
region_count=$(echo "$data" | grep -o '"id"' | wc -l | tr -d ' ')
48+
echo "contentstack/utils: regions.json downloaded (${region_count} regions)."
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package com.contentstack.utils;
2+
3+
import org.json.JSONArray;
4+
import org.json.JSONException;
5+
import org.json.JSONObject;
6+
7+
import java.io.BufferedReader;
8+
import java.io.IOException;
9+
import java.io.InputStream;
10+
import java.io.InputStreamReader;
11+
import java.net.HttpURLConnection;
12+
import java.net.URL;
13+
import java.nio.charset.StandardCharsets;
14+
import java.util.LinkedHashMap;
15+
import java.util.Map;
16+
import java.util.stream.Collectors;
17+
18+
/**
19+
* Resolves Contentstack API endpoints for any region and service.
20+
*
21+
* <p>Endpoint data is loaded from the bundled {@code regions.json} resource.
22+
* The parsed result is cached for the lifetime of the JVM process.
23+
* If the bundled file is absent, a live download from
24+
* {@code https://artifacts.contentstack.com/regions.json} is attempted as a fallback.
25+
*
26+
* <pre>{@code
27+
* // Get a specific service URL
28+
* String cdnUrl = Endpoint.getContentstackEndpoint("eu", "contentDelivery");
29+
* // → "https://eu-cdn.contentstack.com"
30+
*
31+
* // Get the host without the https:// scheme
32+
* String host = Endpoint.getContentstackEndpoint("eu", "contentDelivery", true);
33+
* // → "eu-cdn.contentstack.com"
34+
*
35+
* // Get all endpoints for a region
36+
* Map<String, String> all = Endpoint.getContentstackEndpoint("eu");
37+
* }</pre>
38+
*/
39+
public class Endpoint {
40+
41+
private static final String REGIONS_URL = "https://artifacts.contentstack.com/regions.json";
42+
private static final String REGIONS_RESOURCE = "regions.json";
43+
44+
private static JSONArray regionsData = null;
45+
46+
private Endpoint() {}
47+
48+
/**
49+
* Returns the URL for a specific service in the given region.
50+
*
51+
* @param region canonical region ID ({@code na}, {@code eu}, {@code au}, {@code azure-na},
52+
* {@code azure-eu}, {@code gcp-na}, {@code gcp-eu}) or any accepted alias.
53+
* Case-insensitive; {@code -} and {@code _} separators are equivalent.
54+
* @param service service name (e.g. {@code contentDelivery}, {@code contentManagement})
55+
* @return full URL including {@code https://}
56+
* @throws IllegalArgumentException if region or service is unknown, or region is empty
57+
* @throws RuntimeException if {@code regions.json} cannot be loaded
58+
*/
59+
public static String getContentstackEndpoint(String region, String service) {
60+
return getContentstackEndpoint(region, service, false);
61+
}
62+
63+
/**
64+
* Returns the URL for a specific service in the given region.
65+
*
66+
* @param region canonical region ID or alias
67+
* @param service service name
68+
* @param omitHttps when {@code true}, strips {@code https://} from the result
69+
* @return URL string, with or without scheme depending on {@code omitHttps}
70+
* @throws IllegalArgumentException if region or service is unknown, or region is empty
71+
* @throws RuntimeException if {@code regions.json} cannot be loaded
72+
*/
73+
public static String getContentstackEndpoint(String region, String service, boolean omitHttps) {
74+
if (service == null || service.trim().isEmpty()) {
75+
throw new IllegalArgumentException("Service must not be empty. Use getContentstackEndpoint(region) to get all endpoints.");
76+
}
77+
JSONObject regionRow = resolveRegion(region);
78+
JSONObject endpoints = regionRow.getJSONObject("endpoints");
79+
if (!endpoints.has(service)) {
80+
throw new IllegalArgumentException("Service \"" + service + "\" not found for region \"" + regionRow.getString("id") + "\"");
81+
}
82+
String url = endpoints.getString(service);
83+
return omitHttps ? stripHttps(url) : url;
84+
}
85+
86+
/**
87+
* Returns all endpoint URLs for the given region as an ordered map.
88+
*
89+
* @param region canonical region ID or alias
90+
* @return map of service name → URL (includes {@code https://})
91+
* @throws IllegalArgumentException if region is unknown or empty
92+
* @throws RuntimeException if {@code regions.json} cannot be loaded
93+
*/
94+
public static Map<String, String> getContentstackEndpoint(String region) {
95+
return getContentstackEndpoint(region, false);
96+
}
97+
98+
/**
99+
* Returns all endpoint URLs for the given region as an ordered map.
100+
*
101+
* @param region canonical region ID or alias
102+
* @param omitHttps when {@code true}, strips {@code https://} from every URL
103+
* @return map of service name → URL
104+
* @throws IllegalArgumentException if region is unknown or empty
105+
* @throws RuntimeException if {@code regions.json} cannot be loaded
106+
*/
107+
public static Map<String, String> getContentstackEndpoint(String region, boolean omitHttps) {
108+
JSONObject regionRow = resolveRegion(region);
109+
JSONObject endpoints = regionRow.getJSONObject("endpoints");
110+
Map<String, String> result = new LinkedHashMap<>();
111+
for (String serviceName : endpoints.keySet()) {
112+
String url = endpoints.getString(serviceName);
113+
result.put(serviceName, omitHttps ? stripHttps(url) : url);
114+
}
115+
return result;
116+
}
117+
118+
// ── internal ──────────────────────────────────────────────────────────────
119+
120+
private static JSONObject resolveRegion(String region) {
121+
if (region == null || region.trim().isEmpty()) {
122+
throw new IllegalArgumentException("Empty region provided. Please provide a valid region.");
123+
}
124+
JSONArray regions = loadRegions();
125+
String normalized = region.trim().toLowerCase().replace('_', '-');
126+
127+
// First pass: exact match on region id field
128+
for (int i = 0; i < regions.length(); i++) {
129+
JSONObject row = regions.getJSONObject(i);
130+
if (row.getString("id").equals(normalized)) {
131+
return row;
132+
}
133+
}
134+
135+
// Second pass: match on accepted alternate names (case-insensitive, normalised separators)
136+
for (int i = 0; i < regions.length(); i++) {
137+
JSONObject row = regions.getJSONObject(i);
138+
JSONArray alternateNames = row.getJSONArray("alias");
139+
for (int j = 0; j < alternateNames.length(); j++) {
140+
String alternateName = alternateNames.getString(j).toLowerCase().replace('_', '-');
141+
if (alternateName.equals(normalized)) {
142+
return row;
143+
}
144+
}
145+
}
146+
147+
throw new IllegalArgumentException("Invalid region: " + region);
148+
}
149+
150+
private static synchronized JSONArray loadRegions() {
151+
if (regionsData != null) {
152+
return regionsData;
153+
}
154+
155+
// Try live download first so users always get the latest regions
156+
try {
157+
String json = downloadRegions();
158+
regionsData = new JSONObject(json).getJSONArray("regions");
159+
return regionsData;
160+
} catch (IOException | JSONException ignored) {
161+
// network unavailable — fall through to bundled fallback
162+
}
163+
164+
// Fallback: bundled regions.json packaged in the JAR (offline safety net)
165+
InputStream is = Endpoint.class.getClassLoader().getResourceAsStream(REGIONS_RESOURCE);
166+
if (is != null) {
167+
try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
168+
String json = reader.lines().collect(Collectors.joining("\n"));
169+
regionsData = new JSONObject(json).getJSONArray("regions");
170+
return regionsData;
171+
} catch (IOException | JSONException ignored) {
172+
// fall through to error
173+
}
174+
}
175+
176+
throw new RuntimeException(
177+
"contentstack/utils: could not load regions — network unavailable and no bundled fallback found.");
178+
}
179+
180+
private static String downloadRegions() throws IOException {
181+
URL url = new URL(REGIONS_URL);
182+
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
183+
conn.setRequestMethod("GET");
184+
conn.setConnectTimeout(10000);
185+
conn.setReadTimeout(10000);
186+
try (InputStream is = conn.getInputStream();
187+
BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
188+
return reader.lines().collect(Collectors.joining("\n"));
189+
} finally {
190+
conn.disconnect();
191+
}
192+
}
193+
194+
private static String stripHttps(String url) {
195+
return url.replaceAll("^https?://", "");
196+
}
197+
198+
/** Clears the in-memory cache. For use in tests only. */
199+
static void resetCache() {
200+
regionsData = null;
201+
}
202+
}

src/main/java/com/contentstack/utils/Utils.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,4 +559,52 @@ private static void updateChildrenArray(JSONArray childrenArray, Map<String, Str
559559
}
560560
}
561561
}
562+
563+
/**
564+
* Returns the URL for a specific service in the given region.
565+
* Proxy for {@link Endpoint#getContentstackEndpoint(String, String)}.
566+
*
567+
* @param region region ID or alias (e.g. {@code na}, {@code eu}, {@code us}, {@code azure-na})
568+
* @param service service name (e.g. {@code contentDelivery}, {@code contentManagement})
569+
* @return full URL including {@code https://}
570+
*/
571+
public static String getContentstackEndpoint(String region, String service) {
572+
return Endpoint.getContentstackEndpoint(region, service);
573+
}
574+
575+
/**
576+
* Returns the URL for a specific service in the given region.
577+
* Proxy for {@link Endpoint#getContentstackEndpoint(String, String, boolean)}.
578+
*
579+
* @param region region ID or alias
580+
* @param service service name
581+
* @param omitHttps when {@code true}, strips {@code https://} from the result
582+
* @return URL string, with or without scheme depending on {@code omitHttps}
583+
*/
584+
public static String getContentstackEndpoint(String region, String service, boolean omitHttps) {
585+
return Endpoint.getContentstackEndpoint(region, service, omitHttps);
586+
}
587+
588+
/**
589+
* Returns all endpoint URLs for the given region.
590+
* Proxy for {@link Endpoint#getContentstackEndpoint(String)}.
591+
*
592+
* @param region region ID or alias
593+
* @return map of service name → URL
594+
*/
595+
public static Map<String, String> getContentstackEndpoint(String region) {
596+
return Endpoint.getContentstackEndpoint(region);
597+
}
598+
599+
/**
600+
* Returns all endpoint URLs for the given region.
601+
* Proxy for {@link Endpoint#getContentstackEndpoint(String, boolean)}.
602+
*
603+
* @param region region ID or alias
604+
* @param omitHttps when {@code true}, strips {@code https://} from every URL
605+
* @return map of service name → URL
606+
*/
607+
public static Map<String, String> getContentstackEndpoint(String region, boolean omitHttps) {
608+
return Endpoint.getContentstackEndpoint(region, omitHttps);
609+
}
562610
}

0 commit comments

Comments
 (0)