Skip to content
Closed
12 changes: 10 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,19 @@

A brief description of what changes project contains

## Apr 30, 2026
## Jun 29, 2026

#### v1.6.0

- Feature: Dynamic endpoint resolution via `Endpoint.getContentstackEndpoint()` backed by the Contentstack Regions Registry
- Feature: `Utils.getContentstackEndpoint()` proxy for backward-compatible access
- Feature: `regions.json` auto-downloaded at build time via `scripts/download-regions.sh` with runtime fallback

## Jun 10, 2026

#### v1.5.1

- Fix: Upgraded `org.springframework:spring-web` to 7.0.7 to address Snyk-reported vulnerabilities in Spring Framework (including transitive `spring-core`)
- Fix: Upgraded `org.springframework:spring-web` to 7.0.8 to address Snyk-reported vulnerabilities in Spring Framework (including transitive `spring-core`)

## Apr 20, 2026

Expand Down
28 changes: 24 additions & 4 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.contentstack.sdk</groupId>
<artifactId>utils</artifactId>
<version>1.5.1</version>
<version>1.6.0</version>
<packaging>jar</packaging>
<name>Contentstack-utils</name>
<description>Java Utils SDK for Contentstack Content Delivery API, Contentstack is a headless CMS</description>
Expand All @@ -20,15 +20,15 @@
<maven-source-plugin.version>2.2.1</maven-source-plugin.version>
<maven-javadoc-plugin.version>3.1.1</maven-javadoc-plugin.version>
<junit.version>4.13.2</junit.version>
<jsoup.version>1.22.1</jsoup.version>
<jsoup.version>1.22.2</jsoup.version>
<json.simple.version>1.1.1</json.simple.version>
<maven-site-plugin.version>3.3</maven-site-plugin.version>
<maven-gpg-plugin.version>1.5</maven-gpg-plugin.version>
<central-publishing-maven-plugin.version>0.8.0</central-publishing-maven-plugin.version>
<maven-release-plugin.version>2.5.3</maven-release-plugin.version>
<validation-version>2.0.1.Final</validation-version>
<json-version>20251224</json-version>
<spring-web-version>7.0.7</spring-web-version>
<json-version>20260522</json-version>
<spring-web-version>7.0.8</spring-web-version>
<org.apache.commons-text>1.15.0</org.apache.commons-text>
</properties>

Expand Down Expand Up @@ -270,6 +270,26 @@
<waitUntil>published</waitUntil>
</configuration>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<id>download-regions</id>
<phase>generate-resources</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<executable>bash</executable>
<arguments>
<argument>${project.basedir}/scripts/download-regions.sh</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
Expand Down
48 changes: 48 additions & 0 deletions scripts/download-regions.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
#
# Downloads the Contentstack regions registry from the official source and
# saves it to src/main/resources/regions.json.
#
# Invoked automatically by Maven on the generate-resources phase, and
# manually via: bash scripts/download-regions.sh
#
# Requires: curl (preferred) or wget as fallback

set -euo pipefail

URL="https://artifacts.contentstack.com/regions.json"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEST="${SCRIPT_DIR}/../src/main/resources/regions.json"
DIR="$(dirname "$DEST")"

mkdir -p "$DIR"

data=""

# --- Attempt 1: curl (preferred) --------------------------------------------
if command -v curl &>/dev/null; then
data=$(curl --silent --fail --location --max-time 30 "$URL") || data=""
fi

# --- Attempt 2: wget fallback -----------------------------------------------
if [[ -z "$data" ]] && command -v wget &>/dev/null; then
data=$(wget --quiet --timeout=30 -O - "$URL") || data=""
fi

# --- Validate and write ------------------------------------------------------
if [[ -z "$data" ]]; then
echo "contentstack/utils: Warning — could not download regions.json." >&2
echo " The SDK will attempt to download it at runtime on first use." >&2
exit 0 # non-fatal: runtime fallback in Endpoint.java handles it
fi

# Basic validation: must contain a "regions" key
if ! echo "$data" | grep -q '"regions"'; then
echo "contentstack/utils: Warning — downloaded data is not valid regions.json." >&2
exit 0
fi

echo "$data" > "$DEST"

region_count=$(echo "$data" | grep -o '"id"' | wc -l | tr -d ' ')
echo "contentstack/utils: regions.json downloaded (${region_count} regions)."
202 changes: 202 additions & 0 deletions src/main/java/com/contentstack/utils/Endpoint.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package com.contentstack.utils;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;

/**
* Resolves Contentstack API endpoints for any region and service.
*
* <p>Endpoint data is loaded from the bundled {@code regions.json} resource.
* The parsed result is cached for the lifetime of the JVM process.
* If the bundled file is absent, a live download from
* {@code https://artifacts.contentstack.com/regions.json} is attempted as a fallback.
*
* <pre>{@code
* // Get a specific service URL
* String cdnUrl = Endpoint.getContentstackEndpoint("eu", "contentDelivery");
* // → "https://eu-cdn.contentstack.com"
*
* // Get the host without the https:// scheme
* String host = Endpoint.getContentstackEndpoint("eu", "contentDelivery", true);
* // → "eu-cdn.contentstack.com"
*
* // Get all endpoints for a region
* Map<String, String> all = Endpoint.getContentstackEndpoint("eu");
* }</pre>
*/
public class Endpoint {

private static final String REGIONS_URL = "https://artifacts.contentstack.com/regions.json";
private static final String REGIONS_RESOURCE = "regions.json";

private static JSONArray regionsData = null;

private Endpoint() {}

/**
* Returns the URL for a specific service in the given region.
*
* @param region canonical region ID ({@code na}, {@code eu}, {@code au}, {@code azure-na},
* {@code azure-eu}, {@code gcp-na}, {@code gcp-eu}) or any accepted alias.
* Case-insensitive; {@code -} and {@code _} separators are equivalent.
* @param service service name (e.g. {@code contentDelivery}, {@code contentManagement})
* @return full URL including {@code https://}
* @throws IllegalArgumentException if region or service is unknown, or region is empty
* @throws RuntimeException if {@code regions.json} cannot be loaded
*/
public static String getContentstackEndpoint(String region, String service) {
return getContentstackEndpoint(region, service, false);
}

/**
* Returns the URL for a specific service in the given region.
*
* @param region canonical region ID or alias
* @param service service name
* @param omitHttps when {@code true}, strips {@code https://} from the result
* @return URL string, with or without scheme depending on {@code omitHttps}
* @throws IllegalArgumentException if region or service is unknown, or region is empty
* @throws RuntimeException if {@code regions.json} cannot be loaded
*/
public static String getContentstackEndpoint(String region, String service, boolean omitHttps) {
if (service == null || service.trim().isEmpty()) {
throw new IllegalArgumentException("Service must not be empty. Use getContentstackEndpoint(region) to get all endpoints.");
}
JSONObject regionRow = resolveRegion(region);
JSONObject endpoints = regionRow.getJSONObject("endpoints");
if (!endpoints.has(service)) {
throw new IllegalArgumentException("Service \"" + service + "\" not found for region \"" + regionRow.getString("id") + "\"");
}
String url = endpoints.getString(service);
return omitHttps ? stripHttps(url) : url;
}

/**
* Returns all endpoint URLs for the given region as an ordered map.
*
* @param region canonical region ID or alias
* @return map of service name → URL (includes {@code https://})
* @throws IllegalArgumentException if region is unknown or empty
* @throws RuntimeException if {@code regions.json} cannot be loaded
*/
public static Map<String, String> getContentstackEndpoint(String region) {
return getContentstackEndpoint(region, false);
}

/**
* Returns all endpoint URLs for the given region as an ordered map.
*
* @param region canonical region ID or alias
* @param omitHttps when {@code true}, strips {@code https://} from every URL
* @return map of service name → URL
* @throws IllegalArgumentException if region is unknown or empty
* @throws RuntimeException if {@code regions.json} cannot be loaded
*/
public static Map<String, String> getContentstackEndpoint(String region, boolean omitHttps) {
JSONObject regionRow = resolveRegion(region);
JSONObject endpoints = regionRow.getJSONObject("endpoints");
Map<String, String> result = new LinkedHashMap<>();
for (String serviceName : endpoints.keySet()) {
String url = endpoints.getString(serviceName);
result.put(serviceName, omitHttps ? stripHttps(url) : url);
}
return result;
}

// ── internal ──────────────────────────────────────────────────────────────

private static JSONObject resolveRegion(String region) {
if (region == null || region.trim().isEmpty()) {
throw new IllegalArgumentException("Empty region provided. Please provide a valid region.");
}
JSONArray regions = loadRegions();
String normalized = region.trim().toLowerCase().replace('_', '-');

// First pass: exact match on region id field
for (int i = 0; i < regions.length(); i++) {
JSONObject row = regions.getJSONObject(i);
if (row.getString("id").equals(normalized)) {
return row;
}
}

// Second pass: match on accepted alternate names (case-insensitive, normalised separators)
for (int i = 0; i < regions.length(); i++) {
JSONObject row = regions.getJSONObject(i);
JSONArray alternateNames = row.getJSONArray("alias");
for (int j = 0; j < alternateNames.length(); j++) {
String alternateName = alternateNames.getString(j).toLowerCase().replace('_', '-');
if (alternateName.equals(normalized)) {
return row;
}
}
}

throw new IllegalArgumentException("Invalid region: " + region);
}

private static synchronized JSONArray loadRegions() {
if (regionsData != null) {
return regionsData;
}

// Try live download first so users always get the latest regions
try {
String json = downloadRegions();
regionsData = new JSONObject(json).getJSONArray("regions");
return regionsData;
} catch (IOException | JSONException ignored) {
// network unavailable — fall through to bundled fallback
}

// Fallback: bundled regions.json packaged in the JAR (offline safety net)
InputStream is = Endpoint.class.getClassLoader().getResourceAsStream(REGIONS_RESOURCE);
if (is != null) {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
String json = reader.lines().collect(Collectors.joining("\n"));
regionsData = new JSONObject(json).getJSONArray("regions");
return regionsData;
} catch (IOException | JSONException ignored) {
// fall through to error
}
}

throw new RuntimeException(
"contentstack/utils: could not load regions — network unavailable and no bundled fallback found.");
}

private static String downloadRegions() throws IOException {
URL url = new URL(REGIONS_URL);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(10000);
conn.setReadTimeout(10000);
try (InputStream is = conn.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
return reader.lines().collect(Collectors.joining("\n"));
} finally {
conn.disconnect();
}
}

private static String stripHttps(String url) {
return url.replaceAll("^https?://", "");
}

/** Clears the in-memory cache. For use in tests only. */
static void resetCache() {
regionsData = null;
}
}
48 changes: 48 additions & 0 deletions src/main/java/com/contentstack/utils/Utils.java
Original file line number Diff line number Diff line change
Expand Up @@ -559,4 +559,52 @@ private static void updateChildrenArray(JSONArray childrenArray, Map<String, Str
}
}
}

/**
* Returns the URL for a specific service in the given region.
* Proxy for {@link Endpoint#getContentstackEndpoint(String, String)}.
*
* @param region region ID or alias (e.g. {@code na}, {@code eu}, {@code us}, {@code azure-na})
* @param service service name (e.g. {@code contentDelivery}, {@code contentManagement})
* @return full URL including {@code https://}
*/
public static String getContentstackEndpoint(String region, String service) {
return Endpoint.getContentstackEndpoint(region, service);
}

/**
* Returns the URL for a specific service in the given region.
* Proxy for {@link Endpoint#getContentstackEndpoint(String, String, boolean)}.
*
* @param region region ID or alias
* @param service service name
* @param omitHttps when {@code true}, strips {@code https://} from the result
* @return URL string, with or without scheme depending on {@code omitHttps}
*/
public static String getContentstackEndpoint(String region, String service, boolean omitHttps) {
return Endpoint.getContentstackEndpoint(region, service, omitHttps);
}

/**
* Returns all endpoint URLs for the given region.
* Proxy for {@link Endpoint#getContentstackEndpoint(String)}.
*
* @param region region ID or alias
* @return map of service name → URL
*/
public static Map<String, String> getContentstackEndpoint(String region) {
return Endpoint.getContentstackEndpoint(region);
}

/**
* Returns all endpoint URLs for the given region.
* Proxy for {@link Endpoint#getContentstackEndpoint(String, boolean)}.
*
* @param region region ID or alias
* @param omitHttps when {@code true}, strips {@code https://} from every URL
* @return map of service name → URL
*/
public static Map<String, String> getContentstackEndpoint(String region, boolean omitHttps) {
return Endpoint.getContentstackEndpoint(region, omitHttps);
}
}
Loading
Loading