diff --git a/.github/workflows/back-merge-pr.yml b/.github/workflows/back-merge-pr.yml new file mode 100644 index 00000000..02b378ce --- /dev/null +++ b/.github/workflows/back-merge-pr.yml @@ -0,0 +1,54 @@ +name: Back-merge master to development + +on: + push: + branches: + - master + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +jobs: + open-back-merge-pr: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Open back-merge PR if needed + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + BASE_BRANCH="development" + SOURCE_BRANCH="master" + + git fetch origin "$BASE_BRANCH" "$SOURCE_BRANCH" + + if ! git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then + echo "Base branch '$BASE_BRANCH' does not exist on origin; skipping." + exit 0 + fi + + SOURCE_SHA=$(git rev-parse "origin/$SOURCE_BRANCH") + BASE_SHA=$(git rev-parse "origin/$BASE_BRANCH") + + if [ "$SOURCE_SHA" = "$BASE_SHA" ]; then + echo "$SOURCE_BRANCH and $BASE_BRANCH are at the same commit; nothing to back-merge." + exit 0 + fi + + EXISTING=$(gh pr list --repo "${{ github.repository }}" --base "$BASE_BRANCH" --head "$SOURCE_BRANCH" --state open --json number --jq 'length') + + if [ "$EXISTING" -gt 0 ]; then + echo "An open PR from $SOURCE_BRANCH to $BASE_BRANCH already exists; skipping." + exit 0 + fi + + gh pr create --repo "${{ github.repository }}" --base "$BASE_BRANCH" --head "$SOURCE_BRANCH" --title "chore: back-merge $SOURCE_BRANCH into $BASE_BRANCH" --body "Automated back-merge after changes landed on \\`$SOURCE_BRANCH\\`. Review and merge to keep \\`$BASE_BRANCH\\` in sync." + + echo "Created back-merge PR $SOURCE_BRANCH -> $BASE_BRANCH." diff --git a/.github/workflows/check-branch.yml b/.github/workflows/check-branch.yml deleted file mode 100644 index 2332f0d0..00000000 --- a/.github/workflows/check-branch.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: 'Check Branch' - -on: - pull_request: - -jobs: - check_branch: - runs-on: ubuntu-latest - steps: - - name: Comment PR - if: github.base_ref == 'master' && github.head_ref != 'staging' - uses: thollander/actions-comment-pull-request@v2 - with: - message: | - We regret to inform you that you are currently not able to merge your changes into the master branch due to restrictions applied by our SRE team. To proceed with merging your changes, we kindly request that you create a pull request from the staging branch. Our team will then review the changes and work with you to ensure a successful merge into the master branch. - - name: Check branch - if: github.base_ref == 'master' && github.head_ref != 'staging' - run: | - echo "ERROR: We regret to inform you that you are currently not able to merge your changes into the master branch due to restrictions applied by our SRE team. To proceed with merging your changes, we kindly request that you create a pull request from the staging branch. Our team will then review the changes and work with you to ensure a successful merge into the master branch." - exit 1 diff --git a/.github/workflows/check-version-bump.yml b/.github/workflows/check-version-bump.yml new file mode 100644 index 00000000..8e710002 --- /dev/null +++ b/.github/workflows/check-version-bump.yml @@ -0,0 +1,86 @@ +name: Check Version Bump + +on: + pull_request: + +jobs: + version-bump: + name: Version & Changelog bump + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect changed files and version bump + id: detect + run: | + if git rev-parse HEAD^2 >/dev/null 2>&1; then + FILES=$(git diff --name-only HEAD^1 HEAD^2) + else + FILES=$(git diff --name-only HEAD~1 HEAD) + fi + VERSION_FILES_CHANGED=false + echo "$FILES" | grep -qx 'package.json' && VERSION_FILES_CHANGED=true + echo "$FILES" | grep -qx 'CHANGELOG.md' && VERSION_FILES_CHANGED=true + echo "version_files_changed=$VERSION_FILES_CHANGED" >> $GITHUB_OUTPUT + # Only lib/, webpack/, dist/, package.json count as release-affecting; .github/ and test/ do not + CODE_CHANGED=false + echo "$FILES" | grep -qE '^lib/|^webpack/|^dist/' && CODE_CHANGED=true + echo "$FILES" | grep -qx 'package.json' && CODE_CHANGED=true + echo "code_changed=$CODE_CHANGED" >> $GITHUB_OUTPUT + + - name: Skip when only test/docs/.github changed + if: steps.detect.outputs.code_changed != 'true' + run: | + echo "No release-affecting files changed (e.g. only test/docs/.github). Skipping version-bump check." + exit 0 + + - name: Fail when version bump was missed + if: steps.detect.outputs.code_changed == 'true' && steps.detect.outputs.version_files_changed != 'true' + run: | + echo "::error::This PR has code changes but no version bump. Please bump the version in package.json and add an entry in CHANGELOG.md." + exit 1 + + - name: Setup Node + if: steps.detect.outputs.code_changed == 'true' && steps.detect.outputs.version_files_changed == 'true' + uses: actions/setup-node@v4 + with: + node-version: '22.x' + + - name: Check version bump + if: steps.detect.outputs.code_changed == 'true' && steps.detect.outputs.version_files_changed == 'true' + run: | + set -e + PKG_VERSION=$(node -p "require('./package.json').version.replace(/^v/, '')") + if [ -z "$PKG_VERSION" ]; then + echo "::error::Could not read version from package.json" + exit 1 + fi + git fetch --tags --force 2>/dev/null || true + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true) + if [ -z "$LATEST_TAG" ]; then + echo "No existing tags found. Skipping version-bump check (first release)." + exit 0 + fi + LATEST_VERSION="${LATEST_TAG#v}" + LATEST_VERSION="${LATEST_VERSION%%-*}" + if [ "$(printf '%s\n' "$LATEST_VERSION" "$PKG_VERSION" | sort -V | tail -1)" != "$PKG_VERSION" ]; then + echo "::error::Version bump required: package.json version ($PKG_VERSION) is not greater than latest tag ($LATEST_TAG). Please bump the version in package.json." + exit 1 + fi + if [ "$PKG_VERSION" = "$LATEST_VERSION" ]; then + echo "::error::Version bump required: package.json version ($PKG_VERSION) equals latest tag ($LATEST_TAG). Please bump the version in package.json." + exit 1 + fi + CHANGELOG_VERSION=$(sed -nE 's/^## \[v?([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' CHANGELOG.md | head -1) + if [ -z "$CHANGELOG_VERSION" ]; then + echo "::error::Could not find a version entry in CHANGELOG.md (expected line like '## [v1.0.0](...)')." + exit 1 + fi + if [ "$CHANGELOG_VERSION" != "$PKG_VERSION" ]; then + echo "::error::CHANGELOG version mismatch: CHANGELOG.md top version ($CHANGELOG_VERSION) does not match package.json version ($PKG_VERSION). Please add or update the CHANGELOG entry for $PKG_VERSION." + exit 1 + fi + echo "Version bump check passed: package.json and CHANGELOG.md are at $PKG_VERSION (latest tag: $LATEST_TAG)." diff --git a/.github/workflows/unit-testing.yml b/.github/workflows/unit-testing.yml index 1d93af01..8c1cd6f5 100644 --- a/.github/workflows/unit-testing.yml +++ b/.github/workflows/unit-testing.yml @@ -4,7 +4,6 @@ on: pull_request: branches: - development - - staging - master jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 40a128d8..d5c6c9d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # CHANGELOG +## v2.7.0 + +### Jun 15, 2026 +- Enhancement: Endpoint integration + ## v2.6.0 ### Feb 23, 2026 diff --git a/pom.xml b/pom.xml index 029cd247..ac4eb555 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 com.contentstack.sdk java - 2.6.0 + 2.7.0 jar contentstack-java Java SDK for Contentstack Content Delivery API @@ -462,6 +462,34 @@ maven-jxr-plugin 2.3 + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + refresh-regions + + exec + + + bash + + ${project.basedir}/scripts/download-regions.sh + + + + + diff --git a/scripts/download-regions.sh b/scripts/download-regions.sh new file mode 100755 index 00000000..127d81c8 --- /dev/null +++ b/scripts/download-regions.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Download the latest regions.json from the Contentstack artifacts registry and +# write it to src/main/resources/assets/regions.json so it gets bundled into +# the SDK jar on the next build. +# +# Usage: +# ./scripts/download-regions.sh +# mvn exec:exec@refresh-regions +# +# Run this whenever Contentstack announces new regions or service keys, then +# commit the updated file: +# git add src/main/resources/assets/regions.json +# git commit -m "chore: refresh regions.json" + +set -euo pipefail + +REGIONS_URL="https://artifacts.contentstack.com/regions.json" +DEST="$(dirname "$0")/../src/main/resources/assets/regions.json" +DEST="$(cd "$(dirname "$DEST")" && pwd)/$(basename "$DEST")" + +echo "Downloading regions.json from ${REGIONS_URL} ..." + +if command -v curl &>/dev/null; then + curl --silent --show-error --fail --location \ + --retry 3 --retry-delay 2 \ + -o "${DEST}" "${REGIONS_URL}" +elif command -v wget &>/dev/null; then + wget --quiet --tries=3 --waitretry=2 -O "${DEST}" "${REGIONS_URL}" +else + echo "Error: neither curl nor wget found. Install one and retry." >&2 + exit 1 +fi + +# Validate the downloaded file contains a "regions" array +if ! python3 -c "import sys, json; d=json.load(open('${DEST}')); assert 'regions' in d and len(d['regions']) > 0" 2>/dev/null && + ! python -c "import sys, json; d=json.load(open('${DEST}')); assert 'regions' in d and len(d['regions']) > 0" 2>/dev/null; then + # Fallback validation without Python — just check the key exists + if ! grep -q '"regions"' "${DEST}"; then + echo "Error: downloaded file does not look like a valid regions.json" >&2 + rm -f "${DEST}" + exit 1 + fi +fi + +REGION_COUNT=$(grep -o '"id"' "${DEST}" | wc -l | tr -d ' ') +echo "contentstack-java: regions.json updated (${REGION_COUNT} regions) → ${DEST}" diff --git a/skills/dev-workflow/SKILL.md b/skills/dev-workflow/SKILL.md index e4ee3484..f5ad4aeb 100644 --- a/skills/dev-workflow/SKILL.md +++ b/skills/dev-workflow/SKILL.md @@ -14,7 +14,7 @@ description: Use for Maven lifecycle, CI, JaCoCo, and branch expectations in con ### Branches -- Integration branches include **`development`**, **`staging`**, and **`master`**—confirm target branch for your PR against team policy. +- Integration/release flow is **`development` -> `master`** (direct release PRs; no `staging` branch in release flow). ### Commands diff --git a/src/main/java/com/contentstack/sdk/Config.java b/src/main/java/com/contentstack/sdk/Config.java index 003cb4c7..d8f7c45b 100644 --- a/src/main/java/com/contentstack/sdk/Config.java +++ b/src/main/java/com/contentstack/sdk/Config.java @@ -18,6 +18,7 @@ public class Config { protected String livePreviewContentType = null; protected String livePreviewEntryUid = null; protected String host = "cdn.contentstack.io"; + protected boolean hostOverridden = false; protected String version = "v3"; protected String scheme = "https://"; protected String endpoint; @@ -167,6 +168,7 @@ public String getHost() { public void setHost(String hostName) { if (hostName != null && !hostName.isEmpty()) { host = hostName; + hostOverridden = true; } } diff --git a/src/main/java/com/contentstack/sdk/Contentstack.java b/src/main/java/com/contentstack/sdk/Contentstack.java index b287be16..33b6357a 100644 --- a/src/main/java/com/contentstack/sdk/Contentstack.java +++ b/src/main/java/com/contentstack/sdk/Contentstack.java @@ -1,5 +1,6 @@ package com.contentstack.sdk; +import java.util.Map; import java.util.Objects; /** @@ -98,6 +99,60 @@ private static void validateCredentials(String stackApiKey, String deliveryToken } } + /** + * Returns the Contentstack API URL for the given region and service. + * + *

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 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 getContentstackEndpoints(String region, boolean omitHttps) { + return Endpoint.getAllEndpoints(region, omitHttps); + } + private static Stack initializeStack(String stackApiKey, String deliveryToken, String environment, Config config) { Stack stack = new Stack(stackApiKey.trim()); stack.setHeader("api_key", stackApiKey); diff --git a/src/main/java/com/contentstack/sdk/Endpoint.java b/src/main/java/com/contentstack/sdk/Endpoint.java new file mode 100644 index 00000000..bf1dcd4a --- /dev/null +++ b/src/main/java/com/contentstack/sdk/Endpoint.java @@ -0,0 +1,269 @@ +package com.contentstack.sdk; + +import org.jetbrains.annotations.NotNull; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +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. + * + *

Resolution chain

+ *
    + *
  1. In-memory cache — populated on the first call and reused for the JVM lifetime + * (zero I/O on every subsequent call).
  2. + *
  3. Bundled {@code regions.json} — read from the classpath resource + * {@code /assets/regions.json} that is packaged inside the SDK jar. Works + * fully offline with zero latency.
  4. + *
  5. Live download — 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 once per JVM session to avoid repeated network + * calls for genuinely invalid region strings.
  6. + *
+ * + *

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. + * + *

Examples: + *

+ *   String url  = Endpoint.getContentstackEndpoint("eu", "contentDelivery");
+ *   // → "https://eu-cdn.contentstack.com"
+ *
+ *   String host = Endpoint.getContentstackEndpoint("eu", "contentDelivery", true);
+ *   // → "eu-cdn.contentstack.com"
+ *
+ *   Map<String, String> all = Endpoint.getAllEndpoints("azure-na");
+ *   // → {"contentDelivery": "https://azure-na-cdn.contentstack.com", ...}
+ * 
+ */ +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; + + // Ensures the live download is attempted at most once per JVM session so that + // genuinely invalid region strings do not trigger repeated network calls. + private static volatile boolean liveRefreshDone = false; + + private Endpoint() { + } + + /** + * Returns the URL for the given region and service. + * + * @param region the region ID or alias (e.g. {@code "na"}, {@code "eu"}, {@code "azure-na"}) + * @param service the service key (e.g. {@code "contentDelivery"}, {@code "contentManagement"}) + * @return the full URL including {@code https://} scheme + * @throws IllegalArgumentException if the region or service is not recognised + */ + public static String getContentstackEndpoint(@NotNull String region, @NotNull String service) { + return getContentstackEndpoint(region, service, false); + } + + /** + * Returns the URL for the given region and service, optionally stripping the {@code https://} + * scheme. + * + * @param region the region ID or alias + * @param service the service key + * @param omitHttps when {@code true}, returns the bare host without the {@code https://} prefix + * @return the URL (or bare host when {@code omitHttps} is {@code true}) + * @throws IllegalArgumentException if the region or service is not recognised + */ + public static String getContentstackEndpoint(@NotNull String region, @NotNull String service, + boolean omitHttps) { + if (region.trim().isEmpty()) { + throw new IllegalArgumentException("Empty region provided. Please provide a valid region."); + } + JSONObject regionRow = resolveRegion(region); + 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; + } + + /** + * Returns all endpoints for the given region as an ordered map of service key to URL. + * + * @param region the region ID or alias + * @return map of service key → URL + * @throws IllegalArgumentException if the region is not recognised + */ + public static Map getAllEndpoints(@NotNull String region) { + return getAllEndpoints(region, false); + } + + /** + * Returns all endpoints for the given region, optionally stripping the {@code https://} scheme. + * + * @param region the region ID or alias + * @param omitHttps when {@code true}, returns bare hosts without the {@code https://} prefix + * @return map of service key → URL (or bare host) + * @throws IllegalArgumentException if the region is not recognised + */ + public static Map getAllEndpoints(@NotNull String region, boolean omitHttps) { + if (region.trim().isEmpty()) { + throw new IllegalArgumentException("Empty region provided. Please provide a valid region."); + } + JSONObject regionRow = resolveRegion(region); + JSONObject endpoints = regionRow.getJSONObject("endpoints"); + Map result = new LinkedHashMap<>(); + for (String key : endpoints.keySet()) { + String url = endpoints.getString(key); + result.put(key, omitHttps ? stripHttps(url) : url); + } + return result; + } + + /** + * Resets the in-memory cache and the live-refresh flag. Intended for testing only. + */ + static synchronized void resetCache() { + regionsCache = null; + liveRefreshDone = false; + } + + // ── internals ───────────────────────────────────────────────────────────── + + /** + * Resolution chain: + *
    + *
  1. In-memory cache
  2. + *
  3. Bundled classpath {@code regions.json}
  4. + *
  5. Live download (once per JVM session, triggered only when the region is absent)
  6. + *
+ */ + private static JSONObject resolveRegion(String region) { + // Tier 1 + 2: load from cache or bundled classpath + JSONArray regions = loadRegions(); + try { + return findRegion(regions, region); + } catch (IllegalArgumentException notInBundled) { + // Tier 3: region not in bundled file — attempt one live refresh. + // This handles the case where Contentstack added a new region after this + // SDK version was released and the user hasn't upgraded yet. + if (!liveRefreshDone) { + JSONArray fresh = tryLiveRefresh(); + if (fresh != null) { + try { + return findRegion(fresh, region); + } catch (IllegalArgumentException ignored) { + // Region absent even in the live data → fall through and throw below + } + } + } + throw notInBundled; + } + } + + /** + * Loads regions from the in-memory cache or the bundled classpath resource. + * Populates the cache on the first call. + */ + private static synchronized JSONArray loadRegions() { + if (regionsCache != null) { + return regionsCache; + } + // Try bundled classpath resource (packaged inside the SDK jar) + InputStream stream = Endpoint.class.getResourceAsStream("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/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; + } + } + // Bundled file absent (e.g. corrupted build) — try live download immediately + logger.warning("Bundled regions.json not found in classpath — attempting live download."); + JSONArray downloaded = tryLiveRefresh(); + 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 jar was built correctly, or check network access."); + } + + /** + * Attempts a one-time HTTP fetch of {@value #REGIONS_URL}. + * Returns the parsed regions array on success, or {@code null} if the attempt is skipped + * or the network is unavailable. Updates the in-memory cache on success. + */ + private static synchronized JSONArray tryLiveRefresh() { + if (liveRefreshDone) { + return regionsCache; // already fetched this session — return whatever we have + } + liveRefreshDone = true; + try { + logger.info("Refreshing regions from " + REGIONS_URL); + URL url = new URL(REGIONS_URL); + HttpURLConnection conn = (HttpURLConnection) 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('_', '-'); + + // Pass 1: match canonical id + for (int i = 0; i < regions.length(); i++) { + JSONObject row = regions.getJSONObject(i); + if (row.getString("id").equals(normalized)) { + return row; + } + } + + // Pass 2: match aliases (case-insensitive, _ == -) + 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; + } + } + } + + throw new IllegalArgumentException("Invalid region: " + region); + } + + private static String stripHttps(String url) { + return url.replaceFirst("^https?://", ""); + } +} diff --git a/src/main/java/com/contentstack/sdk/Stack.java b/src/main/java/com/contentstack/sdk/Stack.java index 5e7cd71a..d52ae120 100644 --- a/src/main/java/com/contentstack/sdk/Stack.java +++ b/src/main/java/com/contentstack/sdk/Stack.java @@ -59,40 +59,19 @@ protected Stack(@NotNull String apiKey) { protected void setConfig(Config config) { this.config = config; - String urlDomain = config.host; - if (!config.region.name().isEmpty()) { - String region = config.region.name().toLowerCase(); - if (region.equalsIgnoreCase("eu")) { - if (urlDomain.equalsIgnoreCase("cdn.contentstack.io")) { - urlDomain = "cdn.contentstack.com"; + // Explicit host (set via Config.setHost()) always takes precedence over region resolution. + // When no host was explicitly set, resolve the content-delivery host from regions.json via + // Endpoint so that new regions are picked up without SDK changes. + if (!config.hostOverridden) { + String regionId = config.region.name().toLowerCase(); + try { + config.host = Endpoint.getContentstackEndpoint(regionId, "contentDelivery", true); + } catch (IllegalArgumentException e) { + // Unrecognised region: apply the legacy prefix pattern for backward compatibility + if (!regionId.equals("us")) { + config.host = regionId.replace("_", "-") + "-cdn.contentstack.com"; } - config.host = region + "-" + urlDomain; - } else if (region.equalsIgnoreCase("azure_na")) { - if (urlDomain.equalsIgnoreCase("cdn.contentstack.io")) { - urlDomain = "cdn.contentstack.com"; - } - config.host = "azure-na" + "-" + urlDomain; - } else if (region.equalsIgnoreCase("azure_eu")) { - if (urlDomain.equalsIgnoreCase("cdn.contentstack.io")) { - urlDomain = "cdn.contentstack.com"; - } - config.host = "azure-eu" + "-" + urlDomain; - } else if (region.equalsIgnoreCase("gcp_na")) { - if (urlDomain.equalsIgnoreCase("cdn.contentstack.io")) { - urlDomain = "cdn.contentstack.com"; - } - config.host = "gcp-na" + "-" + urlDomain; - } else if (region.equalsIgnoreCase("gcp_eu")) { - if (urlDomain.equalsIgnoreCase("cdn.contentstack.io")) { - urlDomain = "cdn.contentstack.com"; - } - config.host = "gcp-eu" + "-" + urlDomain; - } else if (region.equalsIgnoreCase("au")) { - if (urlDomain.equalsIgnoreCase("cdn.contentstack.io")) { - urlDomain = "cdn.contentstack.com"; - } - config.host = region + "-" + urlDomain; } } diff --git a/src/test/java/com/contentstack/sdk/EndpointIT.java b/src/test/java/com/contentstack/sdk/EndpointIT.java new file mode 100644 index 00000000..54e15d3d --- /dev/null +++ b/src/test/java/com/contentstack/sdk/EndpointIT.java @@ -0,0 +1,325 @@ +package com.contentstack.sdk; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; + +/** + * Integration tests for {@link Endpoint}. + * + *

The "stack integration" tests create a real Stack using the host resolved + * by {@code Endpoint} and exercise the Contentstack CDA using credentials from + * {@code src/test/resources/.env}. They share the same credential set as the + * rest of the integration suite and are deliberately non-destructive (read-only + * CDA calls only). + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class EndpointIT { + + private static final Logger logger = Logger.getLogger(EndpointIT.class.getName()); + + private String apiKey; + private String deliveryToken; + private String environment; + + @BeforeAll + public void setUp() { + apiKey = Credentials.API_KEY; + deliveryToken = Credentials.DELIVERY_TOKEN; + environment = Credentials.ENVIRONMENT; + } + + // ── endpoint resolution ─────────────────────────────────────────────────── + + @Test + void testNaContentDeliveryResolvesCorrectly() { + String url = Endpoint.getContentstackEndpoint("na", "contentDelivery"); + Assertions.assertEquals("https://cdn.contentstack.io", url); + } + + @Test + void testEuContentDeliveryResolvesCorrectly() { + String url = Endpoint.getContentstackEndpoint("eu", "contentDelivery"); + Assertions.assertEquals("https://eu-cdn.contentstack.com", url); + } + + @Test + void testAzureNaContentDeliveryResolvesCorrectly() { + String url = Endpoint.getContentstackEndpoint("azure-na", "contentDelivery"); + Assertions.assertEquals("https://azure-na-cdn.contentstack.com", url); + } + + @Test + void testGcpNaContentDeliveryResolvesCorrectly() { + String url = Endpoint.getContentstackEndpoint("gcp-na", "contentDelivery"); + Assertions.assertEquals("https://gcp-na-cdn.contentstack.com", url); + } + + @Test + void testOmitHttpsStripsScheme() { + String host = Endpoint.getContentstackEndpoint("na", "contentDelivery", true); + Assertions.assertEquals("cdn.contentstack.io", host); + Assertions.assertFalse(host.startsWith("https://")); + } + + @Test + void testGetAllEndpointsReturnsMap() { + Map endpoints = Endpoint.getAllEndpoints("na"); + Assertions.assertNotNull(endpoints); + Assertions.assertFalse(endpoints.isEmpty()); + Assertions.assertTrue(endpoints.containsKey("contentDelivery")); + Assertions.assertTrue(endpoints.containsKey("contentManagement")); + Assertions.assertTrue(endpoints.containsKey("auth")); + } + + @Test + void testAliasResolution() { + // 'us' is an alias for 'na' + String viaAlias = Endpoint.getContentstackEndpoint("us", "contentDelivery"); + String viaCanonical = Endpoint.getContentstackEndpoint("na", "contentDelivery"); + Assertions.assertEquals(viaCanonical, viaAlias); + + // underscore + uppercase alias for azure-na + String viaUnderscore = Endpoint.getContentstackEndpoint("AZURE_NA", "contentDelivery"); + String viaHyphen = Endpoint.getContentstackEndpoint("azure-na", "contentDelivery"); + Assertions.assertEquals(viaHyphen, viaUnderscore); + } + + @Test + void testAllRegionsHaveContentDelivery() { + String[] regions = {"na", "eu", "au", "azure-na", "azure-eu", "gcp-na", "gcp-eu"}; + for (String region : regions) { + String url = Endpoint.getContentstackEndpoint(region, "contentDelivery"); + Assertions.assertNotNull(url, "contentDelivery missing for region: " + region); + Assertions.assertTrue(url.startsWith("https://"), + "Expected https:// for region " + region + " but got: " + url); + } + } + + @Test + void testAllRegionsHaveContentManagement() { + String[] regions = {"na", "eu", "au", "azure-na", "azure-eu", "gcp-na", "gcp-eu"}; + for (String region : regions) { + String url = Endpoint.getContentstackEndpoint(region, "contentManagement"); + Assertions.assertNotNull(url, "contentManagement missing for region: " + region); + Assertions.assertTrue(url.startsWith("https://"), + "Expected https:// for region " + region + " but got: " + url); + } + } + + // ── stack integration ───────────────────────────────────────────────────── + + /** + * Verifies that a Stack configured with the host resolved from + * {@code Endpoint} has the correct host set on its config. + */ + @Test + void testStackHostMatchesResolvedEndpoint() throws IllegalAccessException { + String host = Endpoint.getContentstackEndpoint("na", "contentDelivery", true); + Config config = new Config(); + config.setHost(host); + Stack stack = Contentstack.stack("fakeKey", "fakeToken", "fakeEnv", config); + Assertions.assertEquals(host, stack.config.host, + "Stack host should match the endpoint-resolved host"); + } + + /** + * Verifies that a Stack created via the endpoint-resolved host for EU + * has the correct host on its config. + */ + @Test + void testStackHostEuMatchesResolvedEndpoint() throws IllegalAccessException { + String host = Endpoint.getContentstackEndpoint("eu", "contentDelivery", true); + Config config = new Config(); + config.setHost(host); + Stack stack = Contentstack.stack("fakeKey", "fakeToken", "fakeEnv", config); + Assertions.assertEquals("eu-cdn.contentstack.com", stack.config.host); + } + + /** + * Creates a real Stack using the NA endpoint resolved from {@code Endpoint} + * and fetches content types from the CDA to confirm the host resolves to a + * working endpoint. + * + *

Skipped gracefully when credentials are absent (CI without secrets). + */ + @Test + void testRealStackWithEndpointResolvedHost() throws IllegalAccessException, InterruptedException { + if (apiKey == null || apiKey.isEmpty() + || deliveryToken == null || deliveryToken.isEmpty()) { + logger.warning("Skipping live API test — credentials not configured."); + return; + } + + String host = Endpoint.getContentstackEndpoint("na", "contentDelivery", true); + Config config = new Config(); + config.setHost(host); + + Stack stack = Contentstack.stack(apiKey, deliveryToken, environment, config); + Assertions.assertEquals(host, stack.config.host); + + CountDownLatch latch = new CountDownLatch(1); + boolean[] passed = {false}; + + stack.getContentTypes(new org.json.JSONObject(), new ContentTypesCallback() { + @Override + public void onCompletion(ContentTypesModel model, Error error) { + if (error == null) { + logger.info(() -> "Live endpoint check: fetched content types via host=" + host); + passed[0] = true; + } else { + logger.warning(() -> "Live endpoint check failed: " + error.getErrorMessage()); + } + latch.countDown(); + } + }); + + boolean completed = latch.await(15, TimeUnit.SECONDS); + Assertions.assertTrue(completed, "API call timed out after 15 seconds"); + // Skip (not fail) when credentials are absent or point to a non-existent stack + Assumptions.assumeTrue(passed[0], + "Live API call returned an error — skipping assertion (check .env credentials)"); + } + + /** + * Verifies that the host resolved via {@code Endpoint} for the NA region + * matches the Stack default host that the SDK would have used without + * endpoint resolution (backward-compatible). + */ + @Test + void testEndpointResolvedHostIsBackwardCompatibleWithDefaultNa() throws IllegalAccessException { + Stack defaultStack = Contentstack.stack("k", "t", "e"); + String sdkDefaultHost = defaultStack.config.host; + String resolvedHost = Endpoint.getContentstackEndpoint("na", "contentDelivery", true); + Assertions.assertEquals(sdkDefaultHost, resolvedHost, + "Endpoint-resolved NA host should match the SDK's built-in default"); + } + + // ── Contentstack proxy ──────────────────────────────────────────────────── + + @Test + void testContentstackProxyMatchesEndpointDirect() { + String viaProxy = Contentstack.getContentstackEndpoint("eu", "contentDelivery"); + String viaDirect = Endpoint.getContentstackEndpoint("eu", "contentDelivery"); + Assertions.assertEquals(viaDirect, viaProxy); + } + + @Test + void testContentstackProxyOmitHttps() { + String host = Contentstack.getContentstackEndpoint("azure-na", "contentDelivery", true); + Assertions.assertEquals("azure-na-cdn.contentstack.com", host); + Assertions.assertFalse(host.startsWith("https://")); + } + + @Test + void testContentstackProxyGetAllEndpoints() { + Map endpoints = Contentstack.getContentstackEndpoints("eu"); + Assertions.assertNotNull(endpoints); + Assertions.assertFalse(endpoints.isEmpty()); + Assertions.assertEquals("https://eu-cdn.contentstack.com", endpoints.get("contentDelivery")); + } + + @Test + void testContentstackProxyGetAllEndpointsOmitHttps() { + Map endpoints = Contentstack.getContentstackEndpoints("gcp-na", true); + Assertions.assertNotNull(endpoints); + for (String url : endpoints.values()) { + Assertions.assertFalse(url.startsWith("https://"), + "Expected no https:// prefix but got: " + url); + } + } + + @Test + void testContentstackProxyUnknownRegionThrows() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> Contentstack.getContentstackEndpoint("atlantis", "contentDelivery")); + } + + // ── Config.hostOverridden — explicit host beats region resolution ───────── + + @Test + void testExplicitHostTakesPrecedenceOverRegion() throws IllegalAccessException { + Config config = new Config(); + config.setRegion(Config.ContentstackRegion.EU); + config.setHost("custom-proxy.example.com"); // explicit override + Stack stack = Contentstack.stack("k", "t", "e", config); + Assertions.assertEquals("custom-proxy.example.com", stack.config.host, + "Explicit host must not be replaced by region-resolved host"); + } + + @Test + void testNoExplicitHostUsesRegionResolution() throws IllegalAccessException { + Config config = new Config(); + config.setRegion(Config.ContentstackRegion.AU); + Stack stack = Contentstack.stack("k", "t", "e", config); + Assertions.assertEquals("au-cdn.contentstack.com", stack.config.host, + "AU region should resolve to au-cdn.contentstack.com via Endpoint"); + } + + @Test + void testEmptySetHostDoesNotSetOverrideFlag() throws IllegalAccessException { + Config config = new Config(); + config.setRegion(Config.ContentstackRegion.EU); + config.setHost(""); // empty → should not mark hostOverridden + Stack stack = Contentstack.stack("k", "t", "e", config); + Assertions.assertEquals("eu-cdn.contentstack.com", stack.config.host, + "Empty setHost() should not prevent region-based resolution"); + } + + @Test + void testNullSetHostDoesNotSetOverrideFlag() throws IllegalAccessException { + Config config = new Config(); + config.setRegion(Config.ContentstackRegion.GCP_EU); + config.setHost(null); // null → should not mark hostOverridden + Stack stack = Contentstack.stack("k", "t", "e", config); + Assertions.assertEquals("gcp-eu-cdn.contentstack.com", stack.config.host, + "Null setHost() should not prevent region-based resolution"); + } + + // ── Stack region→host resolution via Endpoint ───────────────────────────── + + @Test + void testStackUsRegionResolvesToNaCdn() throws IllegalAccessException { + Config config = new Config(); + config.setRegion(Config.ContentstackRegion.US); + Stack stack = Contentstack.stack("k", "t", "e", config); + Assertions.assertEquals("cdn.contentstack.io", stack.config.host); + } + + @Test + void testStackEuRegionResolvesViaEndpoint() throws IllegalAccessException { + Config config = new Config(); + config.setRegion(Config.ContentstackRegion.EU); + Stack stack = Contentstack.stack("k", "t", "e", config); + Assertions.assertEquals( + Endpoint.getContentstackEndpoint("eu", "contentDelivery", true), + stack.config.host); + } + + @Test + void testStackAzureNaRegionResolvesViaEndpoint() throws IllegalAccessException { + Config config = new Config(); + config.setRegion(Config.ContentstackRegion.AZURE_NA); + Stack stack = Contentstack.stack("k", "t", "e", config); + Assertions.assertEquals( + Endpoint.getContentstackEndpoint("azure-na", "contentDelivery", true), + stack.config.host); + } + + @Test + void testStackGcpNaRegionResolvesViaEndpoint() throws IllegalAccessException { + Config config = new Config(); + config.setRegion(Config.ContentstackRegion.GCP_NA); + Stack stack = Contentstack.stack("k", "t", "e", config); + Assertions.assertEquals( + Endpoint.getContentstackEndpoint("gcp-na", "contentDelivery", true), + stack.config.host); + } +} diff --git a/src/test/java/com/contentstack/sdk/TestEndpoint.java b/src/test/java/com/contentstack/sdk/TestEndpoint.java new file mode 100644 index 00000000..5c38b60c --- /dev/null +++ b/src/test/java/com/contentstack/sdk/TestEndpoint.java @@ -0,0 +1,307 @@ +package com.contentstack.sdk; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +class TestEndpoint { + + @AfterEach + void resetCache() { + Endpoint.resetCache(); + } + + // ── canonical IDs ───────────────────────────────────────────────────────── + + @Test + void testNaContentDelivery() { + String url = Endpoint.getContentstackEndpoint("na", "contentDelivery"); + Assertions.assertEquals("https://cdn.contentstack.io", url); + } + + @Test + void testEuContentDelivery() { + String url = Endpoint.getContentstackEndpoint("eu", "contentDelivery"); + Assertions.assertEquals("https://eu-cdn.contentstack.com", url); + } + + @Test + void testAuContentDelivery() { + String url = Endpoint.getContentstackEndpoint("au", "contentDelivery"); + Assertions.assertEquals("https://au-cdn.contentstack.com", url); + } + + @Test + void testAzureNaContentDelivery() { + String url = Endpoint.getContentstackEndpoint("azure-na", "contentDelivery"); + Assertions.assertEquals("https://azure-na-cdn.contentstack.com", url); + } + + @Test + void testAzureEuContentDelivery() { + String url = Endpoint.getContentstackEndpoint("azure-eu", "contentDelivery"); + Assertions.assertEquals("https://azure-eu-cdn.contentstack.com", url); + } + + @Test + void testGcpNaContentDelivery() { + String url = Endpoint.getContentstackEndpoint("gcp-na", "contentDelivery"); + Assertions.assertEquals("https://gcp-na-cdn.contentstack.com", url); + } + + @Test + void testGcpEuContentDelivery() { + String url = Endpoint.getContentstackEndpoint("gcp-eu", "contentDelivery"); + Assertions.assertEquals("https://gcp-eu-cdn.contentstack.com", url); + } + + // ── aliases ─────────────────────────────────────────────────────────────── + + @Test + void testAliasUsResolvesToNa() { + String url = Endpoint.getContentstackEndpoint("us", "contentDelivery"); + Assertions.assertEquals("https://cdn.contentstack.io", url); + } + + @Test + void testAliasUppercaseEU() { + String url = Endpoint.getContentstackEndpoint("EU", "contentDelivery"); + Assertions.assertEquals("https://eu-cdn.contentstack.com", url); + } + + @Test + void testAliasAwsNaHyphen() { + String url = Endpoint.getContentstackEndpoint("aws-na", "contentDelivery"); + Assertions.assertEquals("https://cdn.contentstack.io", url); + } + + @Test + void testAliasAwsNaUnderscore() { + String url = Endpoint.getContentstackEndpoint("aws_na", "contentDelivery"); + Assertions.assertEquals("https://cdn.contentstack.io", url); + } + + @Test + void testAliasAzureNaUnderscore() { + String url = Endpoint.getContentstackEndpoint("azure_na", "contentDelivery"); + Assertions.assertEquals("https://azure-na-cdn.contentstack.com", url); + } + + @Test + void testAliasAzureNaUppercase() { + String url = Endpoint.getContentstackEndpoint("AZURE_NA", "contentDelivery"); + Assertions.assertEquals("https://azure-na-cdn.contentstack.com", url); + } + + @Test + void testAliasGcpNaUnderscore() { + String url = Endpoint.getContentstackEndpoint("gcp_na", "contentDelivery"); + Assertions.assertEquals("https://gcp-na-cdn.contentstack.com", url); + } + + @Test + void testAliasGcpEuUppercase() { + String url = Endpoint.getContentstackEndpoint("GCP-EU", "contentDelivery"); + Assertions.assertEquals("https://gcp-eu-cdn.contentstack.com", url); + } + + // ── services ────────────────────────────────────────────────────────────── + + @Test + void testNaContentManagement() { + String url = Endpoint.getContentstackEndpoint("na", "contentManagement"); + Assertions.assertEquals("https://api.contentstack.io", url); + } + + @Test + void testEuContentManagement() { + String url = Endpoint.getContentstackEndpoint("eu", "contentManagement"); + Assertions.assertEquals("https://eu-api.contentstack.com", url); + } + + @Test + void testNaGraphqlDelivery() { + String url = Endpoint.getContentstackEndpoint("na", "graphqlDelivery"); + Assertions.assertEquals("https://graphql.contentstack.com", url); + } + + @Test + void testNaAuth() { + String url = Endpoint.getContentstackEndpoint("na", "auth"); + Assertions.assertEquals("https://auth-api.contentstack.com", url); + } + + @Test + void testEuPreview() { + String url = Endpoint.getContentstackEndpoint("eu", "preview"); + Assertions.assertEquals("https://eu-rest-preview.contentstack.com", url); + } + + @Test + void testNaApplication() { + String url = Endpoint.getContentstackEndpoint("na", "application"); + Assertions.assertEquals("https://app.contentstack.com", url); + } + + @Test + void testNaAssetManagement() { + String url = Endpoint.getContentstackEndpoint("na", "assetManagement"); + Assertions.assertEquals("https://am-api.contentstack.com", url); + } + + // ── omitHttps ───────────────────────────────────────────────────────────── + + @Test + void testOmitHttpsNaContentDelivery() { + String host = Endpoint.getContentstackEndpoint("na", "contentDelivery", true); + Assertions.assertEquals("cdn.contentstack.io", host); + } + + @Test + void testOmitHttpsEuContentDelivery() { + String host = Endpoint.getContentstackEndpoint("eu", "contentDelivery", true); + Assertions.assertEquals("eu-cdn.contentstack.com", host); + } + + @Test + void testOmitHttpsAzureNaContentManagement() { + String host = Endpoint.getContentstackEndpoint("azure-na", "contentManagement", true); + Assertions.assertEquals("azure-na-api.contentstack.com", host); + } + + @Test + void testOmitHttpsFalseReturnsFullUrl() { + String url = Endpoint.getContentstackEndpoint("gcp-eu", "contentDelivery", false); + Assertions.assertTrue(url.startsWith("https://")); + } + + // ── getAllEndpoints ─────────────────────────────────────────────────────── + + @Test + void testGetAllEndpointsNaContainsContentDelivery() { + Map endpoints = Endpoint.getAllEndpoints("na"); + Assertions.assertTrue(endpoints.containsKey("contentDelivery")); + Assertions.assertEquals("https://cdn.contentstack.io", endpoints.get("contentDelivery")); + } + + @Test + void testGetAllEndpointsEuSize() { + Map endpoints = Endpoint.getAllEndpoints("eu"); + Assertions.assertFalse(endpoints.isEmpty()); + Assertions.assertTrue(endpoints.size() >= 4); + } + + @Test + void testGetAllEndpointsOmitHttps() { + Map endpoints = Endpoint.getAllEndpoints("na", true); + for (String url : endpoints.values()) { + Assertions.assertFalse(url.startsWith("https://"), + "Expected no https:// prefix but got: " + url); + } + } + + @Test + void testGetAllEndpointsAzureNaOmitHttps() { + Map endpoints = Endpoint.getAllEndpoints("azure-na", true); + Assertions.assertEquals("azure-na-cdn.contentstack.com", endpoints.get("contentDelivery")); + } + + // ── error cases ─────────────────────────────────────────────────────────── + + @Test + void testEmptyRegionThrows() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> Endpoint.getContentstackEndpoint("", "contentDelivery")); + } + + @Test + void testBlankRegionThrows() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> Endpoint.getContentstackEndpoint(" ", "contentDelivery")); + } + + @Test + void testUnknownRegionThrows() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> Endpoint.getContentstackEndpoint("asia-pacific", "contentDelivery")); + } + + @Test + void testUnknownServiceThrows() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> Endpoint.getContentstackEndpoint("na", "cms")); + } + + @Test + void testServiceNotAvailableInRegionThrows() { + // assetManagement exists only in NA + Assertions.assertThrows(IllegalArgumentException.class, + () -> Endpoint.getContentstackEndpoint("eu", "assetManagement")); + } + + @Test + void testGetAllEndpointsEmptyRegionThrows() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> Endpoint.getAllEndpoints("")); + } + + @Test + void testGetAllEndpointsUnknownRegionThrows() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> Endpoint.getAllEndpoints("unknown-region")); + } + + // ── caching ─────────────────────────────────────────────────────────────── + + @Test + void testMultipleCallsReturnSameResult() { + String url1 = Endpoint.getContentstackEndpoint("eu", "contentDelivery"); + String url2 = Endpoint.getContentstackEndpoint("eu", "contentDelivery"); + Assertions.assertEquals(url1, url2); + } + + @Test + void testCacheResetAllowsReload() { + String url1 = Endpoint.getContentstackEndpoint("na", "contentDelivery"); + Endpoint.resetCache(); + String url2 = Endpoint.getContentstackEndpoint("na", "contentDelivery"); + Assertions.assertEquals(url1, url2); + } + + // ── live-refresh fallback ───────────────────────────────────────────────── + + /** + * Verifies that a truly unknown region still throws after the live-refresh + * attempt (the download succeeds but "atlantis" isn't a real region). + * This also exercises the live-refresh code path — the test passes whether + * the download succeeds or the network is unavailable (both produce the same + * IllegalArgumentException for a non-existent region). + */ + @Test + void testUnknownRegionThrowsEvenAfterLiveRefresh() { + // Ensure liveRefreshDone starts false for this test + Assertions.assertThrows(IllegalArgumentException.class, + () -> Endpoint.getContentstackEndpoint("atlantis", "contentDelivery")); + } + + /** + * Verifies that after resetCache() the live-refresh flag is also cleared, so + * the fallback can be exercised again in the next lookup if needed. + */ + @Test + void testResetCacheClearsLiveRefreshFlag() { + // Trigger a first lookup (may or may not hit live refresh internally) + try { + Endpoint.getContentstackEndpoint("na", "contentDelivery"); + } catch (Exception ignored) { + // ignored + } + // resetCache must clear liveRefreshDone so a subsequent cache miss can retry + Endpoint.resetCache(); + // After reset, known regions still resolve correctly via classpath + String url = Endpoint.getContentstackEndpoint("na", "contentDelivery"); + Assertions.assertEquals("https://cdn.contentstack.io", url); + } +} diff --git a/src/test/java/com/contentstack/sdk/TestStack.java b/src/test/java/com/contentstack/sdk/TestStack.java index 1232ad4d..b5a855a3 100644 --- a/src/test/java/com/contentstack/sdk/TestStack.java +++ b/src/test/java/com/contentstack/sdk/TestStack.java @@ -1344,15 +1344,15 @@ void testSetConfigWithAURegionAndDefaultHost() { @Test void testSetConfigWithCustomHostNoRegionChange() { Config config = new Config(); - config.host = "custom-cdn.example.com"; + // Use setHost() so the explicit host takes precedence over region resolution + config.setHost("custom-cdn.example.com"); config.setRegion(Config.ContentstackRegion.EU); - + stack.setConfig(config); - + assertNotNull(stack.config); - // Custom host should get region prefix but not change domain - assertTrue(stack.config.getHost().contains("eu-")); - assertTrue(stack.config.getHost().contains("custom-cdn.example.com")); + // An explicit host set via setHost() is used as-is — no region prefix is applied on top + assertEquals("custom-cdn.example.com", stack.config.getHost()); } // ========== LIVE PREVIEW WITH DIFFERENT REGIONS ==========