Skip to content

Commit 28f09df

Browse files
authored
Merge pull request #76 from contentstack/DX-7281
feat: add multi-region URL resolver and bump version to 1.6.0
2 parents 9a54524 + 8de3485 commit 28f09df

8 files changed

Lines changed: 617 additions & 2 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,4 @@ venv.bak/
113113
.mypy_cache/
114114
.idea/
115115
.vscode/
116+
*/assets/regions.json

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## v1.6.0 (2026-06-05)
4+
5+
### New feature: Multi-region endpoint resolution
6+
7+
- Added `Endpoint.get_contentstack_endpoint()` for dynamic region-aware URL resolution across all Contentstack regions and services.
8+
- Added `Utils.get_contentstack_endpoint()` proxy for backward-compatible access via the existing `Utils` import path.
9+
- Added `getContentstackEndpoint` camelCase alias on both `Endpoint` and `Utils` for cross-SDK parity.
10+
- Bundled `contentstack_utils/assets/regions.json` — the authoritative registry of 7 regions (AWS NA/EU/AU, Azure NA/EU, GCP NA/EU) and 18 service endpoint keys.
11+
- Added runtime fallback in `Endpoint._load_regions()` — downloads `regions.json` from `artifacts.contentstack.com` on first use when the file is absent.
12+
- Added `scripts/refresh_regions.py` to manually pull the latest regions from Contentstack .
13+
- Exported `Endpoint` at package level in `__all__`.
14+
315
## v1.5.0
416

517
### New feature: Variants utility (CDA entry variant aliases)

contentstack_utils/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from contentstack_utils.embedded.item_type import ItemType
1212
from contentstack_utils.embedded.styletype import StyleType
13+
from contentstack_utils.endpoint import Endpoint
1314
from contentstack_utils.helper.metadata import Metadata
1415
from contentstack_utils.helper.node_to_html import NodeToHtml
1516
from contentstack_utils.render.options import Options
@@ -19,6 +20,7 @@
1920
from contentstack_utils.entry_editable import addEditableTags, addTags, getTag
2021

2122
__all__ = (
23+
"Endpoint",
2224
"Utils",
2325
"Options",
2426
"Metadata",
@@ -35,6 +37,6 @@
3537
__title__ = 'contentstack_utils'
3638
__author__ = 'contentstack'
3739
__status__ = 'debug'
38-
__version__ = '1.4.0'
40+
__version__ = '1.6.0'
3941
__endpoint__ = 'cdn.contentstack.io'
4042
__contact__ = 'support@contentstack.com'

contentstack_utils/endpoint.py

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
"""
2+
Endpoint resolution for Contentstack services across all regions.
3+
4+
Reads a bundled regions.json (src/assets/regions.json) and resolves
5+
the correct base URL for any region + service combination. No runtime
6+
HTTP calls — the file is shipped with the package and updated via
7+
``python scripts/refresh_regions.py``.
8+
"""
9+
10+
import json
11+
import os
12+
import re
13+
import urllib.request
14+
from typing import Dict, Optional, Union
15+
16+
_REGIONS_URL = "https://artifacts.contentstack.com/regions.json"
17+
_ASSETS_DIR = os.path.join(os.path.dirname(__file__), "assets")
18+
_REGIONS_FILE = os.path.join(_ASSETS_DIR, "regions.json")
19+
20+
21+
class Endpoint:
22+
"""
23+
Resolve Contentstack service URLs for any region.
24+
25+
All public methods are static — no instantiation required.
26+
27+
Example::
28+
29+
from contentstack_utils import Endpoint
30+
31+
# Full URL
32+
url = Endpoint.get_contentstack_endpoint("na", "contentDelivery")
33+
# → "https://cdn.contentstack.io"
34+
35+
# Host only (strip https://) — useful for SDK setHost() calls
36+
host = Endpoint.get_contentstack_endpoint("eu", "contentDelivery", omit_https=True)
37+
# → "eu-cdn.contentstack.com"
38+
39+
# All endpoints for a region
40+
all_endpoints = Endpoint.get_contentstack_endpoint("azure-na")
41+
# → {"contentDelivery": "...", "contentManagement": "...", ...}
42+
"""
43+
44+
# Module-level cache — loaded once per Python process, shared across all calls.
45+
_regions_data: Optional[Dict] = None
46+
47+
@staticmethod
48+
def get_contentstack_endpoint(
49+
region: str = "us",
50+
service: str = "",
51+
omit_https: bool = False,
52+
) -> Union[str, Dict[str, str]]:
53+
"""
54+
Resolve a Contentstack service endpoint URL for the given region.
55+
56+
:param region:
57+
Region ID or any accepted alias (case-insensitive, ``-`` and ``_``
58+
are interchangeable). Examples: ``'na'``, ``'us'``, ``'eu'``,
59+
``'AWS-NA'``, ``'azure_eu'``, ``'gcp-na'``.
60+
Defaults to ``'us'`` (AWS North America).
61+
:param service:
62+
Optional service key. When provided, a single URL string is
63+
returned. When omitted, a dict of **all** service URLs is returned.
64+
Valid keys include: ``'contentDelivery'``, ``'contentManagement'``,
65+
``'auth'``, ``'graphqlDelivery'``, ``'preview'``, ``'images'``,
66+
``'assets'``, ``'automate'``, ``'launch'``, ``'developerHub'``,
67+
``'brandKit'``, ``'genAI'``, ``'personalizeManagement'``,
68+
``'personalizeEdge'``, ``'composableStudio'``, ``'assetManagement'``.
69+
:param omit_https:
70+
When ``True``, strips the ``https://`` (or ``http://``) scheme from
71+
every returned URL. Useful when passing the host to an SDK that
72+
constructs its own URLs (e.g. ``stack.set_host(host)``).
73+
:returns:
74+
- A ``str`` URL when *service* is specified.
75+
- A ``dict[str, str]`` mapping service keys → URLs when *service*
76+
is omitted.
77+
:raises ValueError:
78+
If *region* is an empty string.
79+
:raises LookupError:
80+
If *region* does not match any known region ID or alias, or if
81+
*service* is not present in the resolved region's endpoint map.
82+
:raises RuntimeError:
83+
If the bundled ``regions.json`` cannot be read or is malformed.
84+
85+
Examples::
86+
87+
Endpoint.get_contentstack_endpoint("na", "contentDelivery")
88+
# → "https://cdn.contentstack.io"
89+
90+
Endpoint.get_contentstack_endpoint("eu", "contentDelivery", omit_https=True)
91+
# → "eu-cdn.contentstack.com"
92+
93+
Endpoint.get_contentstack_endpoint("azure-na")
94+
# → {"contentDelivery": "https://...", ...}
95+
"""
96+
if not region:
97+
raise ValueError("Empty region provided. Please put valid region.")
98+
99+
data = Endpoint._load_regions()
100+
normalized = region.strip().lower()
101+
102+
if not normalized:
103+
raise ValueError("Empty region provided. Please put valid region.")
104+
region_row = Endpoint._find_region_by_id_or_alias(data["regions"], normalized)
105+
106+
if region_row is None:
107+
raise LookupError(f"Invalid region: {region}")
108+
109+
if service:
110+
endpoints = region_row["endpoints"]
111+
if service not in endpoints:
112+
raise LookupError(
113+
f'Service "{service}" not found for region "{region_row["id"]}"'
114+
)
115+
url = endpoints[service]
116+
return Endpoint._strip_https(url) if omit_https else url
117+
118+
endpoints = dict(region_row["endpoints"])
119+
return Endpoint._strip_https_from_map(endpoints) if omit_https else endpoints
120+
121+
122+
getContentstackEndpoint = get_contentstack_endpoint
123+
124+
@staticmethod
125+
def reset_cache() -> None:
126+
"""
127+
Clear the in-memory region cache.
128+
129+
Intended for testing only — forces the next call to re-read
130+
``regions.json`` from disk.
131+
"""
132+
Endpoint._regions_data = None
133+
134+
# ------------------------------------------------------------------
135+
# Internal helpers
136+
# ------------------------------------------------------------------
137+
138+
@staticmethod
139+
def _load_regions() -> Dict:
140+
"""
141+
Load and cache regions data.
142+
143+
Resolution order:
144+
1. In-memory cache (zero I/O after the first call in a process)
145+
2. Bundled ``contentstack_utils/assets/regions.json`` on disk
146+
3. Live download from ``artifacts.contentstack.com`` (fallback when
147+
the file is absent — e.g. an editable install without assets)
148+
"""
149+
if Endpoint._regions_data is not None:
150+
return Endpoint._regions_data
151+
152+
if not os.path.exists(_REGIONS_FILE):
153+
Endpoint._download_and_save(_REGIONS_FILE)
154+
155+
if not os.path.exists(_REGIONS_FILE):
156+
raise RuntimeError(
157+
"contentstack_utils: regions.json not found and could not be downloaded. "
158+
"Run 'python scripts/refresh_regions.py' and ensure network access."
159+
)
160+
161+
try:
162+
with open(_REGIONS_FILE, "r", encoding="utf-8") as fh:
163+
raw = fh.read()
164+
except OSError as exc:
165+
raise RuntimeError(
166+
f"contentstack_utils: Could not read regions.json: {exc}"
167+
) from exc
168+
169+
try:
170+
decoded = json.loads(raw)
171+
except json.JSONDecodeError as exc:
172+
raise RuntimeError(
173+
"contentstack_utils: regions.json is corrupt. "
174+
"Run 'python scripts/refresh_regions.py' to re-download it."
175+
) from exc
176+
177+
if not isinstance(decoded, dict) or "regions" not in decoded:
178+
raise RuntimeError(
179+
"contentstack_utils: regions.json is corrupt. "
180+
"Run 'python scripts/refresh_regions.py' to re-download it."
181+
)
182+
183+
Endpoint._regions_data = decoded
184+
return Endpoint._regions_data
185+
186+
@staticmethod
187+
def _download_and_save(dest: str) -> None:
188+
"""
189+
Fetch regions.json from the Contentstack CDN and write it to *dest*.
190+
191+
Silent on failure — the caller decides whether a missing file is fatal.
192+
"""
193+
os.makedirs(os.path.dirname(dest), exist_ok=True)
194+
try:
195+
with urllib.request.urlopen(_REGIONS_URL, timeout=30) as resp:
196+
data = resp.read().decode("utf-8")
197+
except Exception:
198+
return
199+
200+
try:
201+
decoded = json.loads(data)
202+
except json.JSONDecodeError:
203+
return
204+
205+
if isinstance(decoded, dict) and "regions" in decoded:
206+
with open(dest, "w", encoding="utf-8") as fh:
207+
fh.write(data)
208+
209+
@staticmethod
210+
def _find_region_by_id_or_alias(
211+
regions: list, normalized_input: str
212+
) -> Optional[Dict]:
213+
"""
214+
Find a region by its ``id`` field first, then by any alias.
215+
216+
Both passes are case-insensitive (caller must pass a lowercased string).
217+
Two-pass approach mirrors the PHP implementation: ID match wins over alias
218+
match, which avoids surprising behaviour when a future alias happens to
219+
collide with another region's canonical ID.
220+
"""
221+
# Pass 1 — exact id match
222+
for row in regions:
223+
if row["id"] == normalized_input:
224+
return row
225+
226+
# Pass 2 — alias match
227+
for row in regions:
228+
for alias in row.get("alias", []):
229+
if alias.lower() == normalized_input:
230+
return row
231+
232+
return None
233+
234+
@staticmethod
235+
def _strip_https(url: str) -> str:
236+
"""Strip ``https://`` or ``http://`` from the start of a URL."""
237+
return re.sub(r"^https?://", "", url)
238+
239+
@staticmethod
240+
def _strip_https_from_map(endpoints: Dict[str, str]) -> Dict[str, str]:
241+
"""Return a new dict with the scheme stripped from every URL value."""
242+
return {key: Endpoint._strip_https(url) for key, url in endpoints.items()}

contentstack_utils/utils.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from lxml import etree
77

88
from contentstack_utils.automate import Automate
9+
from contentstack_utils.endpoint import Endpoint
910
from contentstack_utils.entry_editable import addEditableTags as _addEditableTags
1011
from contentstack_utils.entry_editable import addTags as _addTags
1112
from contentstack_utils.entry_editable import getTag as _getTag
@@ -219,6 +220,34 @@ def __get_metadata(elements):
219220
metadata = Metadata(element.text, typeof, uid, content_type, style, outer_html, attributes)
220221
return metadata
221222

223+
# ------------------------------------------------------------------
224+
# Endpoint resolution — thin proxy to Endpoint class
225+
# ------------------------------------------------------------------
226+
227+
@staticmethod
228+
def get_contentstack_endpoint(
229+
region: str = "us",
230+
service: str = "",
231+
omit_https: bool = False,
232+
):
233+
"""
234+
Resolve a Contentstack service URL for the given region.
235+
236+
Delegates entirely to :class:`~contentstack_utils.endpoint.Endpoint`.
237+
Both ``Utils.get_contentstack_endpoint(...)`` and
238+
``Endpoint.get_contentstack_endpoint(...)`` produce identical results —
239+
choose whichever import path suits your codebase.
240+
241+
:param region: Region ID or alias (e.g. ``'na'``, ``'eu'``, ``'azure-na'``).
242+
:param service: Optional service key (e.g. ``'contentDelivery'``).
243+
:param omit_https: Strip ``https://`` from the returned URL(s).
244+
:returns: URL string when *service* is given, dict of all URLs otherwise.
245+
"""
246+
return Endpoint.get_contentstack_endpoint(region, service, omit_https)
247+
248+
# camelCase alias for cross-SDK parity
249+
getContentstackEndpoint = get_contentstack_endpoint
250+
222251
####################################################
223252
# SUPERCHARGED #
224253
####################################################

scripts/refresh_regions.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Pull the latest regions.json from the Contentstack CDN and overwrite the
4+
bundled copy at contentstack_utils/assets/regions.json.
5+
6+
Usage:
7+
python3 scripts/refresh_regions.py
8+
9+
Run this whenever Contentstack adds a new region or service, then commit the
10+
updated file so all consumers get the change on their next install.
11+
"""
12+
13+
import json
14+
import os
15+
import sys
16+
import urllib.request
17+
18+
REGIONS_URL = "https://artifacts.contentstack.com/regions.json"
19+
DEST = os.path.join(
20+
os.path.dirname(__file__),
21+
"..",
22+
"contentstack_utils",
23+
"assets",
24+
"regions.json",
25+
)
26+
27+
28+
def main() -> int:
29+
dest = os.path.normpath(DEST)
30+
print(f"Fetching {REGIONS_URL} ...")
31+
32+
try:
33+
with urllib.request.urlopen(REGIONS_URL, timeout=30) as resp:
34+
data = resp.read().decode("utf-8")
35+
except Exception as exc:
36+
print(f"ERROR: Could not download regions.json: {exc}", file=sys.stderr)
37+
return 1
38+
39+
try:
40+
decoded = json.loads(data)
41+
except json.JSONDecodeError as exc:
42+
print(f"ERROR: Downloaded content is not valid JSON: {exc}", file=sys.stderr)
43+
return 1
44+
45+
if not isinstance(decoded, dict) or "regions" not in decoded:
46+
print("ERROR: Downloaded JSON does not contain a 'regions' key.", file=sys.stderr)
47+
return 1
48+
49+
region_count = len(decoded["regions"])
50+
os.makedirs(os.path.dirname(dest), exist_ok=True)
51+
with open(dest, "w", encoding="utf-8") as fh:
52+
json.dump(decoded, fh, indent=2, ensure_ascii=False)
53+
fh.write("\n")
54+
55+
print(f"OK: Wrote {region_count} regions to {dest}")
56+
print("Next steps:")
57+
print(" git add contentstack_utils/assets/regions.json")
58+
print(' git commit -m "chore: refresh regions.json"')
59+
return 0
60+
61+
62+
if __name__ == "__main__":
63+
sys.exit(main())

0 commit comments

Comments
 (0)