From edec155bf9f87591bb118b0928a4d33f2611a323 Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Mon, 8 Jun 2026 13:28:19 +0530 Subject: [PATCH 1/2] feat: Added Endpoint Integration for CMA Python SDK --- .gitignore | 1 + CHANGELOG.md | 12 + ENDPOINT_INTEGRATION.md | 367 +++++++++++++++++++++++ contentstack_management/__init__.py | 20 +- contentstack_management/contentstack.py | 26 +- contentstack_management/endpoint.py | 180 +++++++++++ scripts/download_regions.py | 68 +++++ setup.py | 1 + tests/unit/contentstack/test_endpoint.py | 298 ++++++++++++++++++ 9 files changed, 964 insertions(+), 9 deletions(-) create mode 100644 ENDPOINT_INTEGRATION.md create mode 100644 contentstack_management/endpoint.py create mode 100644 scripts/download_regions.py create mode 100644 tests/unit/contentstack/test_endpoint.py diff --git a/.gitignore b/.gitignore index 3030607..f31c87b 100644 --- a/.gitignore +++ b/.gitignore @@ -137,4 +137,5 @@ tests/resources/.DS_Store tests/.DS_Store tests/resources/.DS_Store .DS_Store +*/data/regions.json .talismanrc \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 4adea91..c56438a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,18 @@ # CHANGELOG ## Content Management SDK For Python +--- +## v1.10.0 + +#### Date: 08 June 2026 + +- Dynamic region endpoint resolution via the Contentstack Regions Registry (`regions.json`). +- Added `Endpoint` class with 3-tier resolution: in-memory cache → bundled `data/regions.json` → live CDN download. +- Exposed `contentstack_management.get_contentstack_endpoint(region, service, omit_https)` module-level proxy. +- `Client` now resolves the `contentManagement` endpoint from the registry instead of a hardcoded host pattern. +- Added `scripts/download_regions.py` to refresh the bundled registry file. +- New regions and services require no SDK code changes — registry update is sufficient. + --- ## v1.9.0 diff --git a/ENDPOINT_INTEGRATION.md b/ENDPOINT_INTEGRATION.md new file mode 100644 index 0000000..16ddfe9 --- /dev/null +++ b/ENDPOINT_INTEGRATION.md @@ -0,0 +1,367 @@ +# Region Endpoint Integration — Management Python SDK + +## Overview + +Contentstack services are deployed across multiple cloud providers and geographic regions. The Management Python SDK resolves service endpoints dynamically using the Contentstack Regions Registry rather than relying on hardcoded URLs. + +This ensures: + +- Consistent endpoint resolution across all SDKs +- Automatic support for newly introduced regions +- Automatic support for newly introduced services +- Single source of truth for endpoint configuration +- Elimination of region-specific host logic inside the SDK + +--- + +## Regions Registry + +All endpoint information is maintained in the Contentstack Regions Registry. + +### Registry URL + +```text +https://artifacts.contentstack.com/regions.json +``` + +The registry contains: + +- Region identifiers +- Region aliases +- Default region information +- Service endpoint mappings + +### Example + +```json +{ + "regions": [ + { + "id": "na", + "alias": ["us", "aws-na"], + "isDefault": true, + "endpoints": { + "contentDelivery": "https://cdn.contentstack.io", + "contentManagement": "https://api.contentstack.io" + } + } + ] +} +``` + +--- + +## Endpoint Resolution Contract + +The SDK exposes a public endpoint resolution API via the `Endpoint` class. + +```text +Endpoint.get_contentstack_endpoint( + region, + service = '', + omit_https = False +) +``` + +### Parameters + +| Parameter | Description | +|-----------|-------------| +| `region` | Region identifier or alias (e.g. `'us'`, `'eu'`, `'azure-na'`) | +| `service` | Service key (e.g. `'contentManagement'`, `'contentDelivery'`). When empty, all endpoints for the region are returned. | +| `omit_https` | When `True`, strips `https://` from the returned URL — useful when constructing the `Client` endpoint string | + +### Returns + +- `str` — service URL when a service key is provided +- `dict[str, str]` — complete endpoint map when service is omitted + +### Raises + +| Exception | When | +|-----------|------| +| `ValueError` | Empty region, unknown region, or unknown service | +| `RuntimeError` | `regions.json` cannot be read or parsed | + +--- + +## Region Resolution Rules + +Region matching must: + +- Ignore case +- Trim whitespace +- Support aliases +- Support both dash (`-`) and underscore (`_`) variants where defined + +### Examples + +| Input | Resolved Region | +|-------|----------------| +| `na` | `na` | +| `us` | `na` | +| `aws-na` | `na` | +| `AWS_NA` | `na` | +| `eu` | `eu` | +| `azure-na` | `azure-na` | +| `gcp-eu` | `gcp-eu` | + +If no region is found: + +```text +ValueError: Invalid region +``` + +--- + +## Service Resolution Rules + +The SDK will: + +1. Locate the resolved region in the registry. +2. Locate the service key within the region endpoints. +3. Return the endpoint URL. + +### Example + +```text +Region: eu +Service: contentManagement + +Result: +https://eu-api.contentstack.com +``` + +If the service key is not present: + +```text +ValueError: Service not found +``` + +--- + +## Supported Service Keys + +- `contentManagement` +- `contentDelivery` +- `graphqlDelivery` +- `graphqlPreview` +- `preview` +- `auth` +- `application` +- `images` +- `assets` +- `automate` +- `launch` +- `developerHub` +- `brandKit` +- `genAI` +- `personalizeManagement` +- `personalizeEdge` +- `composableStudio` + +The SDK does not hardcode this list. The registry remains the source of truth. + +--- + +## Registry Loading Requirements + +Resolution order: + +1. **In-memory cache** — `Endpoint._regions_data` class-level variable. Zero I/O after first call. +2. **Local registry file** — `contentstack_management/data/regions.json` on disk, bundled at install time. +3. **Live download fallback** — `GET https://artifacts.contentstack.com/regions.json` via `requests`. + +```text +get_contentstack_endpoint() + │ + ▼ + In-memory cache hit? + Yes → return + No ↓ + ▼ + regions.json on disk? + Yes → load, cache, return + No ↓ + ▼ + Download from CDN + Success → write to disk, cache, return + Failure → RuntimeError +``` + +--- + +## Registry Management + +The `regions.json` file is stored locally at `contentstack_management/data/regions.json` and managed via a download script. + +> **Note:** The registry is stored under `data/` rather than `assets/` because `contentstack_management/assets/` is already a Python package used for the Assets Management API. + +### Initial Download + +Run once after cloning or installing the SDK: + +```bash +python3 scripts/download_regions.py +``` + +This downloads the latest `regions.json` from the Contentstack Regions Registry and stores it in: + +```text +contentstack_management/data/regions.json +``` + +### Refresh Registry + +To manually refresh and overwrite the existing file: + +```bash +python3 scripts/download_regions.py +``` + +This command: + +1. Downloads the latest `regions.json` from the registry. +2. Replaces the existing local copy. +3. Makes newly added regions and services immediately available without requiring SDK code changes. + +### Example Workflow + +```text +python3 scripts/download_regions.py + │ + ▼ +Download latest regions.json + │ + ▼ +Store in contentstack_management/data/regions.json + │ + ▼ +Available on next Endpoint call +``` + +> **Note:** If `regions.json` is absent at runtime, the SDK performs a live download automatically on the first `Endpoint` call. The script is recommended for production environments to avoid the startup latency. + +--- + +## SDK Integration + +```text +Resolve Region + ↓ +Resolve contentManagement Endpoint + ↓ +Configure Client Endpoint + ↓ +Execute Management API Requests +``` + +`Client` automatically resolves its endpoint via `Endpoint` during initialization. The endpoint is configured from the resolved `contentManagement` URL rather than a hardcoded hostname. + +### Resolution Priority + +| Condition | Endpoint Source | +|-----------|----------------| +| Custom `host` provided | Custom host wins — Endpoint is not consulted | +| No custom `host` | `Endpoint.get_contentstack_endpoint(region, 'contentManagement')` | +| Unknown region (fallback) | Legacy pattern: `{region}-api.contentstack.com` | + +--- + +## Error Handling + +| Scenario | Exception | Message | +|----------|-----------|---------| +| Empty region | `ValueError` | `Empty region provided. Please put valid region.` | +| Unknown region | `ValueError` | `Invalid region: ` | +| Unknown service | `ValueError` | `Service "" not found for region ""` | +| Registry unavailable | `RuntimeError` | `regions.json not found and could not be downloaded.` | +| Registry corrupt | `RuntimeError` | `regions.json is corrupt. Run scripts/download_regions.py to re-download it.` | + +--- + +## Caching Requirements + +Goals: + +- Avoid repeated disk reads +- Avoid repeated network requests +- Improve endpoint lookup performance + +The Management Python SDK uses a class-level variable `Endpoint._regions_data` as the in-memory cache. It is populated on first use and persists for the lifetime of the process. Call `Endpoint.reset_cache()` to invalidate (intended for testing only). + +--- + +## Future Compatibility + +SDK implementations must not: + +- Hardcode endpoint URLs +- Hardcode region mappings +- Hardcode service mappings + +All endpoint information must originate from the Regions Registry. + +--- + +## Management Python SDK Example + +```python +import contentstack_management +from contentstack_management.endpoint import Endpoint +from contentstack_management.contentstack import Region, Client + +# --- Resolve a single service URL --- +url = Endpoint.get_contentstack_endpoint('eu', 'contentManagement') +# https://eu-api.contentstack.com + +# --- Resolve without scheme (for use as a host string) --- +host = Endpoint.get_contentstack_endpoint('gcp-na', 'contentManagement', omit_https=True) +# gcp-na-api.contentstack.com + +# --- Resolve all services for a region --- +endpoints = Endpoint.get_contentstack_endpoint('azure-na') +# { +# 'contentDelivery': 'https://azure-na-cdn.contentstack.com', +# 'contentManagement': 'https://azure-na-api.contentstack.com', +# ... +# } + +# --- Module-level proxy --- +url = contentstack_management.get_contentstack_endpoint('eu', 'contentManagement') +# https://eu-api.contentstack.com + +# --- Client endpoint is auto-resolved via Endpoint --- +client = Client( + authtoken='', + region=Region.EU.value +) +# client.endpoint → 'https://eu-api.contentstack.com/v3/' + +# Custom host still overrides Endpoint resolution +client = Client( + authtoken='', + region='au', + host='custom.example.com' +) +# client.endpoint → 'https://au-api.custom.example.com/v3/' + +# --- Make Management API calls using the resolved endpoint --- +response = client.stack('').content_types().find() +``` + +--- + +## Supported Regions + +| Region ID | Aliases | Content Management URL | +|-----------|---------|----------------------| +| `na` | `us`, `aws-na`, `aws_na` | `https://api.contentstack.io` | +| `eu` | — | `https://eu-api.contentstack.com` | +| `au` | — | `https://au-api.contentstack.com` | +| `azure-na` | `azure_na` | `https://azure-na-api.contentstack.com` | +| `azure-eu` | `azure_eu` | `https://azure-eu-api.contentstack.com` | +| `gcp-na` | `gcp_na` | `https://gcp-na-api.contentstack.com` | +| `gcp-eu` | `gcp_eu` | `https://gcp-eu-api.contentstack.com` | + +Region aliases are case-insensitive. `AWS-NA`, `aws-na`, and `aws_na` all resolve to the `na` region. diff --git a/contentstack_management/__init__.py b/contentstack_management/__init__.py index efadd82..ba7b064 100644 --- a/contentstack_management/__init__.py +++ b/contentstack_management/__init__.py @@ -18,6 +18,7 @@ from .entries.entry import Entry from .entry_variants.entry_variants import EntryVariants from .contentstack import Client, Region +from .endpoint import Endpoint from ._api_client import _APIClient from .common import Parameter from ._errors import ArgumentException @@ -41,6 +42,7 @@ __all__ = ( "Client", "Region", +"Endpoint", "_APIClient", "Parameter", "ArgumentException", @@ -78,11 +80,27 @@ "OAuthInterceptor" ) +def get_contentstack_endpoint(region='us', service='', omit_https=False): + """ + Resolve a Contentstack service endpoint URL for a given region. + + Proxy to :class:`Endpoint.get_contentstack_endpoint` for convenience — + mirrors ``Contentstack::getContentstackEndpoint()`` in the PHP SDK. + + :param region: Region ID or alias ('us', 'eu', 'azure-na', 'gcp-eu', ...). + :param service: Service key ('contentDelivery', 'contentManagement', ...). + When empty, returns a dict of all endpoints for the region. + :param omit_https: When True, strips 'https://' from the returned URL(s). + :returns: str when service is provided, dict[str,str] otherwise. + """ + return Endpoint.get_contentstack_endpoint(region, service, omit_https) + + __title__ = 'contentstack-management-python' __author__ = 'dev-ex' __status__ = 'debug' __region__ = 'na' -__version__ = '1.9.0' +__version__ = '1.10.0' __host__ = 'api.contentstack.io' __protocol__ = 'https://' __api_version__ = 'v3' diff --git a/contentstack_management/contentstack.py b/contentstack_management/contentstack.py index 09192cf..2ad6198 100644 --- a/contentstack_management/contentstack.py +++ b/contentstack_management/contentstack.py @@ -2,6 +2,7 @@ import os import pyotp from ._api_client import _APIClient +from .endpoint import Endpoint from contentstack_management.organizations import organization from contentstack_management.stack import stack from contentstack_management.user_session import user_session @@ -37,16 +38,25 @@ def __init__(self, host: str = 'api.contentstack.io', scheme: str = 'https://', authtoken: str = None , management_token=None, headers: dict = None, region: Region = Region.US.value, version='v3', timeout=2, max_retries: int = 18, early_access: list = None, oauth_config: dict = None, **kwargs): - self.endpoint = 'https://api.contentstack.io/v3/' - - if region is not None and region is not Region.US.value: - if host is not None and host != 'api.contentstack.io': + _DEFAULT_HOST = 'api.contentstack.io' + self.endpoint = f'{scheme}{_DEFAULT_HOST}/{version}/' + + if host is None or host == _DEFAULT_HOST: + # No custom host — resolve via Endpoint (regions.json-driven) + try: + base = Endpoint.get_contentstack_endpoint( + region or 'us', 'contentManagement', omit_https=True) + self.endpoint = f'{scheme}{base}/{version}/' + except (ValueError, RuntimeError): + # Unknown/custom region string — fall back to legacy pattern + if region and region != Region.US.value: + self.endpoint = f'{scheme}{region}-api.contentstack.com/{version}/' + else: + # Explicit custom host always wins; apply region prefix when non-US + if region and region != Region.US.value: self.endpoint = f'{scheme}{region}-api.{host}/{version}/' else: - host = 'api.contentstack.com' - self.endpoint = f'{scheme}{region}-{host}/{version}/' - elif host is not None and host != 'api.contentstack.io': - self.endpoint = f'{scheme}{host}/{version}/' + self.endpoint = f'{scheme}{host}/{version}/' if headers is None: headers = {} if early_access is not None: diff --git a/contentstack_management/endpoint.py b/contentstack_management/endpoint.py new file mode 100644 index 0000000..cf2801f --- /dev/null +++ b/contentstack_management/endpoint.py @@ -0,0 +1,180 @@ +""" +Endpoint — Contentstack region-to-URL resolver for the Management SDK. + +Resolves Contentstack service endpoint URLs for any supported region. +Region data is loaded from contentstack_management/data/regions.json (bundled) +and cached in-memory for the lifetime of the process. When the bundled file is +absent the class attempts a live download from the Contentstack CDN so the +SDK continues to work even when the file was not created during installation. +""" + +import json +import os +import re + +REGIONS_URL = 'https://artifacts.contentstack.com/regions.json' + + +class Endpoint: + """ + Resolves Contentstack service endpoint URLs for any supported region. + + Usage:: + + from contentstack_management.endpoint import Endpoint + + # Single service URL + url = Endpoint.get_contentstack_endpoint('eu', 'contentManagement') + # 'https://eu-api.contentstack.com' + + # All services for a region + endpoints = Endpoint.get_contentstack_endpoint('azure-na') + # {'contentDelivery': '...', 'contentManagement': '...', ...} + + # Strip scheme (useful when building endpoint strings manually) + host = Endpoint.get_contentstack_endpoint('gcp-eu', 'contentManagement', omit_https=True) + # 'gcp-eu-api.contentstack.com' + """ + + _regions_data = None # in-memory cache — shared across all instances + + @staticmethod + def get_contentstack_endpoint(region='us', service='', omit_https=False): + """ + Resolve a Contentstack service endpoint URL for a given region. + + :param region: Region ID or alias ('us', 'eu', 'azure-na', 'gcp-eu', etc.). + Defaults to 'us' (AWS North America). + :param service: Service key ('contentDelivery', 'contentManagement', ...). + When empty, returns a dict of all endpoints for the region. + :param omit_https: When True, strips 'https://' prefix from returned URL(s). + :returns: str when service is provided, dict[str,str] otherwise. + :raises ValueError: When region is empty, unknown, or service is not found. + :raises RuntimeError: When regions.json cannot be read or parsed. + """ + if not region: + raise ValueError('Empty region provided. Please put valid region.') + + data = Endpoint._load_regions() + normalized = region.strip().lower() + region_row = Endpoint._find_region(data['regions'], normalized) + + if region_row is None: + raise ValueError(f'Invalid region: {region}') + + if service: + if service not in region_row['endpoints']: + raise ValueError( + f'Service "{service}" not found for region "{region_row["id"]}"' + ) + url = region_row['endpoints'][service] + return Endpoint._strip_https(url) if omit_https else url + + endpoints = region_row['endpoints'] + if omit_https: + return {k: Endpoint._strip_https(v) for k, v in endpoints.items()} + return dict(endpoints) + + @staticmethod + def _load_regions(): + """ + Load and cache regions.json. + + Resolution order: + 1. In-memory static cache (zero I/O after first call) + 2. contentstack_management/data/regions.json on disk (written by download script) + 3. Live download from artifacts.contentstack.com (fallback) + """ + if Endpoint._regions_data is not None: + return Endpoint._regions_data + + data_dir = os.path.join(os.path.dirname(__file__), 'data') + path = os.path.join(data_dir, 'regions.json') + + if not os.path.exists(path): + Endpoint._download_and_save(path) + + if not os.path.exists(path): + raise RuntimeError( + 'contentstack-management: regions.json not found and could not be downloaded. ' + 'Run "python scripts/download_regions.py" and ensure network access.' + ) + + try: + with open(path, 'r', encoding='utf-8') as f: + decoded = json.load(f) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError( + f'contentstack-management: Could not read or parse regions.json: {exc}. ' + 'Run "python scripts/download_regions.py" to re-download it.' + ) from exc + + if not isinstance(decoded, dict) or 'regions' not in decoded: + raise RuntimeError( + 'contentstack-management: regions.json is corrupt. ' + 'Run "python scripts/download_regions.py" to re-download it.' + ) + + Endpoint._regions_data = decoded + return Endpoint._regions_data + + @staticmethod + def _download_and_save(dest): + """ + Download regions.json from the Contentstack CDN and save to disk. + Uses the requests library (already an SDK dependency). + Silent on failure — the caller decides whether a missing file is fatal. + + :param dest: Absolute path to write the file to. + """ + os.makedirs(os.path.dirname(dest), exist_ok=True) + + try: + import requests + response = requests.get(REGIONS_URL, timeout=30) + response.raise_for_status() + data = response.text + except Exception: # noqa: BLE001 + return + + try: + decoded = json.loads(data) + except json.JSONDecodeError: + return + + if isinstance(decoded, dict) and 'regions' in decoded: + try: + with open(dest, 'w', encoding='utf-8') as f: + f.write(data) + except OSError: + pass + + @staticmethod + def _find_region(regions, input_str): + """ + Find a region entry by its id or any alias (case-insensitive). + + Two-pass: exact id match first, then alias[] scan — mirrors PHP implementation. + + :param regions: list of region dicts from regions.json + :param input_str: already-lowercased input + :returns: region dict or None + """ + for row in regions: + if row['id'] == input_str: + return row + for row in regions: + for alias in row.get('alias', []): + if alias.lower() == input_str: + return row + return None + + @staticmethod + def _strip_https(url): + """Strip the https:// (or http://) scheme from a URL string.""" + return re.sub(r'^https?://', '', url) + + @staticmethod + def reset_cache(): + """Reset the internal region cache. Intended for testing only.""" + Endpoint._regions_data = None diff --git a/scripts/download_regions.py b/scripts/download_regions.py new file mode 100644 index 0000000..3a78301 --- /dev/null +++ b/scripts/download_regions.py @@ -0,0 +1,68 @@ +""" +Downloads the Contentstack regions registry from the official source and +saves it to contentstack_management/data/regions.json. + +Run manually: + python3 scripts/download_regions.py +""" + +import json +import os +import sys +import requests + +REGIONS_URL = 'https://artifacts.contentstack.com/regions.json' + +DEST = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'contentstack_management', 'data', 'regions.json' +) + + +def download(): + dest_dir = os.path.dirname(DEST) + os.makedirs(dest_dir, exist_ok=True) + + print(f'contentstack-management: Downloading regions.json from {REGIONS_URL} ...') + + try: + response = requests.get(REGIONS_URL, timeout=30) + response.raise_for_status() + data = response.text + except Exception as exc: + sys.stderr.write( + f'contentstack-management: Warning — could not download regions.json: {exc}. ' + 'The SDK will attempt to download it at runtime on first use.\n' + ) + sys.exit(0) + + try: + decoded = json.loads(data) + except json.JSONDecodeError: + sys.stderr.write( + 'contentstack-management: Warning — downloaded data is not valid JSON.\n' + ) + sys.exit(0) + + if not isinstance(decoded, dict) or 'regions' not in decoded or \ + not isinstance(decoded['regions'], list): + sys.stderr.write( + 'contentstack-management: Warning — downloaded data is not a valid regions.json.\n' + ) + sys.exit(0) + + try: + with open(DEST, 'w', encoding='utf-8') as f: + f.write(data) + except OSError as exc: + sys.stderr.write( + f'contentstack-management: Warning — could not write regions.json to {DEST}: {exc}\n' + ) + sys.exit(0) + + region_count = len(decoded['regions']) + print(f'contentstack-management: regions.json downloaded ({region_count} regions) → {DEST}') + + +if __name__ == '__main__': + download() diff --git a/setup.py b/setup.py index e7ac797..684be4c 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,7 @@ def get_author_email(package): name="contentstack-management", version=get_version(package), packages=find_packages(exclude=['tests']), + package_data={'contentstack_management': ['data/regions.json']}, py_modules=['_api_client', 'contentstack','common','_errors','_constant'], description="Contentstack API Client Library for Python", long_description=long_description, diff --git a/tests/unit/contentstack/test_endpoint.py b/tests/unit/contentstack/test_endpoint.py new file mode 100644 index 0000000..2851d3e --- /dev/null +++ b/tests/unit/contentstack/test_endpoint.py @@ -0,0 +1,298 @@ +import unittest + +import contentstack_management +from contentstack_management.endpoint import Endpoint +from contentstack_management.contentstack import Region, Client + +AUTHTOKEN = 'test_authtoken' + + +class TestEndpoint(unittest.TestCase): + + def setUp(self): + Endpoint.reset_cache() + + # ------------------------------------------------------------------------- + # Default region (us / na) + # ------------------------------------------------------------------------- + + def test_default_region_returns_all_endpoints(self): + endpoints = Endpoint.get_contentstack_endpoint() + self.assertIsInstance(endpoints, dict) + self.assertIn('contentDelivery', endpoints) + self.assertIn('contentManagement', endpoints) + + def test_default_region_content_management(self): + url = Endpoint.get_contentstack_endpoint('us', 'contentManagement') + self.assertEqual('https://api.contentstack.io', url) + + def test_default_region_content_delivery(self): + url = Endpoint.get_contentstack_endpoint('us', 'contentDelivery') + self.assertEqual('https://cdn.contentstack.io', url) + + # ------------------------------------------------------------------------- + # All 7 regions — contentManagement spot-checks (primary service for Mgmt SDK) + # ------------------------------------------------------------------------- + + def test_content_management_na(self): + url = Endpoint.get_contentstack_endpoint('na', 'contentManagement') + self.assertEqual('https://api.contentstack.io', url) + + def test_content_management_eu(self): + url = Endpoint.get_contentstack_endpoint('eu', 'contentManagement') + self.assertEqual('https://eu-api.contentstack.com', url) + + def test_content_management_au(self): + url = Endpoint.get_contentstack_endpoint('au', 'contentManagement') + self.assertEqual('https://au-api.contentstack.com', url) + + def test_content_management_azure_na(self): + url = Endpoint.get_contentstack_endpoint('azure-na', 'contentManagement') + self.assertEqual('https://azure-na-api.contentstack.com', url) + + def test_content_management_azure_eu(self): + url = Endpoint.get_contentstack_endpoint('azure-eu', 'contentManagement') + self.assertEqual('https://azure-eu-api.contentstack.com', url) + + def test_content_management_gcp_na(self): + url = Endpoint.get_contentstack_endpoint('gcp-na', 'contentManagement') + self.assertEqual('https://gcp-na-api.contentstack.com', url) + + def test_content_management_gcp_eu(self): + url = Endpoint.get_contentstack_endpoint('gcp-eu', 'contentManagement') + self.assertEqual('https://gcp-eu-api.contentstack.com', url) + + # ------------------------------------------------------------------------- + # All 7 regions — contentDelivery spot-checks + # ------------------------------------------------------------------------- + + def test_content_delivery_na(self): + url = Endpoint.get_contentstack_endpoint('na', 'contentDelivery') + self.assertEqual('https://cdn.contentstack.io', url) + + def test_content_delivery_eu(self): + url = Endpoint.get_contentstack_endpoint('eu', 'contentDelivery') + self.assertEqual('https://eu-cdn.contentstack.com', url) + + def test_content_delivery_au(self): + url = Endpoint.get_contentstack_endpoint('au', 'contentDelivery') + self.assertEqual('https://au-cdn.contentstack.com', url) + + def test_content_delivery_azure_na(self): + url = Endpoint.get_contentstack_endpoint('azure-na', 'contentDelivery') + self.assertEqual('https://azure-na-cdn.contentstack.com', url) + + def test_content_delivery_azure_eu(self): + url = Endpoint.get_contentstack_endpoint('azure-eu', 'contentDelivery') + self.assertEqual('https://azure-eu-cdn.contentstack.com', url) + + def test_content_delivery_gcp_na(self): + url = Endpoint.get_contentstack_endpoint('gcp-na', 'contentDelivery') + self.assertEqual('https://gcp-na-cdn.contentstack.com', url) + + def test_content_delivery_gcp_eu(self): + url = Endpoint.get_contentstack_endpoint('gcp-eu', 'contentDelivery') + self.assertEqual('https://gcp-eu-cdn.contentstack.com', url) + + # ------------------------------------------------------------------------- + # NA aliases all resolve to the same endpoint + # ------------------------------------------------------------------------- + + def test_alias_na(self): + url = Endpoint.get_contentstack_endpoint('na', 'contentManagement') + self.assertEqual('https://api.contentstack.io', url) + + def test_alias_us(self): + url = Endpoint.get_contentstack_endpoint('us', 'contentManagement') + self.assertEqual('https://api.contentstack.io', url) + + def test_alias_aws_na_hyphen(self): + url = Endpoint.get_contentstack_endpoint('aws-na', 'contentManagement') + self.assertEqual('https://api.contentstack.io', url) + + def test_alias_aws_na_underscore(self): + url = Endpoint.get_contentstack_endpoint('aws_na', 'contentManagement') + self.assertEqual('https://api.contentstack.io', url) + + def test_alias_na_uppercase(self): + url = Endpoint.get_contentstack_endpoint('NA', 'contentManagement') + self.assertEqual('https://api.contentstack.io', url) + + def test_alias_us_uppercase(self): + url = Endpoint.get_contentstack_endpoint('US', 'contentManagement') + self.assertEqual('https://api.contentstack.io', url) + + # ------------------------------------------------------------------------- + # Case-insensitive alias matching for other regions + # ------------------------------------------------------------------------- + + def test_alias_aws_na_uppercase(self): + url = Endpoint.get_contentstack_endpoint('AWS-NA', 'contentManagement') + self.assertEqual('https://api.contentstack.io', url) + + def test_alias_azure_na_underscore(self): + url = Endpoint.get_contentstack_endpoint('azure_na', 'contentManagement') + self.assertEqual('https://azure-na-api.contentstack.com', url) + + def test_alias_gcp_eu_underscore(self): + url = Endpoint.get_contentstack_endpoint('gcp_eu', 'contentManagement') + self.assertEqual('https://gcp-eu-api.contentstack.com', url) + + # ------------------------------------------------------------------------- + # Region enum constants resolve correctly + # ------------------------------------------------------------------------- + + def test_region_constant_us(self): + url = Endpoint.get_contentstack_endpoint(Region.US.value, 'contentManagement') + self.assertEqual('https://api.contentstack.io', url) + + def test_region_constant_eu(self): + url = Endpoint.get_contentstack_endpoint(Region.EU.value, 'contentManagement') + self.assertEqual('https://eu-api.contentstack.com', url) + + def test_region_constant_au(self): + url = Endpoint.get_contentstack_endpoint(Region.AU.value, 'contentManagement') + self.assertEqual('https://au-api.contentstack.com', url) + + def test_region_constant_azure_na(self): + url = Endpoint.get_contentstack_endpoint(Region.AZURE_NA.value, 'contentManagement') + self.assertEqual('https://azure-na-api.contentstack.com', url) + + def test_region_constant_azure_eu(self): + url = Endpoint.get_contentstack_endpoint(Region.AZURE_EU.value, 'contentManagement') + self.assertEqual('https://azure-eu-api.contentstack.com', url) + + def test_region_constant_gcp_na(self): + url = Endpoint.get_contentstack_endpoint(Region.GCP_NA.value, 'contentManagement') + self.assertEqual('https://gcp-na-api.contentstack.com', url) + + def test_region_constant_gcp_eu(self): + url = Endpoint.get_contentstack_endpoint(Region.GCP_EU.value, 'contentManagement') + self.assertEqual('https://gcp-eu-api.contentstack.com', url) + + # ------------------------------------------------------------------------- + # omit_https flag + # ------------------------------------------------------------------------- + + def test_omit_https_strips_scheme_single_service(self): + host = Endpoint.get_contentstack_endpoint('eu', 'contentManagement', omit_https=True) + self.assertEqual('eu-api.contentstack.com', host) + + def test_omit_https_strips_scheme_all_services(self): + endpoints = Endpoint.get_contentstack_endpoint('na', omit_https=True) + self.assertIsInstance(endpoints, dict) + for key, url in endpoints.items(): + self.assertNotIn('https://', url, f'Service {key} still has https://') + self.assertNotIn('http://', url, f'Service {key} still has http://') + + def test_omit_https_false_retains_scheme(self): + url = Endpoint.get_contentstack_endpoint('na', 'contentManagement', omit_https=False) + self.assertTrue(url.startswith('https://')) + + # ------------------------------------------------------------------------- + # No service — returns full dict + # ------------------------------------------------------------------------- + + def test_no_service_returns_dict(self): + result = Endpoint.get_contentstack_endpoint('au') + self.assertIsInstance(result, dict) + self.assertGreater(len(result), 1) + + def test_no_service_contains_correct_urls(self): + endpoints = Endpoint.get_contentstack_endpoint('au') + self.assertEqual('https://au-cdn.contentstack.com', endpoints['contentDelivery']) + self.assertEqual('https://au-api.contentstack.com', endpoints['contentManagement']) + + # ------------------------------------------------------------------------- + # Error cases + # ------------------------------------------------------------------------- + + def test_empty_region_raises_value_error(self): + with self.assertRaises(ValueError) as ctx: + Endpoint.get_contentstack_endpoint('') + self.assertIn('Empty region', str(ctx.exception)) + + def test_unknown_region_raises_value_error(self): + with self.assertRaises(ValueError) as ctx: + Endpoint.get_contentstack_endpoint('invalid-region') + self.assertIn('Invalid region', str(ctx.exception)) + + def test_unknown_service_raises_value_error(self): + with self.assertRaises(ValueError) as ctx: + Endpoint.get_contentstack_endpoint('na', 'unknownService') + self.assertIn('unknownService', str(ctx.exception)) + + # ------------------------------------------------------------------------- + # contentstack_management.get_contentstack_endpoint() module-level proxy + # ------------------------------------------------------------------------- + + def test_module_proxy_returns_same_result(self): + via_class = Endpoint.get_contentstack_endpoint('eu', 'contentManagement') + via_module = contentstack_management.get_contentstack_endpoint('eu', 'contentManagement') + self.assertEqual(via_class, via_module) + + def test_module_proxy_default_region(self): + url = contentstack_management.get_contentstack_endpoint('us', 'contentManagement') + self.assertEqual('https://api.contentstack.io', url) + + def test_module_proxy_omit_https(self): + host = contentstack_management.get_contentstack_endpoint( + 'gcp-na', 'contentManagement', omit_https=True) + self.assertEqual('gcp-na-api.contentstack.com', host) + + def test_module_proxy_all_endpoints(self): + endpoints = contentstack_management.get_contentstack_endpoint('azure-eu') + self.assertIsInstance(endpoints, dict) + self.assertIn('contentManagement', endpoints) + + # ------------------------------------------------------------------------- + # Client endpoint resolution via Endpoint + # ------------------------------------------------------------------------- + + def test_client_us_endpoint(self): + client = Client(authtoken=AUTHTOKEN, region=Region.US.value) + self.assertEqual('https://api.contentstack.io/v3/', client.endpoint) + + def test_client_eu_endpoint(self): + client = Client(authtoken=AUTHTOKEN, region='eu') + self.assertEqual('https://eu-api.contentstack.com/v3/', client.endpoint) + + def test_client_au_endpoint(self): + client = Client(authtoken=AUTHTOKEN, region='au') + self.assertEqual('https://au-api.contentstack.com/v3/', client.endpoint) + + def test_client_azure_na_endpoint(self): + client = Client(authtoken=AUTHTOKEN, region='azure-na') + self.assertEqual('https://azure-na-api.contentstack.com/v3/', client.endpoint) + + def test_client_azure_eu_endpoint(self): + client = Client(authtoken=AUTHTOKEN, region='azure-eu') + self.assertEqual('https://azure-eu-api.contentstack.com/v3/', client.endpoint) + + def test_client_gcp_na_endpoint(self): + client = Client(authtoken=AUTHTOKEN, region='gcp-na') + self.assertEqual('https://gcp-na-api.contentstack.com/v3/', client.endpoint) + + def test_client_gcp_eu_endpoint(self): + client = Client(authtoken=AUTHTOKEN, region='gcp-eu') + self.assertEqual('https://gcp-eu-api.contentstack.com/v3/', client.endpoint) + + def test_client_region_enum_value_au(self): + client = Client(authtoken=AUTHTOKEN, region=Region.AU.value) + self.assertEqual('https://au-api.contentstack.com/v3/', client.endpoint) + + def test_client_custom_host_overrides_endpoint(self): + client = Client(authtoken=AUTHTOKEN, region='au', host='example.com') + self.assertEqual('https://au-api.example.com/v3/', client.endpoint) + + def test_client_custom_host_us_region(self): + client = Client(authtoken=AUTHTOKEN, host='custom.contentstack.io') + self.assertEqual('https://custom.contentstack.io/v3/', client.endpoint) + + def test_client_default_us_no_region(self): + client = Client(authtoken=AUTHTOKEN) + self.assertEqual('https://api.contentstack.io/v3/', client.endpoint) + + +if __name__ == '__main__': + unittest.main() From 22b475065a406f2ecddec8da85a037a876d24473 Mon Sep 17 00:00:00 2001 From: OM PAWAR Date: Mon, 8 Jun 2026 13:37:09 +0530 Subject: [PATCH 2/2] Delete ENDPOINT_INTEGRATION.md --- ENDPOINT_INTEGRATION.md | 367 ---------------------------------------- 1 file changed, 367 deletions(-) delete mode 100644 ENDPOINT_INTEGRATION.md diff --git a/ENDPOINT_INTEGRATION.md b/ENDPOINT_INTEGRATION.md deleted file mode 100644 index 16ddfe9..0000000 --- a/ENDPOINT_INTEGRATION.md +++ /dev/null @@ -1,367 +0,0 @@ -# Region Endpoint Integration — Management Python SDK - -## Overview - -Contentstack services are deployed across multiple cloud providers and geographic regions. The Management Python SDK resolves service endpoints dynamically using the Contentstack Regions Registry rather than relying on hardcoded URLs. - -This ensures: - -- Consistent endpoint resolution across all SDKs -- Automatic support for newly introduced regions -- Automatic support for newly introduced services -- Single source of truth for endpoint configuration -- Elimination of region-specific host logic inside the SDK - ---- - -## Regions Registry - -All endpoint information is maintained in the Contentstack Regions Registry. - -### Registry URL - -```text -https://artifacts.contentstack.com/regions.json -``` - -The registry contains: - -- Region identifiers -- Region aliases -- Default region information -- Service endpoint mappings - -### Example - -```json -{ - "regions": [ - { - "id": "na", - "alias": ["us", "aws-na"], - "isDefault": true, - "endpoints": { - "contentDelivery": "https://cdn.contentstack.io", - "contentManagement": "https://api.contentstack.io" - } - } - ] -} -``` - ---- - -## Endpoint Resolution Contract - -The SDK exposes a public endpoint resolution API via the `Endpoint` class. - -```text -Endpoint.get_contentstack_endpoint( - region, - service = '', - omit_https = False -) -``` - -### Parameters - -| Parameter | Description | -|-----------|-------------| -| `region` | Region identifier or alias (e.g. `'us'`, `'eu'`, `'azure-na'`) | -| `service` | Service key (e.g. `'contentManagement'`, `'contentDelivery'`). When empty, all endpoints for the region are returned. | -| `omit_https` | When `True`, strips `https://` from the returned URL — useful when constructing the `Client` endpoint string | - -### Returns - -- `str` — service URL when a service key is provided -- `dict[str, str]` — complete endpoint map when service is omitted - -### Raises - -| Exception | When | -|-----------|------| -| `ValueError` | Empty region, unknown region, or unknown service | -| `RuntimeError` | `regions.json` cannot be read or parsed | - ---- - -## Region Resolution Rules - -Region matching must: - -- Ignore case -- Trim whitespace -- Support aliases -- Support both dash (`-`) and underscore (`_`) variants where defined - -### Examples - -| Input | Resolved Region | -|-------|----------------| -| `na` | `na` | -| `us` | `na` | -| `aws-na` | `na` | -| `AWS_NA` | `na` | -| `eu` | `eu` | -| `azure-na` | `azure-na` | -| `gcp-eu` | `gcp-eu` | - -If no region is found: - -```text -ValueError: Invalid region -``` - ---- - -## Service Resolution Rules - -The SDK will: - -1. Locate the resolved region in the registry. -2. Locate the service key within the region endpoints. -3. Return the endpoint URL. - -### Example - -```text -Region: eu -Service: contentManagement - -Result: -https://eu-api.contentstack.com -``` - -If the service key is not present: - -```text -ValueError: Service not found -``` - ---- - -## Supported Service Keys - -- `contentManagement` -- `contentDelivery` -- `graphqlDelivery` -- `graphqlPreview` -- `preview` -- `auth` -- `application` -- `images` -- `assets` -- `automate` -- `launch` -- `developerHub` -- `brandKit` -- `genAI` -- `personalizeManagement` -- `personalizeEdge` -- `composableStudio` - -The SDK does not hardcode this list. The registry remains the source of truth. - ---- - -## Registry Loading Requirements - -Resolution order: - -1. **In-memory cache** — `Endpoint._regions_data` class-level variable. Zero I/O after first call. -2. **Local registry file** — `contentstack_management/data/regions.json` on disk, bundled at install time. -3. **Live download fallback** — `GET https://artifacts.contentstack.com/regions.json` via `requests`. - -```text -get_contentstack_endpoint() - │ - ▼ - In-memory cache hit? - Yes → return - No ↓ - ▼ - regions.json on disk? - Yes → load, cache, return - No ↓ - ▼ - Download from CDN - Success → write to disk, cache, return - Failure → RuntimeError -``` - ---- - -## Registry Management - -The `regions.json` file is stored locally at `contentstack_management/data/regions.json` and managed via a download script. - -> **Note:** The registry is stored under `data/` rather than `assets/` because `contentstack_management/assets/` is already a Python package used for the Assets Management API. - -### Initial Download - -Run once after cloning or installing the SDK: - -```bash -python3 scripts/download_regions.py -``` - -This downloads the latest `regions.json` from the Contentstack Regions Registry and stores it in: - -```text -contentstack_management/data/regions.json -``` - -### Refresh Registry - -To manually refresh and overwrite the existing file: - -```bash -python3 scripts/download_regions.py -``` - -This command: - -1. Downloads the latest `regions.json` from the registry. -2. Replaces the existing local copy. -3. Makes newly added regions and services immediately available without requiring SDK code changes. - -### Example Workflow - -```text -python3 scripts/download_regions.py - │ - ▼ -Download latest regions.json - │ - ▼ -Store in contentstack_management/data/regions.json - │ - ▼ -Available on next Endpoint call -``` - -> **Note:** If `regions.json` is absent at runtime, the SDK performs a live download automatically on the first `Endpoint` call. The script is recommended for production environments to avoid the startup latency. - ---- - -## SDK Integration - -```text -Resolve Region - ↓ -Resolve contentManagement Endpoint - ↓ -Configure Client Endpoint - ↓ -Execute Management API Requests -``` - -`Client` automatically resolves its endpoint via `Endpoint` during initialization. The endpoint is configured from the resolved `contentManagement` URL rather than a hardcoded hostname. - -### Resolution Priority - -| Condition | Endpoint Source | -|-----------|----------------| -| Custom `host` provided | Custom host wins — Endpoint is not consulted | -| No custom `host` | `Endpoint.get_contentstack_endpoint(region, 'contentManagement')` | -| Unknown region (fallback) | Legacy pattern: `{region}-api.contentstack.com` | - ---- - -## Error Handling - -| Scenario | Exception | Message | -|----------|-----------|---------| -| Empty region | `ValueError` | `Empty region provided. Please put valid region.` | -| Unknown region | `ValueError` | `Invalid region: ` | -| Unknown service | `ValueError` | `Service "" not found for region ""` | -| Registry unavailable | `RuntimeError` | `regions.json not found and could not be downloaded.` | -| Registry corrupt | `RuntimeError` | `regions.json is corrupt. Run scripts/download_regions.py to re-download it.` | - ---- - -## Caching Requirements - -Goals: - -- Avoid repeated disk reads -- Avoid repeated network requests -- Improve endpoint lookup performance - -The Management Python SDK uses a class-level variable `Endpoint._regions_data` as the in-memory cache. It is populated on first use and persists for the lifetime of the process. Call `Endpoint.reset_cache()` to invalidate (intended for testing only). - ---- - -## Future Compatibility - -SDK implementations must not: - -- Hardcode endpoint URLs -- Hardcode region mappings -- Hardcode service mappings - -All endpoint information must originate from the Regions Registry. - ---- - -## Management Python SDK Example - -```python -import contentstack_management -from contentstack_management.endpoint import Endpoint -from contentstack_management.contentstack import Region, Client - -# --- Resolve a single service URL --- -url = Endpoint.get_contentstack_endpoint('eu', 'contentManagement') -# https://eu-api.contentstack.com - -# --- Resolve without scheme (for use as a host string) --- -host = Endpoint.get_contentstack_endpoint('gcp-na', 'contentManagement', omit_https=True) -# gcp-na-api.contentstack.com - -# --- Resolve all services for a region --- -endpoints = Endpoint.get_contentstack_endpoint('azure-na') -# { -# 'contentDelivery': 'https://azure-na-cdn.contentstack.com', -# 'contentManagement': 'https://azure-na-api.contentstack.com', -# ... -# } - -# --- Module-level proxy --- -url = contentstack_management.get_contentstack_endpoint('eu', 'contentManagement') -# https://eu-api.contentstack.com - -# --- Client endpoint is auto-resolved via Endpoint --- -client = Client( - authtoken='', - region=Region.EU.value -) -# client.endpoint → 'https://eu-api.contentstack.com/v3/' - -# Custom host still overrides Endpoint resolution -client = Client( - authtoken='', - region='au', - host='custom.example.com' -) -# client.endpoint → 'https://au-api.custom.example.com/v3/' - -# --- Make Management API calls using the resolved endpoint --- -response = client.stack('').content_types().find() -``` - ---- - -## Supported Regions - -| Region ID | Aliases | Content Management URL | -|-----------|---------|----------------------| -| `na` | `us`, `aws-na`, `aws_na` | `https://api.contentstack.io` | -| `eu` | — | `https://eu-api.contentstack.com` | -| `au` | — | `https://au-api.contentstack.com` | -| `azure-na` | `azure_na` | `https://azure-na-api.contentstack.com` | -| `azure-eu` | `azure_eu` | `https://azure-eu-api.contentstack.com` | -| `gcp-na` | `gcp_na` | `https://gcp-na-api.contentstack.com` | -| `gcp-eu` | `gcp_eu` | `https://gcp-eu-api.contentstack.com` | - -Region aliases are case-insensitive. `AWS-NA`, `aws-na`, and `aws_na` all resolve to the `na` region.